Skip to content

Commit

Permalink
refactor(graphql,auth,#1026): Move authorizer argument into context
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 4343821 commit 1f4b239
Show file tree
Hide file tree
Showing 8 changed files with 66 additions and 37 deletions.
8 changes: 5 additions & 3 deletions documentation/docs/graphql/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ If you are extending the `CRUDResolver` directly be sure to [register your DTOs
Sometimes it might be necessary to perform different authorization based on the kind of operation an user wants to execute.
E.g. some users could be allowed to read all todo items but only update/delete their own.

In this case we can make use of the second parameter of the `authorize` function in our custom `Authorizer` or the one passed to the `@Authorizer` decorator which gets passed the name of the operation that should be authorized:
In this case we can make use of the second parameter of the `authorize` function in our custom `Authorizer` or the one passed to the `@Authorizer` decorator which gets passed additional `AuthorizationContext` such as the name of the operation that should be authorized:

```ts title='sub-task/sub-task.authorizer.ts'
import { Injectable } from '@nestjs/common';
Expand All @@ -408,7 +408,9 @@ import { SubTaskDTO } from './dto/sub-task.dto';

@Injectable()
export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
authorize(context: UserContext, operationName?: string): Promise<Filter<SubTaskDTO>> {
authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise<Filter<SubTaskDTO>> {
const operationName = authorizationContext?.operationName;

if (
operationName.startsWith('query') ||
operationName.startsWith('find') ||
Expand All @@ -429,7 +431,7 @@ export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
}
```

The operation name is the name of the method that makes use of the `@AuthorizerFilter()` or the argument passed to this decorator (`@AuthorizerFilter('customMethodName')`).
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`
Expand Down
5 changes: 3 additions & 2 deletions examples/auth/src/sub-task/sub-task.authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Authorizer } from '@nestjs-query/query-graphql';
import { Authorizer, AuthorizationContext } from '@nestjs-query/query-graphql';
import { Filter } from '@nestjs-query/core';
import { UserContext } from '../auth/auth.interfaces';
import { SubTaskDTO } from './dto/sub-task.dto';

export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
authorize(context: UserContext, operationName?: string): Promise<Filter<SubTaskDTO>> {
authorize(context: UserContext, authorizationContext?: AuthorizationContext): Promise<Filter<SubTaskDTO>> {
const operationName = authorizationContext?.operationName;
if (
context.req.user.username === 'nestjs-query-3' &&
(operationName?.startsWith('query') ||
Expand Down
4 changes: 3 additions & 1 deletion examples/auth/src/todo-item/dto/todo-item.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Relation,
FilterableCursorConnection,
QueryOptions,
AuthorizationContext,
} from '@nestjs-query/query-graphql';
import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql';
import { SubTaskDTO } from '../../sub-task/dto/sub-task.dto';
Expand All @@ -14,7 +15,8 @@ import { UserContext } from '../../auth/auth.interfaces';
@ObjectType('TodoItem')
@QueryOptions({ enableTotalCount: true })
@Authorize({
authorize: (context: UserContext, operationName?: string) => {
authorize: (context: UserContext, authorizationContext?: AuthorizationContext) => {
const operationName = authorizationContext?.operationName;
if (
context.req.user.username === 'nestjs-query-3' &&
(operationName?.startsWith('query') ||
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 { getAuthorizerToken } from '../../src/auth';
import { AuthorizationContext, getAuthorizerToken } from '../../src/auth';
import { createAuthorizerProviders } from '../../src/providers';

describe('createDefaultAuthorizer', () => {
Expand Down Expand Up @@ -36,13 +36,15 @@ describe('createDefaultAuthorizer', () => {
}

@Authorize({
authorize: (ctx: UserContext, operationName?: string) =>
operationName === 'other' ? { ownerId: { neq: ctx.user.id } } : { ownerId: { eq: ctx.user.id } },
authorize: (ctx: UserContext, authorizationContext?: AuthorizationContext) =>
authorizationContext?.operationName === 'other'
? { ownerId: { neq: ctx.user.id } }
: { ownerId: { eq: ctx.user.id } },
})
@Relation('relations', () => TestRelation, {
auth: {
authorize: (ctx: UserContext, operationName?: string) =>
operationName === 'other'
authorize: (ctx: UserContext, authorizationContext?: AuthorizationContext) =>
authorizationContext?.operationName === 'other'
? { relationOwnerId: { neq: ctx.user.id } }
: { relationOwnerId: { eq: ctx.user.id } },
},
Expand Down Expand Up @@ -81,7 +83,7 @@ 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 } }, 'other');
const filter = await authorizer.authorize({ user: { id: 2 } }, { operationName: 'other' });
expect(filter).toEqual({ ownerId: { neq: 2 } });
});

Expand All @@ -105,7 +107,7 @@ 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 } }, 'other');
const filter = await authorizer.authorizeRelation('relations', { user: { id: 2 } }, { operationName: 'other' });
expect(filter).toEqual({ relationOwnerId: { neq: 2 } });
});

Expand Down
14 changes: 11 additions & 3 deletions packages/query-graphql/src/auth/authorizer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Filter } from '@nestjs-query/core';

export interface AuthorizationContext {
operationName: string;
}

export interface Authorizer<DTO> {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any
authorize(context: any, operationName?: string): Promise<Filter<DTO>>;
authorize(context: any, authorizerContext?: AuthorizationContext): Promise<Filter<DTO>>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
authorizeRelation(relationName: string, context: any, operationName?: string): Promise<Filter<unknown>>;
authorizeRelation(
relationName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
authorizerContext?: AuthorizationContext,
): Promise<Filter<unknown>>;
}
22 changes: 13 additions & 9 deletions packages/query-graphql/src/auth/default-crud.authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { Injectable } from '@nestjs/common';
import { getAuthorizer, getRelations } from '../decorators';
import { getAuthorizerToken } from './tokens';
import { ResolverRelation } from '../resolvers/relations';
import { Authorizer } from './authorizer';
import { Authorizer, AuthorizationContext } from './authorizer';

export interface AuthorizerOptions<DTO> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authorize: (context: any, operationName?: string) => Filter<DTO> | Promise<Filter<DTO>>;
authorize: (context: any, authorizationContext?: AuthorizationContext) => Filter<DTO> | Promise<Filter<DTO>>;
}

const createRelationAuthorizer = (opts: AuthorizerOptions<unknown>): Authorizer<unknown> => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async authorize(context: any, operationName?: string): Promise<Filter<unknown>> {
return opts.authorize(context, operationName) ?? {};
async authorize(context: any, authorizationContext?: AuthorizationContext): Promise<Filter<unknown>> {
return opts.authorize(context, authorizationContext) ?? {};
},
authorizeRelation(): Promise<Filter<unknown>> {
return Promise.reject(new Error('Not implemented'));
Expand Down Expand Up @@ -43,13 +43,17 @@ export function createDefaultAuthorizer<DTO>(
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async authorize(context: any, operationName?: string): Promise<Filter<DTO>> {
return this.authOptions?.authorize(context, operationName) ?? {};
async authorize(context: any, authorizationContext?: AuthorizationContext): Promise<Filter<DTO>> {
return this.authOptions?.authorize(context, authorizationContext) ?? {};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async authorizeRelation(relationName: string, context: any, operationName?: string): Promise<Filter<unknown>> {
return this.relationsAuthorizers.get(relationName)?.authorize(context, operationName) ?? {};
async authorizeRelation(
relationName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
authorizationContext?: AuthorizationContext,
): Promise<Filter<unknown>> {
return this.relationsAuthorizers.get(relationName)?.authorize(context, authorizationContext) ?? {};
}

private get relations(): Map<string, ResolverRelation<unknown>> {
Expand Down
32 changes: 21 additions & 11 deletions packages/query-graphql/src/decorators/authorize-filter.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,71 @@
import { ModifyRelationOptions } from '@nestjs-query/core';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthorizationContext } from '../auth';
import { AuthorizerContext } from '../interceptors';

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

function getAuthorizerFilter<C extends AuthorizerContext<unknown>>(context: C, operationName: string) {
function getAuthorizerFilter<C extends AuthorizerContext<unknown>>(
context: C,
authorizationContext: AuthorizationContext,
) {
if (!context.authorizer) {
return undefined;
}
return context.authorizer.authorize(context, operationName);
return context.authorizer.authorize(context, authorizationContext);
}

function getRelationAuthFilter<C extends AuthorizerContext<unknown>>(
context: C,
relationName: string,
operationName: string,
authorizationContext: AuthorizationContext,
) {
if (!context.authorizer) {
return undefined;
}
return context.authorizer.authorizeRelation(relationName, context, operationName);
return context.authorizer.authorizeRelation(relationName, context, authorizationContext);
}

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

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

export function ModifyRelationAuthorizerFilter<DTO>(relationName: string, operationName?: string): ParameterDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return (target: Object, propertyKey: string | symbol, parameterIndex: number) => {
const name = operationName ?? propertyKey.toString();
const authorizationContext: AuthorizationContext = {
operationName: operationName ?? propertyKey.toString(),
};
return createParamDecorator(
async (data: unknown, executionContext: ExecutionContext): Promise<ModifyRelationOptions<unknown, unknown>> => {
const context = getContext<AuthorizerContext<DTO>>(executionContext);
return {
filter: await getAuthorizerFilter(context, name),
relationFilter: await getRelationAuthFilter(context, relationName, name),
filter: await getAuthorizerFilter(context, authorizationContext),
relationFilter: await getRelationAuthFilter(context, relationName, authorizationContext),
};
},
)()(target, propertyKey, parameterIndex);
Expand Down
2 changes: 1 addition & 1 deletion packages/query-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export { DTONamesOpts } from './common';
export { NestjsQueryGraphQLModule } from './module';
export { AutoResolverOpts } from './providers';
export { pubSubToken, GraphQLPubSub } from './subscription';
export { Authorizer, AuthorizerOptions } from './auth';
export { Authorizer, AuthorizerOptions, AuthorizationContext } from './auth';
export {
Hook,
HookTypes,
Expand Down

0 comments on commit 1f4b239

Please sign in to comment.