Skip to content

Commit

Permalink
refactor(graphql,authorize): Update authorizer to use param decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Feb 26, 2021
1 parent cb3dc62 commit b65544f
Show file tree
Hide file tree
Showing 24 changed files with 330 additions and 284 deletions.
45 changes: 43 additions & 2 deletions documentation/docs/graphql/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -318,15 +318,56 @@ export class SubTaskDTO {
}
```

## Using Authorizers In Your Resolver

The easiest way to leverage `Authorizers` in a custom resolver is to use the `AuthorizerInterceptor` and
`AuthorizerFilter` param decorator.

In this example there are two important additions:
1. The `AuthorizerInterceptor` is added to the `TodoItemResolver` as an interceptor, this interceptor will add the
authorizer to the context so it can be used down stream
2. The `AuthorizerFilter` param decorator uses the authorizer added by the interceptor to create an authorizer filter.

```ts title="todo-item/todo-item.resolver.ts" {10,18}
import { Filter, InjectAssemblerQueryService, mergeFilter, mergeQuery, QueryService } from '@nestjs-query/core';
import { AuthorizerInterceptor, AuthorizerFilter, ConnectionType } from '@nestjs-query/query-graphql';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { UseInterceptors } from '@nestjs/common';
import { TodoItemDTO } from './dto/todo-item.dto';
import { TodoItemAssembler } from './todo-item.assembler';
import { TodoItemConnection, TodoItemQuery } from './types';

@Resolver(() => TodoItemDTO)
@UseInterceptors(AuthorizerInterceptor(TodoItemDTO))
export class TodoItemResolver {
constructor(@InjectAssemblerQueryService(TodoItemAssembler) readonly service: QueryService<TodoItemDTO>) {}

// Set the return type to the TodoItemConnection
@Query(() => TodoItemConnection)
async uncompletedTodoItems(
@Args() query: TodoItemQuery,
@AuthorizerFilter() authFilter: Filter<TodoItemDTO>,
): Promise<ConnectionType<TodoItemDTO>> {
// add the completed filter the user provided filter
const filter: Filter<TodoItemDTO> = mergeFilter(query.filter ?? {}, { completed: { is: false } });
const uncompletedQuery = mergeQuery(query, { filter: mergeFilter(filter, authFilter) });
return TodoItemConnection.createFromPromise(
(q) => this.service.query(q),
uncompletedQuery,
(q) => this.service.count(q),
);
}
}
```

## @InjectAuthorizer Decorator

If you need access to an authorizer for a DTO you can use the `@InjectAuthorizer` decorator.

The most common use case for using the `@InjectAuthorizer` decorator is when you are not using the autogenerated
resolvers provided by `nestjs-query`.

In this example the `Authorizer` is inject as a `readonly` property so the `CRUDResolver` can use it to authorize all
incoming requests.
In this example the `Authorizer` is injected as a `readonly` property you can then use it for any custom methods.

```ts title="todo-item/todo-item.resolver.ts"
import { QueryService, InjectQueryService } from '@nestjs-query/core';
Expand Down
19 changes: 7 additions & 12 deletions examples/auth/src/todo-item/todo-item.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { Filter, InjectAssemblerQueryService, mergeFilter, mergeQuery, QueryService } from '@nestjs-query/core';
import { Authorizer, ConnectionType, InjectAuthorizer } from '@nestjs-query/query-graphql';
import { Args, Query, Resolver, Context } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { AuthorizerInterceptor, AuthorizerFilter, ConnectionType } 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';
import { TodoItemAssembler } from './todo-item.assembler';
import { TodoItemConnection, TodoItemQuery } from './types';
import { UserContext } from '../auth/auth.interfaces';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Resolver(() => TodoItemDTO)
@UseGuards(JwtAuthGuard)
@UseInterceptors(AuthorizerInterceptor(TodoItemDTO))
export class TodoItemResolver {
constructor(
@InjectAssemblerQueryService(TodoItemAssembler) readonly service: QueryService<TodoItemDTO>,
@InjectAuthorizer(TodoItemDTO) readonly authorizer: Authorizer<TodoItemDTO>,
) {}
constructor(@InjectAssemblerQueryService(TodoItemAssembler) readonly service: QueryService<TodoItemDTO>) {}

// Set the return type to the TodoItemConnection
@Query(() => TodoItemConnection)
async completedTodoItems(
@Args() query: TodoItemQuery,
@Context() context: UserContext,
@AuthorizerFilter() authFilter: Filter<TodoItemDTO>,
): Promise<ConnectionType<TodoItemDTO>> {
const authFilter = await this.authorizer.authorize(context);
// add the completed filter the user provided filter
const filter: Filter<TodoItemDTO> = mergeFilter(query.filter ?? {}, { completed: { is: true } });
const completedQuery = mergeQuery(query, { filter: mergeFilter(filter, authFilter) });
Expand All @@ -37,9 +33,8 @@ export class TodoItemResolver {
@Query(() => TodoItemConnection)
async uncompletedTodoItems(
@Args() query: TodoItemQuery,
@Context() context: UserContext,
@AuthorizerFilter() authFilter: Filter<TodoItemDTO>,
): Promise<ConnectionType<TodoItemDTO>> {
const authFilter = await this.authorizer.authorize(context);
// add the completed filter the user provided filter
const filter: Filter<TodoItemDTO> = mergeFilter(query.filter ?? {}, { completed: { is: false } });
const uncompletedQuery = mergeQuery(query, { filter: mergeFilter(filter, authFilter) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,23 @@ interface ResolverMock<T> {
mockAuthorizer: Authorizer<TestResolverDTO>;
}

export const createResolverFromNest = async <T>(ResolverClass: Class<T>): Promise<ResolverMock<T>> => {
export const createResolverFromNest = async <T>(
ResolverClass: Class<T>,
DTOClass: Class<unknown> = TestResolverDTO,
): Promise<ResolverMock<T>> => {
const mockService = mock(TestService);
const mockPubSub = mock(PubSub);
const mockAuthorizer = mock(TestResolverAuthorizer);
const moduleRef = await Test.createTestingModule({
providers: [
ResolverClass,
TestService,
{ provide: getAuthorizerToken(TestResolverDTO), useClass: TestResolverAuthorizer },
{ provide: getAuthorizerToken(DTOClass), useValue: instance(mockAuthorizer) },
{ provide: pubSubToken(), useValue: instance(mockPubSub) },
],
})
.overrideProvider(TestService)
.useValue(instance(mockService))
.overrideProvider(getAuthorizerToken(TestResolverDTO))
.useValue(instance(mockAuthorizer))
.compile();
return { resolver: moduleRef.get(ResolverClass), mockService, mockPubSub, mockAuthorizer };
};
Expand Down
66 changes: 19 additions & 47 deletions packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,7 @@ 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';
import {
Authorizer,
DeleteManyInputType,
DeleteOneInputType,
DeleteResolver,
DeleteResolverOpts,
InjectAuthorizer,
InjectPubSub,
} from '../../src';
import { DeleteManyInputType, DeleteOneInputType, DeleteResolver, DeleteResolverOpts, InjectPubSub } from '../../src';
import { DeletedEvent } from '../../src/resolvers/delete.resolver';
import { EventType, getDTOEventName } from '../../src/subscription';
import { expectSDL } from '../__fixtures__';
Expand Down Expand Up @@ -47,11 +39,7 @@ describe('DeleteResolver', () => {
const createTestResolver = (opts?: DeleteResolverOpts<TestResolverDTO>) => {
@Resolver(() => TestResolverDTO)
class TestResolver extends DeleteResolver(TestResolverDTO, opts) {
constructor(
service: TestService,
@InjectPubSub() readonly pubSub: PubSub,
@InjectAuthorizer(TestResolverDTO) readonly authorizer: Authorizer<TestResolverDTO>,
) {
constructor(service: TestService, @InjectPubSub() readonly pubSub: PubSub) {
super(service);
}
}
Expand Down Expand Up @@ -91,35 +79,31 @@ describe('DeleteResolver', () => {
expectResolverSDL(deleteOneDisabledResolverSDL, { one: { disabled: true } }));

it('should call the service deleteOne with the provided input', async () => {
const { resolver, mockService, mockAuthorizer } = await createTestResolver();
const { resolver, mockService } = await createTestResolver();
const input: DeleteOneInputType = {
id: 'id-1',
};
const output: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
};
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input });
return expect(result).toEqual(output);
});

it('should call the service deleteOne with the provided input and authorizer filter', async () => {
const { resolver, mockService, mockAuthorizer } = await createTestResolver();
const { resolver, mockService } = await createTestResolver();
const input: DeleteOneInputType = {
id: 'id-1',
};
const output: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
};
const context = {};
const authorizeFilter: Filter<TestResolverDTO> = { stringField: { eq: 'foo' } };
when(mockAuthorizer.authorize(context)).thenResolve(authorizeFilter);
when(mockService.deleteOne(input.id, deepEqual({ filter: authorizeFilter }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input }, authorizeFilter);
return expect(result).toEqual(output);
});
});
Expand All @@ -140,29 +124,25 @@ describe('DeleteResolver', () => {
expectResolverSDL(deleteManyDisabledResolverSDL, { many: { disabled: true } }));

it('should call the service deleteMany with the provided input', async () => {
const { resolver, mockService, mockAuthorizer } = await createTestResolver();
const { resolver, mockService } = await createTestResolver();
const input: DeleteManyInputType<TestResolverDTO> = {
filter: { id: { eq: 'id-1' } },
};
const output: DeleteManyResponse = { deletedCount: 1 };
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteMany(objectContaining(input.filter))).thenResolve(output);
const result = await resolver.deleteMany({ input }, context);
const result = await resolver.deleteMany({ input });
return expect(result).toEqual(output);
});

it('should call the service deleteMany with the provided input and filter from authorizer', async () => {
const { resolver, mockService, mockAuthorizer } = await createTestResolver();
it('should call the service deleteMany with the provided input and auth filter', async () => {
const { resolver, mockService } = await createTestResolver();
const input: DeleteManyInputType<TestResolverDTO> = {
filter: { id: { eq: 'id-1' } },
};
const output: DeleteManyResponse = { deletedCount: 1 };
const context = {};
const authorizeFilter: Filter<TestResolverDTO> = { stringField: { eq: 'foo' } };
when(mockAuthorizer.authorize(context)).thenResolve(authorizeFilter);
when(mockService.deleteMany(objectContaining({ and: [authorizeFilter, input.filter] }))).thenResolve(output);
const result = await resolver.deleteMany({ input }, context);
const result = await resolver.deleteMany({ input }, authorizeFilter);
return expect(result).toEqual(output);
});
});
Expand Down Expand Up @@ -192,7 +172,7 @@ 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, mockAuthorizer } = await createTestResolver({
const { resolver, mockService, mockPubSub } = await createTestResolver({
enableSubscriptions: true,
});
const input: DeleteOneInputType = {
Expand All @@ -204,17 +184,15 @@ describe('DeleteResolver', () => {
};
const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO);
const event = { [eventName]: output };
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve();
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input });
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, mockAuthorizer } = await createTestResolver({
const { resolver, mockService, mockPubSub } = await createTestResolver({
one: { enableSubscriptions: true },
});
const input: DeleteOneInputType = {
Expand All @@ -226,17 +204,15 @@ describe('DeleteResolver', () => {
};
const eventName = getDTOEventName(EventType.DELETED_ONE, TestResolverDTO);
const event = { [eventName]: output };
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve();
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input });
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, mockAuthorizer } = await createTestResolver({
const { resolver, mockService, mockPubSub } = await createTestResolver({
enableSubscriptions: false,
});
const input: DeleteOneInputType = {
Expand All @@ -246,16 +222,14 @@ describe('DeleteResolver', () => {
id: 'id-1',
stringField: 'foo',
};
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input });
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, mockAuthorizer } = await createTestResolver({
const { resolver, mockService, mockPubSub } = await createTestResolver({
enableSubscriptions: true,
one: { enableSubscriptions: false },
});
Expand All @@ -266,10 +240,8 @@ describe('DeleteResolver', () => {
id: 'id-1',
stringField: 'foo',
};
const context = {};
when(mockAuthorizer.authorize(context)).thenResolve({});
when(mockService.deleteOne(input.id, deepEqual({ filter: {} }))).thenResolve(output);
const result = await resolver.deleteOne({ input }, context);
const result = await resolver.deleteOne({ input });
verify(mockPubSub.publish(anything(), anything())).never();
return expect(result).toEqual(output);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('FederationResolver', () => {
describe('one', () => {
describe('one relation', () => {
it('should call the service findRelation with the provided dto', async () => {
const { resolver, mockService } = await createResolverFromNest(TestResolver);
const { resolver, mockService } = await createResolverFromNest(TestResolver, TestFederatedDTO);
const dto: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
Expand All @@ -75,7 +75,7 @@ describe('FederationResolver', () => {
});

it('should call the service findRelation with the provided dto and correct relation name', async () => {
const { resolver, mockService } = await createResolverFromNest(TestResolver);
const { resolver, mockService } = await createResolverFromNest(TestResolver, TestFederatedDTO);
const dto: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
Expand All @@ -98,7 +98,7 @@ describe('FederationResolver', () => {
describe('many - connection', () => {
describe('with cursor paging strategy', () => {
it('should call the service findRelation with the provided dto', async () => {
const { resolver, mockService } = await createResolverFromNest(TestResolver);
const { resolver, mockService } = await createResolverFromNest(TestResolver, TestFederatedDTO);
const dto: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('FederationResolver', () => {

describe('with offset paging strategy', () => {
it('should call the service findRelation with the provided dto', async () => {
const { resolver, mockService } = await createResolverFromNest(TestResolver);
const { resolver, mockService } = await createResolverFromNest(TestResolver, TestFederatedDTO);
const dto: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
Expand Down Expand Up @@ -184,7 +184,7 @@ describe('FederationResolver', () => {

describe('with no paging strategy', () => {
it('should call the service findRelation with the provided dto', async () => {
const { resolver, mockService } = await createResolverFromNest(TestResolver);
const { resolver, mockService } = await createResolverFromNest(TestResolver, TestFederatedDTO);
const dto: TestResolverDTO = {
id: 'id-1',
stringField: 'foo',
Expand Down
Loading

0 comments on commit b65544f

Please sign in to comment.