diff --git a/lefthook.yml b/lefthook.yml index c35659c5..61774369 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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} diff --git a/packages/wabe/src/hooks/protected.ts b/packages/wabe/src/hooks/protected.ts index aa9288fc..8f5258c7 100644 --- a/packages/wabe/src/hooks/protected.ts +++ b/packages/wabe/src/hooks/protected.ts @@ -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, @@ -50,6 +52,18 @@ const _checkProtected = ( }) } +export const canUserReadField = ( + schemaClass: SchemaClassWithProtectedFields, + fieldName: string, + context: Pick, '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) => _checkProtected(object, OperationType.BeforeRead) diff --git a/packages/wabe/src/hooks/virtualFields.ts b/packages/wabe/src/hooks/virtualFields.ts index fd0d81fc..f67563bc 100644 --- a/packages/wabe/src/hooks/virtualFields.ts +++ b/packages/wabe/src/hooks/virtualFields.ts @@ -1,5 +1,6 @@ import type { Hook } from '.' import type { DevWabeTypes } from '../utils/helper' +import { canUserReadField } from './protected' type VirtualFieldDefinition = { type: 'Virtual' @@ -33,6 +34,15 @@ export const defaultVirtualFieldsAfterRead: Hook['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) } } diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 0ade3159..037b7ad6 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -45,13 +45,19 @@ export type WabeObject = { required?: boolean } +export type ProtectedFieldConfig = { + authorizedRoles: Array + protectedOperations: Array<'create' | 'read' | 'update'> +} + +export type SchemaClassWithProtectedFields = { + fields: Record +} + type FieldBase = { required?: boolean description?: string - protected?: { - authorizedRoles: Array - protectedOperations: Array<'create' | 'read' | 'update'> - } + protected?: ProtectedFieldConfig } type TypeFieldBase = { diff --git a/packages/wabe/src/security.test.ts b/packages/wabe/src/security.test.ts index 63d2fb9f..c7ae931e 100644 --- a/packages/wabe/src/security.test.ts +++ b/packages/wabe/src/security.test.ts @@ -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(gql` + mutation createVirtualFieldAclTest { + createVirtualFieldAclTest(input: { fields: { name: "test", secret: "sensitive" } }) { + virtualFieldAclTest { + id + } + } + } + `) + + const objectId = objectCreated.createVirtualFieldAclTest.virtualFieldAclTest.id + + await rootClient.request(gql` + mutation updateACL { + updateVirtualFieldAclTest(input: { + id: "${objectId}", + fields: { + acl: { + users: [{ + userId: "${userId}", + read: false, + write: true + }] + } + } + }) { + virtualFieldAclTest { + id + } + } + } + `) + + const resList = await userClient.request(gql` + query virtualFieldAclTests { + virtualFieldAclTests { + edges { + node { + id + name + secretInfo { + summary + } + } + } + } + } + `) + + expect(resList.virtualFieldAclTests.edges.length).toEqual(0) + + await expect( + userClient.request(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(gql` + mutation createVirtualFieldProtectedTest { + createVirtualFieldProtectedTest(input: { fields: { name: "test", secret: "sensitive" } }) { + virtualFieldProtectedTest { + id + } + } + } + `) + + const objectId = objectCreated.createVirtualFieldProtectedTest.virtualFieldProtectedTest.id + + const res = await userClient.request(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([ {