Skip to content

Commit

Permalink
feat(core): Enable the use of Permissions of GraphQL field resolvers
Browse files Browse the repository at this point in the history
Relates to #730
  • Loading branch information
michaelbromley committed Mar 30, 2021
1 parent d3fa83a commit 5c837b8
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 14 deletions.
72 changes: 69 additions & 3 deletions packages/core/e2e/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import gql from 'graphql-tag';
import path from 'path';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';

import { ProtectedFieldsPlugin, transactions } from './fixtures/test-plugins/with-protected-field-resolver';
import {
CreateAdministrator,
CreateRole,
Expand All @@ -32,7 +33,10 @@ import {
import { assertThrowsWithMessage } from './utils/assert-throws-with-message';

describe('Authorization & permissions', () => {
const { server, adminClient } = createTestEnvironment(testConfig);
const { server, adminClient } = createTestEnvironment({
...testConfig,
plugins: [ProtectedFieldsPlugin],
});

beforeAll(async () => {
await server.init({
Expand Down Expand Up @@ -106,7 +110,7 @@ describe('Authorization & permissions', () => {
await assertRequestAllowed(GET_PRODUCT_LIST);
});

it('cannot uppdate', async () => {
it('cannot update', async () => {
await assertRequestForbidden<MutationUpdateProductArgs>(UPDATE_PRODUCT, {
input: {
id: '1',
Expand Down Expand Up @@ -175,6 +179,68 @@ describe('Authorization & permissions', () => {
});
});

describe('protected field resolvers', () => {
let readCatalogAdmin: { identifier: string; password: string };
let transactionsAdmin: { identifier: string; password: string };

const GET_PRODUCT_WITH_TRANSACTIONS = `
query GetProductWithTransactions($id: ID!) {
product(id: $id) {
id
transactions {
id
amount
description
}
}
}
`;

beforeAll(async () => {
await adminClient.asSuperAdmin();
transactionsAdmin = await createAdministratorWithPermissions('Transactions', [
Permission.ReadCatalog,
transactions.Permission,
]);
readCatalogAdmin = await createAdministratorWithPermissions('ReadCatalog', [
Permission.ReadCatalog,
]);
});

it('protected field not resolved without permissions', async () => {
await adminClient.asUserWithCredentials(readCatalogAdmin.identifier, readCatalogAdmin.password);

try {
const status = await adminClient.query(
gql`
${GET_PRODUCT_WITH_TRANSACTIONS}
`,
{ id: 'T_1' },
);
fail(`Should have thrown`);
} catch (e) {
expect(getErrorCode(e)).toBe('FORBIDDEN');
}
});

it('protected field is resolved with permissions', async () => {
await adminClient.asUserWithCredentials(transactionsAdmin.identifier, transactionsAdmin.password);

const { product } = await adminClient.query(
gql`
${GET_PRODUCT_WITH_TRANSACTIONS}
`,
{ id: 'T_1' },
);

expect(product.id).toBe('T_1');
expect(product.transactions).toEqual([
{ id: 'T_1', amount: 100, description: 'credit' },
{ id: 'T_2', amount: -50, description: 'debit' },
]);
});
});

async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
try {
const status = await adminClient.queryStatus(operation, variables);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ResolveField, Resolver } from '@nestjs/graphql';
import { Allow, PermissionDefinition, VendurePlugin } from '@vendure/core';
import gql from 'graphql-tag';

export const transactions = new PermissionDefinition({
name: 'Transactions',
description: 'Allows reading of transaction data',
});

@Resolver('Product')
export class ProductEntityResolver {
@Allow(transactions.Permission)
@ResolveField()
transactions() {
return [
{ id: 1, amount: 100, description: 'credit' },
{ id: 2, amount: -50, description: 'debit' },
];
}
}

@VendurePlugin({
adminApiExtensions: {
resolvers: [ProductEntityResolver],
schema: gql`
extend type Query {
transactions: [Transaction!]!
}
extend type Product {
transactions: [Transaction!]!
}
type Transaction implements Node {
id: ID!
amount: Int!
description: String!
}
`,
},
configuration: config => {
config.authOptions.customPermissions.push(transactions);
return config;
},
})
export class ProtectedFieldsPlugin {}
1 change: 0 additions & 1 deletion packages/core/src/api/common/parse-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export type GraphQLContext = {
*/
export function parseContext(context: ExecutionContext | ArgumentsHost): RestContext | GraphQLContext {
const graphQlContext = GqlExecutionContext.create(context as ExecutionContext);
const restContext = GqlExecutionContext.create(context as ExecutionContext);
const info = graphQlContext.getInfo();
let req: Request;
let res: Response;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/api/common/request-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export class RequestContextService {
);
}

/**
* TODO: Deprecate and remove, since this function is now handled internally in the RequestContext.
* @private
*/
private userHasRequiredPermissionsOnChannel(
permissions: Permission[] = [],
channel?: Channel,
Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/api/common/request-context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { LanguageCode } from '@vendure/common/lib/generated-types';
import { LanguageCode, Permission } from '@vendure/common/lib/generated-types';
import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
import { isObject } from '@vendure/common/lib/shared-utils';
import { Request } from 'express';
import { TFunction } from 'i18next';

import { idsAreEqual } from '../../common/utils';
import { CachedSession } from '../../config/session-cache/session-cache-strategy';
import { Channel } from '../../entity/channel/channel.entity';

Expand Down Expand Up @@ -109,6 +110,23 @@ export class RequestContext {
});
}

/**
* @description
* Returns `true` if there is an active Session & User associated with this request,
* and that User has the specified permissions on the active Channel.
*/
userHasPermissions(permissions: Permission[]): boolean {
const user = this.session?.user;
if (!user || !this.channelId) {
return false;
}
const permissionsOnChannel = user.channelPermissions.find(c => idsAreEqual(c.id, this.channelId));
if (permissionsOnChannel) {
return this.arraysIntersect(permissionsOnChannel.permissions, permissions);
}
return false;
}

/**
* @description
* Serializes the RequestContext object into a JSON-compatible simple object.
Expand Down Expand Up @@ -176,6 +194,8 @@ export class RequestContext {
/**
* @description
* True if the current session is authorized to access the current resolver method.
*
* @deprecated Use `userHasPermissions()` method instead.
*/
get isAuthorized(): boolean {
return this._isAuthorized;
Expand All @@ -202,6 +222,15 @@ export class RequestContext {
}
}

/**
* Returns true if any element of arr1 appears in arr2.
*/
private arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
return arr1.reduce((intersects, role) => {
return intersects || arr2.includes(role);
}, false as boolean);
}

/**
* The Express "Request" object is huge and contains many circular
* references. We will preserve just a subset of the whole, by preserving
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/config/configure-graphql-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ async function createGraphQLOptions(
path: '/' + options.apiPath,
typeDefs: printSchema(builtSchema),
include: [options.resolverModule, ...getDynamicGraphQlModulesForPlugins(options.apiType)],
fieldResolverEnhancers: ['guards'],
resolvers,
// We no longer rely on the upload facility bundled with Apollo Server, and instead
// manually configure the graphql-upload package. See https://github.com/vendure-ecommerce/vendure/issues/396
Expand Down
48 changes: 39 additions & 9 deletions packages/core/src/api/middleware/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Permission } from '@vendure/common/lib/generated-types';
import { Request, Response } from 'express';
import { GraphQLResolveInfo } from 'graphql';

import { REQUEST_CONTEXT_KEY } from '../../common/constants';
import { ForbiddenError } from '../../common/error/errors';
Expand All @@ -19,8 +20,13 @@ import { setSessionToken } from '../common/set-session-token';
import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';

/**
* A guard which checks for the existence of a valid session token in the request and if found,
* @description
* A guard which:
*
* 1. checks for the existence of a valid session token in the request and if found,
* attaches the current User entity to the request.
* 2. enforces any permissions required by the target handler (resolver, field resolver or route),
* and throws a ForbiddenError if those permissions are not present.
*/
@Injectable()
export class AuthGuard implements CanActivate {
Expand All @@ -37,23 +43,38 @@ export class AuthGuard implements CanActivate {

async canActivate(context: ExecutionContext): Promise<boolean> {
const { req, res, info } = parseContext(context);
const authDisabled = this.configService.authOptions.disableAuth;
const isFieldResolver = this.isFieldResolver(info);
const permissions = this.reflector.get<Permission[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
if (isFieldResolver && !permissions) {
return true;
}
const authDisabled = this.configService.authOptions.disableAuth;
const isPublic = !!permissions && permissions.includes(Permission.Public);
const hasOwnerPermission = !!permissions && permissions.includes(Permission.Owner);
const session = await this.getSession(req, res, hasOwnerPermission);
let requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);

const requestContextShouldBeReinitialized = await this.setActiveChannel(requestContext, session);
if (requestContextShouldBeReinitialized) {
let requestContext: RequestContext;
if (isFieldResolver) {
requestContext = (req as any)[REQUEST_CONTEXT_KEY];
} else {
const session = await this.getSession(req, res, hasOwnerPermission);
requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);

const requestContextShouldBeReinitialized = await this.setActiveChannel(requestContext, session);
if (requestContextShouldBeReinitialized) {
requestContext = await this.requestContextService.fromRequest(
req,
info,
permissions,
session,
);
}
(req as any)[REQUEST_CONTEXT_KEY] = requestContext;
}
(req as any)[REQUEST_CONTEXT_KEY] = requestContext;

if (authDisabled || !permissions || isPublic) {
return true;
} else {
const canActivate = requestContext.isAuthorized || requestContext.authorizedAsOwnerOnly;
const canActivate =
requestContext.userHasPermissions(permissions) || requestContext.authorizedAsOwnerOnly;
if (!canActivate) {
throw new ForbiddenError();
} else {
Expand Down Expand Up @@ -129,4 +150,13 @@ export class AuthGuard implements CanActivate {
}
return serializedSession;
}

/**
* Returns true is this guard is being called on a FieldResolver, i.e. not a top-level
* Query or Mutation resolver.
*/
private isFieldResolver(info?: GraphQLResolveInfo): boolean {
const parentType = info?.parentType.name;
return parentType != null && parentType !== 'Query' && parentType !== 'Mutation';
}
}

0 comments on commit 5c837b8

Please sign in to comment.