diff --git a/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts b/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts index 3580dbe56..f3b8a47d4 100644 --- a/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts +++ b/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts @@ -95,6 +95,30 @@ describe('createDefaultAuthorizer', () => { ownerId!: number } + @Injectable() + class TestMutationHooksAuthorizer implements CustomAuthorizer { + authorize(context: UserContext): Promise> { + return Promise.resolve({ ownerId: { eq: context.user.id } }) + } + + authorizeUpdate(context: UserContext): Promise> { + return Promise.resolve({ ownerId: { eq: context.user.id }, canUpdate: { is: true } }) + } + + authorizeDelete(context: UserContext): Promise> { + return Promise.resolve({ ownerId: { eq: context.user.id }, canDelete: { is: true } }) + } + } + + @Authorize(TestMutationHooksAuthorizer) + class TestMutationHooksDTO { + ownerId!: number + + canUpdate!: boolean + + canDelete!: boolean + } + beforeEach(async () => { testingModule = await Test.createTestingModule({ providers: [ @@ -104,7 +128,8 @@ describe('createDefaultAuthorizer', () => { RelationWithAuthorizer, TestDTO, TestNoAuthDTO, - TestWithAuthorizerDTO + TestWithAuthorizerDTO, + TestMutationHooksDTO ]) ] }).compile() @@ -344,4 +369,71 @@ describe('createDefaultAuthorizer', () => { } ) }) + + it('should use authorizeUpdate filter on UPDATE operations when defined', async () => { + const authorizer = testingModule.get>(getAuthorizerToken(TestMutationHooksDTO)) + const filter = await authorizer.authorize( + { user: { id: 2 } }, + { + operationName: 'updateOne', + operationGroup: OperationGroup.UPDATE, + readonly: false, + many: false + } + ) + expect(filter).toEqual({ ownerId: { eq: 2 }, canUpdate: { is: true } }) + }) + + it('should use authorizeDelete filter on DELETE operations when defined', async () => { + const authorizer = testingModule.get>(getAuthorizerToken(TestMutationHooksDTO)) + const filter = await authorizer.authorize( + { user: { id: 2 } }, + { + operationName: 'deleteOne', + operationGroup: OperationGroup.DELETE, + readonly: false, + many: false + } + ) + expect(filter).toEqual({ ownerId: { eq: 2 }, canDelete: { is: true } }) + }) + + it('should fall back to authorize on READ for authorizers with mutation hooks', async () => { + const authorizer = testingModule.get>(getAuthorizerToken(TestMutationHooksDTO)) + const filter = await authorizer.authorize( + { user: { id: 2 } }, + { + operationName: 'queryMany', + operationGroup: OperationGroup.READ, + readonly: true, + many: true + } + ) + expect(filter).toEqual({ ownerId: { eq: 2 } }) + }) + + it('should fall back to authorize on UPDATE/DELETE when hooks are not defined (backwards compat)', async () => { + const authorizer = testingModule.get>(getAuthorizerToken(TestDTO)) + const updateFilter = await authorizer.authorize( + { user: { id: 2 } }, + { + operationName: 'updateOne', + operationGroup: OperationGroup.UPDATE, + readonly: false, + many: false + } + ) + expect(updateFilter).toEqual({ ownerId: { eq: 2 } }) + + const deleteFilter = await authorizer.authorize( + { user: { id: 2 } }, + { + operationName: 'deleteOne', + operationGroup: OperationGroup.DELETE, + readonly: false, + many: false + } + ) + expect(deleteFilter).toEqual({ ownerId: { eq: 2 } }) + }) }) diff --git a/packages/query-graphql/src/auth/authorizer.ts b/packages/query-graphql/src/auth/authorizer.ts index 06f57dcc7..2c95e9ed4 100644 --- a/packages/query-graphql/src/auth/authorizer.ts +++ b/packages/query-graphql/src/auth/authorizer.ts @@ -32,6 +32,12 @@ export interface CustomAuthorizer { context: any, authorizerContext: AuthorizationContext ): Promise | undefined> + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authorizeUpdate?(context: any, authorizerContext: AuthorizationContext): Promise> + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authorizeDelete?(context: any, authorizerContext: AuthorizationContext): Promise> } export interface Authorizer extends CustomAuthorizer { diff --git a/packages/query-graphql/src/auth/default-crud.authorizer.ts b/packages/query-graphql/src/auth/default-crud.authorizer.ts index 468f77d37..73c62b9ad 100644 --- a/packages/query-graphql/src/auth/default-crud.authorizer.ts +++ b/packages/query-graphql/src/auth/default-crud.authorizer.ts @@ -4,7 +4,7 @@ import { Class, Filter } from '@ptc-org/nestjs-query-core' import { getAuthorizer, getRelations } from '../decorators' import { ResolverRelation } from '../resolvers/relations' -import { AuthorizationContext, Authorizer, CustomAuthorizer } from './authorizer' +import { AuthorizationContext, Authorizer, CustomAuthorizer, OperationGroup } from './authorizer' import { getAuthorizerToken, getCustomAuthorizerToken } from './tokens' export interface AuthorizerOptions { @@ -44,6 +44,14 @@ export function createDefaultAuthorizer( // eslint-disable-next-line @typescript-eslint/no-explicit-any async authorize(context: any, authorizationContext: AuthorizationContext): Promise> { + if (authorizationContext?.operationGroup === OperationGroup.UPDATE && this.customAuthorizer?.authorizeUpdate) { + const filter = await this.customAuthorizer.authorizeUpdate(context, authorizationContext) + if (filter) return filter + } + if (authorizationContext?.operationGroup === OperationGroup.DELETE && this.customAuthorizer?.authorizeDelete) { + const filter = await this.customAuthorizer.authorizeDelete(context, authorizationContext) + if (filter) return filter + } return ( this.customAuthorizer?.authorize(context, authorizationContext) ?? this.authOptions?.authorize(context, authorizationContext) ??