Techniques
Authorization
Decide what each request may do with CASL abilities, narrowed by the grant of the credential the caller presents.
On this page 18 sections
- Define a permission catalog
- Write the user's abilities
- Identify the caller and their grant
- Register the module
- Protect routes with @Can()
- Typed @Can()
- Check records with AbilityService
- Tag records with asSubject()
- Throw CASL's own ForbiddenError
- Strip fields from responses
- Issue tokens safely with validateGrant()
- Apply AbilityGuard to the whole app
- WebSocket endpoints
- GraphQL
- Field-level rules with @AbilitySubject()
- Subscriptions
- How the user and the grant combine
- Pitfalls
@knead-js/casl builds a CASL ability for every request and checks it in
guards, in your handlers and on individual fields. The rule it enforces is simple: a request may do
only what both its user and its credential's grant allow.
- The user's own abilities come from your
defineUserAbility()function, written with CASL'sAbilityBuilder. They may use conditions ({ authorId: user.id }), field lists andcannot. - The grant comes with the credential (a personal access token's scopes, a JWT claim). It names a level for each area of a permission catalog you define, and every rule outside it is removed.
A token never widens what its user can do, and a user never gets more than the token grants. An administrator holding a read-only token can only read.
bun add @knead-js/caslThe package depends on its own pinned copy of @casl/ability (7.0.2), so you do not install CASL
yourself. @knead-js/core and @knead-js/http are peers, and @knead-js/graphql is an optional
peer needed only for the GraphQL features. The full API is on the
reference page.
#Define a permission catalog
The catalog lists what a credential can be granted. It is made of areas (posts, billing),
and each area has ordered levels:
export const permissions = definePermissions({
posts: {
description: 'Blog posts',
levels: ['none', 'read', 'write', 'manage'],
rules: {
read: [{ action: 'read', subject: 'Post' }],
write: [{ action: ['create', 'update'], subject: 'Post' }],
manage: [{ action: 'delete', subject: 'Post' }],
},
},
users: {
description: 'User profiles',
levels: ['none', 'read'],
rules: {
read: [{ action: 'read', subject: 'User', fields: ['id', 'name', 'email'] }],
},
},
billing: {
description: 'Billing details',
levels: ['none', 'read'],
rules: {
read: [{ action: 'read', subject: 'User', fields: ['billingAddress'] }],
},
},
});The rules of the catalog work as follows:
levels[0]means no access. It must not have rules, and every other level must add at least one.- Levels are cumulative.
posts:writeincludes the rules ofposts:read, andposts:manageincludes both. - A rule has only
action,subjectand optionallyfields. Each is a string or a string array (fieldsmust be an array).manage(any action) andall(any subject) are allowed. Fields must not contain*. - Area and level names match
/^[a-z][a-z0-9_-]{0,63}$/. - Conditions,
invertedandreasonare rejected. Conditions belong to the user's own abilities, where they are kept when the two sides are intersected.
definePermissions() copies and validates its input immediately. Anything malformed (an unknown
rule key, a duplicate level, a level without rules, a rules key that is not a level, an empty
catalog) throws a CaslConfigError naming the problem, for example
area "posts": level "read" must add at least one rule. CaslConfigError is a KneadError, so
invalid catalogs and options stop the application at startup, never at request time. One wiring
mistake is not caught at startup: a route with @PermittedFields() but no AbilityGuard responds
403 to every request (see strip fields from responses).
The result is deep-frozen. Two methods expose it:
// A JSON-safe list of areas and levels, for example for a token-issuing screen.
export const catalogForClients = permissions.describe();
// { areas: [{ name: 'posts', description: 'Blog posts', levels: ['none', 'read', 'write', 'manage'] }, ...] }
// The cumulative CASL raw rules of one level: posts:write includes posts:read.
export const writeRules = permissions.rulesFor('posts', 'write');
// [{ action: 'read', subject: 'Post' }, { action: ['create', 'update'], subject: 'Post' }]describe() returns the same frozen object on every call, so it is safe to send to clients, for
example to build a token-issuing screen.
#Write the user's abilities
defineUserAbility(user, builder) describes the most this user may ever do. The builder is a CASL
AbilityBuilder, so use can and cannot as in any CASL app:
export interface User {
id: string;
name: string;
admin: boolean;
}
/** The most this user may ever do; the credential's grant narrows it. */
export function defineUserAbility(user: User, { can }: AbilityBuilder<MongoAbility>): void {
can('read', 'Post');
can(['create', 'update'], 'Post', { authorId: user.id });
can('read', 'User', ['id', 'name']);
can('read', 'User', { id: user.id });
if (user.admin) can('manage', 'all');
}Here every user may read posts, edit their own posts, read the id and name of any user and every
field of their own user record. An administrator may do anything, but only within the grant.
#Identify the caller and their grant
resolve(ctx) tells the module who is calling. It returns an Identity, { user, grant }, or
undefined (or null) for an unauthenticated caller. It may be async. A typical implementation
looks the bearer token up in your token store:
/** Your token store: each access token maps to its user and the grant it was issued with. */
export const sessions = new Map<string, Identity<User>>();
export function sessionFrom(req: Request): Identity<User> | undefined {
const header = req.headers.get('authorization');
if (!header?.startsWith('Bearer ')) return undefined;
return sessions.get(header.slice('Bearer '.length));
}The grant is untrusted input, and parseGrant() accepts three shapes:
| Shape | Example |
|---|---|
| Object | { posts: 'write', billing: 'read' } |
| Scope string | 'posts:write billing:read' (separated by spaces, at most MAX_SCOPE_LENGTH, 4096, characters) |
| Array | ['posts:write', 'billing:read'] |
Areas that the grant leaves out are at their levels[0], no access. The whole grant is
malformed, and allows nothing, if it has an unknown area or level, names an area twice, has more
entries than the catalog has areas, has a non-string level, has an entry longer than 4096
characters or an entry without area: before its first colon, or is any other kind of value
(undefined, null, a number). An empty grant ({}, '', []) is valid but also allows nothing.
Only own enumerable keys of an object are read, and __proto__, constructor and prototype are
skipped.
For a first-party session, a user signed in to your own app rather than a third-party token, grant
everything the catalog has with fullGrant(). The user's own abilities still apply:
// Every area at its highest level, for a first-party session.
export const everything = fullGrant(permissions);
// { posts: 'manage', users: 'read', billing: 'read' }#Register the module
Import CaslModule.forRoot() once, in your root module:
@Module({
imports: [
CaslModule.forRoot<User>({
permissions,
// Who is calling and with which grant; undefined or null means unauthenticated (401).
resolve: (ctx) => ('req' in ctx ? sessionFrom(ctx.req) : undefined),
defineUserAbility,
}),
],
controllers: [PostsController, UsersController],
})
export class AppModule {}| Option | Type | Description |
|---|---|---|
permissions | Permissions | The catalog returned by definePermissions(). Required. |
resolve | (ctx: AbilityCtx) => Identity<U> | null | undefined | Promise<...> | Who is calling, with which grant. Required. |
defineUserAbility | (user: U, builder: AbilityBuilder<MongoAbility>) => void | Promise<void> | The user's own abilities. Required. |
forRoot() validates its options when it is called and throws a CaslConfigError if permissions
did not come from definePermissions() or resolve or defineUserAbility is not a function. The
module is global. It provides AbilityService, AbilityGuard and the CASL_OPTIONS token, and
registers CaslForbiddenFilter as an app-wide exception filter. It does not register
AbilityGuard as a global guard: you choose where it runs.
resolve() receives an AbilityCtx, which is either a REST Ctx or a GraphQL resolver context.
Checking 'req' in ctx tells them apart, as above.
#Protect routes with @Can()
Put AbilityGuard on a controller or route with @UseGuards(), and declare what each route needs
with @Can(action, subject, field?):
@Controller('/posts')
@UseGuards(AbilityGuard)
export class PostsController {
readonly #abilities = inject(AbilityService);
@Get('/')
@Can('read', 'Post')
list() {
return [...posts.values()];
}
@Get('/count')
@SkipAbility()
count() {
return { count: posts.size };
}
@Put('/:id')
@Can('update', 'Post')
async update(ctx: Ctx<{ params: { id: string } }>) {
const post = posts.get(ctx.params.id);
if (!post) throw new NotFoundException();
// @Can() checked the subject type; this checks { authorId: user.id } against the record.
await this.#abilities.authorize(ctx, 'update', asSubject('Post', post));
return post;
}
@Post('/:id/publish')
@Can('update', 'Post')
async publish(ctx: Ctx<{ params: { id: string } }>) {
const post = posts.get(ctx.params.id);
if (!post) throw new NotFoundException();
const ability = await this.#abilities.forCtx(ctx);
// CaslForbiddenFilter turns this into a 403 problem; CASL's message is not sent.
ForbiddenError.from(ability).throwUnlessCan('update', asSubject('Post', post));
post.published = true;
return post;
}
@Get('/drafts')
drafts() {
// No @Can() and no @SkipAbility(): AbilityGuard denies this route.
return [...posts.values()].filter((p) => !p.published);
}
}AbilityGuard decides in this order:
- A route with
@SkipAbility()passes without resolving an identity. Use it for public routes. When both@SkipAbility()and@Can()apply, the more specific one wins:@SkipAbility()on the controller and@Can()on a method checks the method's@Can(), and@Can()on the controller with@SkipAbility()on a method makes that method public. On the same method or controller,@SkipAbility()wins. - A route without
@Can()is denied with a403(fail closed). This happens beforeresolve()runs, so an anonymous caller also gets a403there, not a401. resolve()returning no identity is a401(UnauthorizedException).- Otherwise the route passes only if
ability.can(action, subject, field)returnstrue, and is a403(ForbiddenException) otherwise.
@Can() may go on the controller class, on a method, or both; a method's @Can() replaces the
class's. Both errors are the standard problem responses:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"Forbidden","status":403,"instance":"/posts/1"}A 401 has the same shape with "title":"Unauthorized" and "status":401.
#Typed @Can()
@Can() accepts any strings. To have the compiler check action and subject names, make your own
Can with createCan(). It writes the same route metadata, so AbilityGuard treats it exactly like
@Can():
type AppActions = 'manage' | 'read' | 'create' | 'update' | 'delete';
type AppSubjects = 'all' | 'Post' | 'User';
export const Can = createCan<AppActions, AppSubjects>();@Controller('/reports')
@UseGuards(AbilityGuard)
export class ReportsController {
@Get('/posts')
@Can('read', 'Post')
posts() {
return [];
}
@Get('/comments')
// @ts-expect-error 'Comment' is not one of AppSubjects
@Can('read', 'Comment')
comments() {
return [];
}
}#Check records with AbilityService
@Can('update', 'Post') checks the subject type: "may this caller update some post?". A user
whose rule is can('update', 'Post', { authorId: user.id }) passes it, because some posts match.
To check the conditions against a particular record, load it and call
AbilityService.authorize(), as the update route above does:
@Put('/:id')
@Can('update', 'Post')
async update(ctx: Ctx<{ params: { id: string } }>) {
const post = posts.get(ctx.params.id);
if (!post) throw new NotFoundException();
// @Can() checked the subject type; this checks { authorId: user.id } against the record.
await this.#abilities.authorize(ctx, 'update', asSubject('Post', post));
return post;
}authorize(ctx, action, subject, field?) throws ForbiddenException (403) unless the ability
allows it, and UnauthorizedException (401) when there is no identity. forCtx(ctx) returns
the ability itself, for checks of your own.
The ability is built once per request and cached on ctx.state: the guard, authorize() and
forCtx() share one, so resolve() and defineUserAbility() run once. A build that throws is not
cached; the next call tries again.
#Tag records with asSubject()
CASL has to know a record's subject type to match it against rules. asSubject(type, record) tags a
plain object with its type and returns the same object. It does not modify the record (the tag is
kept in a WeakMap), so it works on frozen objects, and tagging an object again replaces its type.
Without a tag, CASL falls back to its own detection: the type set by CASL's subject() helper,
otherwise the constructor's modelName or name. A plain object literal is therefore of type
Object, which matches no Post rule. subject() is re-exported too, but unlike asSubject() it
defines a hidden property on the record and throws if the record was already cast to another type.
#Throw CASL's own ForbiddenError
Code that uses CASL's ForbiddenError.from(ability).throwUnlessCan(...) works as well:
@Post('/:id/publish')
@Can('update', 'Post')
async publish(ctx: Ctx<{ params: { id: string } }>) {
const post = posts.get(ctx.params.id);
if (!post) throw new NotFoundException();
const ability = await this.#abilities.forCtx(ctx);
// CaslForbiddenFilter turns this into a 403 problem; CASL's message is not sent.
ForbiddenError.from(ability).throwUnlessCan('update', asSubject('Post', post));
post.published = true;
return post;
}CaslForbiddenFilter, registered by forRoot(), turns a CASL ForbiddenError into the same 403
problem as above. CASL's message is not sent, because it can describe your rules. Import
ForbiddenError from @knead-js/casl, not from @casl/ability: the filter recognizes it with
instanceof, which matches only errors created by the package's own copy of CASL.
#Strip fields from responses
@PermittedFields(subjectType) filters a handler's result to the fields the caller may read on
subjectType, record by record, so conditions apply per record:
@Controller('/users')
@UseGuards(AbilityGuard)
export class UsersController {
@Get('/')
@Can('read', 'User')
@PermittedFields('User')
list() {
// Each record is stripped to the fields the caller may read on it.
return userRecords;
}
@Get('/page')
@Can('read', 'User')
@PermittedFields('User')
page() {
// Wrong: an envelope is stripped as one record, so `data` (not a User field) is dropped.
return { data: userRecords, total: userRecords.length };
}
}With the abilities above and a full grant, Alice gets her own record in full and only id and
name of everyone else:
[
{ "id": "alice", "name": "Alice", "email": "alice@example.com", "billingAddress": "1 Main St" },
{ "id": "bob", "name": "Bob" }
]With a grant of only users:read, her own record also loses billingAddress, because that area
grants only id, name and email.
What it strips:
- The result itself when it is an object, or each element of an array, recursively.
- Every object, whatever its prototype: class instances are stripped too.
- An object with a
toJSON()method is stripped aftertoJSON()runs. - Values that are not records pass through unchanged: primitives,
null,undefined(a204),Response,Blob,ReadableStream,ArrayBuffer, typed arrays andDate. - A result that contains a cycle throws
PermittedFields: the result contains a cycle, which is a500, rather than overflowing the stack.
@PermittedFields() reads the ability that AbilityGuard cached for the request. Without one it
refuses to run the handler and responds 403 with the detail
PermittedFields requires AbilityGuard.
pickPermitted(ability, subjectType, record) does the same for one record by hand: it returns a new
object with the record's own enumerable string keys that the ability lets the caller read.
#Issue tokens safely with validateGrant()
Before you issue a token with a requested grant, check that the grant does not exceed its user:
@Injectable()
export class TokenIssuer {
readonly #abilities = inject(AbilityService);
/** Returns the parsed, frozen grant to store with the new token. */
async grantFor(user: User, requested: unknown): Promise<Grant> {
const result = await this.#abilities.validateGrant(user, requested);
if (!result.ok) {
// [{ reason: 'malformed' }] or
// [{ reason: 'exceeds-user', area, level, action, subject, field? }, ...]
throw new BadRequestException('The token cannot be granted more than you can do', {
extensions: { problems: result.problems },
});
}
return result.grant;
}
}validateGrant(user, requested) resolves to { ok: true, grant }, with the parsed, frozen grant
to store, or { ok: false, problems }. A problem is either { reason: 'malformed' } or
{ "reason": "exceeds-user", "area": "posts", "level": "manage", "action": "delete", "subject": "Post" }for every action and subject pair of the granted levels that the user cannot do. With the abilities
above, a regular user asking for posts:manage gets exactly that problem, because they cannot
delete posts. When a catalog rule names fields, each field is checked too and the problem
carries a field property: a user with only can('read', 'User', ['id', 'name']) cannot be issued
billing:read, and the problem has field: 'billingAddress'. A cannot on a field also counts,
and so does an unconditional cannot on the pair (no conditions, no fields) that comes after its
can rules.
#Apply AbilityGuard to the whole app
To require a decision on every route, register AbilityGuard as a global guard. Every route then
needs @Can() or @SkipAbility(), and any route you forget is denied:
@Module({
imports: [
CaslModule.forRoot<User>({
permissions,
// Global guards run before controller and route guards: resolve() authenticates by itself.
resolve: (ctx) => ('req' in ctx ? sessionFrom(ctx.req) : undefined),
defineUserAbility,
}),
],
controllers: [StatusController],
providers: [FeedGateway, { provide: APP_GUARD, multi: true, useExisting: AbilityGuard }],
})
export class AppModule {}@Controller('/')
export class StatusController {
@Get('/health')
@SkipAbility()
health() {
return { ok: true };
}
@Get('/me')
@Can('read', 'User')
me() {
return { ok: true };
}
@Get('/internal')
internal() {
// Neither @Can() nor @SkipAbility(): denied by the global AbilityGuard.
return { ok: true };
}
}#WebSocket endpoints
A global guard also runs on WebSocket upgrades. Endpoints registered with WsRegistry have no
decorators, so declare their requirement in the meta option with canMeta() or
skipAbilityMeta(), the metadata forms of @Can() and @SkipAbility():
@Injectable()
export class FeedGateway implements OnModuleInit {
readonly #ws = inject(WsRegistry);
onModuleInit() {
const feed: WsEndpoint = {
upgrade: () => ({}),
open(socket) {
socket.send('welcome');
},
};
// The meta forms of @Can() and @SkipAbility(), for endpoints without a decorator.
this.#ws.register('/feed', feed, { meta: canMeta('read', 'Post') });
this.#ws.register('/status', feed, { meta: skipAbilityMeta() });
// Registered without either: every upgrade is denied with 403.
this.#ws.register('/admin-feed', feed);
}
}The upgrade is then decided like a route: 101 when the ability allows it, 401 without an
identity, 403 when denied, and 403 for an endpoint registered with neither. An endpoint on the
path of a GET route also gets that route's metadata, @Can() included; its own meta wins. See
WebSockets for the registry itself.
The GraphQL endpoint is handled for you: the global guard passes the /graphql request and its
subscription upgrades when GraphQLModule.forRoot({ guards }) includes AbilityGuard (or a
subclass), because every root field is then checked against its own @Can(). Without that, the
endpoint has no @Can() and is denied.
#GraphQL
The same guard and decorators work on GraphQL root fields. Register AbilityGuard as a module guard
of GraphQLModule, put the identity on the GraphQL context, and add mapCaslError:
@Module({
imports: [
GraphQLModule.forRoot({
// Fields of this object are on every resolver ctx.
context: (http) => ({ identity: sessionFrom(http.req) }),
// Every query, mutation and subscription root field needs @Can() or @SkipAbility().
guards: [AbilityGuard],
// A CASL ForbiddenError thrown by a resolver is FORBIDDEN, not a masked internal error.
mapError: mapCaslError,
subscriptions: {
// Before each recheck: look the token up again, so a revoked one ends the subscription.
refreshContext: ({ http }) => {
const identity = sessionFrom(http.req);
return identity ? { identity } : false;
},
},
}),
CaslModule.forRoot<User>({ permissions, defineUserAbility, resolve: identityOf }),
],
providers: [UsersResolver],
})
export class AppModule {}Declare the context field so that it is typed, including in refreshContext:
declare module '@knead-js/graphql' {
interface GraphQLContext {
/** The caller, from the bearer token; read by CaslModule's resolve(). */
identity?: Identity<User>;
}
}resolve() now receives resolver contexts too. The fields of the object returned by context are
on every resolver context, so it reads identity there:
/** REST requests carry the token; GraphQL resolvers read the identity the context put there. */
function identityOf(ctx: AbilityCtx): Identity<User> | undefined {
if ('req' in ctx) return sessionFrom(ctx.req);
return 'identity' in ctx ? (ctx.identity as Identity<User> | undefined) : undefined;
}How root fields are checked:
guards: [AbilityGuard]runs on every query, mutation and subscription root field. Each needs@Can()or@SkipAbility(); a field with neither isFORBIDDEN, and no identity isUNAUTHENTICATED.@Public()from@knead-js/graphqlskips module guards,AbilityGuardincluded.- The ability is built once per operation and shared by all of its resolvers.
mapError: mapCaslErrorreports a CASLForbiddenErrorthrown by a resolver asFORBIDDEN, without CASL's message. Without it, the error is an unexpected one: with error masking on (the default outside development) the client sees only a masked internal error.
@Resolver()
export class UsersResolver {
@Query(() => [UserType])
@Can('read', 'User')
users() {
return userRecords;
}
@Query(() => UserType, { nullable: true })
@SkipAbility()
featuredUser() {
// Open to anyone, but each UserType field still needs an identity and `read` on it.
return userRecords[1];
}
@Subscription(() => Int, { topic: 'post-count', recheckGuards: true })
@Can('read', 'Post')
postCount() {}
}#Field-level rules with @AbilitySubject()
@AbilitySubject(subjectType) on an @ObjectType() class makes every field of the type, plain
@Field and @ResolveField alike, require read on subjectType for the parent record and that
field:
@ObjectType()
@AbilitySubject('User')
export class UserType {
@Field(() => String) id!: string;
@Field(() => String, { nullable: true }) name?: string | null;
@Field(() => String, { nullable: true }) email?: string | null;
@Field(() => String, { nullable: true }) billingAddress?: string | null;
}It adds FieldAbilityGuard as a field guard of the type. Field rules name the GraphQL field names.
A denied field is null with a FORBIDDEN error at its path, and the rest of the result is kept.
For the query { users { id email } }, Alice with a users:read grant gets:
{
"data": {
"users": [
{ "id": "alice", "email": "alice@example.com" },
{ "id": "bob", "email": null }
]
},
"errors": [
{
"message": "Forbidden",
"locations": [{ "line": 1, "column": 14 }],
"path": ["users", 1, "email"],
"extensions": { "code": "FORBIDDEN" }
}
]
}Things to know about field rules:
- Put it on each concrete
@ObjectType.@InterfaceTypeclasses are not wrapped. A subclass inherits@AbilitySubject(), and a subclass's own@AbilitySubject()replaces the subject type. It combines with@UseFieldGuards(). - Anonymous callers. A root field open to anyone (
@SkipAbility()or@Public()) may return an@AbilitySubjecttype, but each of its fields needs an identity. Without one, every such field isnullwith anUNAUTHENTICATEDerror, asfeaturedUserabove shows. - Make deniable fields nullable. For a non-null field, graphql-js nulls the nearest nullable
parent instead, possibly the whole
data. - Conditions are matched against the parent object your resolver returned, by its own data properties.
#Subscriptions
A subscription root field's ability is never cached: it is rebuilt every time its guards run. With
recheckGuards: true the guards run again before every event, so a narrowed or revoked grant takes
effect at the next event:
@Subscription(() => Int, { topic: 'post-count', recheckGuards: true })
@Can('read', 'Post')
postCount() {}resolve() sees the context of the subscription's connection, which is fixed when it connects.
For the recheck to see a change, the module above sets subscriptions.refreshContext to look the
token up again before each recheck; returning false there ends the subscription with FORBIDDEN.
See GraphQL for subscriptions and their options.
#How the user and the grant combine
AbilityService intersects the user's rules with the rules of the grant's levels:
- Each positive user rule is replaced, at its own position, by its intersection with every grant
rule. Actions and subjects must match by name;
manageandallon either side match anything and keep the other side's names. Field lists are intersected, and the user's field patterns (CASL supports*there) are matched against the catalog's fields. - The user's conditions are kept on every intersected rule.
- Each
cannotrule of the user is kept unchanged at its position, so CASL's "later rules win" order is preserved.
The result never allows more than either side. This is why a manage all administrator with a
posts:read token can read posts and do nothing else.
#Pitfalls
- No action aliases. CASL's
createAliasResolveris not supported: an alias is not expanded when the user's rules and the grant are intersected. Usemanageandall, or list actions. - Class subjects. A user rule whose subject is a class rather than a string never matches a
catalog subject by name; only a catalog rule with subject
allkeeps it. Use string subject types andasSubject(). - Conditions on plain data. Conditions are matched against the record's own data properties. Write them against plain data, not getters, methods or computed values.
- Lists.
@Can('read', 'Post')checks the type, not which records a list returns. Fetch only the records the caller may read, for example with CASL's@casl/prismapackage, which turns an ability into a Prismawhereclause. It is not a dependency of@knead-js/casl: install it yourself, and check that it supports the@casl/abilityversion this package pins (7.0.2). @PermittedFields()needs the guard. It works only on routes whereAbilityGuardran.- Import CASL from
@knead-js/casl. Use the re-exportedForbiddenError,subject,AbilityBuilder,MongoAbilityandRawRuleOf, so that your code and the package share one copy of CASL.