Skip to content

Commit

Permalink
refactor(graphql,auth,#1026): Removed auth context inference
Browse files Browse the repository at this point in the history
  • Loading branch information
mwoelk authored and doug-martin committed Apr 13, 2021
1 parent 4c7905e commit eecf082
Show file tree
Hide file tree
Showing 15 changed files with 185 additions and 98 deletions.
53 changes: 32 additions & 21 deletions documentation/docs/graphql/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -428,12 +428,20 @@ export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
The `AuthorizationContext` has the following shape:

```ts title='authorizer.ts'
export enum OperationGroup {
READ = 'read',
AGGREGATE = 'aggregate',
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
}

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';
operationGroup: OperationGroup;

/** If the operation does not modify any entities */
readonly: boolean;
Expand All @@ -444,26 +452,27 @@ interface AuthorizationContext {
```

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

- `query`
If you authorize custom methods by using the `@AuthorizerFilter()`, you should pass the context as argument to the decorator:

```ts
@AuthorizerFilter({
operationName: 'completedTodoItems',
operationGroup: OperationGroup.READ,
readonly: true,
many: true
})
```

You can leave out the `operationName` to let the context use the name of the decorated Method.
If you leave out the `readonly` property, it's inferred from the `operationGroup`.

The `operationNames` of the generated CRUD resolver methods are similar to the ones of the [QueryService](../concepts/services.mdx):

- `queryMany`
- `findById`
- `aggregate`
- `createOne`
- `createMany`
- `updateOne`
- `updateMany`
- `deleteOne`
Expand All @@ -474,8 +483,10 @@ The names of the generated CRUD resolver methods are similar to the ones of the
- `query{PluralRelationName}` (e.g. querySubTasks)
- `find{SingularRelationName}` (e.g. findTodoItem)
- `aggregate{PluralRelationName}` (e.g. aggregateSubTasks)
- `remove{RelationName}from{SingularParentName}` (e.g. removeSubTaskFromTodoItem)
- `set{RelationName}On{SingularParentName}` (e.g. setSubTaskOnTodoItem)
- `remove{SingularRelationName}from{SingularParentName}` (e.g. removeSubTaskFromTodoItem)
- `remove{PluralRelationName}from{SingularParentName}` (e.g. removeSubTasksFromTodoItem)
- `set{SingularRelationName}On{SingularParentName}` (e.g. setSubTaskOnTodoItem)
- `add{PluralRelationName}On{SingularParentName}` (e.g. addSubTasksOnTodoItem)

## Complete Example

Expand Down
17 changes: 12 additions & 5 deletions examples/auth/src/todo-item/todo-item.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Filter, InjectAssemblerQueryService, mergeFilter, mergeQuery, QueryService } from '@nestjs-query/core';
import { AuthorizerInterceptor, AuthorizerFilter, ConnectionType } from '@nestjs-query/query-graphql';
import {
AuthorizerInterceptor,
AuthorizerFilter,
ConnectionType,
AuthorizationOperationGroup,
} from '@nestjs-query/query-graphql';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { TodoItemDTO } from './dto/todo-item.dto';
Expand All @@ -17,7 +22,11 @@ export class TodoItemResolver {
@Query(() => TodoItemConnection)
async completedTodoItems(
@Args() query: TodoItemQuery,
@AuthorizerFilter('queryCompletedTodoItems') authFilter: Filter<TodoItemDTO>,
@AuthorizerFilter({
operationGroup: AuthorizationOperationGroup.READ,
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: true } });
Expand All @@ -34,9 +43,7 @@ export class TodoItemResolver {
async uncompletedTodoItems(
@Args() query: TodoItemQuery,
@AuthorizerFilter({
operationName: 'queryUncompletedTodoItems',
operationGroup: 'read',
readonly: true,
operationGroup: AuthorizationOperationGroup.READ,
many: true,
})
authFilter: Filter<TodoItemDTO>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { Filter } from '@nestjs-query/core';
import { Injectable } from '@nestjs/common';
import { Authorizer, Relation, Authorize, UnPagedRelation } from '../../src';
import { AuthorizationContext, getAuthorizerToken } from '../../src/auth';
import { AuthorizationContext, OperationGroup, getAuthorizerToken } from '../../src/auth';
import { createAuthorizerProviders } from '../../src/providers';

describe('createDefaultAuthorizer', () => {
Expand Down Expand Up @@ -85,7 +85,7 @@ describe('createDefaultAuthorizer', () => {
const authorizer = testingModule.get<Authorizer<TestDTO>>(getAuthorizerToken(TestDTO));
const filter = await authorizer.authorize(
{ user: { id: 2 } },
{ operationName: 'other', operationGroup: 'read', readonly: true, many: true },
{ operationName: 'other', operationGroup: OperationGroup.READ, readonly: true, many: true },
);
expect(filter).toEqual({ ownerId: { neq: 2 } });
});
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'relations',
{ user: { id: 2 } },
{ operationName: 'other', operationGroup: 'read', readonly: true, many: true },
{ operationName: 'other', operationGroup: OperationGroup.READ, readonly: true, many: true },
);
expect(filter).toEqual({ relationOwnerId: { neq: 2 } });
});
Expand Down
10 changes: 8 additions & 2 deletions packages/query-graphql/src/auth/authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Filter } from '@nestjs-query/core';

export type AuthorizationOperationGroup = 'read' | 'aggregate' | 'create' | 'update' | 'delete';
export enum OperationGroup {
READ = 'read',
AGGREGATE = 'aggregate',
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
}

export interface AuthorizationContext {
/** The name of the method that uses the @AuthorizeFilter decorator */
operationName: string;

/** The group this operation belongs to */
operationGroup: AuthorizationOperationGroup;
operationGroup: OperationGroup;

/** If the operation does not modify any entities */
readonly: boolean;
Expand Down
70 changes: 22 additions & 48 deletions packages/query-graphql/src/decorators/authorize-filter.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { ModifyRelationOptions } from '@nestjs-query/core';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthorizationContext, AuthorizationOperationGroup } from '../auth';
import { AuthorizationContext, OperationGroup } from '../auth';
import { AuthorizerContext } from '../interceptors';

type PartialAuthorizationContext = Partial<AuthorizationContext> &
Pick<AuthorizationContext, 'operationGroup' | 'many'>;

function getContext<C>(executionContext: ExecutionContext): C {
const gqlExecutionContext = GqlExecutionContext.create(executionContext);
return gqlExecutionContext.getContext<C>();
}

function getAuthorizerFilter<C extends AuthorizerContext<unknown>>(
context: C,
authorizationContext: AuthorizationContext,
authorizationContext?: AuthorizationContext,
) {
if (!context.authorizer) {
return undefined;
Expand All @@ -22,88 +25,59 @@ function getAuthorizerFilter<C extends AuthorizerContext<unknown>>(
function getRelationAuthFilter<C extends AuthorizerContext<unknown>>(
context: C,
relationName: string,
authorizationContext: AuthorizationContext,
authorizationContext?: AuthorizationContext,
) {
if (!context.authorizer) {
return undefined;
}
return context.authorizer.authorizeRelation(relationName, context, authorizationContext);
}

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';
}
function getAuthorizationContext(
methodName: string | symbol,
partialAuthContext?: PartialAuthorizationContext,
): AuthorizationContext | undefined {
if (!partialAuthContext) return undefined;

return {
operationName: operationNameOrContext,
operationGroup,
readonly: isQuery || isFind || isAggregate,
many: isMany || isQuery || isAggregate,
operationName: methodName.toString(),
readonly:
partialAuthContext.operationGroup === OperationGroup.READ ||
partialAuthContext.operationGroup === OperationGroup.AGGREGATE,
...partialAuthContext,
};
}

export function AuthorizerFilter(): ParameterDecorator;
export function AuthorizerFilter(context: AuthorizationContext): ParameterDecorator;
export function AuthorizerFilter(operationName: string): ParameterDecorator;
export function AuthorizerFilter<DTO>(operationNameOrContext?: string | AuthorizationContext): ParameterDecorator {
export function AuthorizerFilter<DTO>(partialAuthContext?: PartialAuthorizationContext): ParameterDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString());
const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext);
return createParamDecorator((data: unknown, executionContext: ExecutionContext) =>
getAuthorizerFilter(getContext<AuthorizerContext<DTO>>(executionContext), authorizationContext),
)()(target, propertyKey, parameterIndex);
};
}

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,
partialAuthContext?: PartialAuthorizationContext,
): ParameterDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString());
const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext);
return createParamDecorator((data: unknown, executionContext: ExecutionContext) =>
getRelationAuthFilter(getContext<AuthorizerContext<DTO>>(executionContext), relationName, authorizationContext),
)()(target, propertyKey, parameterIndex);
};
}

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,
partialAuthContext?: PartialAuthorizationContext,
): ParameterDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
const authorizationContext = getAuthorizationContext(operationNameOrContext ?? propertyKey.toString());
const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext);
return createParamDecorator(
async (data: unknown, executionContext: ExecutionContext): Promise<ModifyRelationOptions<unknown, unknown>> => {
const context = getContext<AuthorizerContext<DTO>>(executionContext);
Expand Down
7 changes: 6 additions & 1 deletion packages/query-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export { DTONamesOpts } from './common';
export { NestjsQueryGraphQLModule } from './module';
export { AutoResolverOpts } from './providers';
export { pubSubToken, GraphQLPubSub } from './subscription';
export { Authorizer, AuthorizerOptions, AuthorizationContext } from './auth';
export {
Authorizer,
AuthorizerOptions,
AuthorizationContext,
OperationGroup as AuthorizationOperationGroup,
} from './auth';
export {
Hook,
HookTypes,
Expand Down
7 changes: 6 additions & 1 deletion packages/query-graphql/src/resolvers/aggregate.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AggregateQueryParam, AuthorizerFilter, ResolverMethodOpts, ResolverQuer
import { AggregateArgsType, AggregateResponseType } from '../types';
import { transformAndValidate } from './helpers';
import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface';
import { OperationGroup } from '../auth';

export type AggregateResolverOpts = {
enabled?: boolean;
Expand Down Expand Up @@ -51,7 +52,11 @@ export const Aggregateable = <DTO, QS extends QueryService<DTO, unknown, unknown
async aggregate(
@Args() args: AA,
@AggregateQueryParam() query: AggregateQuery<DTO>,
@AuthorizerFilter() authFilter?: Filter<DTO>,
@AuthorizerFilter({
operationGroup: OperationGroup.AGGREGATE,
many: true,
})
authFilter?: Filter<DTO>,
): Promise<AggregateResponse<DTO>[]> {
const qa = await transformAndValidate(AA, args);
return this.service.aggregate(mergeFilter(qa.filter || {}, authFilter ?? {}), query);
Expand Down
22 changes: 18 additions & 4 deletions packages/query-graphql/src/resolvers/create.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../types';
import { createSubscriptionFilter } from './helpers';
import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface';
import { OperationGroup } from '../auth';

export type CreatedEvent<DTO> = { [eventName: string]: DTO };

Expand Down Expand Up @@ -135,8 +136,14 @@ export const Creatable = <DTO, C, QS extends QueryService<DTO, C, unknown>>(
},
opts.one ?? {},
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async createOne(@MutationHookArgs() input: CO, @AuthorizerFilter() authorizeFilter?: Filter<DTO>): Promise<DTO> {
async createOne(
@MutationHookArgs() input: CO,
@AuthorizerFilter({
operationGroup: OperationGroup.CREATE,
many: false,
}) // eslint-disable-next-line @typescript-eslint/no-unused-vars
authorizeFilter?: Filter<DTO>,
): Promise<DTO> {
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createOne(input.input.input);
if (enableOneSubscriptions) {
Expand All @@ -157,8 +164,15 @@ export const Creatable = <DTO, C, QS extends QueryService<DTO, C, unknown>>(
},
opts.many ?? {},
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async createMany(@MutationHookArgs() input: CM, @AuthorizerFilter() authorizeFilter?: Filter<DTO>): Promise<DTO[]> {
async createMany(
@MutationHookArgs() input: CM,

@AuthorizerFilter({
operationGroup: OperationGroup.CREATE,
many: true,
}) // eslint-disable-next-line @typescript-eslint/no-unused-vars
authorizeFilter?: Filter<DTO>,
): Promise<DTO[]> {
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createMany(input.input.input);
if (enableManySubscriptions) {
Expand Down
Loading

0 comments on commit eecf082

Please sign in to comment.