From be1923c6e6e4fa7f66e69205f0a4c53d069096d0 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Wed, 22 Apr 2026 00:23:02 +0200 Subject: [PATCH] feat(query-graphql): add authorizeUpdate/authorizeDelete hooks Add optional authorizeUpdate and authorizeDelete methods to the CustomAuthorizer interface. DefaultAuthorizer routes UPDATE and DELETE operations through these hooks when present, falling back to authorize() for backwards compatibility. This lets mutation authorizers add WHERE clauses that make unauthorized updateOne/deleteOne requests naturally 404 instead of loading the row and throwing ForbiddenException after-the-fact. --- .../auth/default-crud-auth.service.spec.ts | 94 ++++++++++++++++++- packages/query-graphql/src/auth/authorizer.ts | 6 ++ .../src/auth/default-crud.authorizer.ts | 10 +- 3 files changed, 108 insertions(+), 2 deletions(-) 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) ??