From 9d76787d031e6a731f28877c0df46cf4472b2faf Mon Sep 17 00:00:00 2001 From: doug-martin Date: Wed, 26 Aug 2020 20:24:27 -0500 Subject: [PATCH] feat(graphql,auth): Add authorization to resolvers and relations --- .../auth/default-crud-auth.service.spec.ts | 56 +++++++++ .../query-graphql/__tests__/module.spec.ts | 4 +- .../__tests__/resolvers/__fixtures__/index.ts | 17 ++- .../read/read-basic.resolver.graphql | 5 +- ...nnection-with-total-count.resolver.graphql | 5 +- .../read-custom-connection.resolver.graphql | 5 +- .../read/read-custom-query.resolver.graphql | 5 +- .../read/read-many-disabled.resolver.graphql | 5 +- .../read/read-offset-query.resolver.graphql | 5 +- .../test-resolver-auth.service.ts | 14 +++ .../resolvers/delete.resolver.spec.ts | 92 +++++++++++--- .../federation/federation.resolver.spec.ts | 12 +- .../__tests__/resolvers/read.resolver.spec.ts | 117 ++++++++++++++++-- .../relations/read-relation.resolver.spec.ts | 10 +- .../remove-relation.resolver.spec.ts | 12 +- .../update-relation.resolver.spec.ts | 10 +- .../resolvers/update.resolver.spec.ts | 108 +++++++++++++--- .../src/auth/crud-auth.interface.ts | 13 ++ .../src/auth/default-crud-auth.service.ts | 34 +++++ packages/query-graphql/src/auth/index.ts | 3 + packages/query-graphql/src/auth/tokens.ts | 3 + packages/query-graphql/src/common/index.ts | 1 + .../src/common/resolver.utils.ts | 20 +++ .../src/decorators/auth-service.decorator.ts | 14 +++ .../src/decorators/auth.decorator.ts | 9 ++ .../query-graphql/src/decorators/constants.ts | 7 ++ .../src/decorators/hook-args.decorator.ts | 27 ++++ .../src/decorators/hook.decorator.ts | 19 ++- .../query-graphql/src/decorators/index.ts | 4 + .../inject-auth-service.decorator.ts | 5 + .../src/decorators/mutation-args.decorator.ts | 24 ++-- .../src/decorators/reference.decorator.ts | 14 ++- .../src/decorators/relation.decorator.ts | 13 +- .../decorators/resolver-method.decorator.ts | 19 +-- packages/query-graphql/src/index.ts | 8 ++ .../src/loader/find-relations.loader.ts | 46 ++++++- packages/query-graphql/src/module.ts | 15 ++- .../src/providers/auth-service.provider.ts | 15 +++ packages/query-graphql/src/providers/index.ts | 2 + .../resolver.provider.ts} | 17 ++- .../src/resolvers/aggregate.resolver.ts | 10 +- .../src/resolvers/crud.resolver.ts | 39 ++++-- .../src/resolvers/delete.resolver.ts | 23 ++-- .../federation/federation.resolver.ts | 8 +- .../query-graphql/src/resolvers/helpers.ts | 24 +++- .../src/resolvers/read.resolver.ts | 40 ++++-- .../relations/aggregate-relations.resolver.ts | 15 ++- .../src/resolvers/relations/helpers.ts | 17 +++ .../relations/read-relations.resolver.ts | 15 ++- .../relations/relations.interface.ts | 3 + .../resolvers/relations/relations.resolver.ts | 7 +- .../relations/remove-relations.resolver.ts | 20 ++- .../relations/update-relations.resolver.ts | 20 ++- .../src/resolvers/resolver.interface.ts | 7 +- .../src/resolvers/update.resolver.ts | 23 ++-- .../src/types/find-one-args.type.ts | 27 ++++ packages/query-graphql/src/types/index.ts | 1 + 57 files changed, 915 insertions(+), 198 deletions(-) create mode 100644 packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts create mode 100644 packages/query-graphql/__tests__/resolvers/__fixtures__/test-resolver-auth.service.ts create mode 100644 packages/query-graphql/src/auth/crud-auth.interface.ts create mode 100644 packages/query-graphql/src/auth/default-crud-auth.service.ts create mode 100644 packages/query-graphql/src/auth/index.ts create mode 100644 packages/query-graphql/src/auth/tokens.ts create mode 100644 packages/query-graphql/src/common/resolver.utils.ts create mode 100644 packages/query-graphql/src/decorators/auth-service.decorator.ts create mode 100644 packages/query-graphql/src/decorators/auth.decorator.ts create mode 100644 packages/query-graphql/src/decorators/hook-args.decorator.ts create mode 100644 packages/query-graphql/src/decorators/inject-auth-service.decorator.ts create mode 100644 packages/query-graphql/src/providers/auth-service.provider.ts create mode 100644 packages/query-graphql/src/providers/index.ts rename packages/query-graphql/src/{providers.ts => providers/resolver.provider.ts} (89%) create mode 100644 packages/query-graphql/src/types/find-one-args.type.ts 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 new file mode 100644 index 000000000..d6f2f3ca2 --- /dev/null +++ b/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts @@ -0,0 +1,56 @@ +import { CRUDAuth, Relation } from '../../src'; +import { createDefaultCRUDAuthService } from '../../src/auth'; + +describe('createDefaultCRUDAuthService', () => { + type UserContext = { user: { id: number } }; + class TestRelation { + relationOwnerId!: number; + } + + @CRUDAuth({ filter: (ctx: UserContext) => ({ decoratorOwnerId: { eq: ctx.user.id } }) }) + class TestDecoratorRelation { + decoratorOwnerId!: number; + } + + @CRUDAuth({ filter: (ctx: UserContext) => ({ ownerId: { eq: ctx.user.id } }) }) + @Relation('relations', () => TestRelation, { + auth: { filter: (ctx: UserContext) => ({ relationOwnerId: { eq: ctx.user.id } }) }, + }) + @Relation('decoratorRelations', () => [TestDecoratorRelation]) + class TestDTO { + ownerId!: number; + } + + it('should create an auth filter', async () => { + const Service = createDefaultCRUDAuthService(TestDTO); + const filter = await new Service().authFilter({ user: { id: 2 } }); + expect(filter).toEqual({ ownerId: { eq: 2 } }); + }); + + it('should return an empty filter if auth not found', async () => { + class TestNoAuthDTO { + ownerId!: number; + } + const Service = createDefaultCRUDAuthService(TestNoAuthDTO); + const filter = await new Service().authFilter({ user: { id: 2 } }); + expect(filter).toEqual({}); + }); + + it('should create an auth filter for relations using the default auth decorator', async () => { + const Service = createDefaultCRUDAuthService(TestDTO); + const filter = await new Service().relationAuthFilter('decoratorRelations', { user: { id: 2 } }); + expect(filter).toEqual({ decoratorOwnerId: { eq: 2 } }); + }); + + it('should create an auth filter for relations using the relation options', async () => { + const Service = createDefaultCRUDAuthService(TestDTO); + const filter = await new Service().relationAuthFilter('relations', { user: { id: 2 } }); + expect(filter).toEqual({ relationOwnerId: { eq: 2 } }); + }); + + it('should return an empty object for an unknown relation', async () => { + const Service = createDefaultCRUDAuthService(TestDTO); + const filter = await new Service().relationAuthFilter('unknownRelations', { user: { id: 2 } }); + expect(filter).toEqual({}); + }); +}); diff --git a/packages/query-graphql/__tests__/module.spec.ts b/packages/query-graphql/__tests__/module.spec.ts index 455907824..9301530da 100644 --- a/packages/query-graphql/__tests__/module.spec.ts +++ b/packages/query-graphql/__tests__/module.spec.ts @@ -21,7 +21,7 @@ describe('NestjsQueryGraphQLModule', () => { }); expect(graphqlModule.imports).toHaveLength(1); expect(graphqlModule.module).toBe(NestjsQueryGraphQLModule); - expect(graphqlModule.providers).toHaveLength(2); - expect(graphqlModule.exports).toHaveLength(3); + expect(graphqlModule.providers).toHaveLength(3); + expect(graphqlModule.exports).toHaveLength(4); }); }); diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts b/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts index 8b8d31130..60d93fda6 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts @@ -3,9 +3,11 @@ import { resolve } from 'path'; import { instance, mock } from 'ts-mockito'; import { Test } from '@nestjs/testing'; import { PubSub } from 'graphql-subscriptions'; -import { pubSubToken } from '../../../src/subscription'; +import { CRUDAuthService, pubSubToken } from '../../../src'; import { readGraphql } from '../../__fixtures__'; import { TestService } from './test-resolver.service'; +import { TestResolverDTO } from './test-resolver.dto'; +import { TestResolverAuthService } from './test-resolver-auth.service'; export { TestResolverDTO } from './test-resolver.dto'; export { TestResolverInputDTO } from './test-resolver-input.dto'; @@ -15,18 +17,27 @@ interface ResolverMock { resolver: T; mockService: TestService; mockPubSub: PubSub; + mockAuthService: CRUDAuthService; } export const createResolverFromNest = async (ResolverClass: Class): Promise> => { const mockService = mock(TestService); const mockPubSub = mock(PubSub); + const mockAuthService = mock(TestResolverAuthService); const moduleRef = await Test.createTestingModule({ - providers: [ResolverClass, TestService, { provide: pubSubToken(), useValue: instance(mockPubSub) }], + providers: [ + ResolverClass, + TestService, + TestResolverAuthService, + { provide: pubSubToken(), useValue: instance(mockPubSub) }, + ], }) .overrideProvider(TestService) .useValue(instance(mockService)) + .overrideProvider(TestResolverAuthService) + .useValue(instance(mockAuthService)) .compile(); - return { resolver: moduleRef.get(ResolverClass), mockService, mockPubSub }; + return { resolver: moduleRef.get(ResolverClass), mockService, mockPubSub, mockAuthService }; }; export const deleteBasicResolverSDL = readGraphql(resolve(__dirname, 'delete', 'delete-basic.resolver.graphql')); diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-basic.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-basic.resolver.graphql index d7fa0de3c..a873ac198 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-basic.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-basic.resolver.graphql @@ -37,7 +37,10 @@ type TestResolverDTOConnection { } type Query { - testResolverDTO(id: ID!): TestResolverDTO + testResolverDTO( + """The id of the record to find.""" + id: ID! + ): TestResolverDTO testResolverDTOS( """Limit or page results.""" paging: CursorPaging = {first: 10} diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql index c93fe52cd..417b07ddb 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql @@ -53,7 +53,10 @@ type TotalCountDTOConnection { } type Query { - totalCountDTO(id: ID!): TotalCountDTO + totalCountDTO( + """The id of the record to find.""" + id: ID! + ): TotalCountDTO totalCountDTOS( """Limit or page results.""" paging: CursorPaging = {first: 10} diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-connection.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-connection.resolver.graphql index 5461748be..81e409901 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-connection.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-connection.resolver.graphql @@ -38,7 +38,10 @@ type CustomConnection { } type Query { - testResolverDTO(id: ID!): TestResolverDTO + testResolverDTO( + """The id of the record to find.""" + id: ID! + ): TestResolverDTO testResolverDTOS( """Limit or page results.""" paging: CursorPaging = {first: 10} diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-query.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-query.resolver.graphql index a466b6c0c..be57cd46a 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-query.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-query.resolver.graphql @@ -37,7 +37,10 @@ type TestResolverDTOConnection { } type Query { - testResolverDTO(id: ID!): TestResolverDTO + testResolverDTO( + """The id of the record to find.""" + id: ID! + ): TestResolverDTO testResolverDTOS( other: String! diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-many-disabled.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-many-disabled.resolver.graphql index a7a8dec9f..f59114539 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-many-disabled.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-many-disabled.resolver.graphql @@ -29,6 +29,9 @@ type PageInfo { } type Query { - testResolverDTO(id: ID!): TestResolverDTO + testResolverDTO( + """The id of the record to find.""" + id: ID! + ): TestResolverDTO test: TestResolverDTO! } diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-offset-query.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-offset-query.resolver.graphql index 5adb25604..23d66c9ef 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-offset-query.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-offset-query.resolver.graphql @@ -29,7 +29,10 @@ type PageInfo { } type Query { - testResolverDTO(id: ID!): TestResolverDTO + testResolverDTO( + """The id of the record to find.""" + id: ID! + ): TestResolverDTO testResolverDTOS( """Limit or page results.""" paging: OffsetPaging = {limit: 10} diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/test-resolver-auth.service.ts b/packages/query-graphql/__tests__/resolvers/__fixtures__/test-resolver-auth.service.ts new file mode 100644 index 000000000..82bf1f19b --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/test-resolver-auth.service.ts @@ -0,0 +1,14 @@ +import { Filter } from '@nestjs-query/core'; +import { CRUDAuthService, AuthorizationService } from '../../../src'; +import { TestResolverDTO } from './test-resolver.dto'; + +@AuthorizationService(TestResolverDTO) +export class TestResolverAuthService implements CRUDAuthService { + authFilter(context: any): Promise> { + return Promise.reject(new Error('authFilter Not Implemented')); + } + + relationAuthFilter(relationName: string, context: any): Promise> { + return Promise.reject(new Error('relationAuthFilter Not Implemented')); + } +} diff --git a/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts index 76345cfcf..f58764c70 100644 --- a/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts @@ -1,4 +1,4 @@ -import { DeleteManyResponse } from '@nestjs-query/core'; +import { DeleteManyResponse, Filter } from '@nestjs-query/core'; import { Field, InputType, Query, Resolver } from '@nestjs/graphql'; import { deepEqual, objectContaining, when, verify, anything, mock, instance } from 'ts-mockito'; import { PubSub } from 'graphql-subscriptions'; @@ -21,6 +21,7 @@ import { TestService, } from './__fixtures__'; import { TestResolverDTO } from './__fixtures__/test-resolver.dto'; +import { TestResolverAuthService } from './__fixtures__/test-resolver-auth.service'; describe('DeleteResolver', () => { const expectResolverSDL = (sdl: string, opts?: DeleteResolverOpts) => { @@ -37,7 +38,11 @@ describe('DeleteResolver', () => { const createTestResolver = (opts?: DeleteResolverOpts) => { @Resolver(() => TestResolverDTO) class TestResolver extends DeleteResolver(TestResolverDTO, opts) { - constructor(service: TestService, @InjectPubSub() readonly pubSub: PubSub) { + constructor( + service: TestService, + @InjectPubSub() readonly pubSub: PubSub, + readonly authService: TestResolverAuthService, + ) { super(service); } } @@ -77,7 +82,7 @@ describe('DeleteResolver', () => { }); it('should call the service deleteOne with the provided input', async () => { - const { resolver, mockService } = await createTestResolver(); + const { resolver, mockService, mockAuthService } = await createTestResolver(); const input: DeleteOneInputType = { id: 'id-1', }; @@ -85,8 +90,27 @@ describe('DeleteResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.deleteOne(input.id)).thenResolve(output); - const result = await resolver.deleteOne({ input }); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output); + const result = await resolver.deleteOne({ input }, context); + return expect(result).toEqual(output); + }); + + it('should call the service deleteOne with the provided input and authService filter', async () => { + const { resolver, mockService, mockAuthService } = await createTestResolver(); + const input: DeleteOneInputType = { + id: 'id-1', + }; + const output: TestResolverDTO = { + id: 'id-1', + stringField: 'foo', + }; + const context = {}; + const authFilter: Filter = { stringField: { eq: 'foo' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when(mockService.deleteOne(input.id, deepEqual({ filter: authFilter }))).thenResolve(output); + const result = await resolver.deleteOne({ input }, context); return expect(result).toEqual(output); }); }); @@ -108,13 +132,29 @@ describe('DeleteResolver', () => { }); it('should call the service deleteMany with the provided input', async () => { - const { resolver, mockService } = await createTestResolver(); + const { resolver, mockService, mockAuthService } = await createTestResolver(); const input: DeleteManyInputType = { filter: { id: { eq: 'id-1' } }, }; const output: DeleteManyResponse = { deletedCount: 1 }; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); when(mockService.deleteMany(objectContaining(input.filter))).thenResolve(output); - const result = await resolver.deleteMany({ input }); + const result = await resolver.deleteMany({ input }, context); + return expect(result).toEqual(output); + }); + + it('should call the service deleteMany with the provided input and filter from authService', async () => { + const { resolver, mockService, mockAuthService } = await createTestResolver(); + const input: DeleteManyInputType = { + filter: { id: { eq: 'id-1' } }, + }; + const output: DeleteManyResponse = { deletedCount: 1 }; + const context = {}; + const authFilter: Filter = { stringField: { eq: 'foo' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when(mockService.deleteMany(objectContaining({ ...input.filter, ...authFilter }))).thenResolve(output); + const result = await resolver.deleteMany({ input }, context); return expect(result).toEqual(output); }); }); @@ -148,7 +188,9 @@ describe('DeleteResolver', () => { describe('delete one events', () => { it('should publish events for create one when enableSubscriptions is set to true for all', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: true }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + enableSubscriptions: true, + }); const input: DeleteOneInputType = { id: 'id-1', }; @@ -158,15 +200,19 @@ describe('DeleteResolver', () => { }; const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO); const event = { [eventName]: output }; - when(mockService.deleteOne(input.id)).thenResolve(output); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output); when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve(); - const result = await resolver.deleteOne({ input }); + const result = await resolver.deleteOne({ input }, context); verify(mockPubSub.publish(eventName, deepEqual(event))).once(); return expect(result).toEqual(output); }); it('should publish events for create one when enableSubscriptions is set to true for createOne', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ one: { enableSubscriptions: true } }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + one: { enableSubscriptions: true }, + }); const input: DeleteOneInputType = { id: 'id-1', }; @@ -176,15 +222,19 @@ describe('DeleteResolver', () => { }; const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO); const event = { [eventName]: output }; - when(mockService.deleteOne(input.id)).thenResolve(output); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output); when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve(); - const result = await resolver.deleteOne({ input }); + const result = await resolver.deleteOne({ input }, context); verify(mockPubSub.publish(eventName, deepEqual(event))).once(); return expect(result).toEqual(output); }); it('should not publish an event if enableSubscriptions is false', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: false }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + enableSubscriptions: false, + }); const input: DeleteOneInputType = { id: 'id-1', }; @@ -192,14 +242,16 @@ describe('DeleteResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.deleteOne(input.id)).thenResolve(output); - const result = await resolver.deleteOne({ input }); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output); + const result = await resolver.deleteOne({ input }, context); verify(mockPubSub.publish(anything(), anything())).never(); return expect(result).toEqual(output); }); it('should not publish an event if enableSubscriptions is true and one.enableSubscriptions is false', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ enableSubscriptions: true, one: { enableSubscriptions: false }, }); @@ -210,8 +262,10 @@ describe('DeleteResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.deleteOne(input.id)).thenResolve(output); - const result = await resolver.deleteOne({ input }); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output); + const result = await resolver.deleteOne({ input }, context); verify(mockPubSub.publish(anything(), anything())).never(); return expect(result).toEqual(output); }); diff --git a/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts index 1f08f4a1a..2f4e91479 100644 --- a/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts @@ -67,9 +67,9 @@ describe('FederationResolver', () => { id: 'id-2', testResolverId: dto.id, }; - when(mockService.findRelation(TestRelationDTO, 'relation', deepEqual([dto]))).thenResolve( - new Map([[dto, output]]), - ); + when( + mockService.findRelation(TestRelationDTO, 'relation', deepEqual([dto]), deepEqual({ filter: undefined })), + ).thenResolve(new Map([[dto, output]])); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.findRelation(dto, {}); @@ -86,9 +86,9 @@ describe('FederationResolver', () => { id: 'id-2', testResolverId: dto.id, }; - when(mockService.findRelation(TestRelationDTO, 'other', deepEqual([dto]))).thenResolve( - new Map([[dto, output]]), - ); + when( + mockService.findRelation(TestRelationDTO, 'other', deepEqual([dto]), deepEqual({ filter: undefined })), + ).thenResolve(new Map([[dto, output]])); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.findCustom(dto, {}); diff --git a/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts index 256793c25..bab5d2c17 100644 --- a/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line max-classes-per-file import { ArgsType, Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; -import { objectContaining, when } from 'ts-mockito'; +import { objectContaining, when, deepEqual } from 'ts-mockito'; +import { Filter } from '@nestjs-query/core'; import { ConnectionType, CursorQueryArgsType, @@ -26,6 +27,7 @@ import { TestResolverDTO, TestService, } from './__fixtures__'; +import { TestResolverAuthService } from './__fixtures__/test-resolver-auth.service'; describe('ReadResolver', () => { const expectResolverSDL = (sdl: string, opts?: ReadResolverOpts) => { @@ -98,13 +100,13 @@ describe('ReadResolver', () => { describe('#queryMany cursor connection', () => { @Resolver(() => TestResolverDTO) class TestResolver extends ReadResolver(TestResolverDTO) { - constructor(service: TestService) { + constructor(service: TestService, readonly authService: TestResolverAuthService) { super(service); } } it('should call the service query with the provided input', async () => { - const { resolver, mockService } = await createResolverFromNest(TestResolver); + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); const input: CursorQueryArgsType = { filter: { stringField: { eq: 'foo' }, @@ -117,8 +119,53 @@ describe('ReadResolver', () => { stringField: 'foo', }, ]; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); when(mockService.query(objectContaining({ ...input, paging: { limit: 2, offset: 0 } }))).thenResolve(output); - const result = await resolver.queryMany(input); + const result = await resolver.queryMany(input, context); + return expect(result).toEqual({ + edges: [ + { + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + node: { + id: 'id-1', + stringField: 'foo', + }, + }, + ], + pageInfo: { + endCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + totalCountFn: expect.any(Function), + }); + }); + + it('should invoke the auth service for a filter for the DTO', async () => { + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); + const input: CursorQueryArgsType = { + filter: { + stringField: { eq: 'foo' }, + }, + paging: { first: 1 }, + }; + const output: TestResolverDTO[] = [ + { + id: 'id-1', + stringField: 'foo', + }, + ]; + const authFilter = { id: { eq: '1' } }; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when( + mockService.query( + objectContaining({ filter: { ...input.filter, ...authFilter }, paging: { limit: 2, offset: 0 } }), + ), + ).thenResolve(output); + const result = await resolver.queryMany(input, context); return expect(result).toEqual({ edges: [ { @@ -140,7 +187,7 @@ describe('ReadResolver', () => { }); it('should call the service count with the provided input', async () => { - const { resolver, mockService } = await createResolverFromNest(TestResolver); + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); const input: CursorQueryArgsType = { filter: { stringField: { eq: 'foo' }, @@ -153,11 +200,40 @@ describe('ReadResolver', () => { stringField: 'foo', }, ]; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); when(mockService.query(objectContaining({ ...input, paging: { limit: 2, offset: 0 } }))).thenResolve(output); - const result = await resolver.queryMany(input); + const result = await resolver.queryMany(input, context); when(mockService.count(objectContaining(input.filter!))).thenResolve(10); return expect(result.totalCount).resolves.toBe(10); }); + + it('should call the service count with the provided input and auth filter', async () => { + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); + const input: CursorQueryArgsType = { + filter: { + stringField: { eq: 'foo' }, + }, + paging: { first: 1 }, + }; + const output: TestResolverDTO[] = [ + { + id: 'id-1', + stringField: 'foo', + }, + ]; + const context = {}; + const authFilter = { id: { eq: '1' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when( + mockService.query( + objectContaining({ filter: { ...input.filter, ...authFilter }, paging: { limit: 2, offset: 0 } }), + ), + ).thenResolve(output); + const result = await resolver.queryMany(input, context); + when(mockService.count(objectContaining({ ...input.filter!, ...authFilter }))).thenResolve(10); + return expect(result.totalCount).resolves.toBe(10); + }); }); describe('queryMany array connection', () => { @@ -219,22 +295,41 @@ describe('ReadResolver', () => { describe('#findById', () => { @Resolver(() => TestResolverDTO) class TestResolver extends ReadResolver(TestResolverDTO) { - constructor(service: TestService) { + constructor(service: TestService, readonly authService: TestResolverAuthService) { super(service); } } + it('should not expose findById method if disabled', () => { return expectResolverSDL(readOneDisabledResolverSDL, { one: { disabled: true } }); }); + it('should call the service findById with the provided input', async () => { - const { resolver, mockService } = await createResolverFromNest(TestResolver); - const input = 'id-1'; + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); + const input = { id: 'id-1' }; + const output: TestResolverDTO = { + id: 'id-1', + stringField: 'foo', + }; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.findById(input.id, deepEqual({ filter: {} }))).thenResolve(output); + const result = await resolver.findById(input, context); + return expect(result).toEqual(output); + }); + + it('should call the service findById with the provided input filter from the authService', async () => { + const { resolver, mockService, mockAuthService } = await createResolverFromNest(TestResolver); + const input = { id: 'id-1' }; const output: TestResolverDTO = { id: 'id-1', stringField: 'foo', }; - when(mockService.findById(input)).thenResolve(output); - const result = await resolver.findById(input); + const context = {}; + const authFilter: Filter = { stringField: { eq: 'foo' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when(mockService.findById(input.id, deepEqual({ filter: authFilter }))).thenResolve(output); + const result = await resolver.findById(input, context); return expect(result).toEqual(output); }); }); diff --git a/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts index bbc2ebc11..72dcf7d9e 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts @@ -74,9 +74,9 @@ describe('ReadRelationsResolver', () => { id: 'id-2', testResolverId: dto.id, }; - when(mockService.findRelation(TestRelationDTO, 'relation', deepEqual([dto]))).thenResolve( - new Map([[dto, output]]), - ); + when( + mockService.findRelation(TestRelationDTO, 'relation', deepEqual([dto]), deepEqual({ filter: undefined })), + ).thenResolve(new Map([[dto, output]])); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.findRelation(dto, {}); @@ -93,7 +93,9 @@ describe('ReadRelationsResolver', () => { id: 'id-2', testResolverId: dto.id, }; - when(mockService.findRelation(TestRelationDTO, 'other', deepEqual([dto]))).thenResolve(new Map([[dto, output]])); + when( + mockService.findRelation(TestRelationDTO, 'other', deepEqual([dto]), deepEqual({ filter: undefined })), + ).thenResolve(new Map([[dto, output]])); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.findCustom(dto, {}); diff --git a/packages/query-graphql/__tests__/resolvers/relations/remove-relation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/remove-relation.resolver.spec.ts index d444e7f4b..cdd7d91f9 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/remove-relation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/remove-relation.resolver.spec.ts @@ -67,7 +67,7 @@ describe('RemoveRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.removeRelation('relation', input.id, input.relationId)).thenResolve(output); + when(mockService.removeRelation('relation', input.id, input.relationId, undefined)).thenResolve(output); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.removeRelationFromTestResolverDTO({ input }); @@ -84,7 +84,7 @@ describe('RemoveRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.removeRelation('other', input.id, input.relationId)).thenResolve(output); + when(mockService.removeRelation('other', input.id, input.relationId, undefined)).thenResolve(output); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.removeCustomFromTestResolverDTO({ input }); @@ -119,7 +119,9 @@ describe('RemoveRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.removeRelations('relations', input.id, deepEqual(input.relationIds))).thenResolve(output); + when(mockService.removeRelations('relations', input.id, deepEqual(input.relationIds), undefined)).thenResolve( + output, + ); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.removeRelationsFromTestResolverDTO({ input }); @@ -136,7 +138,9 @@ describe('RemoveRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.removeRelations('others', input.id, deepEqual(input.relationIds))).thenResolve(output); + when(mockService.removeRelations('others', input.id, deepEqual(input.relationIds), undefined)).thenResolve( + output, + ); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.removeCustomsFromTestResolverDTO({ input }); diff --git a/packages/query-graphql/__tests__/resolvers/relations/update-relation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/update-relation.resolver.spec.ts index bccf7efd2..375f101c6 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/update-relation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/update-relation.resolver.spec.ts @@ -68,7 +68,7 @@ describe('UpdateRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.setRelation('relation', input.id, input.relationId)).thenResolve(output); + when(mockService.setRelation('relation', input.id, input.relationId, undefined)).thenResolve(output); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.setRelationOnTestResolverDTO({ input }); @@ -85,7 +85,7 @@ describe('UpdateRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.setRelation('other', input.id, input.relationId)).thenResolve(output); + when(mockService.setRelation('other', input.id, input.relationId, undefined)).thenResolve(output); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.setCustomOnTestResolverDTO({ input }); @@ -120,7 +120,9 @@ describe('UpdateRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.addRelations('relations', input.id, deepEqual(input.relationIds))).thenResolve(output); + when(mockService.addRelations('relations', input.id, deepEqual(input.relationIds), undefined)).thenResolve( + output, + ); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.addRelationsToTestResolverDTO({ input }); @@ -137,7 +139,7 @@ describe('UpdateRelationsResolver', () => { id: 'record-id', stringField: 'foo', }; - when(mockService.addRelations('others', input.id, deepEqual(input.relationIds))).thenResolve(output); + when(mockService.addRelations('others', input.id, deepEqual(input.relationIds), undefined)).thenResolve(output); // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await resolver.addCustomsToTestResolverDTO({ input }); diff --git a/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts index ecd9b974c..1587dc131 100644 --- a/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts @@ -1,5 +1,5 @@ import { when, objectContaining, anything, verify, deepEqual, mock, instance } from 'ts-mockito'; -import { UpdateManyResponse } from '@nestjs-query/core'; +import { Filter, UpdateManyResponse } from '@nestjs-query/core'; import { Resolver, Query, Field, InputType } from '@nestjs/graphql'; import { PubSub } from 'graphql-subscriptions'; import { @@ -30,6 +30,7 @@ import { updateOneSubscriptionResolverSDL, updateSubscriptionResolverSDL, } from './__fixtures__'; +import { TestResolverAuthService } from './__fixtures__/test-resolver-auth.service'; describe('UpdateResolver', () => { const expectResolverSDL = (sdl: string, opts?: UpdateResolverOpts) => { @@ -46,7 +47,11 @@ describe('UpdateResolver', () => { const createTestResolver = (opts?: UpdateResolverOpts) => { @Resolver(() => TestResolverDTO) class TestResolver extends UpdateResolver(TestResolverDTO, opts) { - constructor(service: TestService, @InjectPubSub() readonly pubSub: PubSub) { + constructor( + service: TestService, + @InjectPubSub() readonly pubSub: PubSub, + readonly authService: TestResolverAuthService, + ) { super(service); } } @@ -87,7 +92,28 @@ describe('UpdateResolver', () => { }); it('should call the service updateOne with the provided input', async () => { - const { resolver, mockService } = await createTestResolver(); + const { resolver, mockService, mockAuthService } = await createTestResolver(); + const input: UpdateOneInputType> = { + id: 'id-1', + update: { + stringField: 'foo', + }, + }; + const output: TestResolverDTO = { + id: 'id-1', + stringField: 'foo', + }; + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: {} }))).thenResolve( + output, + ); + const result = await resolver.updateOne({ input }, context); + return expect(result).toEqual(output); + }); + + it('should call the service updateOne with the provided input and filter from authService', async () => { + const { resolver, mockService, mockAuthService } = await createTestResolver(); const input: UpdateOneInputType> = { id: 'id-1', update: { @@ -98,8 +124,13 @@ describe('UpdateResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.updateOne(input.id, objectContaining(input.update))).thenResolve(output); - const result = await resolver.updateOne({ input }); + const context = {}; + const authFilter: Filter = { stringField: { eq: 'foo' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when( + mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: authFilter })), + ).thenResolve(output); + const result = await resolver.updateOne({ input }, context); return expect(result).toEqual(output); }); }); @@ -137,6 +168,27 @@ describe('UpdateResolver', () => { const result = await resolver.updateMany(input); return expect(result).toEqual(output); }); + + it('should call the service updateMany with the provided input and filter from authService', async () => { + const { resolver, mockService, mockAuthService } = await createTestResolver(); + const input: MutationArgsType>> = { + input: { + filter: { id: { eq: 'id-1' } }, + update: { + stringField: 'foo', + }, + }, + }; + const output: UpdateManyResponse = { updatedCount: 1 }; + const context = {}; + const authFilter: Filter = { stringField: { eq: 'foo' } }; + when(mockAuthService.authFilter(context)).thenResolve(authFilter); + when( + mockService.updateMany(objectContaining(input.input.update), objectContaining(input.input.filter)), + ).thenResolve(output); + const result = await resolver.updateMany(input, context); + return expect(result).toEqual(output); + }); }); describe('updated subscription', () => { @@ -168,7 +220,9 @@ describe('UpdateResolver', () => { describe('update one events', () => { it('should publish events for create one when enableSubscriptions is set to true for all', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: true }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + enableSubscriptions: true, + }); const input: UpdateOneInputType> = { id: 'id-1', update: { @@ -181,15 +235,21 @@ describe('UpdateResolver', () => { }; const eventName = getDTOEventName(EventType.UPDATED_ONE, TestResolverDTO); const event = { [eventName]: output }; - when(mockService.updateOne(input.id, objectContaining(input.update))).thenResolve(output); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: {} }))).thenResolve( + output, + ); when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve(); - const result = await resolver.updateOne({ input }); + const result = await resolver.updateOne({ input }, context); verify(mockPubSub.publish(eventName, deepEqual(event))).once(); return expect(result).toEqual(output); }); it('should publish events for create one when enableSubscriptions is set to true for createOne', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ one: { enableSubscriptions: true } }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + one: { enableSubscriptions: true }, + }); const input: UpdateOneInputType> = { id: 'id-1', update: { @@ -202,15 +262,21 @@ describe('UpdateResolver', () => { }; const eventName = getDTOEventName(EventType.UPDATED_ONE, TestResolverDTO); const event = { [eventName]: output }; - when(mockService.updateOne(input.id, objectContaining(input.update))).thenResolve(output); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: {} }))).thenResolve( + output, + ); when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve(); - const result = await resolver.updateOne({ input }); + const result = await resolver.updateOne({ input }, context); verify(mockPubSub.publish(eventName, deepEqual(event))).once(); return expect(result).toEqual(output); }); it('should not publish an event if enableSubscriptions is false', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ enableSubscriptions: false }); + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ + enableSubscriptions: false, + }); const input: UpdateOneInputType> = { id: 'id-1', update: { @@ -221,14 +287,18 @@ describe('UpdateResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.updateOne(input.id, objectContaining(input.update))).thenResolve(output); - const result = await resolver.updateOne({ input }); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: {} }))).thenResolve( + output, + ); + const result = await resolver.updateOne({ input }, context); verify(mockPubSub.publish(anything(), anything())).never(); return expect(result).toEqual(output); }); it('should not publish an event if enableSubscriptions is true and one.enableSubscriptions is false', async () => { - const { resolver, mockService, mockPubSub } = await createTestResolver({ + const { resolver, mockService, mockPubSub, mockAuthService } = await createTestResolver({ enableSubscriptions: true, one: { enableSubscriptions: false }, }); @@ -242,8 +312,12 @@ describe('UpdateResolver', () => { id: 'id-1', stringField: 'foo', }; - when(mockService.updateOne(input.id, objectContaining(input.update))).thenResolve(output); - const result = await resolver.updateOne({ input }); + const context = {}; + when(mockAuthService.authFilter(context)).thenResolve({}); + when(mockService.updateOne(input.id, objectContaining(input.update), deepEqual({ filter: {} }))).thenResolve( + output, + ); + const result = await resolver.updateOne({ input }, context); verify(mockPubSub.publish(anything(), anything())).never(); return expect(result).toEqual(output); }); diff --git a/packages/query-graphql/src/auth/crud-auth.interface.ts b/packages/query-graphql/src/auth/crud-auth.interface.ts new file mode 100644 index 000000000..3502cf53a --- /dev/null +++ b/packages/query-graphql/src/auth/crud-auth.interface.ts @@ -0,0 +1,13 @@ +import { Filter } from '@nestjs-query/core'; + +export interface CRUDAuthOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter: (context: any) => Filter | Promise>; +} + +export interface CRUDAuthService { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + authFilter(context: any): Promise>; + + relationAuthFilter(relationName: string, context: any): Promise>; +} diff --git a/packages/query-graphql/src/auth/default-crud-auth.service.ts b/packages/query-graphql/src/auth/default-crud-auth.service.ts new file mode 100644 index 000000000..c0c5d1d26 --- /dev/null +++ b/packages/query-graphql/src/auth/default-crud-auth.service.ts @@ -0,0 +1,34 @@ +import { Class, Filter } from '@nestjs-query/core'; +import { CRUDAuthOptions, CRUDAuthService } from './crud-auth.interface'; +import { getCRUDAuth, getRelations } from '../decorators'; +import { ResolverRelation } from '../resolvers'; + +export function createDefaultCRUDAuthService(DTOClass: Class): Class> { + const { many = {}, one = {} } = getRelations(DTOClass); + const relationsAuthMap = new Map | undefined>(); + + function getRelationAuth(relation: ResolverRelation) { + const relationAuth = relation.auth; + if (relationAuth) { + return relationAuth; + } + return getCRUDAuth(relation.DTO); + } + + Object.keys(many).forEach((relation) => relationsAuthMap.set(relation, getRelationAuth(many[relation]))); + Object.keys(one).forEach((relation) => relationsAuthMap.set(relation, getRelationAuth(one[relation]))); + class DefaultAuthService implements CRUDAuthService { + readonly authOptions?: CRUDAuthOptions = getCRUDAuth(DTOClass); + + readonly relationsAuth: Map | undefined> = relationsAuthMap; + + async authFilter(context: any): Promise> { + return this.authOptions?.filter(context) ?? {}; + } + + async relationAuthFilter(relationName: string, context: any): Promise> { + return this.relationsAuth.get(relationName)?.filter(context) ?? {}; + } + } + return DefaultAuthService; +} diff --git a/packages/query-graphql/src/auth/index.ts b/packages/query-graphql/src/auth/index.ts new file mode 100644 index 000000000..90fca1b78 --- /dev/null +++ b/packages/query-graphql/src/auth/index.ts @@ -0,0 +1,3 @@ +export * from './crud-auth.interface'; +export * from './default-crud-auth.service'; +export * from './tokens'; diff --git a/packages/query-graphql/src/auth/tokens.ts b/packages/query-graphql/src/auth/tokens.ts new file mode 100644 index 000000000..6448159cf --- /dev/null +++ b/packages/query-graphql/src/auth/tokens.ts @@ -0,0 +1,3 @@ +import { Class } from '@nestjs-query/core'; + +export const getAuthServiceToken = (DTOClass: Class) => `${DTOClass.name}CRUDAuthService`; diff --git a/packages/query-graphql/src/common/index.ts b/packages/query-graphql/src/common/index.ts index 5eae5a343..23bc44f8f 100644 --- a/packages/query-graphql/src/common/index.ts +++ b/packages/query-graphql/src/common/index.ts @@ -1,2 +1,3 @@ export { DTONamesOpts, getDTONames, DTONames } from './get-dto-names'; export * from './external.utils'; +export * from './resolver.utils'; diff --git a/packages/query-graphql/src/common/resolver.utils.ts b/packages/query-graphql/src/common/resolver.utils.ts new file mode 100644 index 000000000..0d79b15d6 --- /dev/null +++ b/packages/query-graphql/src/common/resolver.utils.ts @@ -0,0 +1,20 @@ +import { BaseResolverOptions } from '../decorators/resolver-method.decorator'; + +const mergeArrays = (arr1?: T[], arr2?: T[]): T[] | undefined => { + if (arr1 || arr2) { + return [...(arr1 ?? []), ...(arr2 ?? [])]; + } + return undefined; +}; + +export const mergeBaseResolverOpts = ( + into: Into, + from: BaseResolverOptions, +): Into => { + const guards = mergeArrays(from.guards, into.guards); + const interceptors = mergeArrays(from.interceptors, into.interceptors); + const pipes = mergeArrays(from.pipes, into.pipes); + const filters = mergeArrays(from.filters, into.filters); + const decorators = mergeArrays(from.decorators, into.decorators); + return { ...into, guards, interceptors, pipes, filters, decorators }; +}; diff --git a/packages/query-graphql/src/decorators/auth-service.decorator.ts b/packages/query-graphql/src/decorators/auth-service.decorator.ts new file mode 100644 index 000000000..56532b22a --- /dev/null +++ b/packages/query-graphql/src/decorators/auth-service.decorator.ts @@ -0,0 +1,14 @@ +import { Class, MetaValue, ValueReflector } from '@nestjs-query/core'; +import { CRUDAuthService } from '../auth'; +import { AUTH_SERVICE_KEY } from './constants'; + +const reflector = new ValueReflector(AUTH_SERVICE_KEY); +export function AuthorizationService(DTOClass: Class) { + return (AuthService: Class>) => { + reflector.set(DTOClass, AuthService); + }; +} + +export const getAuthService = (DTOClass: Class): MetaValue>> => { + return reflector.get(DTOClass); +}; diff --git a/packages/query-graphql/src/decorators/auth.decorator.ts b/packages/query-graphql/src/decorators/auth.decorator.ts new file mode 100644 index 000000000..04095007e --- /dev/null +++ b/packages/query-graphql/src/decorators/auth.decorator.ts @@ -0,0 +1,9 @@ +import { Class, MetaValue, classMetadataDecorator, getClassMetadata } from '@nestjs-query/core'; +import { AUTH_KEY } from './constants'; +import { CRUDAuthOptions } from '../auth'; + +export const CRUDAuth = classMetadataDecorator>(AUTH_KEY); + +export const getCRUDAuth = (Cls: Class): MetaValue> => { + return getClassMetadata(Cls, AUTH_KEY); +}; diff --git a/packages/query-graphql/src/decorators/constants.ts b/packages/query-graphql/src/decorators/constants.ts index 54ed66274..a4940f032 100644 --- a/packages/query-graphql/src/decorators/constants.ts +++ b/packages/query-graphql/src/decorators/constants.ts @@ -7,6 +7,13 @@ export const BEFORE_UPDATE_MANY_KEY = 'nestjs-query:before-update-many'; export const BEFORE_DELETE_ONE_KEY = 'nestjs-query:before-delete-one'; export const BEFORE_DELETE_MANY_KEY = 'nestjs-query:before-delete-many'; +export const BEFORE_QUERY_MANY_KEY = 'nestjs-query:before-query-many'; +export const BEFORE_FIND_ONE_KEY = 'nestjs-query:before-find-one'; + export const FILTERABLE_FIELD_KEY = 'nestjs-query:filterable-field'; export const RELATION_KEY = 'nestjs-query:relation'; export const REFERENCE_KEY = 'nestjs-query:reference'; + +export const AUTH_KEY = 'nestjs-query:auth'; + +export const AUTH_SERVICE_KEY = 'nestjs-query:auth-service'; diff --git a/packages/query-graphql/src/decorators/hook-args.decorator.ts b/packages/query-graphql/src/decorators/hook-args.decorator.ts new file mode 100644 index 000000000..eda4df116 --- /dev/null +++ b/packages/query-graphql/src/decorators/hook-args.decorator.ts @@ -0,0 +1,27 @@ +import { Class, MetaValue } from '@nestjs-query/core'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Args, GqlExecutionContext } from '@nestjs/graphql'; +import { HookFunc } from './hook.decorator'; +import { composeDecorators } from './decorator.utils'; +import { transformAndValidate } from '../resolvers/helpers'; + +export const HookArgs = ( + HookArgsClass: Class, + ...hooks: MetaValue>[] +): ParameterDecorator => { + return composeDecorators( + Args(), + createParamDecorator(async (data: unknown, ctx: ExecutionContext) => { + const gqlContext = GqlExecutionContext.create(ctx); + const args = await transformAndValidate(HookArgsClass, gqlContext.getArgs()); + if (hooks && hooks.length) { + return hooks.reduce( + (hookedArgs, hook) => + hook ? Object.assign(hookedArgs, hook(hookedArgs, gqlContext.getContext())) : hookedArgs, + args, + ); + } + return args; + })(), + ); +}; diff --git a/packages/query-graphql/src/decorators/hook.decorator.ts b/packages/query-graphql/src/decorators/hook.decorator.ts index 020373c03..157aa3edb 100644 --- a/packages/query-graphql/src/decorators/hook.decorator.ts +++ b/packages/query-graphql/src/decorators/hook.decorator.ts @@ -1,9 +1,10 @@ -import { Class, DeepPartial, getClassMetadata, classMetadataDecorator, MetaValue } from '@nestjs-query/core'; +import { Class, DeepPartial, getClassMetadata, classMetadataDecorator, MetaValue, Query } from '@nestjs-query/core'; import { CreateManyInputType, CreateOneInputType, DeleteManyInputType, DeleteOneInputType, + FindOneArgsType, UpdateManyInputType, UpdateOneInputType, } from '../types'; @@ -12,6 +13,8 @@ import { BEFORE_CREATE_ONE_KEY, BEFORE_DELETE_MANY_KEY, BEFORE_DELETE_ONE_KEY, + BEFORE_FIND_ONE_KEY, + BEFORE_QUERY_MANY_KEY, BEFORE_UPDATE_MANY_KEY, BEFORE_UPDATE_ONE_KEY, } from './constants'; @@ -61,3 +64,17 @@ export const BeforeDeleteMany = classMetadataDecorator>(BEFO export function getDeleteManyHook(DTOClass: Class): MetaValue> { return getClassMetadata(DTOClass, BEFORE_DELETE_MANY_KEY); } + +export type BeforeQueryManyHook = HookFunc>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeQueryMany = classMetadataDecorator>(BEFORE_QUERY_MANY_KEY); +export function getQueryManyHook(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_QUERY_MANY_KEY); +} + +export type BeforeFindOneHook = HookFunc; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BeforeFindOne = classMetadataDecorator>(BEFORE_FIND_ONE_KEY); +export function getFindOneHook(DTOClass: Class): MetaValue> { + return getClassMetadata(DTOClass, BEFORE_FIND_ONE_KEY); +} diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 58926e96c..e6e753f89 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -25,3 +25,7 @@ export * from './aggregate-query-param.decorator'; export * from './hook.decorator'; export * from './mutation-args.decorator'; export * from './decorator.utils'; +export * from './hook-args.decorator'; +export * from './auth.decorator'; +export * from './auth-service.decorator'; +export * from './inject-auth-service.decorator'; diff --git a/packages/query-graphql/src/decorators/inject-auth-service.decorator.ts b/packages/query-graphql/src/decorators/inject-auth-service.decorator.ts new file mode 100644 index 000000000..a98b566d3 --- /dev/null +++ b/packages/query-graphql/src/decorators/inject-auth-service.decorator.ts @@ -0,0 +1,5 @@ +import { Class } from '@nestjs-query/core'; +import { Inject } from '@nestjs/common'; +import { getAuthServiceToken } from '../auth'; + +export const InjectAuthService = (DTOClass: Class) => Inject(getAuthServiceToken(DTOClass)); diff --git a/packages/query-graphql/src/decorators/mutation-args.decorator.ts b/packages/query-graphql/src/decorators/mutation-args.decorator.ts index 2c966b70f..b72d6a706 100644 --- a/packages/query-graphql/src/decorators/mutation-args.decorator.ts +++ b/packages/query-graphql/src/decorators/mutation-args.decorator.ts @@ -1,24 +1,14 @@ -import { Class } from '@nestjs-query/core'; -import { Args, GqlExecutionContext } from '@nestjs/graphql'; -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { composeDecorators } from './decorator.utils'; -import { transformAndValidate } from '../resolvers/helpers'; +import { Class, MetaValue } from '@nestjs-query/core'; import { MutationArgsType } from '../types'; import { HookFunc } from './hook.decorator'; +import { HookArgs } from './hook-args.decorator'; export const MutationArgs = ( HookArgsClass: Class>, - hook?: HookFunc, + hook: MetaValue>, ): ParameterDecorator => { - return composeDecorators( - Args(), - createParamDecorator(async (data: unknown, ctx: ExecutionContext) => { - const gqlContext = GqlExecutionContext.create(ctx); - const args = await transformAndValidate(HookArgsClass, gqlContext.getArgs()); - if (hook) { - return Object.assign(args, { input: hook(args.input, gqlContext.getContext()) }); - } - return args; - })(), - ); + if (!hook) { + return HookArgs(HookArgsClass); + } + return HookArgs(HookArgsClass, (args, context) => Object.assign(args, { input: hook(args.input, context) })); }; diff --git a/packages/query-graphql/src/decorators/reference.decorator.ts b/packages/query-graphql/src/decorators/reference.decorator.ts index cc9de7af3..7db7f05dc 100644 --- a/packages/query-graphql/src/decorators/reference.decorator.ts +++ b/packages/query-graphql/src/decorators/reference.decorator.ts @@ -2,6 +2,8 @@ import { ArrayReflector, Class, getPrototypeChain } from '@nestjs-query/core'; import { ReferencesOpts, ResolverRelationReference } from '../resolvers/relations'; import { ReferencesKeys } from '../resolvers/relations/relations.interface'; import { REFERENCE_KEY } from './constants'; +import { BaseResolverOptions } from './resolver-method.decorator'; +import { mergeBaseResolverOpts } from '../common'; const reflector = new ArrayReflector(REFERENCE_KEY); export type ReferenceDecoratorOpts = Omit, 'DTO'>; @@ -23,14 +25,20 @@ function getReferenceDescriptors(DTOClass: Class): ReferenceDescriptor }, [] as ReferenceDescriptor[]); } -function convertReferencesToOpts(references: ReferenceDescriptor[]): ReferencesOpts { +function convertReferencesToOpts( + references: ReferenceDescriptor[], + baseOpts?: BaseResolverOptions, +): ReferencesOpts { return references.reduce((referenceOpts, r) => { - const opts = { ...r.relationOpts, DTO: r.relationTypeFunc(), keys: r.keys }; + const opts = mergeBaseResolverOpts>( + { ...r.relationOpts, DTO: r.relationTypeFunc(), keys: r.keys }, + baseOpts ?? {}, + ); return { ...referenceOpts, [r.name]: opts }; }, {} as ReferencesOpts); } -export function getReferences(DTOClass: Class): ReferencesOpts { +export function getReferences(DTOClass: Class, opts?: BaseResolverOptions): ReferencesOpts { const referenceDescriptors = getReferenceDescriptors(DTOClass); return convertReferencesToOpts(referenceDescriptors); } diff --git a/packages/query-graphql/src/decorators/relation.decorator.ts b/packages/query-graphql/src/decorators/relation.decorator.ts index 639f7d751..c4baa912d 100644 --- a/packages/query-graphql/src/decorators/relation.decorator.ts +++ b/packages/query-graphql/src/decorators/relation.decorator.ts @@ -2,6 +2,8 @@ import { ArrayReflector, Class, getPrototypeChain } from '@nestjs-query/core'; import { RelationsOpts, ResolverRelation } from '../resolvers/relations'; import { PagingStrategies } from '../types/query/paging'; import { RELATION_KEY } from './constants'; +import { BaseResolverOptions } from './resolver-method.decorator'; +import { mergeBaseResolverOpts } from '../common'; export const reflector = new ArrayReflector(RELATION_KEY); @@ -25,12 +27,15 @@ function getRelationsDescriptors(DTOClass: Class): RelationDescriptor< }, [] as RelationDescriptor[]); } -function convertRelationsToOpts(relations: RelationDescriptor[]): RelationsOpts { +function convertRelationsToOpts( + relations: RelationDescriptor[], + baseOpts?: BaseResolverOptions, +): RelationsOpts { const relationOpts: RelationsOpts = {}; relations.forEach((r) => { const relationType = r.relationTypeFunc(); const DTO = Array.isArray(relationType) ? relationType[0] : relationType; - const opts = { ...r.relationOpts, DTO }; + const opts = mergeBaseResolverOpts({ ...r.relationOpts, DTO }, baseOpts ?? {}); if (r.isMany) { relationOpts.many = { ...relationOpts.many, [r.name]: opts }; } else { @@ -40,9 +45,9 @@ function convertRelationsToOpts(relations: RelationDescriptor[]): Relat return relationOpts; } -export function getRelations(DTOClass: Class): RelationsOpts { +export function getRelations(DTOClass: Class, opts?: BaseResolverOptions): RelationsOpts { const relationDescriptors = getRelationsDescriptors(DTOClass); - return convertRelationsToOpts(relationDescriptors); + return convertRelationsToOpts(relationDescriptors, opts); } export function Relation( diff --git a/packages/query-graphql/src/decorators/resolver-method.decorator.ts b/packages/query-graphql/src/decorators/resolver-method.decorator.ts index a5a761ca3..9efb721b9 100644 --- a/packages/query-graphql/src/decorators/resolver-method.decorator.ts +++ b/packages/query-graphql/src/decorators/resolver-method.decorator.ts @@ -12,14 +12,9 @@ import { } from '@nestjs/common'; import { Class } from '@nestjs-query/core'; -/** - * Options for resolver methods. - */ -export interface ResolverMethodOpts { - /** Set to true to disable the endpoint */ - disabled?: boolean; +export interface BaseResolverOptions { /** An array of `nestjs` guards to apply to a graphql endpoint */ - guards?: Class[]; + guards?: (Class | CanActivate)[]; /** An array of `nestjs` interceptors to apply to a graphql endpoint */ interceptors?: Class>[]; /** An array of `nestjs` pipes to apply to a graphql endpoint */ @@ -30,6 +25,14 @@ export interface ResolverMethodOpts { decorators?: (PropertyDecorator | MethodDecorator)[]; } +/** + * Options for resolver methods. + */ +export interface ResolverMethodOpts extends BaseResolverOptions { + /** Set to true to disable the endpoint */ + disabled?: boolean; +} + /** * @internal * Creates a unique set of items. @@ -58,7 +61,7 @@ export function isDisabled(opts: ResolverMethodOpts[]): boolean { */ export function ResolverMethod(...opts: ResolverMethodOpts[]): MethodDecorator { return applyDecorators( - UseGuards(...createSetArray>(...opts.map((o) => o.guards ?? []))), + UseGuards(...createSetArray | CanActivate>(...opts.map((o) => o.guards ?? []))), UseInterceptors(...createSetArray>(...opts.map((o) => o.interceptors ?? []))), UsePipes(...createSetArray>(...opts.map((o) => o.pipes ?? []))), UseFilters(...createSetArray>(...opts.map((o) => o.filters ?? []))), diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index a8b52f049..fb91ec2b9 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -25,6 +25,13 @@ export { DeleteOneHook, BeforeDeleteMany, DeleteManyHook, + BeforeQueryMany, + BeforeQueryManyHook, + BeforeFindOne, + BeforeFindOneHook, + CRUDAuth, + InjectAuthService, + AuthorizationService, } from './decorators'; export * from './resolvers'; export * from './federation'; @@ -32,3 +39,4 @@ export { DTONamesOpts } from './common'; export { NestjsQueryGraphQLModule } from './module'; export { AutoResolverOpts } from './providers'; export { pubSubToken, GraphQLPubSub } from './subscription'; +export { CRUDAuthService, CRUDAuthOptions } from './auth'; diff --git a/packages/query-graphql/src/loader/find-relations.loader.ts b/packages/query-graphql/src/loader/find-relations.loader.ts index b09d9fbb4..a83eb4af1 100644 --- a/packages/query-graphql/src/loader/find-relations.loader.ts +++ b/packages/query-graphql/src/loader/find-relations.loader.ts @@ -1,15 +1,49 @@ -import { Class, QueryService } from '@nestjs-query/core'; +import { Class, Filter, QueryService } from '@nestjs-query/core'; import { NestjsQueryDataloader } from './relations.loader'; +export type FindRelationsArgs = { dto: DTO; filter?: Filter }; +type FindRelationsMap = Map & { index: number })[]>; + export class FindRelationsLoader - implements NestjsQueryDataloader { + implements NestjsQueryDataloader, Relation | undefined | Error> { constructor(readonly RelationDTO: Class, readonly relationName: string) {} createLoader(service: QueryService) { - return async (args: ReadonlyArray): Promise<(Relation | undefined | Error)[]> => { - const dtos = args.map((a) => a); - const results = await service.findRelation(this.RelationDTO, this.relationName, dtos); - return dtos.map((dto) => results.get(dto)); + return async (args: ReadonlyArray>): Promise<(Relation | undefined | Error)[]> => { + const grouped = this.groupFinds(args); + return this.loadResults(service, grouped); }; } + + private async loadResults( + service: QueryService, + findRelationsMap: FindRelationsMap, + ): Promise<(Relation | undefined)[]> { + const results: (Relation | undefined)[] = []; + await Promise.all( + [...findRelationsMap.values()].map(async (args) => { + const { filter } = args[0]; + const dtos = args.map((a) => a.dto); + const relationResults = await service.findRelation(this.RelationDTO, this.relationName, dtos, { filter }); + const dtoRelations: (Relation | undefined)[] = dtos.map((dto) => relationResults.get(dto)); + dtoRelations.forEach((relation, index) => { + results[args[index].index] = relation; + }); + }), + ); + return results; + } + + private groupFinds(queryArgs: ReadonlyArray>): FindRelationsMap { + // group + return queryArgs.reduce((map, args, index) => { + const filterJson = JSON.stringify(args.filter); + if (!map.has(filterJson)) { + map.set(filterJson, []); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + map.get(filterJson)!.push({ ...args, index }); + return map; + }, new Map & { index: number })[]>()); + } } diff --git a/packages/query-graphql/src/module.ts b/packages/query-graphql/src/module.ts index 00b54d48a..33b832908 100644 --- a/packages/query-graphql/src/module.ts +++ b/packages/query-graphql/src/module.ts @@ -1,6 +1,6 @@ import { Assembler, NestjsQueryCoreModule, Class } from '@nestjs-query/core'; import { DynamicModule, ForwardReference, Provider } from '@nestjs/common'; -import { AutoResolverOpts, createResolvers } from './providers'; +import { AutoResolverOpts, createAuthServiceProviders, createResolvers } from './providers'; import { ReadResolverOpts } from './resolvers'; import { defaultPubSub, pubSubToken, GraphQLPubSub } from './subscription'; import { PagingStrategies } from './types/query/paging'; @@ -23,13 +23,22 @@ export class NestjsQueryGraphQLModule { imports: opts.imports, }); const pubSubProvider = opts.pubSub ?? this.defaultPubSubProvider(); + const DTOClasses = opts.resolvers.map((r) => r.DTOClass); const services = opts.services || []; + const authServiceProviders = createAuthServiceProviders(DTOClasses); const resolverProviders = createResolvers(opts.resolvers); return { module: NestjsQueryGraphQLModule, imports: [...opts.imports, coreModule], - providers: [...services, ...resolverProviders, pubSubProvider], - exports: [...resolverProviders, ...services, ...opts.imports, coreModule, pubSubProvider], + providers: [...services, ...authServiceProviders, ...resolverProviders, pubSubProvider], + exports: [ + ...resolverProviders, + ...services, + ...authServiceProviders, + ...opts.imports, + coreModule, + pubSubProvider, + ], }; } diff --git a/packages/query-graphql/src/providers/auth-service.provider.ts b/packages/query-graphql/src/providers/auth-service.provider.ts new file mode 100644 index 000000000..6150cb098 --- /dev/null +++ b/packages/query-graphql/src/providers/auth-service.provider.ts @@ -0,0 +1,15 @@ +import { Provider } from '@nestjs/common'; +import { Class } from '@nestjs-query/core'; +import { createDefaultCRUDAuthService, getAuthServiceToken } from '../auth'; +import { getAuthService } from '../decorators'; + +function createServiceProvider(DTOClass: Class): Provider { + return { + provide: getAuthServiceToken(DTOClass), + useClass: getAuthService(DTOClass) ?? createDefaultCRUDAuthService(DTOClass), + }; +} + +export const createAuthServiceProviders = (opts: Class[]): Provider[] => { + return opts.map((opt) => createServiceProvider(opt)); +}; diff --git a/packages/query-graphql/src/providers/index.ts b/packages/query-graphql/src/providers/index.ts new file mode 100644 index 000000000..9495733fc --- /dev/null +++ b/packages/query-graphql/src/providers/index.ts @@ -0,0 +1,2 @@ +export * from './resolver.provider'; +export * from './auth-service.provider'; diff --git a/packages/query-graphql/src/providers.ts b/packages/query-graphql/src/providers/resolver.provider.ts similarity index 89% rename from packages/query-graphql/src/providers.ts rename to packages/query-graphql/src/providers/resolver.provider.ts index 5066bb183..529e35298 100644 --- a/packages/query-graphql/src/providers.ts +++ b/packages/query-graphql/src/providers/resolver.provider.ts @@ -10,9 +10,11 @@ import { import { Provider, Inject } from '@nestjs/common'; import { Resolver } from '@nestjs/graphql'; import { PubSub } from 'graphql-subscriptions'; -import { InjectPubSub } from './decorators'; -import { CRUDResolver, CRUDResolverOpts, FederationResolver } from './resolvers'; -import { PagingStrategies } from './types/query/paging'; +import { InjectPubSub } from '../decorators'; +import { CRUDResolver, CRUDResolverOpts, FederationResolver } from '../resolvers'; +import { PagingStrategies } from '../types/query/paging'; +import { InjectAuthService } from '../decorators/inject-auth-service.decorator'; +import { CRUDAuthService } from '../auth'; type CRUDAutoResolverOpts = CRUDResolverOpts & { DTOClass: Class; @@ -89,6 +91,7 @@ function createFederatedResolver(resolverOpts: FederatedAutoResolv constructor( @Inject(resolverOpts.Service) readonly service: QueryService, @InjectPubSub() readonly pubSub: PubSub, + @InjectAuthService(DTOClass) readonly authService?: CRUDAuthService, ) { super(service); } @@ -114,6 +117,7 @@ function createEntityAutoResolver, @InjectPubSub() readonly pubSub: PubSub, + @InjectAuthService(DTOClass) readonly authService?: CRUDAuthService, ) { super(new Service(service)); } @@ -133,6 +137,7 @@ function createAssemblerAutoResolver>) service: QueryService, @InjectPubSub() readonly pubSub: PubSub, + @InjectAuthService(DTOClass) readonly authService?: CRUDAuthService, ) { super(service); } @@ -148,7 +153,11 @@ function createServiceAutoResolver DTOClass) class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { - constructor(@Inject(ServiceClass) service: QueryService, @InjectPubSub() readonly pubSub: PubSub) { + constructor( + @Inject(ServiceClass) service: QueryService, + @InjectPubSub() readonly pubSub: PubSub, + @InjectAuthService(DTOClass) readonly authService?: CRUDAuthService, + ) { super(service); } } diff --git a/packages/query-graphql/src/resolvers/aggregate.resolver.ts b/packages/query-graphql/src/resolvers/aggregate.resolver.ts index f2c9e4825..b8bcf81e3 100644 --- a/packages/query-graphql/src/resolvers/aggregate.resolver.ts +++ b/packages/query-graphql/src/resolvers/aggregate.resolver.ts @@ -1,10 +1,10 @@ -import { AggregateQuery, AggregateResponse, Class } from '@nestjs-query/core'; -import { Args, ArgsType, Resolver } from '@nestjs/graphql'; +import { AggregateQuery, AggregateResponse, Class, mergeFilter } from '@nestjs-query/core'; +import { Args, ArgsType, Resolver, Context } from '@nestjs/graphql'; import omit from 'lodash.omit'; import { getDTONames } from '../common'; import { AggregateQueryParam, ResolverMethodOpts, ResolverQuery, SkipIf } from '../decorators'; import { AggregateArgsType, AggregateResponseType } from '../types'; -import { transformAndValidate } from './helpers'; +import { getAuthFilter, transformAndValidate } from './helpers'; import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface'; export type AggregateResolverOpts = { @@ -37,9 +37,11 @@ export const Aggregateable = (DTOClass: Class, opts?: AggregateResolve async aggregate( @Args() args: AA, @AggregateQueryParam() query: AggregateQuery, + @Context() context?: unknown, ): Promise> { const qa = await transformAndValidate(AA, args); - return this.service.aggregate(qa.filter || {}, query); + const authFilter = await getAuthFilter(this.authService, context); + return this.service.aggregate(mergeFilter(qa.filter || {}, authFilter ?? {}), query); } } return AggregateResolverBase; diff --git a/packages/query-graphql/src/resolvers/crud.resolver.ts b/packages/query-graphql/src/resolvers/crud.resolver.ts index 823c99620..0595eabde 100644 --- a/packages/query-graphql/src/resolvers/crud.resolver.ts +++ b/packages/query-graphql/src/resolvers/crud.resolver.ts @@ -8,6 +8,9 @@ import { Refereceable, ReferenceResolverOpts } from './reference.resolver'; import { MergePagingStrategyOpts, ResolverClass } from './resolver.interface'; import { Updateable, UpdateResolver, UpdateResolverOpts } from './update.resolver'; import { DeleteResolver, DeleteResolverOpts } from './delete.resolver'; +import { BaseResolverOptions } from '../decorators/resolver-method.decorator'; +import { mergeBaseResolverOpts } from '../common'; +import { RelatableOpts } from './relations/relations.resolver'; export interface CRUDResolverOpts< DTO, @@ -15,7 +18,7 @@ export interface CRUDResolverOpts< U extends DeepPartial = DeepPartial, R extends ReadResolverOpts = ReadResolverOpts, PS extends PagingStrategies = PagingStrategies.CURSOR -> { +> extends BaseResolverOptions { /** * The DTO that should be used as input for create endpoints. */ @@ -89,16 +92,30 @@ export const CRUDResolver = < } = opts; const referencable = Refereceable(DTOClass, referenceBy); - const relatable = Relatable(DTOClass, { enableTotalCount, enableAggregate }); - const aggregateable = Aggregateable(DTOClass, { enabled: enableAggregate, ...aggregate }); - const creatable = Creatable(DTOClass, { CreateDTOClass, enableSubscriptions, ...create }); - const readable = Readable(DTOClass, { enableTotalCount, pagingStrategy, ...read } as MergePagingStrategyOpts< - DTO, - R, - PS - >); - const updateable = Updateable(DTOClass, { UpdateDTOClass, enableSubscriptions, ...update }); - const deleteResolver = DeleteResolver(DTOClass, { enableSubscriptions, ...deleteArgs }); + const relatable = Relatable( + DTOClass, + mergeBaseResolverOpts({ enableTotalCount, enableAggregate } as RelatableOpts, opts), + ); + const aggregateable = Aggregateable(DTOClass, { + enabled: enableAggregate, + ...mergeBaseResolverOpts(aggregate ?? {}, opts), + }); + const creatable = Creatable(DTOClass, { + CreateDTOClass, + enableSubscriptions, + ...mergeBaseResolverOpts(create ?? {}, opts), + }); + const readable = Readable(DTOClass, { + enableTotalCount, + pagingStrategy, + ...mergeBaseResolverOpts(read, opts), + } as MergePagingStrategyOpts); + const updateable = Updateable(DTOClass, { + UpdateDTOClass, + enableSubscriptions, + ...mergeBaseResolverOpts(update, opts), + }); + const deleteResolver = DeleteResolver(DTOClass, { enableSubscriptions, ...mergeBaseResolverOpts(deleteArgs, opts) }); return referencable(relatable(aggregateable(creatable(readable(updateable(deleteResolver)))))); }; diff --git a/packages/query-graphql/src/resolvers/delete.resolver.ts b/packages/query-graphql/src/resolvers/delete.resolver.ts index a15fd440c..88cbcbc1e 100644 --- a/packages/query-graphql/src/resolvers/delete.resolver.ts +++ b/packages/query-graphql/src/resolvers/delete.resolver.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line max-classes-per-file -import { Class, DeleteManyResponse } from '@nestjs-query/core'; +import { Class, DeleteManyResponse, mergeFilter } from '@nestjs-query/core'; import omit from 'lodash.omit'; -import { ObjectType, ArgsType, Resolver, Args, PartialType, InputType } from '@nestjs/graphql'; +import { ObjectType, ArgsType, Resolver, Args, PartialType, InputType, Context } from '@nestjs/graphql'; import { DTONames, getDTONames } from '../common'; import { EventType, getDTOEventName } from '../subscription'; import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface'; @@ -20,7 +20,7 @@ import { ResolverMutation, ResolverSubscription, } from '../decorators'; -import { createSubscriptionFilter, transformAndValidate } from './helpers'; +import { createSubscriptionFilter, getAuthFilter, transformAndValidate } from './helpers'; export type DeletedEvent = { [eventName: string]: DTO }; export interface DeleteResolverOpts extends SubscriptionResolverOpts { @@ -35,9 +35,9 @@ export interface DeleteResolverOpts extends SubscriptionResolverOpts { } export interface DeleteResolver extends ServiceResolver { - deleteOne(input: MutationArgsType): Promise>; + deleteOne(input: MutationArgsType, context?: unknown): Promise>; - deleteMany(input: MutationArgsType>): Promise; + deleteMany(input: MutationArgsType>, context?: unknown): Promise; deletedOneSubscription(input?: SubscriptionArgsType): AsyncIterator>>; @@ -98,9 +98,10 @@ export const Deletable = (DTOClass: Class, opts: DeleteResolverOpts
DTOClass, { isAbstract: true }) class DeleteResolverBase extends BaseClass { @ResolverMutation(() => DeleteOneResponse, { name: `deleteOne${baseName}` }, commonResolverOpts, opts.one ?? {}) - async deleteOne(@MutationArgs(DO, deleteOneHook) input: DO): Promise> { + async deleteOne(@MutationArgs(DO, deleteOneHook) input: DO, @Context() context?: unknown): Promise> { const deleteOne = await transformAndValidate(DO, input); - const deletedResponse = await this.service.deleteOne(deleteOne.input.id); + const authFilter = await getAuthFilter(this.authService, context); + const deletedResponse = await this.service.deleteOne(deleteOne.input.id, { filter: authFilter }); if (enableOneSubscriptions) { await this.publishDeletedOneEvent(deletedResponse); } @@ -108,9 +109,13 @@ export const Deletable = (DTOClass: Class, opts: DeleteResolverOpts
DMR, { name: `deleteMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {}) - async deleteMany(@MutationArgs(DM, deleteManyHook) input: DM): Promise { + async deleteMany( + @MutationArgs(DM, deleteManyHook) input: DM, + @Context() context?: unknown, + ): Promise { const deleteMany = await transformAndValidate(DM, input); - const deleteManyResponse = await this.service.deleteMany(deleteMany.input.filter); + const authFilter = await getAuthFilter(this.authService, context); + const deleteManyResponse = await this.service.deleteMany(mergeFilter(deleteMany.input.filter, authFilter ?? {})); if (enableManySubscriptions) { await this.publishDeletedManyEvent(deleteManyResponse); } diff --git a/packages/query-graphql/src/resolvers/federation/federation.resolver.ts b/packages/query-graphql/src/resolvers/federation/federation.resolver.ts index 890e93701..3864034da 100644 --- a/packages/query-graphql/src/resolvers/federation/federation.resolver.ts +++ b/packages/query-graphql/src/resolvers/federation/federation.resolver.ts @@ -2,7 +2,11 @@ import { Class } from '@nestjs-query/core'; import { ReadRelationsResolver } from '../relations'; import { ServiceResolver } from '../resolver.interface'; import { getRelations } from '../../decorators'; +import { BaseResolverOptions } from '../../decorators/resolver-method.decorator'; -export const FederationResolver = (DTOClass: Class): Class> => { - return ReadRelationsResolver(DTOClass, getRelations(DTOClass)); +export const FederationResolver = ( + DTOClass: Class, + opts: BaseResolverOptions = {}, +): Class> => { + return ReadRelationsResolver(DTOClass, getRelations(DTOClass, opts)); }; diff --git a/packages/query-graphql/src/resolvers/helpers.ts b/packages/query-graphql/src/resolvers/helpers.ts index d676d04cb..5e4bdc2c6 100644 --- a/packages/query-graphql/src/resolvers/helpers.ts +++ b/packages/query-graphql/src/resolvers/helpers.ts @@ -1,8 +1,9 @@ -import { applyFilter, Class } from '@nestjs-query/core'; +import { applyFilter, Class, Filter } from '@nestjs-query/core'; import { plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; import { BadRequestException } from '@nestjs/common'; import { SubscriptionArgsType, SubscriptionFilterInputType } from '../types'; +import { CRUDAuthService } from '../auth'; /** @internal */ export const transformAndValidate = async (TClass: Class, partial: T): Promise => { @@ -34,3 +35,24 @@ export const createSubscriptionFilter = ( + authService?: CRUDAuthService, + context?: unknown, +): Promise | undefined> => { + if (!context || !authService) { + return undefined; + } + return authService.authFilter(context); +}; + +export const getRelationAuthFilter = async ( + relationName: string, + authService?: CRUDAuthService, + context?: unknown, +): Promise | undefined> => { + if (!context || !authService) { + return undefined; + } + return authService.relationAuthFilter(relationName, context); +}; diff --git a/packages/query-graphql/src/resolvers/read.resolver.ts b/packages/query-graphql/src/resolvers/read.resolver.ts index aaef538ea..320b6e164 100644 --- a/packages/query-graphql/src/resolvers/read.resolver.ts +++ b/packages/query-graphql/src/resolvers/read.resolver.ts @@ -1,12 +1,18 @@ -import { Class } from '@nestjs-query/core'; -import { Args, ArgsType, ID, Resolver } from '@nestjs/graphql'; +import { Class, mergeQuery, MetaValue } from '@nestjs-query/core'; +import { ArgsType, Resolver, Context } from '@nestjs/graphql'; import omit from 'lodash.omit'; import { getDTONames } from '../common'; -import { ResolverQuery } from '../decorators'; -import { ConnectionType, QueryArgsType, QueryArgsTypeOpts, StaticConnectionType, StaticQueryArgsType } from '../types'; +import { getQueryManyHook, HookArgs, ResolverQuery, HookFunc, getFindOneHook } from '../decorators'; +import { + ConnectionType, + FindOneArgsType, + QueryArgsType, + QueryArgsTypeOpts, + StaticConnectionType, + StaticQueryArgsType, +} from '../types'; import { CursorConnectionOptions } from '../types/connection/cursor'; import { CursorQueryArgsTypeOpts } from '../types/query/query-args'; -import { transformAndValidate } from './helpers'; import { BaseServiceResolver, ConnectionTypeFromOpts, @@ -15,6 +21,7 @@ import { ResolverOpts, ServiceResolver, } from './resolver.interface'; +import { getAuthFilter, transformAndValidate } from './helpers'; export type ReadResolverFromOpts> = ReadResolver< DTO, @@ -31,8 +38,8 @@ export type ReadResolverOpts = { export interface ReadResolver, CT extends ConnectionType> extends ServiceResolver { - queryMany(query: QT): Promise; - findById(id: string | number): Promise; + queryMany(query: QT, context?: unknown): Promise; + findById(id: FindOneArgsType, context?: unknown): Promise; } /** @@ -54,16 +61,27 @@ export const Readable = >(DTOClass: @ArgsType() class QA extends QueryArgs {} + @ArgsType() + class FO extends FindOneArgsType() {} + + const queryManyHook = getQueryManyHook(DTOClass) as MetaValue>; + const findOneHook = getFindOneHook(DTOClass) as MetaValue>; + @Resolver(() => DTOClass, { isAbstract: true }) class ReadResolverBase extends BaseClass { @ResolverQuery(() => DTOClass, { nullable: true, name: baseNameLower }, commonResolverOpts, opts.one ?? {}) - async findById(@Args({ name: 'id', type: () => ID }) id: string | number): Promise { - return this.service.findById(id); + async findById(@HookArgs(FO, findOneHook) input: FO, @Context() context?: unknown): Promise { + const authFilter = await getAuthFilter(this.authService, context); + return this.service.findById(input.id, { filter: authFilter }); } @ResolverQuery(() => Connection.resolveType, { name: pluralBaseNameLower }, commonResolverOpts, opts.many ?? {}) - async queryMany(@Args() query: QA): Promise> { - const qa = await transformAndValidate(QA, query); + async queryMany( + @HookArgs(QA, queryManyHook) query: QA, + @Context() context?: unknown, + ): Promise> { + const authFilter = await getAuthFilter(this.authService, context); + const qa = await transformAndValidate(QA, mergeQuery(query, { filter: authFilter })); return Connection.createFromPromise( (q) => this.service.query(q), qa, 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 02752a62d..88223ad42 100644 --- a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts @@ -1,11 +1,11 @@ -import { AggregateQuery, AggregateResponse, Class } from '@nestjs-query/core'; +import { AggregateQuery, AggregateResponse, Class, mergeFilter } from '@nestjs-query/core'; import { ExecutionContext } from '@nestjs/common'; import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql'; import { getDTONames } from '../../common'; import { AggregateQueryParam, ResolverField } from '../../decorators'; import { AggregateRelationsLoader, DataLoaderFactory } from '../../loader'; import { AggregateArgsType, AggregateResponseType } from '../../types'; -import { transformAndValidate } from '../helpers'; +import { getRelationAuthFilter, transformAndValidate } from '../helpers'; import { BaseServiceResolver, ServiceResolver } from '../resolver.interface'; import { flattenRelations, removeRelationOpts } from './helpers'; import { RelationsOpts, ResolverRelation } from './relations.interface'; @@ -32,7 +32,9 @@ const AggregateRelationMixin = (DTOClass: Class, relation: A const commonResolverOpts = removeRelationOpts(relation); const relationDTO = relation.DTO; const dtoName = getDTONames(DTOClass).baseName; - const { pluralBaseNameLower, pluralBaseName } = getDTONames(relationDTO, { dtoName: relation.dtoName }); + const { baseNameLower, pluralBaseNameLower, pluralBaseName } = getDTONames(relationDTO, { + dtoName: relation.dtoName, + }); const relationName = relation.relationName ?? pluralBaseNameLower; const aggregateRelationLoaderName = `aggregate${pluralBaseName}For${dtoName}`; const aggregateLoader = new AggregateRelationsLoader(relationDTO, relationName); @@ -55,7 +57,12 @@ const AggregateRelationMixin = (DTOClass: Class, relation: A aggregateRelationLoaderName, aggregateLoader.createLoader(this.service), ); - return loader.load({ dto, filter: qa.filter ?? {}, aggregate: aggregateQuery }); + const relationFilter = await getRelationAuthFilter(baseNameLower, this.authService, context); + return loader.load({ + dto, + filter: mergeFilter(qa.filter ?? {}, relationFilter ?? {}), + aggregate: aggregateQuery, + }); } } return AggregateMixin; diff --git a/packages/query-graphql/src/resolvers/relations/helpers.ts b/packages/query-graphql/src/resolvers/relations/helpers.ts index c5abbdead..bcc407382 100644 --- a/packages/query-graphql/src/resolvers/relations/helpers.ts +++ b/packages/query-graphql/src/resolvers/relations/helpers.ts @@ -1,6 +1,9 @@ import omit from 'lodash.omit'; +import { ModifyRelationOptions } from '@nestjs-query/core'; import { ResolverMethodOpts } from '../../decorators'; import { RelationTypeMap, ResolverRelation, ResolverRelationReference } from './relations.interface'; +import { CRUDAuthService } from '../../auth'; +import { getAuthFilter, getRelationAuthFilter } from '../helpers'; export const flattenRelations = | ResolverRelationReference>( relationOptions: RelationTypeMap, @@ -23,3 +26,17 @@ export const removeRelationOpts = ( 'disableRemove', ); }; + +export const getModifyRelationOptions = async ( + relationName: string, + authService?: CRUDAuthService, + context?: unknown, +): Promise | undefined> => { + if (!authService) { + return undefined; + } + return { + filter: await getAuthFilter(authService, context), + relationFilter: await getRelationAuthFilter(relationName, authService, context), + }; +}; 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 56c9bbc1a..6682a7f95 100644 --- a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts @@ -1,11 +1,11 @@ -import { Class } from '@nestjs-query/core'; +import { Class, mergeQuery } from '@nestjs-query/core'; import { ExecutionContext } from '@nestjs/common'; import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql'; import { getDTONames } from '../../common'; import { ResolverField } from '../../decorators'; import { CountRelationsLoader, DataLoaderFactory, FindRelationsLoader, QueryRelationsLoader } from '../../loader'; import { ConnectionType, QueryArgsType } from '../../types'; -import { transformAndValidate } from '../helpers'; +import { getRelationAuthFilter, transformAndValidate } from '../helpers'; import { BaseServiceResolver, ServiceResolver } from '../resolver.interface'; import { flattenRelations, removeRelationOpts } from './helpers'; import { RelationsOpts, ResolverRelation } from './relations.interface'; @@ -37,8 +37,12 @@ const ReadOneRelationMixin = (DTOClass: Class, relation: Res { nullable: relation.nullable, complexity: relation.complexity }, commonResolverOpts, ) - [`find${baseName}`](@Parent() dto: DTO, @Context() context: ExecutionContext): Promise { - return DataLoaderFactory.getOrCreateLoader(context, loaderName, findLoader.createLoader(this.service)).load(dto); + async [`find${baseName}`](@Parent() dto: DTO, @Context() context: ExecutionContext): Promise { + const relationFilter = await getRelationAuthFilter(baseNameLower, this.authService, context); + return DataLoaderFactory.getOrCreateLoader(context, loaderName, findLoader.createLoader(this.service)).load({ + dto, + filter: relationFilter, + }); } } return ReadOneMixin; @@ -90,9 +94,10 @@ const ReadManyRelationMixin = (DTOClass: Class, relation: Re countRelationLoaderName, countLoader.createLoader(this.service), ); + const relationFilter = await getRelationAuthFilter(pluralBaseNameLower, this.authService, context); return CT.createFromPromise( (query) => relationLoader.load({ dto, query }), - qa, + mergeQuery(qa, { filter: relationFilter }), (filter) => relationCountLoader.load({ dto, filter }), ); } diff --git a/packages/query-graphql/src/resolvers/relations/relations.interface.ts b/packages/query-graphql/src/resolvers/relations/relations.interface.ts index 61d54b4fb..545c5d07e 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.interface.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.interface.ts @@ -4,6 +4,7 @@ import { DTONamesOpts } from '../../common'; import { ResolverMethodOpts } from '../../decorators'; import { QueryArgsTypeOpts } from '../../types'; import { CursorConnectionOptions } from '../../types/connection/cursor'; +import { CRUDAuthOptions } from '../../auth'; export type ReferencesKeys = { [F in keyof Reference]?: keyof DTO; @@ -68,6 +69,8 @@ export type ResolverRelation = { allowFiltering?: boolean; complexity?: Complexity; + + auth?: CRUDAuthOptions; } & DTONamesOpts & ResolverMethodOpts & QueryArgsTypeOpts & diff --git a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts index 8ba1a851c..fccb16936 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts @@ -7,8 +7,9 @@ import { RemoveRelationsMixin } from './remove-relations.resolver'; import { UpdateRelationsMixin } from './update-relations.resolver'; import { getRelations } from '../../decorators'; import { getReferences } from '../../decorators/reference.decorator'; +import { BaseResolverOptions } from '../../decorators/resolver-method.decorator'; -export interface RelatableOpts { +export interface RelatableOpts extends BaseResolverOptions { enableTotalCount?: boolean; enableAggregate?: boolean; } @@ -19,8 +20,8 @@ export const Relatable = (DTOClass: Class, opts: RelatableOpts) = Base: B, ): B => { const { enableTotalCount, enableAggregate } = opts; - const relations = getRelations(DTOClass); - const references = getReferences(DTOClass); + const relations = getRelations(DTOClass, opts); + const references = getReferences(DTOClass, opts); const referencesMixin = ReferencesRelationMixin(DTOClass, references); const aggregateRelationsMixin = AggregateRelationsMixin(DTOClass, { ...relations, enableAggregate }); diff --git a/packages/query-graphql/src/resolvers/relations/remove-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/remove-relations.resolver.ts index 23451e58a..38c566ba3 100644 --- a/packages/query-graphql/src/resolvers/relations/remove-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/remove-relations.resolver.ts @@ -1,11 +1,11 @@ import { Class } from '@nestjs-query/core'; -import { Resolver, ArgsType, Args } from '@nestjs/graphql'; +import { Resolver, ArgsType, Args, Context } from '@nestjs/graphql'; import { getDTONames } from '../../common'; import { ResolverMutation } from '../../decorators'; import { MutationArgsType, RelationInputType, RelationsInputType } from '../../types'; import { transformAndValidate } from '../helpers'; import { ServiceResolver, BaseServiceResolver } from '../resolver.interface'; -import { flattenRelations, removeRelationOpts } from './helpers'; +import { flattenRelations, getModifyRelationOptions, removeRelationOpts } from './helpers'; import { RelationsOpts, ResolverRelation } from './relations.interface'; const RemoveOneRelationMixin = (DTOClass: Class, relation: ResolverRelation) => < @@ -27,9 +27,13 @@ const RemoveOneRelationMixin = (DTOClass: Class, relation: R @Resolver(() => DTOClass, { isAbstract: true }) class RemoveOneMixin extends Base { @ResolverMutation(() => DTOClass, {}, commonResolverOpts) - async [`remove${baseName}From${dtoNames.baseName}`](@Args() setArgs: SetArgs): Promise { + async [`remove${baseName}From${dtoNames.baseName}`]( + @Args() setArgs: SetArgs, + @Context() context?: unknown, + ): Promise { const { input } = await transformAndValidate(SetArgs, setArgs); - return this.service.removeRelation(relationName, input.id, input.relationId); + const opts = await getModifyRelationOptions(baseNameLower, this.authService, context); + return this.service.removeRelation(relationName, input.id, input.relationId, opts); } } return RemoveOneMixin; @@ -54,9 +58,13 @@ const RemoveManyRelationsMixin = (DTOClass: Class, relation: @Resolver(() => DTOClass, { isAbstract: true }) class Mixin extends Base { @ResolverMutation(() => DTOClass, {}, commonResolverOpts) - async [`remove${pluralBaseName}From${dtoNames.baseName}`](@Args() addArgs: AddArgs): Promise { + async [`remove${pluralBaseName}From${dtoNames.baseName}`]( + @Args() addArgs: AddArgs, + @Context() context?: unknown, + ): Promise { const { input } = await transformAndValidate(AddArgs, addArgs); - return this.service.removeRelations(relationName, input.id, input.relationIds); + const opts = await getModifyRelationOptions(pluralBaseNameLower, this.authService, context); + return this.service.removeRelations(relationName, input.id, input.relationIds, opts); } } return Mixin; diff --git a/packages/query-graphql/src/resolvers/relations/update-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/update-relations.resolver.ts index dcce04495..27e4dab60 100644 --- a/packages/query-graphql/src/resolvers/relations/update-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/update-relations.resolver.ts @@ -1,11 +1,11 @@ import { Class } from '@nestjs-query/core'; -import { Resolver, ArgsType, Args } from '@nestjs/graphql'; +import { Resolver, ArgsType, Args, Context } from '@nestjs/graphql'; import { getDTONames } from '../../common'; import { ResolverMutation } from '../../decorators'; import { MutationArgsType, RelationInputType, RelationsInputType } from '../../types'; import { transformAndValidate } from '../helpers'; import { ServiceResolver, BaseServiceResolver } from '../resolver.interface'; -import { flattenRelations, removeRelationOpts } from './helpers'; +import { flattenRelations, getModifyRelationOptions, removeRelationOpts } from './helpers'; import { RelationsOpts, ResolverRelation } from './relations.interface'; const UpdateOneRelationMixin = (DTOClass: Class, relation: ResolverRelation) => < @@ -27,9 +27,13 @@ const UpdateOneRelationMixin = (DTOClass: Class, relation: R @Resolver(() => DTOClass, { isAbstract: true }) class UpdateOneMixin extends Base { @ResolverMutation(() => DTOClass, {}, commonResolverOpts) - async [`set${baseName}On${dtoNames.baseName}`](@Args() setArgs: SetArgs): Promise { + async [`set${baseName}On${dtoNames.baseName}`]( + @Args() setArgs: SetArgs, + @Context() context?: unknown, + ): Promise { const { input } = await transformAndValidate(SetArgs, setArgs); - return this.service.setRelation(relationName, input.id, input.relationId); + const opts = await getModifyRelationOptions(baseNameLower, this.authService, context); + return this.service.setRelation(relationName, input.id, input.relationId, opts); } } return UpdateOneMixin; @@ -54,9 +58,13 @@ const UpdateManyRelationMixin = (DTOClass: Class, relation: @Resolver(() => DTOClass, { isAbstract: true }) class UpdateManyMixin extends Base { @ResolverMutation(() => DTOClass, {}, commonResolverOpts) - async [`add${pluralBaseName}To${dtoNames.baseName}`](@Args() addArgs: AddArgs): Promise { + async [`add${pluralBaseName}To${dtoNames.baseName}`]( + @Args() addArgs: AddArgs, + @Context() context?: unknown, + ): Promise { const { input } = await transformAndValidate(AddArgs, addArgs); - return this.service.addRelations(relationName, input.id, input.relationIds); + const opts = await getModifyRelationOptions(pluralBaseNameLower, this.authService, context); + return this.service.addRelations(relationName, input.id, input.relationIds, opts); } } return UpdateManyMixin; diff --git a/packages/query-graphql/src/resolvers/resolver.interface.ts b/packages/query-graphql/src/resolvers/resolver.interface.ts index ac06a756b..556b6e849 100644 --- a/packages/query-graphql/src/resolvers/resolver.interface.ts +++ b/packages/query-graphql/src/resolvers/resolver.interface.ts @@ -11,7 +11,7 @@ import { OffsetQueryArgsType, QueryArgsTypeOpts, } from '../types/query/query-args'; -import { RelationsOpts } from './relations'; +import { CRUDAuthService } from '../auth'; export interface ResolverOpts extends ResolverMethodOpts, DTONamesOpts { /** @@ -22,10 +22,6 @@ export interface ResolverOpts extends ResolverMethodOpts, DTONamesOpts { * Options for multiple record graphql endpoints */ many?: ResolverMethodOpts; - /** - * All relations that should be exposed on this resolver through `@ResolveField` from `@nestjs/graphql` - */ - relations?: RelationsOpts; } export interface SubscriptionResolverOpts extends SubscriptionResolverMethodOpts, DTONamesOpts { @@ -37,6 +33,7 @@ export interface SubscriptionResolverOpts extends SubscriptionResolverMethodOpts export interface ServiceResolver { service: QueryService; readonly pubSub?: GraphQLPubSub; + readonly authService?: CRUDAuthService; } /** @internal */ diff --git a/packages/query-graphql/src/resolvers/update.resolver.ts b/packages/query-graphql/src/resolvers/update.resolver.ts index ef1937f16..2d5d723ff 100644 --- a/packages/query-graphql/src/resolvers/update.resolver.ts +++ b/packages/query-graphql/src/resolvers/update.resolver.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line max-classes-per-file -import { Class, DeepPartial, DeleteManyResponse, UpdateManyResponse } from '@nestjs-query/core'; -import { ArgsType, InputType, Resolver, Args, PartialType } from '@nestjs/graphql'; +import { Class, DeepPartial, DeleteManyResponse, mergeFilter, UpdateManyResponse } from '@nestjs-query/core'; +import { ArgsType, InputType, Resolver, Args, PartialType, Context } from '@nestjs/graphql'; import omit from 'lodash.omit'; import { DTONames, getDTONames } from '../common'; import { EventType, getDTOEventName } from '../subscription'; @@ -22,7 +22,7 @@ import { UpdateOneHook, UpdateManyHook, } from '../decorators'; -import { createSubscriptionFilter, transformAndValidate } from './helpers'; +import { createSubscriptionFilter, getAuthFilter, transformAndValidate } from './helpers'; export type UpdatedEvent = { [eventName: string]: DTO }; export interface UpdateResolverOpts = DeepPartial> @@ -33,9 +33,9 @@ export interface UpdateResolverOpts = DeepPartia } export interface UpdateResolver> extends ServiceResolver { - updateOne(input: MutationArgsType>): Promise; + updateOne(input: MutationArgsType>, context?: unknown): Promise; - updateMany(input: MutationArgsType>): Promise; + updateMany(input: MutationArgsType>, context?: unknown): Promise; updatedOneSubscription(input?: SubscriptionArgsType): AsyncIterator>; @@ -142,10 +142,11 @@ export const Updateable = >(DTOClass: Class @Resolver(() => DTOClass, { isAbstract: true }) class UpdateResolverBase extends BaseClass { @ResolverMutation(() => DTOClass, { name: `updateOne${baseName}` }, commonResolverOpts, opts.one ?? {}) - async updateOne(@MutationArgs(UO, updateOneHook) input: UO): Promise { + async updateOne(@MutationArgs(UO, updateOneHook) input: UO, @Context() context?: unknown): Promise { const updateOne = await transformAndValidate(UO, input); + const authFilter = await getAuthFilter(this.authService, context); const { id, update } = updateOne.input; - const updateResult = await this.service.updateOne(id, update); + const updateResult = await this.service.updateOne(id, update, { filter: authFilter }); if (enableOneSubscriptions) { await this.publishUpdatedOneEvent(updateResult); } @@ -153,10 +154,14 @@ export const Updateable = >(DTOClass: Class } @ResolverMutation(() => UMR, { name: `updateMany${pluralBaseName}` }, commonResolverOpts, opts.many ?? {}) - async updateMany(@MutationArgs(UM, updateManyHook) input: UM): Promise { + async updateMany( + @MutationArgs(UM, updateManyHook) input: UM, + @Context() context?: unknown, + ): Promise { const updateMany = await transformAndValidate(UM, input); + const authFilter = await getAuthFilter(this.authService, context); const { update, filter } = updateMany.input; - const updateManyResponse = await this.service.updateMany(update, filter); + const updateManyResponse = await this.service.updateMany(update, mergeFilter(filter, authFilter ?? {})); if (enableManySubscriptions) { await this.publishUpdatedManyEvent(updateManyResponse); } diff --git a/packages/query-graphql/src/types/find-one-args.type.ts b/packages/query-graphql/src/types/find-one-args.type.ts new file mode 100644 index 000000000..778a94d36 --- /dev/null +++ b/packages/query-graphql/src/types/find-one-args.type.ts @@ -0,0 +1,27 @@ +import { Class } from '@nestjs-query/core'; +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { IsNotEmpty } from 'class-validator'; + +export interface FindOneArgsType { + id: string | number; +} + +/** @internal */ +let findOneType: Class | null = null; + +/** + * The input type for delete one endpoints. + */ +export function FindOneArgsType(): Class { + if (findOneType) { + return findOneType; + } + @ArgsType() + class FindOneArgs implements FindOneArgsType { + @IsNotEmpty() + @Field(() => ID, { description: 'The id of the record to find.' }) + id!: string | number; + } + findOneType = FindOneArgs; + return findOneType; +} diff --git a/packages/query-graphql/src/types/index.ts b/packages/query-graphql/src/types/index.ts index 7c4284268..6064b13d1 100644 --- a/packages/query-graphql/src/types/index.ts +++ b/packages/query-graphql/src/types/index.ts @@ -44,3 +44,4 @@ export { RelationInputType } from './relation-input.type'; export { SubscriptionArgsType } from './subscription-args.type'; export { SubscriptionFilterInputType } from './subscription-filter-input.type'; export { AggregateResponseType, AggregateResponseOpts, AggregateArgsType } from './aggregate'; +export { FindOneArgsType } from './find-one-args.type';