InteractiveFrameworks

Policies

Roles run out when the answer depends on the thing itself: a member of staff may rename the cat they adopted, nobody may delete an adopted cat. Describe abilities with CASL, check them in a guard for the route and in the service for the record.

What you'll learn

  • Say when a role is not enough: permissions that depend on the record being touched
  • Build an ability with CASL's AbilityBuilder: can and cannot, a subject class, conditions on its fields, and why a later cannot beats an earlier can
  • Check an ability against a subject type in a guard and against an instance in the service, and know what each answer means
  • Attach policies to routes with metadata and a guard that runs them all

Roles answered lesson 3's question: may this kind of user do this kind of thing? They stop answering when the thing itself matters. Ada may rename a cat, but only a cat she adopted. An admin may delete cats, but not one that has been adopted, because the shelter keeps the record of every adoption. Neither rule is about a role; both are about a relationship between the user and the record. Encoding them as if statements scattered through the service works until the third rule, and then nobody can say what a user may do without reading every handler. An ability says it in one place.

Abilities with CASL

CASL is a library for exactly this. An ability is a list of rules built for one user, each rule an action on a subject, optionally with conditions on the subject's fields:

const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

can(Action.Read, Article);                              // any article
can(Action.Update, Article, { authorId: user.id });    // only their own
cannot(Action.Delete, Article, { isPublished: true }); // nobody, even after a can
can(Action.Manage, 'all');                             // an admin: every action on everything

manage is CASL's word for every action, 'all' for every subject. Conditions use the same syntax as a MongoDB query, { authorId: 7 }, { age: { $gte: 2 } }, matched against the fields of a plain object or a class instance. Rules are read in order and a later rule wins, which is what makes cannot useful: can(Manage, 'all') followed by cannot(Delete, Article, { isPublished: true }) is an admin who can do everything except that. build() turns the list into an ability, and detectSubjectType tells it how to name a subject given an instance, here its class.

Asking is ability.can(action, subject), and the subject can be either of two things, with different meanings:

ability.can(Action.Update, Article);          // the class: may they update some article?
ability.can(Action.Update, oneArticle);       // an instance: may they update this one?

Against the class, a conditional rule counts as a yes, because there exist articles they may update; the conditions cannot be checked without a record. Against an instance, the conditions are evaluated against its fields. So a guard, which runs before the handler loads anything, can only ask the first question; the second belongs where the record is, in the service. Both are needed: the first stops a user who may never delete anything before a database query, the second stops the admin from deleting the one cat the rules protect.

The factory, the decorator, the guard

The docs wrap ability-building in an injectable CaslAbilityFactory with one method, createForUser(user), so a guard or a service can ask for the current user's ability anywhere. Policies attach to routes the way roles did, as metadata: @CheckPolicies() takes one or more handlers, each a function of the ability (or an object with a handle method, for a policy worth naming), and the guard reads them back:

@CheckPolicies((ability) => ability.can(Action.Read, Article))

PoliciesGuard has three steps: read the handlers off the handler with reflector.get() (no handlers, nothing to check), build the ability for request.user, and pass only if every handler returns true. Returning false gets Nest's 403 Forbidden resource, as in lesson 3. The guard is bound with @UseGuards(PoliciesGuard) on the routes that declare policies; it needs the factory injected, so the module that uses it imports CaslModule.

Where it sits

The global AuthGuard runs first and leaves the verified token on the request; a route's own guards run after the global ones, so PoliciesGuard finds request.user ready. Then the handler, which for a record-level decision builds the same ability again and hands it to the service with the record.

Your task

Ada looks after Tom; nobody deletes an adopted cat.

  1. In casl/casl-ability.factory.ts, build the ability: an admin manages all; everyone else reads all and updates the cats whose ownerId is their own id (user.sub); nobody deletes a cat whose adopted is true.
  2. In casl/policies.guard.ts, read the route's policy handlers, build the user's ability, and pass only if every handler approves.
  3. In cats/cats.service.ts, ask the ability about the specific cat before renaming (403 You may only rename a cat you adopted) and before deleting (403 An adopted cat stays on the record).
  4. In cats/cats.controller.ts, guard creating and deleting with the policies guard and a policy each: may this user create cats at all, delete cats at all? Renaming gets no guard: every member of staff may update some cat, so the class-level answer is always yes and only the service's question about this cat can refuse.

Do step 1 and 4 first, then Run: ada is refused on the class-level checks, but john can still delete the adopted cat, because a guard never sees the record. Step 3 closes that.

When it fails

  • John deletes the adopted cat with a 204: the service never asked about the instance, or cannot was written before can(Manage, 'all') and the later rule won.
  • Ada renames Luna with a 200: the update rule has no condition, or the condition names a field the entity does not have (owner for ownerId), which matches nothing and everything.
  • Ada gets 403 renaming her own cat: the condition compares ownerId with the wrong thing; the token's user id is sub.
  • Every guarded route is 403, for john too: the guard reads handlers that were never attached, or checks them with some instead of every; or the ability was built for undefined because the guard read the user from the wrong place.
  • Nest can't resolve dependencies of the PoliciesGuard (Reflector, ?): the controller's module does not import CaslModule.
  • The instance check always says yes: ability.can(Action.Update, Cat) was asked with the class where the instance was meant.
  • A policy on the rename route changes nothing: it asks the class-level question, which every signed-in user passes; a guard that cannot refuse anyone is noise, and the mutation harness behind this course flags it as one.

Remember

  • An ability is one user's rules: can/cannot, action, subject, conditions; a later rule wins.
  • can(action, Class) asks whether some record is allowed; can(action, instance) checks this record's fields.
  • The guard answers the class question before the handler; the service answers the instance question with the record in hand.
  • Policies are metadata on the route; the guard runs every handler and returns false for Nest's 403.
Stuck? Show a hint

casl-ability.factory.ts: if (user.roles.includes(Role.Admin)) can(Action.Manage, 'all'); else can(Action.Read, 'all'); can(Action.Update, Cat, { ownerId: user.sub }); cannot(Action.Delete, Cat, { adopted: true }). policies.guard.ts: this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, context.getHandler()) || []; ability = this.caslAbilityFactory.createForUser(request['user']); handlers.every((h) => this.execPolicyHandler(h, ability)). cats.service.ts: if (!ability.can(Action.Update, cat)) throw new ForbiddenException(...). cats.controller.ts: @UseGuards(PoliciesGuard) and @CheckPolicies((ability: AppAbility) => ability.can(Action.Create, Cat)) on create, Delete on remove.