Skip to content

Commit

Permalink
feat(graphql,auth,doug-martin#1026): Added convenience fields to auth…
Browse files Browse the repository at this point in the history
… context
  • Loading branch information
mwoelk committed Apr 5, 2021
1 parent 81ea148 commit 13a4d73
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 35 deletions.
41 changes: 34 additions & 7 deletions documentation/docs/graphql/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -409,13 +409,7 @@ import { SubTaskDTO } from './dto/sub-task.dto';
@Injectable()
export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise<Filter<SubTaskDTO>> {
const operationName = authorizationContext?.operationName;

if (
operationName.startsWith('query') ||
operationName.startsWith('find') ||
operationName.startsWith('aggregate')
) {
if (authorizationContext?.readonly) {
return Promise.resolve({});
}

Expand All @@ -431,6 +425,39 @@ export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
}
```

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):

Expand Down
8 changes: 1 addition & 7 deletions examples/auth/src/sub-task/sub-task.authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,7 @@ import { SubTaskDTO } from './dto/sub-task.dto';

export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise<Filter<SubTaskDTO>> {
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 } });
Expand Down
5 changes: 1 addition & 4 deletions examples/auth/src/todo-item/dto/todo-item.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
Expand Down
10 changes: 8 additions & 2 deletions examples/auth/src/todo-item/todo-item.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class TodoItemResolver {
@Query(() => TodoItemConnection)
async completedTodoItems(
@Args() query: TodoItemQuery,
@AuthorizerFilter() authFilter: Filter<TodoItemDTO>,
@AuthorizerFilter('queryCompletedTodoItems') authFilter: Filter<TodoItemDTO>,
): Promise<ConnectionType<TodoItemDTO>> {
// add the completed filter the user provided filter
const filter: Filter<TodoItemDTO> = mergeFilter(query.filter ?? {}, { completed: { is: true } });
Expand All @@ -33,7 +33,13 @@ export class TodoItemResolver {
@Query(() => TodoItemConnection)
async uncompletedTodoItems(
@Args() query: TodoItemQuery,
@AuthorizerFilter() authFilter: Filter<TodoItemDTO>,
@AuthorizerFilter({
operationName: 'queryUncompletedTodoItems',
operationGroup: 'read',
readonly: true,
many: true,
})
authFilter: Filter<TodoItemDTO>,
): Promise<ConnectionType<TodoItemDTO>> {
// add the completed filter the user provided filter
const filter: Filter<TodoItemDTO> = mergeFilter(query.filter ?? {}, { completed: { is: false } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ describe('createDefaultAuthorizer', () => {

it('should create an auth filter that depends on the passed operation name', async () => {
const authorizer = testingModule.get<Authorizer<TestDTO>>(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 } });
});

Expand All @@ -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<Authorizer<TestDTO>>(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 } });
});

Expand Down
12 changes: 12 additions & 0 deletions packages/query-graphql/src/auth/authorizer.ts
Original file line number Diff line number Diff line change
@@ -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<DTO> {
Expand Down
70 changes: 57 additions & 13 deletions packages/query-graphql/src/decorators/authorize-filter.decorator.ts
Original file line number Diff line number Diff line change
@@ -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<C>(executionContext: ExecutionContext): C {
Expand Down Expand Up @@ -30,36 +30,80 @@ function getRelationAuthFilter<C extends AuthorizerContext<unknown>>(
return context.authorizer.authorizeRelation(relationName, context, authorizationContext);
}

export function AuthorizerFilter<DTO>(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<DTO>(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<AuthorizerContext<DTO>>(executionContext), authorizationContext),
)()(target, propertyKey, parameterIndex);
};
}

export function RelationAuthorizerFilter<DTO>(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<DTO>(
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<AuthorizerContext<DTO>>(executionContext), relationName, authorizationContext),
)()(target, propertyKey, parameterIndex);
};
}

export function ModifyRelationAuthorizerFilter<DTO>(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<DTO>(
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<ModifyRelationOptions<unknown, unknown>> => {
const context = getContext<AuthorizerContext<DTO>>(executionContext);
Expand Down

0 comments on commit 13a4d73

Please sign in to comment.