From a000d7ac1d7969b64d18aa548e3ef887a6a28a00 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Wed, 22 Apr 2026 00:25:15 +0200 Subject: [PATCH] feat(query-graphql): expose typed clientFilter on AuthorizerContext Extend AuthorizerContext with optional clientFilter and clientFilterReferences so authorizers can introspect the filter the client sent without reaching into raw request bodies. AuthorizerInterceptor now extracts the filter from args.filter (or args.input.filter for input-wrapped mutations) and exposes a typed helper that walks the Filter DSL (including and/or groups) to check whether a given field was referenced. Also export filterReferencesField so consumers can run the same walk over any Filter. --- .../authorizer.interceptor.spec.ts | 107 ++++++++++++++++++ .../interceptors/authorizer.interceptor.ts | 35 +++++- 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts diff --git a/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts b/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts new file mode 100644 index 000000000..5971ad94c --- /dev/null +++ b/packages/query-graphql/__tests__/interceptors/authorizer.interceptor.spec.ts @@ -0,0 +1,107 @@ +import { ExecutionContext } from '@nestjs/common' +import { Filter } from '@ptc-org/nestjs-query-core' + +import { Authorizer } from '../../src/auth' +import { + AuthorizerContext, + AuthorizerInterceptor, + filterReferencesField +} from '../../src/interceptors/authorizer.interceptor' + +describe('filterReferencesField', () => { + type Row = { id: number; deletedAt: Date | null; name: string } + + it('returns true when the field is a direct key', () => { + const filter: Filter = { deletedAt: { isNot: null } } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('returns false when the field is absent', () => { + const filter: Filter = { name: { eq: 'x' } } + expect(filterReferencesField(filter, 'deletedAt')).toBe(false) + }) + + it('descends into and groups', () => { + const filter: Filter = { + and: [{ name: { eq: 'x' } }, { deletedAt: { isNot: null } }] + } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('descends into or groups', () => { + const filter: Filter = { + or: [{ name: { eq: 'x' } }, { and: [{ deletedAt: { isNot: null } }] }] + } + expect(filterReferencesField(filter, 'deletedAt')).toBe(true) + }) + + it('returns false for undefined filter', () => { + expect(filterReferencesField(undefined, 'deletedAt')).toBe(false) + }) +}) + +describe('AuthorizerInterceptor', () => { + type Row = { id: number; deletedAt: Date | null } + + class TestDTO { + id!: number + + deletedAt!: Date | null + } + + const fakeAuthorizer = {} as Authorizer + + const createExecutionContext = (args: Record, ctx: Record = {}): ExecutionContext => { + const gqlArgs = [null, args, ctx, null] as const + return { + getType: () => 'graphql', + getArgs: () => gqlArgs, + getArgByIndex: (i: number) => gqlArgs[i], + switchToHttp: () => ({}), + switchToRpc: () => ({}), + switchToWs: () => ({}), + getClass: () => undefined, + getHandler: () => undefined + } as unknown as ExecutionContext + } + + it('populates authorizer, clientFilter, and clientFilterReferences on the gql context', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const filter: Filter = { deletedAt: { isNot: null } } + const execContext = createExecutionContext({ filter }, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.authorizer).toBe(fakeAuthorizer) + expect(gqlContext.clientFilter).toEqual(filter) + expect(gqlContext.clientFilterReferences?.('deletedAt')).toBe(true) + expect(gqlContext.clientFilterReferences?.('id')).toBe(false) + }) + + it('extracts filter from args.input.filter when top-level filter is missing', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const filter: Filter = { id: { eq: 1 } } + const execContext = createExecutionContext({ input: { filter } }, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.clientFilter).toEqual(filter) + expect(gqlContext.clientFilterReferences?.('id')).toBe(true) + }) + + it('leaves clientFilter undefined when no filter is provided', async () => { + const InterceptorClass = AuthorizerInterceptor(TestDTO) + const interceptor = new InterceptorClass(fakeAuthorizer) + const gqlContext: AuthorizerContext = {} as AuthorizerContext + const execContext = createExecutionContext({}, gqlContext) + + await interceptor.intercept(execContext, { handle: () => ({ subscribe: () => undefined }) as never }) + + expect(gqlContext.clientFilter).toBeUndefined() + expect(gqlContext.clientFilterReferences?.('deletedAt')).toBe(false) + }) +}) diff --git a/packages/query-graphql/src/interceptors/authorizer.interceptor.ts b/packages/query-graphql/src/interceptors/authorizer.interceptor.ts index 429cdfd5b..8c11378cb 100644 --- a/packages/query-graphql/src/interceptors/authorizer.interceptor.ts +++ b/packages/query-graphql/src/interceptors/authorizer.interceptor.ts @@ -1,11 +1,37 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' import { GqlExecutionContext } from '@nestjs/graphql' -import { Class } from '@ptc-org/nestjs-query-core' +import { Class, Filter } from '@ptc-org/nestjs-query-core' import { Authorizer } from '../auth' import { InjectAuthorizer } from '../decorators' -export type AuthorizerContext = { authorizer: Authorizer } +export type ClientFilterReferences = (field: keyof DTO & string) => boolean + +export type AuthorizerContext = { + authorizer: Authorizer + clientFilter?: Filter + clientFilterReferences?: ClientFilterReferences +} + +export function filterReferencesField(filter: Filter | undefined, field: keyof DTO & string): boolean { + if (!filter) return false + for (const key of Object.keys(filter) as (keyof Filter)[]) { + if (key === field) return true + if (key === 'and' || key === 'or') { + const group = filter[key] as Filter[] | undefined + if (group?.some((sub) => filterReferencesField(sub, field))) return true + } + } + return false +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractClientFilter(args: Record | undefined): Filter | undefined { + if (!args) return undefined + if (args.filter) return args.filter as Filter + if (args.input?.filter) return args.input.filter as Filter + return undefined +} export function AuthorizerInterceptor(DTOClass: Class): Class { @Injectable() @@ -16,6 +42,11 @@ export function AuthorizerInterceptor(DTOClass: Class): Class>() ctx.authorizer = this.authorizer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = gqlContext.getArgs>() + const clientFilter = extractClientFilter(args) + ctx.clientFilter = clientFilter + ctx.clientFilterReferences = (field) => filterReferencesField(clientFilter, field) return next.handle() } }