Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ pre-commit:
parallel: true
commands:
check:
glob: '*.{js,ts,jsx,tsx}'
glob: '*.{js,ts,jsx,tsx,graphql}'
run: bun oxlint {staged_files} && bun format && git add {staged_files}
14 changes: 14 additions & 0 deletions packages/wabe/src/hooks/protected.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { OperationType } from '.'
import type { DevWabeTypes } from '../utils/helper'
import type { HookObject } from './HookObject'
import type { SchemaClassWithProtectedFields } from '../schema/Schema'
import type { WabeContext } from '../server/interface'

const _checkProtected = (
hookObject: HookObject<DevWabeTypes, any>,
Expand Down Expand Up @@ -50,6 +52,18 @@ const _checkProtected = (
})
}

export const canUserReadField = (
schemaClass: SchemaClassWithProtectedFields,
fieldName: string,
context: Pick<WabeContext<any>, 'isRoot' | 'user'>,
): boolean => {
const protectedForField = schemaClass.fields[fieldName]?.protected
if (!protectedForField || !protectedForField.protectedOperations.includes('read')) return true
if (context.isRoot && protectedForField.authorizedRoles.includes('rootOnly')) return true
const userRole = context.user?.role?.name || ''
return protectedForField.authorizedRoles.includes(userRole)
}

export const defaultCheckProtectedOnBeforeRead = (object: HookObject<DevWabeTypes, any>) =>
_checkProtected(object, OperationType.BeforeRead)

Expand Down
10 changes: 10 additions & 0 deletions packages/wabe/src/hooks/virtualFields.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Hook } from '.'
import type { DevWabeTypes } from '../utils/helper'
import { canUserReadField } from './protected'

type VirtualFieldDefinition = {
type: 'Virtual'
Expand Down Expand Up @@ -33,6 +34,15 @@ export const defaultVirtualFieldsAfterRead: Hook<DevWabeTypes, any>['callback']

if (!isVirtualFieldDefinition(fieldDefinition)) continue

const hasAccessToAllDependencies = fieldDefinition.dependsOn.every((dep) =>
canUserReadField(classDefinition, String(dep), hookObject.context),
)

if (!hasAccessToAllDependencies) {
object[fieldName] = undefined
continue
}

object[fieldName] = fieldDefinition.callback(object)
}
}
14 changes: 10 additions & 4 deletions packages/wabe/src/schema/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ export type WabeObject<T extends WabeTypes> = {
required?: boolean
}

export type ProtectedFieldConfig<TRole = string> = {
authorizedRoles: Array<TRole>
protectedOperations: Array<'create' | 'read' | 'update'>
}

export type SchemaClassWithProtectedFields = {
fields: Record<string, { protected?: ProtectedFieldConfig }>
}

type FieldBase<T extends WabeTypes> = {
required?: boolean
description?: string
protected?: {
authorizedRoles: Array<T['enums']['RoleEnum'] | 'rootOnly'>
protectedOperations: Array<'create' | 'read' | 'update'>
}
protected?: ProtectedFieldConfig<T['enums']['RoleEnum'] | 'rootOnly'>
}

type TypeFieldBase<U, K extends WabeFieldTypes> = {
Expand Down
213 changes: 213 additions & 0 deletions packages/wabe/src/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1986,6 +1986,219 @@ describe('Security tests', () => {
await closeTests(wabe)
})

it('should return undefined for virtual field (object type) when user has read:false and write:true in ACL', async () => {
const setup = await setupTests([
{
name: 'VirtualFieldAclTest',
fields: {
name: { type: 'String' },
secret: {
type: 'String',
},
secretInfo: {
type: 'Virtual',
returnType: 'Object',
object: {
name: 'SecretInfo',
fields: {
summary: { type: 'String' },
},
},
dependsOn: ['secret'],
callback: (object: any) =>
object.secret != null ? { summary: `${object.secret.slice(0, 3)}...` } : null,
},
},
permissions: {
read: {
authorizedRoles: ['Client'],
requireAuthentication: true,
},
create: {
authorizedRoles: ['Client'],
requireAuthentication: true,
},
acl: async (hookObject) => {
await hookObject.addACL('roles', {
role: RoleEnum.Client,
read: true,
write: true,
})
},
},
},
])

const wabe = setup.wabe
const port = setup.port
const client = getAnonymousClient(port)
const rootClient = getGraphqlClient(port)

const { userClient, userId } = await createUserAndUpdateRole({
anonymousClient: client,
port,
roleName: 'Client',
rootClient,
})

const objectCreated = await rootClient.request<any>(gql`
mutation createVirtualFieldAclTest {
createVirtualFieldAclTest(input: { fields: { name: "test", secret: "sensitive" } }) {
virtualFieldAclTest {
id
}
}
}
`)

const objectId = objectCreated.createVirtualFieldAclTest.virtualFieldAclTest.id

await rootClient.request<any>(gql`
mutation updateACL {
updateVirtualFieldAclTest(input: {
id: "${objectId}",
fields: {
acl: {
users: [{
userId: "${userId}",
read: false,
write: true
}]
}
}
}) {
virtualFieldAclTest {
id
}
}
}
`)

const resList = await userClient.request<any>(gql`
query virtualFieldAclTests {
virtualFieldAclTests {
edges {
node {
id
name
secretInfo {
summary
}
}
}
}
}
`)

expect(resList.virtualFieldAclTests.edges.length).toEqual(0)

await expect(
userClient.request<any>(gql`
query virtualFieldAclTest {
virtualFieldAclTest(id: "${objectId}") {
id
name
secretInfo {
summary
}
}
}
`),
).rejects.toThrow('Object not found')

await closeTests(wabe)
})

it('should return undefined for virtual field (object type) when user cannot read a protected dependency field', async () => {
const setup = await setupTests([
{
name: 'VirtualFieldProtectedTest',
fields: {
name: { type: 'String' },
secret: {
type: 'String',
protected: {
authorizedRoles: ['rootOnly'],
protectedOperations: ['read'],
},
},
secretInfo: {
type: 'Virtual',
returnType: 'Object',
object: {
name: 'SecretInfo',
fields: {
summary: { type: 'String' },
},
},
dependsOn: ['secret'],
callback: (object: any) =>
object.secret != null ? { summary: `${object.secret.slice(0, 3)}...` } : null,
},
},
permissions: {
read: {
authorizedRoles: ['Client'],
requireAuthentication: true,
},
create: {
authorizedRoles: ['Client'],
requireAuthentication: true,
},
acl: async (hookObject) => {
await hookObject.addACL('roles', {
role: RoleEnum.Client,
read: true,
write: true,
})
},
},
},
])

const wabe = setup.wabe
const port = setup.port
const client = getAnonymousClient(port)
const rootClient = getGraphqlClient(port)

const { userClient } = await createUserAndUpdateRole({
anonymousClient: client,
port,
roleName: 'Client',
rootClient,
})

const objectCreated = await rootClient.request<any>(gql`
mutation createVirtualFieldProtectedTest {
createVirtualFieldProtectedTest(input: { fields: { name: "test", secret: "sensitive" } }) {
virtualFieldProtectedTest {
id
}
}
}
`)

const objectId = objectCreated.createVirtualFieldProtectedTest.virtualFieldProtectedTest.id

const res = await userClient.request<any>(gql`
query virtualFieldProtectedTest {
virtualFieldProtectedTest(id: "${objectId}") {
id
name
secretInfo {
summary
}
}
}
`)

expect(res.virtualFieldProtectedTest).toBeDefined()
expect(res.virtualFieldProtectedTest.name).toBe('test')
expect(res.virtualFieldProtectedTest.secretInfo).toBeNull()

await closeTests(wabe)
})

it('should not authorize an user to read an object when explicitly denied in acl.users even if role has access (MongoDB notContains)', async () => {
const setup = await setupTests([
{
Expand Down