Skip to content
This repository has been archived by the owner on Apr 3, 2024. It is now read-only.

Authorization

Erik Roberts edited this page Jan 4, 2024 · 19 revisions

Overview

Authorization in the Glimpse API takes advantage of GraphQL directives to apply CASL rules. Directives allow you to perform actions before and after the resolver is called. Directives also allow you to transform the response before sending it to the client.

ℹ️ Before reading further, it may be helpful to read the article on Glimpse's CASL integration.

Glimpse's authorization flow is handled by the files located in the /src/casl directory.

  • casl.module.ts - The CASL module which can be imported into other NestJS modules, allowing you to use CASL services within those modules.
  • casl-ability.factory.ts - A NestJS Provider that allows you to generate CASL abilities for a given user. This is done immediately for you within the CASL interceptor.
  • casl.interceptor.ts - An NestJS interceptor that sets up the user's CASL permissions before processing an HTTP request.
  • casl.plugin.ts - An Apollo plugin that sets up the user's CASL permissions before processing a GraphQL request.
  • casl.helper.ts - A NestJS Provider with a number of helper functions for CASL rules checks. Primarily, this class includes methods to test CASL abilities against Rules, and is used within the CASL interceptor.
  • rules.decorator.ts - @Rule decorator; type-safe wrapper around the GraphQL @rule directive.
  • rule.directive.ts - A class containing definitions for the GraphQL @rule directive. These directives can be applied to resolvers to easily define required permissions for a given resource. Underlying mechanism used by rules.decorator.ts.

Authorization for non-GraphQL contexts (e.g. HTTP) is currently not supported. If you believe that you need this functionality for future features of the API, please read the dropdown below. Otherwise, if you only care about authorization in GraphQL contexts, then carry on!

Authorization in HTTP contexts

When originally building the authorization system for the Glimpse API, everything was built on NestJS interceptors which, similarly to directives, allow you to perform logic before and after requests. NestJS is designed with RESTful applications in mind first, but still treats GraphQL as a first-class citizen. To my delight, NestJS interceptors are purported to work perfectly within GraphQL contexts as well as the typical HTTP contexts. Instead of just intercepting the whole initial HTTP GraphQL request, interceptors appeared to be capable of intercepting at the resolver-level. This would have been perfect for our application, as it would allow us to have a single @Rule interceptor within our code that can be applied to either GraphQL resolvers or classical HTTP endpoints, and the blackbox behavior would remain the same.

This worked well for a while, but unfortunately once it came time to implement and test nested relational resolvers, I quickly realized that NestJS interceptors weren't going to cut it. NestJS interceptors can only be applied to top-level resolvers within the built-in Query, Mutation, and Subscription types. Bummer!

The backup plan was to ultimately split the way in which rules are applied into two systems: GraphQL directives for GraphQL contexts, and continue to use NestJS interceptors for HTTP contexts. Under the hood, both systems would still call the same functions to handle permission checks. This works, however a few minor shortcuts were taken to get the old interceptor-based system working seamlessly with the new directive-based system.

At this point in the life of the API, there is no need for authorization within HTTP contexts. It was going to be there and ready to go with the interceptors in case it was needed, however with the departure from the interceptors system for GraphQL contexts, it did not make sense to spend time polishing and testing a feature which we may never use. For this reason, the @Rule decorator now only supports GraphQL resolvers. If a valid use-case for rules in HTTP contexts comes up in the future, open an issue to update the @Rule decorator to support both GraphQL and HTTP contexts.


@Rule Decorator

Rules are applied to GraphQL resolvers using the @Rule decorator. This decorator is in fact just a wrapper around the GraphQL @rule directive, which provides some features not available when using the @rule directive directly:

  • Automatic rule handler registration
  • Abstraction of rule handler IDs
  • Type safety

This might not mean much to you right now, so let's go through how the @Rule decorator works under the hood.

Rule Handlers

Each rule is defined using a RuleHandler. A RuleHandler is defined as:

type RuleHandler<T = any> = (
    context: ExecutionContext | GraphQLResolverArgs,
    rule: RuleDef,
    next: () => Observable<T>,
    caslHelper: CaslHelper
) => Observable<T>;

Rule handlers are responsible for executing checks based on the current context (e.g. the user's permissions). If permission checks pass, then the rule handler should call the next rule handler via the passed next function. If there are no more rule handlers to call, then the next function will call the actual resolver.

On the other hand, if permission checks fail, then the rule handler should set Express.Request#passed from the context to false and stop execution. Rule handlers can perform checks (and fail) after the next function has been called. If a rule check fails after the resolver has been called, the resolver's changes are rolled back (see Database).

❗Currently, each rule handler must check whether the next function set Express.Request#passed to false, and stop execution if so. These checks are not performed automatically.

Rule handlers are also passed the original rule definition, which contains the subject and any options. Specifically, RuleDef is defined as:

type RuleDef = {
    fn: RuleHandler;
    subject: AbilitySubjects | null;
    options?: RuleOptions;
};

fn will always be a reference to the handler itself. Please check the in-code documentation for the RuleOptions type to see what options are available.

Registering handlers

In the GraphQL schema, we aren't able to reference these defined RuleHandler functions directly, or put the code inline within the @rule directive definition. Instead, to actually use a RuleHandler, it first needs to be registered and given an ID. That ID can then be used within the schema to refer to the corresponding RuleHandler.

To register a RuleHandler, you can pass the handler into the registerHandler function exported from rule.decorator.ts. This function will return the handler's ID, which is guaranteed to be unique. Optionally, you may pass in a custom ID as the second argument to this function. If a handler has already been registered with the passed ID, an Error will be thrown.

ℹ️ Automatic IDs are generated based on a hash of the RuleHandler's source code. In the event of a collision, a hash of the hash is taken until a unique ID is found.

ℹ️ If you attempt to register a handler which has already been registered, the same ID will be returned. However, it must be a reference to the same function object in memory. If you pass in a different function which has the exact same source code, it will be registered as a second handler, despite having the same behavior.

There are a few additional utility functions exported by rule.decorator.ts which you may find helpful:

  • getRuleHandlerId - Generate the ID for a handler without actually registering it.
  • getRuleHandlerForRuleType - Get the RuleHandler for a built-in RuleType (e.g. RuleType.ReadOne). We will discuss RuleTypes later.
  • getRuleHandler - Get the RuleHandler function for a given ID. Returns null if no RuleHandler exists under the given ID.

@rule Directive

The GraphQL @rule directive has three arguments:

  • id - The ID of the registered rule handler to apply.
  • subject - The subject to pass to the rule, if applicable. Can be null if a given rule handler doesn't expect or require a subject.
  • options - A RuleOptions object with any additional options to supply to the RuleHandler. Optional.

ℹ️ In NestJS, GraphQL directives will not appear in the final schema.

GraphQL directives are applied via the built-in decorator @Directive which takes the directive as a string argument. This isn't ideal, since there is no type safety or syntax highlighting. As an example, a typical rule directive may look something like this:

@Directive('@rule(id: "e089f461c834bd960178001271c8c03c", subject: User, options: { name: "Custom rule name" })')

As long as you register your RuleHandler before calling this directive, and then use the directive ID within the Directive call, this works fine. However, dealing with IDs isn't IDeal, and it'd be nice to have some type-safety and syntax highlighting.

Bringing it all Together

The @Rule decorator is an abstraction of the @rule directive which takes care of registering handlers for you, while also providing type safety and syntax highlighting. When using @Rule, you do not have to worry about the underlying IDs. Instead, you can pass the handler directly.

@Rule(yourRuleHandler, User, { name: "Custom rule name" })

Rule handlers can also be inline:

@Rule((ctx, rule, next, caslHelper) => {
  return next();
}, User, { name: "Custom rule name" })

ℹ️ The @Rule decorator does not currently support registering handlers under a custom ID. If this is necessary, for now you must use the @rule directive directly.

⚠️ Inline rules should only be used if you are using that rule in one location. If you do otherwise, ignoring the fact that you are copy-pasting code, you are also needlessly registering two instances of the exact same function. Instead, use one function and reference it in multiple locations, as seen in the previous example.

Rule Types

RuleTypes (also called rule types, or built-in rule handlers) are a shorthand, more human-readable way of referring to specific RuleHandlers. In general, the Glimpse API is composed of six different types of resolvers:

  • Read one
  • Read many
  • Create
  • Create invisible
  • Update
  • Delete
  • Count

Across Glimpse's 25+ different resource types, at least some of these six resolvers are used on all of them. Instead of importing and referring to the corresponding RuleHandler for each of these, the @Rule decorator can take in a RuleType instead of a RuleHandler as its first argument.

@Rule(RuleType.ReadOne, User, { name: "Custom rule name" })

The RuleType values are simply strings which are mapped to a RuleHandler internally. This allows the CaslHelper (and by extension, @rule directive) to infer the rule's name based on the RuleType when a name is not explicitly set within the options.

ℹ️ RuleHandlers which do not correspond to a RuleType are sometimes referred to as "custom rule handlers".

If you find that you are using a specific rule handler very frequently, you can define a RuleType value for it. Simply add a value to the RuleType enum and then map that enum value to the handler within the ruleHandlers variable in rule.decorator.ts.

Let's go over the implementation details for each of these default rule types, and what they are doing within their rule handlers. For the remainder of this article, we're going to talk about performing these actions against a "Production" type resource, however the same concepts apply to all types of resources.

Read One

  1. Checks the user has permission to read at least one field on at least one Production.
  2. Checks the user has permission to read all of the requested fields on at least one Production.
  3. Calls next and makes sure req.passed is not false.
  4. Checks the user has permission to read at least one field on the Production returned by next.
  5. Check the user has permission to read all of the requested fields on the Production returned by next.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, req.passed is set to true and the Production returned by next is returned. If the defer option is set, steps 1 and 2 are skipped. You can view the implementation here.

Read Many

  1. Checks the user has permission to read at least one field on at least one Production.
  2. If sorting arguments were provided, checks the user has permission to sort by all of the fields they wish to sort by.
  3. If filtering arguments were provided, checks the user has permission to filter by all of the fields they wish to filter by.
  4. If a cursor pagination argument was provided, checks the user has permission to sort by the id field.
  5. Checks the user has permission to read all of the requested fields on at least one Production.
  6. Calls next and makes sure req.passed is not false.
  7. Checks the user has permission to read at least one field on all of the Productions returned by next.
  8. Check the user has permission to read all of the requested fields on all of the Productions returned by next.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, req.passed is set to true and the Production returned by next is returned. If the defer option is set, steps 1 and 5 are skipped and steps 2-4 are completed after next has been called. If the strict option is set, fields which the user doesn't have permission to read in step 8 are set to null, and execution continues. The returned value is the modified list of Productions. You can view the implementation here.

Create

  1. Checks the user has permission to create at least one field on Productions.
  2. Checks the user has permission to create all of the submitted fields on Productions.
  3. Calls next and makes sure req.passed is not false.
  4. Checks the user has permission to create at least one field on the Production returned by next.
  5. Check the user has permission to create all of the submitted fields on the Production returned by next.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, the value returned by next is piped through the "Read One" rule handler. If the defer option is set, steps 1 and 2 are skipped. You can view the implementation here.

Create Invisible

This rule operates the same as "Create", however when it is complete, it returns true instead of the created value. This is useful when you want users to be able to create an object but not be able to read any of the created data (most notable example is contact submissions). Note that when using this, it means that your resolver will return a type of the value you want the permissions to apply to, but the resolver must be annotated as returning a boolean. Despite being advertised as returning a boolean, the rule handler will never return false.

Update

  1. Checks the user has permission to update at least one field on at least one Production.
  2. Checks the user has permission to update all of the submitted fields on at least one Production.
  3. Calls next and makes sure req.passed is not false.
  4. Checks the user has permission to update at least one field on the Production returned by next.
  5. Check the user has permission to update all of the submitted fields on the Production returned by next. Notably, generated fields are not checked. See #79.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, the value returned by next is piped through the "Read One" rule handler. If the defer option is set, steps 1 and 2 are skipped. You can view the implementation here.

Note that this rule handler does not check that the user has permission to update the Production before it has been updated. It only checks that the user has permission to update objects of the Production type. Currently, the resolver must perform this check itself. Future versions of the API may improve this.

Delete

  1. Checks the user has permission to delete at least one field on at least one Production.
  2. Calls next and makes sure req.passed is not false.
  3. Checks the user has permission to update at least one field on the Production returned by next.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, the value returned by next is piped through the "Read One" rule handler. If the defer option is set, steps 1 is skipped. You can view the implementation here.

Note that this rule handler does not check that the user has permission to delete the Production before it has been deleted. It only checks that the user has permission to delete objects of the Production type. Currently, the resolver must perform this check itself. Future versions of the API may improve this.

Also note that field-based permissions do not make sense in the context of record deletion. There should be constraints (either in code or the database itself) to specifically prevent field restrictions from being set on delete rules. Users either have permission to delete an object in its entirety, or not at all. To delete specific fields, you probably want to instead update the object and set the relevant fields to null.

Count

  1. Checks the user has permission to read at least one field on at least one Production.
  2. If filtering arguments were provided, checks the user has permission to filter by all of the fields they wish to filter by.
  3. Calls next and makes sure req.passed is not false.

If any of the above checks fail, req.passed is set to false and the rule handler returns null. Otherwise, req.passed is set to true and the Production returned by next is returned. If the defer option is set, steps 1 and 2 are completed after next has been called. You can view the implementation here.