diff --git a/documentation/docs/graphql/authorization.mdx b/documentation/docs/graphql/authorization.mdx index 4fb1452fc..0a13944c0 100644 --- a/documentation/docs/graphql/authorization.mdx +++ b/documentation/docs/graphql/authorization.mdx @@ -409,13 +409,7 @@ import { SubTaskDTO } from './dto/sub-task.dto'; @Injectable() export class SubTaskAuthorizer implements Authorizer { authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise> { - const operationName = authorizationContext?.operationName; - - if ( - operationName.startsWith('query') || - operationName.startsWith('find') || - operationName.startsWith('aggregate') - ) { + if (authorizationContext?.readonly) { return Promise.resolve({}); } @@ -431,6 +425,39 @@ export class SubTaskAuthorizer implements Authorizer { } ``` +The `AuthorizationContext` has the following shape: + +```ts title='authorizer.ts' +interface AuthorizationContext { + /** The name of the method that uses the @AuthorizeFilter decorator */ + operationName: string; + + /** The group this operation belongs to */ + operationGroup: 'read' | 'aggregate' | 'create' | 'update' | 'delete'; + + /** If the operation does not modify any entities */ + readonly: boolean; + + /** If the operation can affect multiple entities */ + many: boolean; +} +``` + +This context is automatially created for you when using the built-in resolvers. +If you authorize custom methods by using the `@AuthorizerFilter()`, you have three options: + +- Pass your own `AuthorizationContext` as argument to the decorator: + ```ts + @AuthorizerFilter({ + operationName: 'completedTodoItems', + operationGroup: 'read', + readonly: true, + many: true + }) + ``` +- Pass a custom operation name as argument and let the context be inferred from the name (the nae should follow the conventions below): `@AuthorizerFilter('queryCompletedTodoItems')`. +- Don't pass a custom name and let the decorator use the name of the decorated method: `@AuthorizerFilter()` + The `operationName` is the name of the method that makes use of the `@AuthorizerFilter()` or the argument passed to this decorator (`@AuthorizerFilter('customMethodName')`). The names of the generated CRUD resolver methods are similar to the ones of the [QueryService](../concepts/services.mdx): diff --git a/examples/auth/src/sub-task/sub-task.authorizer.ts b/examples/auth/src/sub-task/sub-task.authorizer.ts index f28f8869c..89a2e1da8 100644 --- a/examples/auth/src/sub-task/sub-task.authorizer.ts +++ b/examples/auth/src/sub-task/sub-task.authorizer.ts @@ -5,13 +5,7 @@ import { SubTaskDTO } from './dto/sub-task.dto'; export class SubTaskAuthorizer implements Authorizer { authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise> { - const operationName = authorizationContext?.operationName; - if ( - context.req.user.username === 'nestjs-query-3' && - (operationName?.startsWith('query') || - operationName?.startsWith('find') || - operationName?.startsWith('aggregate')) - ) { + if (context.req.user.username === 'nestjs-query-3' && authorizationContext?.readonly) { return Promise.resolve({}); } return Promise.resolve({ ownerId: { eq: context.req.user.id } }); diff --git a/examples/auth/src/todo-item/dto/todo-item.dto.ts b/examples/auth/src/todo-item/dto/todo-item.dto.ts index f93191eb5..235fa9c02 100644 --- a/examples/auth/src/todo-item/dto/todo-item.dto.ts +++ b/examples/auth/src/todo-item/dto/todo-item.dto.ts @@ -16,12 +16,9 @@ import { UserContext } from '../../auth/auth.interfaces'; @QueryOptions({ enableTotalCount: true }) @Authorize({ authorize: (context: UserContext, authorizationContext?: AuthorizationContext) => { - const operationName = authorizationContext?.operationName; if ( context.req.user.username === 'nestjs-query-3' && - (operationName?.startsWith('query') || - operationName?.startsWith('find') || - operationName?.startsWith('aggregate')) + (authorizationContext?.operationGroup === 'read' || authorizationContext?.operationGroup === 'aggregate') ) { return {}; } diff --git a/examples/auth/src/todo-item/todo-item.resolver.ts b/examples/auth/src/todo-item/todo-item.resolver.ts index c14db3f80..071461860 100644 --- a/examples/auth/src/todo-item/todo-item.resolver.ts +++ b/examples/auth/src/todo-item/todo-item.resolver.ts @@ -17,7 +17,7 @@ export class TodoItemResolver { @Query(() => TodoItemConnection) async completedTodoItems( @Args() query: TodoItemQuery, - @AuthorizerFilter() authFilter: Filter, + @AuthorizerFilter('queryCompletedTodoItems') authFilter: Filter, ): Promise> { // add the completed filter the user provided filter const filter: Filter = mergeFilter(query.filter ?? {}, { completed: { is: true } }); @@ -33,7 +33,13 @@ export class TodoItemResolver { @Query(() => TodoItemConnection) async uncompletedTodoItems( @Args() query: TodoItemQuery, - @AuthorizerFilter() authFilter: Filter, + @AuthorizerFilter({ + operationName: 'queryUncompletedTodoItems', + operationGroup: 'read', + readonly: true, + many: true, + }) + authFilter: Filter, ): Promise> { // add the completed filter the user provided filter const filter: Filter = mergeFilter(query.filter ?? {}, { completed: { is: false } }); diff --git a/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts b/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts index 5d69f7e01..85d2aec62 100644 --- a/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts +++ b/packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts @@ -83,7 +83,10 @@ describe('createDefaultAuthorizer', () => { it('should create an auth filter that depends on the passed operation name', async () => { const authorizer = testingModule.get>(getAuthorizerToken(TestDTO)); - const filter = await authorizer.authorize({ user: { id: 2 } }, { operationName: 'other' }); + const filter = await authorizer.authorize( + { user: { id: 2 } }, + { operationName: 'other', operationGroup: 'read', readonly: true, many: true }, + ); expect(filter).toEqual({ ownerId: { neq: 2 } }); }); @@ -107,7 +110,11 @@ describe('createDefaultAuthorizer', () => { it('should create an auth filter that depends on the passed operation name for relations using the relation options', async () => { const authorizer = testingModule.get>(getAuthorizerToken(TestDTO)); - const filter = await authorizer.authorizeRelation('relations', { user: { id: 2 } }, { operationName: 'other' }); + const filter = await authorizer.authorizeRelation( + 'relations', + { user: { id: 2 } }, + { operationName: 'other', operationGroup: 'read', readonly: true, many: true }, + ); expect(filter).toEqual({ relationOwnerId: { neq: 2 } }); }); diff --git a/packages/query-graphql/src/auth/authorizer.ts b/packages/query-graphql/src/auth/authorizer.ts index 52476f9f3..b9c402af8 100644 --- a/packages/query-graphql/src/auth/authorizer.ts +++ b/packages/query-graphql/src/auth/authorizer.ts @@ -1,7 +1,19 @@ import { Filter } from '@nestjs-query/core'; +export type AuthorizationOperationGroup = 'read' | 'aggregate' | 'create' | 'update' | 'delete'; + export interface AuthorizationContext { + /** The name of the method that uses the @AuthorizeFilter decorator */ operationName: string; + + /** The group this operation belongs to */ + operationGroup: AuthorizationOperationGroup; + + /** If the operation does not modify any entities */ + readonly: boolean; + + /** If the operation can affect multiple entities */ + many: boolean; } export interface Authorizer { diff --git a/packages/query-graphql/src/decorators/authorize-filter.decorator.ts b/packages/query-graphql/src/decorators/authorize-filter.decorator.ts index 240dc76f4..6ffc09756 100644 --- a/packages/query-graphql/src/decorators/authorize-filter.decorator.ts +++ b/packages/query-graphql/src/decorators/authorize-filter.decorator.ts @@ -1,7 +1,7 @@ import { ModifyRelationOptions } from '@nestjs-query/core'; import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; -import { AuthorizationContext } from '../auth'; +import { AuthorizationContext, AuthorizationOperationGroup } from '../auth'; import { AuthorizerContext } from '../interceptors'; function getContext(executionContext: ExecutionContext): C { @@ -30,36 +30,80 @@ function getRelationAuthFilter>( return context.authorizer.authorizeRelation(relationName, context, authorizationContext); } -export function AuthorizerFilter(operationName?: string): ParameterDecorator { +function getAuthorizationContext(operationNameOrContext: string | AuthorizationContext): AuthorizationContext { + if (typeof operationNameOrContext !== 'string') { + return operationNameOrContext; + } + + const lcMethodName = operationNameOrContext.toLowerCase(); + + const isCreate = lcMethodName.startsWith('create'); + const isUpdate = lcMethodName.startsWith('update') || lcMethodName.startsWith('set'); + const isDelete = lcMethodName.startsWith('delete') || lcMethodName.startsWith('remove'); + const isAggregate = lcMethodName.startsWith('aggregate'); + const isQuery = lcMethodName.startsWith('query'); + const isFind = lcMethodName.startsWith('find'); + const isMany = lcMethodName.endsWith('many'); + + let operationGroup: AuthorizationOperationGroup = 'read'; + + if (isCreate) { + operationGroup = 'create'; + } else if (isDelete) { + operationGroup = 'delete'; + } else if (isUpdate) { + operationGroup = 'update'; + } else if (isAggregate) { + operationGroup = 'aggregate'; + } + + return { + operationName: operationNameOrContext, + operationGroup, + readonly: isQuery || isFind || isAggregate, + many: isMany || isQuery || isAggregate, + }; +} + +export function AuthorizerFilter(): ParameterDecorator; +export function AuthorizerFilter(context: AuthorizationContext): ParameterDecorator; +export function AuthorizerFilter(operationName: string): ParameterDecorator; +export function AuthorizerFilter(operationNameOrContext?: string | AuthorizationContext): ParameterDecorator { // eslint-disable-next-line @typescript-eslint/ban-types return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { - const authorizationContext: AuthorizationContext = { - operationName: operationName ?? propertyKey.toString(), - }; + const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString()); return createParamDecorator((data: unknown, executionContext: ExecutionContext) => getAuthorizerFilter(getContext>(executionContext), authorizationContext), )()(target, propertyKey, parameterIndex); }; } -export function RelationAuthorizerFilter(relationName: string, operationName?: string): ParameterDecorator { +export function RelationAuthorizerFilter(relationName: string): ParameterDecorator; +export function RelationAuthorizerFilter(relationName: string, operationName: string): ParameterDecorator; +export function RelationAuthorizerFilter(relationName: string, context: AuthorizationContext): ParameterDecorator; +export function RelationAuthorizerFilter( + relationName: string, + operationNameOrContext?: string | AuthorizationContext, +): ParameterDecorator { // eslint-disable-next-line @typescript-eslint/ban-types return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { - const authorizationContext: AuthorizationContext = { - operationName: operationName ?? propertyKey.toString(), - }; + const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString()); return createParamDecorator((data: unknown, executionContext: ExecutionContext) => getRelationAuthFilter(getContext>(executionContext), relationName, authorizationContext), )()(target, propertyKey, parameterIndex); }; } -export function ModifyRelationAuthorizerFilter(relationName: string, operationName?: string): ParameterDecorator { +export function ModifyRelationAuthorizerFilter(relationName: string): ParameterDecorator; +export function ModifyRelationAuthorizerFilter(relationName: string, operationName: string): ParameterDecorator; +export function ModifyRelationAuthorizerFilter(relationName: string, context: AuthorizationContext): ParameterDecorator; +export function ModifyRelationAuthorizerFilter( + relationName: string, + operationNameOrContext?: string | AuthorizationContext, +): ParameterDecorator { // eslint-disable-next-line @typescript-eslint/ban-types return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { - const authorizationContext: AuthorizationContext = { - operationName: operationName ?? propertyKey.toString(), - }; + const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString()); return createParamDecorator( async (data: unknown, executionContext: ExecutionContext): Promise> => { const context = getContext>(executionContext);