Browse Source

feat(core): Enable the use of Permissions of GraphQL field resolvers

Relates to #730
Michael Bromley 4 years ago
parent
commit
5c837b85c3

+ 69 - 3
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<MutationUpdateProductArgs>(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<V>(operation: DocumentNode, variables?: V) {
         try {
             const status = await adminClient.queryStatus(operation, variables);

+ 46 - 0
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 {}

+ 0 - 1
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;

+ 4 - 0
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,

+ 30 - 1
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<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

+ 1 - 0
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

+ 39 - 9
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<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 {
@@ -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';
+    }
 }