From 5c837b85c3e03007acff7e31c2a6a5e061766200 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 30 Mar 2021 22:32:28 +0200 Subject: [PATCH] feat(core): Enable the use of Permissions of GraphQL field resolvers Relates to #730 --- packages/core/e2e/auth.e2e-spec.ts | 72 ++++++++++++++++++- .../with-protected-field-resolver.ts | 46 ++++++++++++ packages/core/src/api/common/parse-context.ts | 1 - .../src/api/common/request-context.service.ts | 4 ++ .../core/src/api/common/request-context.ts | 31 +++++++- .../api/config/configure-graphql-module.ts | 1 + .../core/src/api/middleware/auth-guard.ts | 48 ++++++++++--- 7 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 packages/core/e2e/fixtures/test-plugins/with-protected-field-resolver.ts diff --git a/packages/core/e2e/auth.e2e-spec.ts b/packages/core/e2e/auth.e2e-spec.ts index b6dfb923dc..f26fe61644 100644 --- a/packages/core/e2e/auth.e2e-spec.ts +++ b/packages/core/e2e/auth.e2e-spec.ts @@ -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, @@ -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({ @@ -106,7 +110,7 @@ describe('Authorization & permissions', () => { await assertRequestAllowed(GET_PRODUCT_LIST); }); - it('cannot uppdate', async () => { + it('cannot update', async () => { await assertRequestForbidden(UPDATE_PRODUCT, { input: { id: '1', @@ -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(operation: DocumentNode, variables?: V) { try { const status = await adminClient.queryStatus(operation, variables); diff --git a/packages/core/e2e/fixtures/test-plugins/with-protected-field-resolver.ts b/packages/core/e2e/fixtures/test-plugins/with-protected-field-resolver.ts new file mode 100644 index 0000000000..4fc51ef12c --- /dev/null +++ b/packages/core/e2e/fixtures/test-plugins/with-protected-field-resolver.ts @@ -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 {} diff --git a/packages/core/src/api/common/parse-context.ts b/packages/core/src/api/common/parse-context.ts index 3fe0f732c9..0830d03803 100644 --- a/packages/core/src/api/common/parse-context.ts +++ b/packages/core/src/api/common/parse-context.ts @@ -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; diff --git a/packages/core/src/api/common/request-context.service.ts b/packages/core/src/api/common/request-context.service.ts index e8a292a66e..0942d058bb 100644 --- a/packages/core/src/api/common/request-context.service.ts +++ b/packages/core/src/api/common/request-context.service.ts @@ -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, diff --git a/packages/core/src/api/common/request-context.ts b/packages/core/src/api/common/request-context.ts index 859aff01eb..8d0ecaf02a 100644 --- a/packages/core/src/api/common/request-context.ts +++ b/packages/core/src/api/common/request-context.ts @@ -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'; @@ -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. @@ -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; @@ -202,6 +222,15 @@ export class RequestContext { } } + /** + * Returns true if any element of arr1 appears in arr2. + */ + private arraysIntersect(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 diff --git a/packages/core/src/api/config/configure-graphql-module.ts b/packages/core/src/api/config/configure-graphql-module.ts index 5d5bdfc00a..0a5dedbeff 100644 --- a/packages/core/src/api/config/configure-graphql-module.ts +++ b/packages/core/src/api/config/configure-graphql-module.ts @@ -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 diff --git a/packages/core/src/api/middleware/auth-guard.ts b/packages/core/src/api/middleware/auth-guard.ts index 54dcc71a74..dc3d59951f 100644 --- a/packages/core/src/api/middleware/auth-guard.ts +++ b/packages/core/src/api/middleware/auth-guard.ts @@ -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'; @@ -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 { @@ -37,23 +43,38 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const { req, res, info } = parseContext(context); - const authDisabled = this.configService.authOptions.disableAuth; + const isFieldResolver = this.isFieldResolver(info); const permissions = this.reflector.get(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 { @@ -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'; + } }