AstrumU / graphql-authz

GraphQL authorization layer
Other
180 stars 10 forks source link

GraphQL authorization layer, flexible, (not only) directive-based, compatible with all modern GraphQL architectures.

Overview

Flexible modern way of adding an authorization layer on top of your existing GraphQL microservices or monolith backend systems.

Full overview can be found in this blog post: https://the-guild.dev/blog/graphql-authz

Features

Examples

Check examples in examples folder:

Integration

To integrate graphql-authz into the project you can follow several steps depending on your architecture:

Read more

Installation

yarn add @graphql-authz/core

Creating Rules

Create rule and rules object

import { preExecRule } from "@graphql-authz/core";

const IsAuthenticated = preExecRule()(context => !!context.user);

const rules = {
  IsAuthenticated
} as const;

Alternatively you can create a rule as a class

  import { PreExecutionRule } from "@graphql-authz/core";

  class IsAuthenticated extends PreExecutionRule {
    public execute(context) {
      if (!context.user) {
        throw this.error;
      }
    }
  }

  const rules = {
    IsAuthenticated
  } as const;

Configuring server

Configuring Apollo Server plugin See [Apollo Server plugin readme](packages/plugins/apollo-server/README.md) or check examples: [Apollo Server (schema-first, directives)](examples/packages/apollo-server-schema-first) [Apollo Server (code-first, extensions)](examples/packages/apollo-server-code-first)


Configuring Envelop plugin See [Envelop plugin readme](packages/plugins/envelop/README.md) or check examples: [Envelop (schema-first, directives)](examples/packages/envelop)


Configuring express-graphql Ensure authenticator is configured to add user info to the request object ```ts const authenticator = (req, res, next) => { const user = someHowAuthenticateUser(req.get("authorization"))); req.user = user; next(); }; app.use(authenticator); ``` Provide `customExecuteFn` to `graphqlHTTP` ```ts import { execute } from 'graphql'; import { graphqlHTTP } from 'express-graphql'; import { wrapExecuteFn } from '@graphql-authz/core'; graphqlHTTP({ ... customExecuteFn: wrapExecuteFn(execute, { rules }), ... }) ``` ### For Directives usage Apply directive transformer to schema ```ts import { authZDirective } from '@graphql-authz/directive'; const { authZDirectiveTransformer } = authZDirective(); graphqlHTTP({ ... schema: authZDirectiveTransformer(schema), customExecuteFn: wrapExecuteFn(execute, { rules }), ... }) ``` ### For AuthSchema usage Pass additional parameter `authSchema` to `wrapExecuteFn` ```ts import { execute } from 'graphql'; import { wrapExecuteFn } from '@graphql-authz/core'; graphqlHTTP({ ... customExecuteFn: wrapExecuteFn(execute, { rules: authZRules, authSchema }), ... }) ``` Check an example: [express-graphql (schema-first, directives)](examples/packages/express-graphql)


Configuring GraphQL Helix Ensure context parser is configured to perform authentication and add user info to context ```ts import { processRequest } from 'graphql-helix'; processRequest({ ... contextFactory: () => ({ user: someHowAuthenticateUser(req.get("authorization"))) }), ... }) ``` Provide `execute` option to `processRequest` ```ts import { execute } from 'graphql'; import { processRequest } from 'graphql-helix'; import { wrapExecuteFn } from '@graphql-authz/core'; processRequest({ ... execute: wrapExecuteFn(execute, { rules }) ... }) ``` ### For Directives usage Apply directive transformer to schema ```ts import { authZDirective } from '@graphql-authz/directive'; const { authZDirectiveTransformer } = authZDirective(); processRequest({ ... schema: authZDirectiveTransformer(schema), execute: wrapExecuteFn(execute, { rules }), ... }) ``` ### For AuthSchema usage Pass additional parameter `authSchema` to `wrapExecuteFn` ```ts import { execute } from 'graphql'; import { processRequest } from 'graphql-helix'; import { wrapExecuteFn } from '@graphql-authz/core'; processRequest({ ... execute: wrapExecuteFn(execute, { rules, authSchema }) ... }) ``` Check an example: [GraphQL Helix (schema-first, authSchema)](examples/packages/graphql-helix)


Configuring schema for directive usage

Schema First

Add rules and directive definition to the schema

    # this is custom list of your rules
    enum AuthZRules {
      IsAuthenticated
      IsAdmin
      CanReadPost
      CanPublishPost
    }

    # this is a common boilerplate
    input AuthZDirectiveCompositeRulesInput {
      and: [AuthZRules]
      or: [AuthZRules]
      not: AuthZRules
    }

    # this is a common boilerplate
    input AuthZDirectiveDeepCompositeRulesInput {
      id: AuthZRules
      and: [AuthZDirectiveDeepCompositeRulesInput]
      or: [AuthZDirectiveDeepCompositeRulesInput]
      not: AuthZDirectiveDeepCompositeRulesInput
    }

    # this is a common boilerplate
    directive @authz(
      rules: [AuthZRules]
      compositeRules: [AuthZDirectiveCompositeRulesInput]
      deepCompositeRules: [AuthZDirectiveDeepCompositeRulesInput]
    ) on FIELD_DEFINITION | OBJECT | INTERFACE

Alternatively generate rules and directive definition

  import { directiveTypeDefs } from "@graphql-authz/core";
  import { authZGraphQLDirective } from "@graphql-authz/directive";

  const directive = authZGraphQLDirective(rules);
  const authZDirectiveTypeDefs = directiveTypeDefs(directive);

  const typeDefs = gql`
    ${authZDirectiveTypeDefs}

    ...

  `;

Code First

Pass directive to schema options to generate rules and directive definition

  import { authZGraphQLDirective } from "@graphql-authz/directive";

  new GraphQLSchema({
    ...
    directives: [authZGraphQLDirective(rules)]
    ...
  });

Attaching rules

Using Directives

Add authz directive to Query/Mutation/Object/Interface/Field you need to perform authorization on

Schema-First

interface TestInterface @authz(rules: [Rule01]) {
  testField1: String! @authz(rules: [Rule02])
}

type TestType implements TestInterface @authz(rules: [Rule01]) {
  testField1: String!
  testField2: Float! @authz(rules: [Rule02])
}

type Query {
  testQuery: TestType! @authz(rules: [Rule01, Rule02])
}

type Mutation {
  testMutation: TestType! @authz(rules: [Rule01, Rule02])
}

Code-First

Using TypeGraphQL or NestJS decorators

@InterfaceType()
@Directive(`@authz(rules: [Rule01]`)
class TestInterface {
  @Field()
  @Directive(`@authz(rules: [Rule02])`)
  public testField1!: string;
}

@ObjectType({ implements: TestInterface })
@Directive(`@authz(rules: [Rule01])`)
class TestType {
  @Field()
  public testField1!: string;

  @Field()
  @Directive(`@authz(rules: [Rule02])`)
  public testField2!: number;
}

@Resolver()
class ResolverClass {

  @Query(() => TestType)
  @Directive(`@authz(rules: [Rule01, Rule02])`)
  public testQuery() {
    ...
  }

  @Mutation(() => TestType)
  @Directive(`@authz(rules: [Rule01, Rule02])`)
  public testMutation() {
    ...
  }
}

Alternatively custom decorator-wrapper can be used

import { IAuthConfig } from "@graphql-authz/core";

function AuthZ(config: IAuthConfig<typeof rules>) {
  const args = Object.keys(config)
    .map(
      key => `${key}: ${JSON.stringify(config[key]).replace(/"/g, '')}`
    )
    .join(', ');
  const directiveArgs = `(${args})`;
  return Directive(`@authz${directiveArgs}`);
}

@InterfaceType()
@AuthZ({ rules: ['Rule01'] })
class TestInterface {
  @Field()
  @AuthZ({ rules: ['Rule02'] })
  public testField1!: string;
}

Using extensions (Code-First only)

Using TypeGraphQL or NestJS decorators

It looks pretty similar to the directive usage with decorators

import { IAuthConfig } from "@graphql-authz/core";

function AuthZ(args: IAuthConfig<typeof rules>) {
  return Extensions({
    authz: {
      directives: [
        {
          name: 'authz',
          arguments: args
        }
      ]
    }
  });
}

@InterfaceType()
@AuthZ({ rules: ['Rule01'] })
class TestInterface {
  @Field()
  @AuthZ({ rules: ['Rule02'] })
  public testField1!: string;
}

Using GraphQL.js constructors

import { IAuthConfig } from "@graphql-authz/core";

function createAuthZExtensions(args: IAuthConfig<typeof rules>) {
  return {
    authz: {
      directives: [
        {
          name: 'authz',
          arguments: args
        }
      ]
    }
  };
}

const Post = new GraphQLObjectType({
  name: 'Post',
  extensions: createAuthZExtensions({
    rules: ['CanReadPost']
  }),
  fields: () => ({
    ...
  })
});

const Query = new GraphQLObjectType({
  name: 'Query',
  fields: {
    post: {
      type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Post))),
      extensions: createAuthZExtensions({
        rules: ['IsAuthenticated']
      }),
      ...
    }
  }
});

Using GiraphQL

import SchemaBuilder from '@giraphql/core';
import AuthzPlugin from '@giraphql/plugin-authz';

const builder = new SchemaBuilder<{
  AuthZRule: keyof typeof rules;
}>({
  plugins: [AuthzPlugin],
});

builder.queryType({
  fields: (t) => ({
    users: t.field({
      type: [User],
      authz: {
        rules: ['IsAuthenticated'],
      },
      resolve: () => users,
    }),
  }),
});

const Post = builder.objectRef<IPost>('Post');

Post.implement({
  authz: {
    rules: ['CanReadPost'],
  },
  fields: (t) => ({
    id: t.exposeID('id'),
  }),
});

GiraphQL provides a native GraphQL Authz plugin @giraphql/plugin-authz that helps to attach auth rules to Queries/Mutations/Objects/Interfaces/Fields in a type-safe way

Using AuthSchema

  const authSchema = {
    Post: { __authz: { rules: ['CanReadPost'] } },
    User: {
      email: { __authz: { rules: ['IsAdmin'] } }
    },
    Mutation: {
      publishPost: { __authz: { rules: ['CanPublishPost'] } }
    },
    Query: {
      users: { __authz: { rules: ['IsAuthenticated'] } }
    }
  };

Pre execution rules vs Post execution rules

Pre execution rules are executed before any resolvers are called and have context and fieldArgs as arguments.

context is GraphQL context

fieldArgs is an object that has all arguments passed to Query/Mutation/Field the rule is applied to. If the rule is applied to ObjectType or InterfaceType then fieldArgs is an empty object.

If no other data is needed to perform authorization (e.g. actual data from the database or actual response to the request) then the Pre execution rule should be used.

To create simple Pre execution rule preExecRule function should be used

import { preExecRule } from "@graphql-authz/core";

const IsAuthenticated = preExecRule()(context => !!context.user);

Alternatively new class extended from PreExecutionRule could be created

import { PreExecutionRule } from "@graphql-authz/core";

class IsAuthenticated extends PreExecutionRule {
  public execute(context) {
    if (!context.user) {
      throw this.error;
    }
  }
}

Post execution rules are executed after all resolvers logic is executed and the result response is ready. Because of that fact, Post execution rules have access to the actual result of query execution. This can resolve authorization cases when the decision depends on attributes of the requested entity, for example, if only the author of the post can see the post if it is in draft status.

In order to create Post execution rule postExecRule function should be used

const CanReadPost = postExecRule({
  selectionSet: '{ status author { id } }'
})(
  (
    context: IContext,
    fieldArgs: unknown,
    post: { status: string; author: { id: string } },
    parent: unknown
  ) =>
    post.status === 'public' || context.user?.id === post.author.id
);

Alternatively a new class extended from PostExecutionRule could be created

class CanReadPost extends PostExecutionRule {
  public execute(
    context: IContext,
    fieldArgs: any,
    post: Post,
    parent: unknown
  ) {
    if (post.status !== "public" && context.user?.id !== post.author.id) {
      throw this.error;
    }
  }

  public selectionSet = "{ status author { id } }";
}

By adding selectionSet we ensure that even if the client originally didn't request fields that are needed to perform authorization these fields are present in the result value that comes to a rule as an argument.

Please note that with post execution rules resolvers are executed even if the rule throws an unauthorized error, so such rules are not suitable for Mutations or Queries that update some counters (count of views for example)

Alternative by querying DB inside rules

All rules can be async so for cases where authorization depends on real data and should be performed strictly before resolvers execution (for Mutation or Queries that update some counters) you can query DB right inside pre execution rule

const CanPublishPost = preExecRule()(
  async (
    context: IContext,
    fieldArgs: { postId: string }
  ) => {
    const post = await db.posts.get(fieldArgs.postId);
    return post.authorId === context.user?.id;
  }
);

Alternative by querying schema itself inside rules

graphql-authz doesn't mutate executable schema so you can query schema itself right inside Pre execution rule

Note: you need to pass schema to context to access it inside rules

const CanPublishPost = preExecRule()(
  async (
    context: IContext,
    fieldArgs: { postId: string }
  ) => {
    // query executable schema from rules
    const graphQLResult = await graphql({
      schema: context.schema,
      source: `query post { post(id: ${fieldArgs.postId}) { author { id } } }`
    });

    const post = graphQLResult.data?.post;

    return post && post.author.id === context.user?.id;
  }
);

Wildcard rules

Attaching rules using wildcards is supported by authSchema. Wildcard can be used as a name of Object and as a name of field in different combinations. Here are some examples:

  // Reject rule is attached to every Object
  const authSchema = {
    '*': { __authz: { rules: ['Reject'] } }
  };
  // Reject rule is attached to every Field of every Object
  const authSchema = {
    '*': {
      '*': { __authz: { rules: ['Reject'] } }
    }
  };
  // Reject rule is attached to every Object AND every Field of every Object
  const authSchema = {
    '*': {
      { __authz: { rules: ['Reject'] } },
      '*': { __authz: { rules: ['Reject'] } }
    }
  };
  // Reject rule is attached to every Field of User Object
  const authSchema = {
    User: {
      '*': { __authz: { rules: ['Reject'] } }
    }
  };
  // Reject rule is attached to createdAt Field of every Object
  const authSchema = {
    '*': {
      createdAt: { __authz: { rules: ['Reject'] } }
    }
  };

Overwriting wildcard rules

Wildcard rules are attached to Object/Field only if no other rules are attached to it so to overwrite wildcard rule it's only necessary to attach any other rule to the Object/Field. Wildcard rules can be overwritten by rules attached using directive or extensions as well.

  // Reject rule is attached to all fields of the User object except of the id field. IsAuthenticated rule is attached to the id field.
  const authSchema = {
    User: {
      '*': { __authz: { rules: ['Reject'] } },
      id: { __authz: { rules: ['IsAuthenticated'] } }
    }
  };
  // Reject rule is attached to all Objects except of the User Object. IsAuthenticated rule is attached to the User Object.
  const authSchema = {
    '*': { __authz: { rules: ['Reject'] } },
    User: { __authz: { rules: ['IsAuthenticated'] } }
  };

Wildcard rules priority

Wildcard rules are not composing with each other. Only one wildcard rule is attached to the certain Object/Field. Wildcard rules are attached to a Field in following priority:

  1. Any Field in certain Object:
    const authSchema = {
    User: {
      '*': { __authz: { rules: ['Reject'] } }
    }
    };
  2. Certain field in any object:
    const authSchema = {
    '*': {
      createdAt: { __authz: { rules: ['Reject'] } }
    }
    };
  3. Any field in any object:
    const authSchema = {
    '*': {
      '*': { __authz: { rules: ['Reject'] } }
    }
    };

With following example:

  const authSchema = {
    '*': {
      '*': { __authz: { rules: ['Rule01'] } },
      id: { __authz: { rules: ['Rule02'] } }
    },
    User: {
      '*': { __authz: { rules: ['Rule03'] } }
    }
  };

only Rule03 is attached to the id field of the User object

Composing rules

Default composition

Rules that are passed to @authz directive as rules list are composing with AND logical operator. So @authz(rules: [Rule01, Rule02]) means that authorization will be passed only if Rule01 AND Rule02 passed.

Create composition rules

To create different composition rules and, or, not functions should be used

import { and, or, not } from "@graphql-authz/core";

const TestAndRule = and([Rule01, Rule02, Rule03]);

const TestOrRule = or([Rule01, Rule02, Rule03]);

const TestNotRule = not([Rule01, Rule02, Rule03]);

Alternatively new class extended from AndRule, OrRule or NotRule can be created

import {
  AndRule,
  OrRule,
  NotRule
} from "@graphql-authz/core";

class TestAndRule extends AndRule {
  public getRules() {
    return [Rule01, Rule02, Rule03];
  }
}

class TestOrRule extends OrRule {
  public getRules() {
    return [Rule01, Rule02, Rule03];
  }
}

class TestNotRule extends NotRule {
  public getRules() {
    return [Rule01, Rule02, Rule03];
  }
}

With such code

TestAndRule will pass only if all of Rule01, Rule02, Rule03 pass

TestOrRule will pass if any of Rule01, Rule02, Rule03 pass

TestNotRule will pass only if every of Rule01, Rule02, Rule03 fail

Composition rules can be used just like regular rules

@authz(rules: [TestAndRule])
@authz(rules: [TestOrRule])
@authz(rules: [TestNotRule])

Also, composite rules can be composed of other composite rules

class TestOrRule02 extends OrRule {
  public getRules() {
    return [TestAndRule, TestOrRule, TestNotRule];
  }
}

Inline composition rules

Rules can be composed in an inline way by using compositeRules and deepCompositeRules parameters. The difference between them is compositeRules supports only one level of inline composing. deepCompositeRules supports any levels of composing but it requires id key for the existing rule identificator.

Inline rules composition works with directives, extensions and authSchema

@authz(compositeRules: [{
  or: [Rule01, Rule03],
  not: [Rule02]
}])

@authz(deepCompositeRules: [{
  or: [
    {
      and: [{ id: Rule01 }, { id: Rule02 }]
    },
    {
      or: [{ id: Rule03 }, { id: Rule04 }]
    }
  ]
}])
const authSchema = {
  User: {
    __authz: {
      compositeRules: [{
        or: ['Rule01', 'Rule03'],
        not: ['Rule02']
      }]
    }
  },
  Post: {
    body: {
      __authz: {
        deepCompositeRules: [{
          or: [
            {
              and: [{ id: 'Rule01' }, { id: 'Rule02' }]
            },
            {
              or: [{ id: 'Rule03' }, { id: 'Rule04' }]
            }
          ]
        }]
      }
    }
  }
}

Pre and Post execution rules can be mixed in any way inside all types of composite rules.

Custom errors

To provide custom error for rule the error option should be provided

import { UnauthorizedError } from '@graphql-authz/core';

const SomeRule = postExecRule({
  error: 'User is not authenticated'
})(() => { /* rule body */ });

Using rule class

import { UnauthorizedError } from '@graphql-authz/core';

class SomeRule extends PreExecutionRule {
  public error = new UnauthorizedError("User is not authenticated");
  public execute() {
    throw this.error;
  }
}

It's important to throw an instance of UnauthorizedError imported from @graphql-authz/core to enable composite rules to correctly handle errors thrown from rules. If an instance of UnauthorizedError is thrown it's treated as a certain rule didn't pass. If the rule is wrapped with NotRule then execution should continue. Any other errors are treated as runtime errors and are thrown up, so if any other error is thrown from the rule that is wrapped with NotRule it will fail the entire request.