Skip to content

Commit

Permalink
fix(query-graphql): Custom authorizers now behave like auth decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
psteinroe authored and doug-martin committed Sep 30, 2021
1 parent 46ea347 commit ff92b9a
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 48 deletions.
18 changes: 12 additions & 6 deletions documentation/docs/graphql/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,12 @@ For example you could define the subtasks with the `auth` option, only allowing
When you need more control over authorization you can create a custom `Authorizer`. You may want to use a custom
`Authorizer` if you need to use additional services to do authorization for a DTO.

The `Authorizer` interface requires two methods to be implemented
The `CustomAuthorizer` interface ensures two methods:

- `authorize` - Should return a filter that should be used for all queries and mutations for the DTO.
- `authorizeRelation` - Should return a filter for the relation that will be used when querying the relation or
adding/removing relations to/from the DTO.
- `authorizeRelation` - Optionally modify the filter for the relation that will be used when querying the relation or
adding/removing relations to/from the DTO. If undefined is returned, the authorization filter of the relation DTO
will be used instead.

In this example we'll create a simple authorizer for `SubTasks`. You can use this as a base to create a more complex
authorizers that depends on other services.
Expand All @@ -266,16 +267,16 @@ import { UserContext } from '../auth/auth.interfaces';
import { SubTaskDTO } from './dto/sub-task.dto';

@Injectable()
export class SubTaskAuthorizer implements Authorizer<SubTaskDTO> {
export class SubTaskAuthorizer implements CustomAuthorizer<SubTaskDTO> {
authorize(context: UserContext): Promise<Filter<SubTaskDTO>> {
return Promise.resolve({ ownerId: { eq: context.req.user.id } });
}

authorizeRelation(relationName: string, context: UserContext): Promise<Filter<unknown>> {
authorizeRelation(relationName: string, context: UserContext): Promise<Filter<unknown> | undefined> {
if (relationName === 'todoItem') {
return Promise.resolve({ ownerId: { eq: context.req.user.id } });
}
return Promise.resolve({});
return Promise.resolve(undefined);
}
}
```
Expand Down Expand Up @@ -398,6 +399,11 @@ export class TodoItemResolver extends CRUDResolver(TodoItemDTO) {
If you are extending the `CRUDResolver` directly be sure to [register your DTOs with the `NestjsQueryGraphQLModule`](./resolvers.mdx#crudresolver)
:::

:::important
When using `@InjectAuthorizer`, the injected Authorizer is not the CustomAuthorizer, but the DefaultCRUDAuthorizer that internally uses the CustomAuthorizer.
If you want to use the CustomAuthorizer directly, inject it with `@InjectCustomAuthorizer` instead.
:::

## Authorize depending on operation

Sometimes it might be necessary to perform different authorization based on the kind of operation an user wants to execute.
Expand Down
213 changes: 201 additions & 12 deletions packages/query-graphql/__tests__/auth/default-crud-auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ 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, OperationGroup, getAuthorizerToken } from '../../src/auth';
import {
AuthorizationContext,
OperationGroup,
getAuthorizerToken,
getCustomAuthorizerToken,
CustomAuthorizer,
} from '../../src/auth';
import { createAuthorizerProviders } from '../../src/providers';

describe('createDefaultAuthorizer', () => {
Expand All @@ -15,12 +21,12 @@ describe('createDefaultAuthorizer', () => {
}

@Injectable()
class RelationAuthorizer implements Authorizer<RelationWithAuthorizer> {
class RelationAuthorizer implements CustomAuthorizer<RelationWithAuthorizer> {
authorize(context: UserContext): Promise<Filter<RelationWithAuthorizer>> {
return Promise.resolve({ authorizerOwnerId: { eq: context.user.id } });
}

authorizeRelation(): Promise<Filter<unknown>> {
authorizeRelation(): Promise<Filter<unknown> | undefined> {
return Promise.reject(new Error('should not have called'));
}
}
Expand All @@ -30,7 +36,11 @@ describe('createDefaultAuthorizer', () => {
authorizerOwnerId!: number;
}

@Authorize({ authorize: (ctx: UserContext) => ({ decoratorOwnerId: { eq: ctx.user.id } }) })
@Authorize({
authorize: (ctx: UserContext) => ({
decoratorOwnerId: { eq: ctx.user.id },
}),
})
class TestDecoratorRelation {
decoratorOwnerId!: number;
}
Expand Down Expand Up @@ -59,6 +69,32 @@ describe('createDefaultAuthorizer', () => {
ownerId!: number;
}

@Injectable()
class TestWithAuthorizerAuthorizer implements CustomAuthorizer<TestWithAuthorizerDTO> {
authorize(context: UserContext): Promise<Filter<TestWithAuthorizerDTO>> {
return Promise.resolve({ ownerId: { eq: context.user.id } });
}

authorizeRelation(): Promise<Filter<unknown> | undefined> {
return Promise.resolve(undefined);
}
}

@Authorize(TestWithAuthorizerAuthorizer)
@Relation('relations', () => TestRelation, {
auth: {
authorize: (ctx: UserContext, authorizationContext?: AuthorizationContext) =>
authorizationContext?.operationName === 'other'
? { relationOwnerId: { neq: ctx.user.id } }
: { relationOwnerId: { eq: ctx.user.id } },
},
})
@UnPagedRelation('unPagedDecoratorRelations', () => TestDecoratorRelation)
@Relation('authorizerRelation', () => RelationWithAuthorizer)
class TestWithAuthorizerDTO {
ownerId!: number;
}

beforeEach(async () => {
testingModule = await Test.createTestingModule({
providers: [
Expand All @@ -68,6 +104,7 @@ describe('createDefaultAuthorizer', () => {
RelationWithAuthorizer,
TestDTO,
TestNoAuthDTO,
TestWithAuthorizerDTO,
]),
],
}).compile();
Expand All @@ -79,7 +116,12 @@ describe('createDefaultAuthorizer', () => {
const authorizer = testingModule.get<Authorizer<TestDTO>>(getAuthorizerToken(TestDTO));
const filter = await authorizer.authorize(
{ user: { id: 2 } },
{ operationName: 'queryMany', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryMany',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ ownerId: { eq: 2 } });
});
Expand All @@ -88,7 +130,12 @@ describe('createDefaultAuthorizer', () => {
const authorizer = testingModule.get<Authorizer<TestDTO>>(getAuthorizerToken(TestDTO));
const filter = await authorizer.authorize(
{ user: { id: 2 } },
{ operationName: 'other', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'other',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ ownerId: { neq: 2 } });
});
Expand All @@ -97,7 +144,12 @@ describe('createDefaultAuthorizer', () => {
const authorizer = testingModule.get<Authorizer<TestNoAuthDTO>>(getAuthorizerToken(TestNoAuthDTO));
const filter = await authorizer.authorize(
{ user: { id: 2 } },
{ operationName: 'queryMany', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryMany',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({});
});
Expand All @@ -107,7 +159,12 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'unPagedDecoratorRelations',
{ user: { id: 2 } },
{ operationName: 'queryRelation', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ decoratorOwnerId: { eq: 2 } });
});
Expand All @@ -117,7 +174,12 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'relations',
{ user: { id: 2 } },
{ operationName: 'queryRelation', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ relationOwnerId: { eq: 2 } });
});
Expand All @@ -127,7 +189,12 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'relations',
{ user: { id: 2 } },
{ operationName: 'other', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'other',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ relationOwnerId: { neq: 2 } });
});
Expand All @@ -137,7 +204,12 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'authorizerRelation',
{ user: { id: 2 } },
{ operationName: 'queryRelation', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({ authorizerOwnerId: { eq: 2 } });
});
Expand All @@ -147,8 +219,125 @@ describe('createDefaultAuthorizer', () => {
const filter = await authorizer.authorizeRelation(
'unknownRelations',
{ user: { id: 2 } },
{ operationName: 'queryRelation', operationGroup: OperationGroup.READ, readonly: true, many: true },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({});
});

it('should call authorizeRelation of authorizer and fallback to authorize decorator', async () => {
const authorizer = testingModule.get<Authorizer<TestWithAuthorizerDTO>>(getAuthorizerToken(TestWithAuthorizerDTO));
jest.spyOn(authorizer, 'authorizeRelation');
const customAuthorizer = testingModule.get<CustomAuthorizer<TestWithAuthorizerDTO>>(
getCustomAuthorizerToken(TestWithAuthorizerDTO),
);
jest.spyOn(customAuthorizer, 'authorizeRelation');
expect(customAuthorizer).toBeDefined();
const filter = await authorizer.authorizeRelation(
'unPagedDecoratorRelations',
{ user: { id: 2 } },
{
operationName: 'queryMany',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({
decoratorOwnerId: { eq: 2 },
});
expect(customAuthorizer.authorizeRelation).toHaveBeenCalledWith(
'unPagedDecoratorRelations',
{ user: { id: 2 } },
{
operationName: 'queryMany',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(authorizer.authorizeRelation).toHaveBeenCalledWith(
'unPagedDecoratorRelations',
{ user: { id: 2 } },
{
operationName: 'queryMany',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
});

it('should call authorizeRelation of authorizer and fallback to custom authorizer of relation', async () => {
const authorizer = testingModule.get<Authorizer<TestWithAuthorizerDTO>>(getAuthorizerToken(TestWithAuthorizerDTO));
jest.spyOn(authorizer, 'authorizeRelation');
const customAuthorizer = testingModule.get<CustomAuthorizer<TestWithAuthorizerDTO>>(
getCustomAuthorizerToken(TestWithAuthorizerDTO),
);
jest.spyOn(customAuthorizer, 'authorizeRelation');
expect(customAuthorizer).toBeDefined();
const relationAuthorizer = testingModule.get<Authorizer<RelationWithAuthorizer>>(
getAuthorizerToken(RelationWithAuthorizer),
);
jest.spyOn(relationAuthorizer, 'authorize');
const customRelationAuthorizer = testingModule.get<CustomAuthorizer<RelationWithAuthorizer>>(
getCustomAuthorizerToken(RelationWithAuthorizer),
);
jest.spyOn(customRelationAuthorizer, 'authorize');
const filter = await authorizer.authorizeRelation(
'authorizerRelation',
{ user: { id: 2 } },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(filter).toEqual({
authorizerOwnerId: { eq: 2 },
});
expect(customAuthorizer.authorizeRelation).toHaveBeenCalledWith(
'authorizerRelation',
{ user: { id: 2 } },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(authorizer.authorizeRelation).toHaveBeenCalledWith(
'authorizerRelation',
{ user: { id: 2 } },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(relationAuthorizer.authorize).toHaveBeenCalledWith(
{ user: { id: 2 } },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
expect(customRelationAuthorizer.authorize).toHaveBeenCalledWith(
{ user: { id: 2 } },
{
operationName: 'queryRelation',
operationGroup: OperationGroup.READ,
readonly: true,
many: true,
},
);
});
});
16 changes: 14 additions & 2 deletions packages/query-graphql/src/auth/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,19 @@ export interface AuthorizationContext {
readonly many: boolean;
}

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

authorizeRelation?(
relationName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
authorizerContext: AuthorizationContext,
): Promise<Filter<unknown> | undefined>;
}

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

Expand All @@ -31,5 +43,5 @@ export interface Authorizer<DTO> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any,
authorizerContext: AuthorizationContext,
): Promise<Filter<unknown>>;
): Promise<Filter<unknown | undefined>>;
}
Loading

0 comments on commit ff92b9a

Please sign in to comment.