From 9069c7abde46f2ba0292a837dde66af8ac1360e5 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Fri, 31 Jan 2025 12:49:57 +0100 Subject: [PATCH 1/5] refactor(examples/auth): add `SubSubTask` entity and DTO --- examples/auth/schema.gql | 234 ++++++++++++++++-- examples/auth/src/app.module.ts | 2 + .../src/sub-sub-task/dto/sub-sub-task.dto.ts | 15 ++ .../src/sub-sub-task/sub-sub-task.entity.ts | 25 ++ .../src/sub-sub-task/sub-sub-task.module.ts | 21 ++ .../auth/src/sub-task/dto/sub-task.dto.ts | 21 +- examples/auth/src/sub-task/sub-task.entity.ts | 5 + 7 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 examples/auth/src/sub-sub-task/dto/sub-sub-task.dto.ts create mode 100644 examples/auth/src/sub-sub-task/sub-sub-task.entity.ts create mode 100644 examples/auth/src/sub-sub-task/sub-sub-task.module.ts diff --git a/examples/auth/schema.gql b/examples/auth/schema.gql index c4f8715d4..3d2b3b28c 100644 --- a/examples/auth/schema.gql +++ b/examples/auth/schema.gql @@ -18,6 +18,64 @@ type LoginResponse { accessToken: String! } +type SubSubTask { + id: ID! + title: String! + public: Boolean! +} + +type DeleteManyResponse { + """The number of records deleted.""" + deletedCount: Int! +} + +type SubSubTaskDeleteResponse { + id: ID + title: String + public: Boolean +} + +type UpdateManyResponse { + """The number of records updated.""" + updatedCount: Int! +} + +type SubSubTaskEdge { + """The node containing the SubSubTask""" + node: SubSubTask! + + """Cursor for this node.""" + cursor: ConnectionCursor! +} + +"""Cursor for paging through collections""" +scalar ConnectionCursor + +type PageInfo { + """true if paging forward and there are more records.""" + hasNextPage: Boolean + + """true if paging backwards and there are more records.""" + hasPreviousPage: Boolean + + """The cursor of the first returned record.""" + startCursor: ConnectionCursor + + """The cursor of the last returned record.""" + endCursor: ConnectionCursor +} + +type SubSubTaskConnection { + """Paging information""" + pageInfo: PageInfo! + + """Array of edges.""" + edges: [SubSubTaskEdge!]! + + """Fetch total count of records""" + totalCount: Int! +} + type Tag { id: ID! name: String! @@ -176,9 +234,6 @@ input CursorPaging { last: Int } -"""Cursor for paging through collections""" -scalar ConnectionCursor - input TodoItemFilter { and: [TodoItemFilter!] or: [TodoItemFilter!] @@ -452,13 +507,44 @@ type SubTask { todoItemId: String! createdBy: String updatedBy: String + subSubTasksAggregate( + """Filter to find records to aggregate on""" + filter: SubSubTaskAggregateFilter + ): [SubTaskSubSubTasksAggregateResponse!]! owner: User! todoItem: TodoItem! + subSubTasks( + """Specify to filter the records returned.""" + filter: SubSubTaskFilter! = {} + + """Specify to sort results.""" + sorting: [SubSubTaskSort!]! = [] + ): [SubSubTask!]! } -type DeleteManyResponse { - """The number of records deleted.""" - deletedCount: Int! +input SubSubTaskAggregateFilter { + and: [SubSubTaskAggregateFilter!] + or: [SubSubTaskAggregateFilter!] + id: IDFilterComparison + public: BooleanFieldComparison +} + +input SubSubTaskFilter { + and: [SubSubTaskFilter!] + or: [SubSubTaskFilter!] + id: IDFilterComparison + public: BooleanFieldComparison +} + +input SubSubTaskSort { + field: SubSubTaskSortFields! + direction: SortDirection! + nulls: SortNulls +} + +enum SubSubTaskSortFields { + id + public } type SubTaskDeleteResponse { @@ -473,11 +559,6 @@ type SubTaskDeleteResponse { updatedBy: String } -type UpdateManyResponse { - """The number of records updated.""" - updatedCount: Int! -} - type SubTaskEdge { """The node containing the SubTask""" node: SubTask! @@ -486,20 +567,6 @@ type SubTaskEdge { cursor: ConnectionCursor! } -type PageInfo { - """true if paging forward and there are more records.""" - hasNextPage: Boolean - - """true if paging backwards and there are more records.""" - hasPreviousPage: Boolean - - """The cursor of the first returned record.""" - startCursor: ConnectionCursor - - """The cursor of the last returned record.""" - endCursor: ConnectionCursor -} - type SubTaskConnection { """Paging information""" pageInfo: PageInfo! @@ -582,6 +649,41 @@ type SubTaskAggregateResponse { max: SubTaskMaxAggregate } +type SubTaskSubSubTasksAggregateGroupBy { + id: ID + public: Boolean +} + +type SubTaskSubSubTasksCountAggregate { + id: Int + public: Int +} + +type SubTaskSubSubTasksSumAggregate { + id: Float +} + +type SubTaskSubSubTasksAvgAggregate { + id: Float +} + +type SubTaskSubSubTasksMinAggregate { + id: ID +} + +type SubTaskSubSubTasksMaxAggregate { + id: ID +} + +type SubTaskSubSubTasksAggregateResponse { + groupBy: SubTaskSubSubTasksAggregateGroupBy + count: SubTaskSubSubTasksCountAggregate + sum: SubTaskSubSubTasksSumAggregate + avg: SubTaskSubSubTasksAvgAggregate + min: SubTaskSubSubTasksMinAggregate + max: SubTaskSubSubTasksMaxAggregate +} + type TagDeleteResponse { id: ID name: String @@ -1055,6 +1157,20 @@ type Query { """Specify to sort results.""" sorting: [SubTaskSort!]! = [] ): SubTaskConnection! + subSubTask( + """The id of the record to find.""" + id: ID! + ): SubSubTask! + subSubTasks( + """Limit or page results.""" + paging: CursorPaging! = {first: 10} + + """Specify to filter the records returned.""" + filter: SubSubTaskFilter! = {} + + """Specify to sort results.""" + sorting: [SubSubTaskSort!]! = [] + ): SubSubTaskConnection! tagAggregate( """Filter to find records to aggregate on""" filter: TagAggregateFilter @@ -1095,6 +1211,12 @@ type Mutation { updateManySubTasks(input: UpdateManySubTasksInput!): UpdateManyResponse! deleteOneSubTask(input: DeleteOneSubTaskInput!): SubTaskDeleteResponse! deleteManySubTasks(input: DeleteManySubTasksInput!): DeleteManyResponse! + createOneSubSubTask(input: CreateOneSubSubTaskInput!): SubSubTask! + createManySubSubTasks(input: CreateManySubSubTasksInput!): [SubSubTask!]! + updateOneSubSubTask(input: UpdateOneSubSubTaskInput!): SubSubTask! + updateManySubSubTasks(input: UpdateManySubSubTasksInput!): UpdateManyResponse! + deleteOneSubSubTask(input: DeleteOneSubSubTaskInput!): SubSubTaskDeleteResponse! + deleteManySubSubTasks(input: DeleteManySubSubTasksInput!): DeleteManyResponse! addTodoItemsToTag(input: AddTodoItemsToTagInput!): Tag! setTodoItemsOnTag(input: SetTodoItemsOnTagInput!): Tag! removeTodoItemsFromTag(input: RemoveTodoItemsFromTagInput!): Tag! @@ -1314,6 +1436,68 @@ input SubTaskDeleteFilter { updatedBy: StringFieldComparison } +input CreateOneSubSubTaskInput { + """The record to create""" + subSubTask: CreateSubSubTask! +} + +input CreateSubSubTask { + id: ID! + title: String! + public: Boolean! +} + +input CreateManySubSubTasksInput { + """Array of records to create""" + subSubTasks: [CreateSubSubTask!]! +} + +input UpdateOneSubSubTaskInput { + """The id of the record to update""" + id: ID! + + """The update to apply.""" + update: UpdateSubSubTask! +} + +input UpdateSubSubTask { + id: ID + title: String + public: Boolean +} + +input UpdateManySubSubTasksInput { + """Filter used to find fields to update""" + filter: SubSubTaskUpdateFilter! + + """The update to apply to all records found using the filter""" + update: UpdateSubSubTask! +} + +input SubSubTaskUpdateFilter { + and: [SubSubTaskUpdateFilter!] + or: [SubSubTaskUpdateFilter!] + id: IDFilterComparison + public: BooleanFieldComparison +} + +input DeleteOneSubSubTaskInput { + """The id of the record to delete.""" + id: ID! +} + +input DeleteManySubSubTasksInput { + """Filter to find records to delete""" + filter: SubSubTaskDeleteFilter! +} + +input SubSubTaskDeleteFilter { + and: [SubSubTaskDeleteFilter!] + or: [SubSubTaskDeleteFilter!] + id: IDFilterComparison + public: BooleanFieldComparison +} + input AddTodoItemsToTagInput { """The id of the record.""" id: ID! diff --git a/examples/auth/src/app.module.ts b/examples/auth/src/app.module.ts index bbfb5ac2f..d386fa458 100644 --- a/examples/auth/src/app.module.ts +++ b/examples/auth/src/app.module.ts @@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm' import { formatGraphqlError, typeormOrmConfig } from '../../helpers' import { AuthModule } from './auth/auth.module' +import { SubSubTaskModule } from './sub-sub-task/sub-sub-task.module' import { SubTaskModule } from './sub-task/sub-task.module' import { TagModule } from './tag/tag.module' import { TodoItemModule } from './todo-item/todo-item.module' @@ -28,6 +29,7 @@ import { UserModule } from './user/user.module' UserModule, TodoItemModule, SubTaskModule, + SubSubTaskModule, TagModule ] }) diff --git a/examples/auth/src/sub-sub-task/dto/sub-sub-task.dto.ts b/examples/auth/src/sub-sub-task/dto/sub-sub-task.dto.ts new file mode 100644 index 000000000..ac3c5e653 --- /dev/null +++ b/examples/auth/src/sub-sub-task/dto/sub-sub-task.dto.ts @@ -0,0 +1,15 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { FilterableField, QueryOptions } from '@ptc-org/nestjs-query-graphql' + +@ObjectType('SubSubTask') +@QueryOptions({ enableTotalCount: true }) +export class SubSubTaskDTO { + @FilterableField(() => ID) + id!: number + + @Field() + title!: string + + @FilterableField() + public!: boolean +} diff --git a/examples/auth/src/sub-sub-task/sub-sub-task.entity.ts b/examples/auth/src/sub-sub-task/sub-sub-task.entity.ts new file mode 100644 index 000000000..5ac8b6f22 --- /dev/null +++ b/examples/auth/src/sub-sub-task/sub-sub-task.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, JoinColumn, ManyToOne, ObjectType, PrimaryGeneratedColumn } from 'typeorm' + +import { SubTaskEntity } from '../sub-task/sub-task.entity' + +@Entity({ name: 'sub_sub_task' }) +export class SubSubTaskEntity { + @PrimaryGeneratedColumn() + id!: number + + @Column() + title!: string + + @Column() + public!: boolean + + @Column({ nullable: false, name: 'sub_task_id' }) + subTaskId!: string + + @ManyToOne((): ObjectType => SubTaskEntity, (st) => st.subSubTasks, { + onDelete: 'CASCADE', + nullable: false + }) + @JoinColumn({ name: 'sub_task_id' }) + subTask!: SubTaskEntity +} diff --git a/examples/auth/src/sub-sub-task/sub-sub-task.module.ts b/examples/auth/src/sub-sub-task/sub-sub-task.module.ts new file mode 100644 index 000000000..c92a8d04b --- /dev/null +++ b/examples/auth/src/sub-sub-task/sub-sub-task.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common' +import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql' +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm' + +import { SubSubTaskDTO } from './dto/sub-sub-task.dto' +import { SubSubTaskEntity } from './sub-sub-task.entity' + +@Module({ + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([SubSubTaskEntity])], + resolvers: [ + { + DTOClass: SubSubTaskDTO, + EntityClass: SubSubTaskEntity + } + ] + }) + ] +}) +export class SubSubTaskModule {} diff --git a/examples/auth/src/sub-task/dto/sub-task.dto.ts b/examples/auth/src/sub-task/dto/sub-task.dto.ts index fe3ffcbf3..8f51a8eba 100644 --- a/examples/auth/src/sub-task/dto/sub-task.dto.ts +++ b/examples/auth/src/sub-task/dto/sub-task.dto.ts @@ -1,6 +1,14 @@ import { GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql' -import { Authorize, FilterableField, FilterableRelation, QueryOptions, Relation } from '@ptc-org/nestjs-query-graphql' - +import { + Authorize, + FilterableField, + FilterableRelation, + QueryOptions, + Relation, + UnPagedRelation +} from '@ptc-org/nestjs-query-graphql' + +import { SubSubTaskDTO } from '../../sub-sub-task/dto/sub-sub-task.dto' import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto' import { UserDTO } from '../../user/user.dto' import { SubTaskAuthorizer } from '../sub-task.authorizer' @@ -10,6 +18,15 @@ import { SubTaskAuthorizer } from '../sub-task.authorizer' @Authorize(SubTaskAuthorizer) @Relation('owner', () => UserDTO) @FilterableRelation('todoItem', () => TodoItemDTO, { update: { enabled: true } }) +@UnPagedRelation('subSubTasks', () => SubSubTaskDTO, { + auth: { + authorize: () => { + return { + public: { is: true } + } + } + } +}) export class SubTaskDTO { @FilterableField(() => ID) id!: number diff --git a/examples/auth/src/sub-task/sub-task.entity.ts b/examples/auth/src/sub-task/sub-task.entity.ts index 107583a2c..a144647da 100644 --- a/examples/auth/src/sub-task/sub-task.entity.ts +++ b/examples/auth/src/sub-task/sub-task.entity.ts @@ -5,10 +5,12 @@ import { JoinColumn, ManyToOne, ObjectType, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' +import { SubSubTaskEntity } from '../sub-sub-task/sub-sub-task.entity' import { TodoItemEntity } from '../todo-item/todo-item.entity' import { UserEntity } from '../user/user.entity' @@ -56,4 +58,7 @@ export class SubTaskEntity { @Column({ nullable: true }) updatedBy?: string + + @OneToMany(() => SubSubTaskEntity, (subSubTask) => subSubTask.subTask) + subSubTasks!: SubSubTaskEntity[] } From ee475aa0313207d725a4966521719ca743faebcc Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Fri, 31 Jan 2025 13:01:04 +0100 Subject: [PATCH 2/5] test: add test to verify whether authorization is resolved correctly for nested relations --- examples/auth/e2e/fixtures.ts | 21 +++++-- examples/auth/e2e/todo-item.resolver.spec.ts | 61 +++++++++++++++++++- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/examples/auth/e2e/fixtures.ts b/examples/auth/e2e/fixtures.ts index d11f741f4..ff19d75e2 100644 --- a/examples/auth/e2e/fixtures.ts +++ b/examples/auth/e2e/fixtures.ts @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm' import { executeTruncate } from '../../helpers' +import { SubSubTaskEntity } from '../src/sub-sub-task/sub-sub-task.entity' import { SubTaskEntity } from '../src/sub-task/sub-task.entity' import { TagEntity } from '../src/tag/tag.entity' import { TodoItemEntity } from '../src/todo-item/todo-item.entity' @@ -15,6 +16,7 @@ export const refresh = async (dataSource: DataSource): Promise => { const userRepo = dataSource.getRepository(UserEntity) const todoRepo = dataSource.getRepository(TodoItemEntity) const subTaskRepo = dataSource.getRepository(SubTaskEntity) + const subSubTaskRepo = dataSource.getRepository(SubSubTaskEntity) const tagsRepo = dataSource.getRepository(TagEntity) const users = await userRepo.save([ @@ -50,15 +52,24 @@ export const refresh = async (dataSource: DataSource): Promise => { Promise.resolve([] as TodoItemEntity[]) ) - await subTaskRepo.save( - todoItems.reduce( - (subTasks, todo) => [ - ...subTasks, + const subTasks: SubTaskEntity[] = await subTaskRepo.save( + todoItems.flatMap( + (todo) => [ { completed: true, title: `${todo.title} - Sub Task 1`, todoItem: todo, ownerId: todo.ownerId }, { completed: false, title: `${todo.title} - Sub Task 2`, todoItem: todo, ownerId: todo.ownerId }, { completed: false, title: `${todo.title} - Sub Task 3`, todoItem: todo, ownerId: todo.ownerId } ], - [] as Partial[] + [] + ) + ) + + await subSubTaskRepo.save( + subTasks.flatMap( + (parent) => [ + { subTask: parent, title: `${parent.title} - Sub Sub Task Public`, public: true }, + { subTask: parent, title: `${parent.title} - Sub Sub Task Private`, public: false } + ], + [] ) ) } diff --git a/examples/auth/e2e/todo-item.resolver.spec.ts b/examples/auth/e2e/todo-item.resolver.spec.ts index 49593207b..f9311e5cf 100644 --- a/examples/auth/e2e/todo-item.resolver.spec.ts +++ b/examples/auth/e2e/todo-item.resolver.spec.ts @@ -426,6 +426,61 @@ describe('TodoItemResolver (auth - e2e)', () => { ]) })) + it(`should allow fetching subSubTasks, but only the ones that are allowed by the nested authorizers`, () => + request(app.getHttpServer()) + .post('/graphql') + .auth(jwtToken, { type: 'bearer' }) + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems { + edges { + node { + subTasks { + edges { + node { + subSubTasks { + title + } + } + } + } + } + } + } + }` + }) + .expect(200) + .then(({ body }) => { + const { + edges: [{ node: task }] + }: { + edges: Array<{ + node: { + subTasks: { + edges: Array<{ + node: { + subSubTasks: Array<{ + title: string + }> + } + }> + } + } + }> + } = body.data.todoItems + const [subTask] = task.subTasks.edges.map((e) => e.node) + + expect(subTask).toEqual({ + subSubTasks: [ + { + title: 'Create Nest App - Sub Task 1 - Sub Sub Task Public' + } + ] + }) + })) + it(`should allow querying on tags`, () => request(app.getHttpServer()) .post('/graphql') @@ -620,7 +675,7 @@ describe('TodoItemResolver (auth - e2e)', () => { .send({ operationName: null, variables: {}, - query: `{ + query: `{ todoItemAggregate { ${todoItemAggregateFields} } @@ -647,7 +702,7 @@ describe('TodoItemResolver (auth - e2e)', () => { .send({ operationName: null, variables: {}, - query: `{ + query: `{ todoItemAggregate { ${todoItemAggregateFields} } @@ -674,7 +729,7 @@ describe('TodoItemResolver (auth - e2e)', () => { .send({ operationName: null, variables: {}, - query: `{ + query: `{ todoItemAggregate(filter: { completed: { is: false } }) { ${todoItemAggregateFields} } From cda81273375faa7cfb8a5dac63056b6390f10c26 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Fri, 31 Jan 2025 14:17:15 +0100 Subject: [PATCH 3/5] fix(read-relations.resolver): use relation's authorizer to resolve auth filter --- packages/query-graphql/src/index.ts | 2 +- .../relations/read-relations.resolver.ts | 52 +++++++++++++------ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 128620031..0d6c82a64 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -1,4 +1,4 @@ -export { AuthorizationContext, Authorizer, AuthorizerOptions, CustomAuthorizer, OperationGroup } from './auth' +export { AuthorizationContext, Authorizer, AuthorizerOptions, CustomAuthorizer, getAuthorizerToken, OperationGroup } from './auth' export { DTONamesOpts } from './common' export { Authorize, diff --git a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts index 7bd109046..bdaa80c40 100644 --- a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts @@ -1,10 +1,10 @@ -import { ExecutionContext } from '@nestjs/common' +import { ExecutionContext, Inject, Optional } from '@nestjs/common' import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql' -import { Class, Filter, mergeQuery, QueryService } from '@ptc-org/nestjs-query-core' +import { Class, mergeQuery, QueryService } from '@ptc-org/nestjs-query-core' -import { OperationGroup } from '../../auth' +import { Authorizer, getAuthorizerToken, OperationGroup } from '../../auth' import { getDTONames } from '../../common' -import { GraphQLResolveInfoResult, GraphQLResultInfo, RelationAuthorizerFilter, ResolverField } from '../../decorators' +import { GraphQLResolveInfoResult, GraphQLResultInfo, ResolverField } from '../../decorators' import { InjectDataLoaderConfig } from '../../decorators/inject-dataloader-config.decorator' import { AuthorizerInterceptor } from '../../interceptors' import { CountRelationsLoader, DataLoaderFactory, FindRelationsLoader, QueryRelationsLoader } from '../../loader' @@ -30,10 +30,16 @@ const ReadOneRelationMixin = const { baseNameLower, baseName } = getDTONames(relationDTO, { dtoName: relation.dtoName }) const relationName = relation.relationName ?? baseNameLower const loaderName = `load${baseName}For${DTOClass.name}` + const authorizerKey = Symbol(`authorizerFor${DTOClass.name}`) + const relationAuthorizerKey = Symbol(`authorizerFor${relation.dtoName}`) const findLoader = new FindRelationsLoader(relationDTO, relationName) @Resolver(() => DTOClass, { isAbstract: true }) class ReadOneMixin extends Base { + @Optional() @Inject(getAuthorizerToken(DTOClass)) [authorizerKey]?: Authorizer; + + @Optional() @Inject(getAuthorizerToken(relationDTO)) [relationAuthorizerKey]?: Authorizer + @ResolverField( baseNameLower, () => relationDTO, @@ -49,16 +55,21 @@ const ReadOneRelationMixin = async [`find${baseName}`]( @Parent() dto: DTO, @Context() context: ExecutionContext, - @RelationAuthorizerFilter(baseNameLower, { - operationGroup: OperationGroup.READ, - many: false - }) - authFilter?: Filter, @GraphQLResultInfo(DTOClass) resolveInfo?: GraphQLResolveInfoResult, @InjectDataLoaderConfig() dataLoaderConfig?: DataLoaderOptions ): Promise { + const authContext = { + operationName: baseNameLower, + operationGroup: OperationGroup.READ, + readonly: true, + many: false + } + const authFilter = relation.auth + ? await relation.auth?.authorize(context, authContext) + : ((await this[authorizerKey]?.authorizeRelation(baseNameLower, context, authContext)) ?? + (await this[relationAuthorizerKey]?.authorize(context, authContext))) return DataLoaderFactory.getOrCreateLoader( context, loaderName, @@ -93,6 +104,8 @@ const ReadManyRelationMixin = const relationName = relation.relationName ?? baseNameLower const relationLoaderName = `load${baseName}For${DTOClass.name}` const countRelationLoaderName = `count${baseName}For${DTOClass.name}` + const authorizerKey = Symbol(`authorizerFor${DTOClass.name}`) + const relationAuthorizerKey = Symbol(`authorizerFor${relation.dtoName}`) const queryLoader = new QueryRelationsLoader(relationDTO, relationName) const countLoader = new CountRelationsLoader(relationDTO, relationName) const connectionName = `${dtoName}${baseName}Connection` @@ -109,6 +122,10 @@ const ReadManyRelationMixin = @Resolver(() => DTOClass, { isAbstract: true }) class ReadManyMixin extends Base { + @Optional() @Inject(getAuthorizerToken(DTOClass)) [authorizerKey]?: Authorizer; + + @Optional() @Inject(getAuthorizerToken(relationDTO)) [relationAuthorizerKey]?: Authorizer + @ResolverField( baseNameLower, () => CT.resolveType, @@ -125,16 +142,21 @@ const ReadManyRelationMixin = @Parent() dto: DTO, @Args() q: RelationQA, @Context() context: ExecutionContext, - @RelationAuthorizerFilter(baseNameLower, { - operationGroup: OperationGroup.READ, - many: true - }) - relationFilter?: Filter, @GraphQLResultInfo(DTOClass) resolveInfo?: GraphQLResolveInfoResult, @InjectDataLoaderConfig() dataLoaderConfig?: DataLoaderOptions ): Promise> { + const authContext = { + operationName: baseNameLower, + operationGroup: OperationGroup.READ, + readonly: true, + many: true + } + const authFilter = relation.auth + ? await relation.auth?.authorize(context, authContext) + : ((await this[authorizerKey]?.authorizeRelation(baseNameLower, context, authContext)) ?? + (await this[relationAuthorizerKey]?.authorize(context, authContext))) const relationQuery = await transformAndValidate(RelationQA, q) const relationLoader = DataLoaderFactory.getOrCreateLoader( context, @@ -152,7 +174,7 @@ const ReadManyRelationMixin = return CT.createFromPromise( (query) => relationLoader.load({ dto, query }), - mergeQuery(relationQuery, { filter: relationFilter, relations: resolveInfo?.relations }), + mergeQuery(relationQuery, { filter: authFilter, relations: resolveInfo?.relations }), (filter) => relationCountLoader.load({ dto, filter }) ) } From 989c8e7ada42af5e4c0022171665b875a178bb59 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Fri, 31 Jan 2025 14:29:33 +0100 Subject: [PATCH 4/5] fix(authorizer): add generic type to `authorizerRelation` method --- packages/query-graphql/src/auth/authorizer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/query-graphql/src/auth/authorizer.ts b/packages/query-graphql/src/auth/authorizer.ts index 06f57dcc7..0aa55fc93 100644 --- a/packages/query-graphql/src/auth/authorizer.ts +++ b/packages/query-graphql/src/auth/authorizer.ts @@ -38,10 +38,10 @@ export interface Authorizer extends CustomAuthorizer { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any authorize(context: any, authorizerContext: AuthorizationContext): Promise> - authorizeRelation( + authorizeRelation( relationName: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any context: any, authorizerContext: AuthorizationContext - ): Promise | undefined> + ): Promise | undefined> } From f8751c53aecd33126288f2c69645179137e49c53 Mon Sep 17 00:00:00 2001 From: Jacek Tomaszewski Date: Fri, 31 Jan 2025 14:29:47 +0100 Subject: [PATCH 5/5] fix(aggregate-relations.resolver): use relation's authorizer to resolve auth filter --- .../relations/aggregate-relations.resolver.ts | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts index aa260b04d..35383d07a 100644 --- a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts @@ -1,10 +1,10 @@ -import { ExecutionContext } from '@nestjs/common' +import { ExecutionContext, Inject, Optional } from '@nestjs/common' import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql' -import { AggregateQuery, AggregateResponse, Class, Filter, mergeFilter, QueryService } from '@ptc-org/nestjs-query-core' +import { AggregateQuery, AggregateResponse, Class, mergeFilter, QueryService } from '@ptc-org/nestjs-query-core' -import { OperationGroup } from '../../auth' +import { Authorizer, getAuthorizerToken, OperationGroup } from '../../auth' import { getDTONames } from '../../common' -import { AggregateQueryParam, RelationAuthorizerFilter, ResolverField } from '../../decorators' +import { AggregateQueryParam, ResolverField } from '../../decorators' import { InjectDataLoaderConfig } from '../../decorators/inject-dataloader-config.decorator' import { AuthorizerInterceptor } from '../../interceptors' import { AggregateRelationsLoader, DataLoaderFactory } from '../../loader' @@ -39,6 +39,8 @@ const AggregateRelationMixin = const relationName = relation.relationName ?? baseNameLower const aggregateRelationLoaderName = `aggregate${baseName}For${dtoName}` const aggregateLoader = new AggregateRelationsLoader(relationDTO, relationName) + const authorizerKey = Symbol(`authorizerFor${DTOClass.name}`) + const relationAuthorizerKey = Symbol(`authorizerFor${relation.dtoName}`) @ArgsType() class RelationQA extends AggregateArgsType(relationDTO) {} @@ -47,6 +49,10 @@ const AggregateRelationMixin = @Resolver(() => DTOClass, { isAbstract: true }) class AggregateMixin extends Base { + @Optional() @Inject(getAuthorizerToken(DTOClass)) [authorizerKey]?: Authorizer; + + @Optional() @Inject(getAuthorizerToken(relationDTO)) [relationAuthorizerKey]?: Authorizer + @ResolverField( `${baseNameLower}Aggregate`, () => [AR], @@ -63,11 +69,6 @@ const AggregateRelationMixin = @Args() q: RelationQA, @AggregateQueryParam() aggregateQuery: AggregateQuery, @Context() context: ExecutionContext, - @RelationAuthorizerFilter(baseNameLower, { - operationGroup: OperationGroup.AGGREGATE, - many: true - }) - relationFilter?: Filter, @InjectDataLoaderConfig() dataLoaderConfig?: DataLoaderOptions ): Promise> { @@ -78,10 +79,20 @@ const AggregateRelationMixin = () => aggregateLoader.createLoader(this.service), dataLoaderConfig ) + const authContext = { + operationName: baseNameLower, + operationGroup: OperationGroup.AGGREGATE, + readonly: true, + many: true + } + const authFilter = relation.auth + ? await relation.auth?.authorize(context, authContext) + : ((await this[authorizerKey]?.authorizeRelation(baseNameLower, context, authContext)) ?? + (await this[relationAuthorizerKey]?.authorize(context, authContext))) return loader.load({ dto, - filter: mergeFilter(qa.filter ?? {}, relationFilter ?? {}), + filter: mergeFilter(qa.filter ?? {}, authFilter ?? {}), aggregate: aggregateQuery }) }