@vantreeseba/graphql-casl
v1.0.0
Published
GraphQL middleware plugin for defining CASL permission rules on resolvers.
Downloads
13
Readme
@vantreeseba/graphql-casl
A graphql-middleware plugin for
defining CASL permission rules that apply to your GraphQL
resolvers. Declare rules per type/field in a PermissionsMap; each rule runs
before the underlying resolver and throws if the request is not allowed.
The library is schema-agnostic — the subject names and condition types are
derived from your own generated Resolvers / ResolversTypes, so there is no
manual type listing.
Install
npm install @vantreeseba/graphql-casl
# peer deps
npm install @casl/ability graphql graphql-middlewareConcepts
| Export | What it does |
|---|---|
| createGraphQLAbility<SubjectMap>() | Returns a CASL AbilityBuilder typed against your schema — can/cannot conditions are checked against each subject's fields — with __typename detection applied by build(). |
| buildGraphQLAbility<SubjectMap>(rules, options?) | Rebuilds an ability from stored GraphQLRules (e.g. rules persisted in a database and loaded at startup). |
| createCan(getAbility, isAuthenticated, buildSubject?) | Factory that returns a requireCan(action, subject, getSubjectData?) rule builder, bound to your context shape and ability builder. |
| createTyped<SubjectMap>() | Returns a typed(type, attrs) helper that tags plain objects with __typename for subject detection. |
| createSubjects<SubjectMap>() | Validates a subject-name const object against your schema's domain types. |
| accept / deny | Always-pass / always-fail rule primitives. |
| Actions | Const map of create / read / update / delete / manage. |
Type helpers: PermissionsMap, Rule, SubjectName, SubjectMap, ArgsOf,
ParentOf, ContextOf, Action, GraphQLAbility, GraphQLAbilities,
GraphQLRule, GraphQLAbilityOptions, AbilityLike.
A failed authentication check throws Not authenticated; a failed ability check
throws Forbidden.
Conditions
GraphQLAbility is a CASL MongoAbility,
so conditions use the standard CASL mongo-query operators. A field maps to either
a bare value (equality) or an operator object:
can('read', 'Note', { userId }); // equality
can('read', 'Note', { status: { $in: ['draft', 'live'] } });
can('read', 'Note', { version: { $gt: 2 }, title: { $ne: '' } });Operators are CASL's mongo set ($eq, $ne, $in, $nin, $gt, $gte,
$lt, $lte, …). Conditions are plain JSON, so you can store rules in a database
and rehydrate with buildGraphQLAbility (see Persisting rules).
Usage
1. Build abilities
Bind the generic helpers to your app's generated types and define abilities with
createGraphQLAbility. It returns a CASL AbilityBuilder typed against your
SubjectMap, so can/cannot conditions are checked against each subject's
fields, and build() wires __typename subject detection for you.
import {
Actions,
createGraphQLAbility,
createSubjects,
createTyped,
type GraphQLAbility,
type SubjectMap,
} from '@vantreeseba/graphql-casl';
import type { Resolvers, ResolversTypes } from './__generated__/resolvers.js';
export type AppSubjectMap = SubjectMap<Resolvers, ResolversTypes>;
export type AppAbility = GraphQLAbility<AppSubjectMap>;
export const typed = createTyped<AppSubjectMap>();
export const Subject = createSubjects<AppSubjectMap>()({
User: 'User',
Note: 'Note',
} as const);
export function defineAbilitiesFor(userId: string | undefined): AppAbility {
const { can, build } = createGraphQLAbility<AppSubjectMap>();
if (!userId) return build(); // no rules ⇒ everything denied
can(Actions.read, Subject.Note);
can(Actions.update, Subject.Note, { userId }); // typed against Note's fields
return build();
}2. Bind createCan to your context
import { createCan } from '@vantreeseba/graphql-casl';
import type { Context } from './context.js';
import { type AppSubjectMap, defineAbilitiesFor, typed } from './abilities.js';
const canUser = createCan<Context, AppSubjectMap>(
async (ctx) => defineAbilitiesFor(ctx.userId),
(ctx) => ctx.userId != null,
typed,
);3. Declare the permissions map
getSubjectData builds the subject instance from the resolver args; the subject
name narrows its return to that subject's fields, so annotate args with your
generated *Args type to type the extraction end to end. Without it the rule
checks against the bare subject type.
import { accept, deny, type PermissionsMap } from '@vantreeseba/graphql-casl';
import type { Resolvers, MutationUpdateNoteArgs } from './__generated__/resolvers.js';
export const permissions: PermissionsMap<Resolvers> = {
Query: {
note: canUser(Actions.read, Subject.Note),
me: canUser(Actions.read, Subject.User),
},
Mutation: {
requestMagicLink: accept, // public
deleteNotes: deny, // nobody, ever
updateNote: canUser(Actions.update, Subject.Note, (args: MutationUpdateNoteArgs) => ({
userId: args.userId,
})),
},
};⚠️ Checks run before the resolver.
graphql-middlewareinvokes a rule before the field resolver, sogetSubjectDataonly seesargs/context— never the to-be-loaded entity. A condition built from a client-supplied arg (args.userId) therefore validates what the client asserted, not the real record. If the resolver then targets a different arg (e.g.args.id), a caller can pass their ownuserId(to pass the check) but someone else'sid— an IDOR. Make the resolver scope by the same field the rule authorized (look up byidanduserId), derive the owner fromcontextrather than args, or enforce ownership in your data layer.
4. Apply to the schema
import { applyPermissions } from '@vantreeseba/graphql-casl';
const schemaWithPermissions = applyPermissions<Resolvers>(schema, permissions);applyPermissions wraps graphql-middleware's applyMiddleware and keeps
permissions typed as a PermissionsMap<Resolvers>, so a mistyped type or
field name is caught at compile time.
5. Persisting rules (optional)
Rules are plain JSON, so they can be stored in a database and loaded/cached at
startup. Read builder.rules (or ability.rules) to persist them, and rebuild
with buildGraphQLAbility:
import { buildGraphQLAbility, type GraphQLRule } from '@vantreeseba/graphql-casl';
// persist
const { can, build } = createGraphQLAbility<AppSubjectMap>();
can(Actions.update, Subject.Note, { userId });
await db.savePermissionRules(build().rules);
// load (per request or cached)
const rules: GraphQLRule<AppSubjectMap>[] = await db.loadPermissionRules();
const ability = buildGraphQLAbility<AppSubjectMap>(rules);Development
npm install
npm test # run vitest
npm run coverage # run vitest with coverage
npm run typecheck # tsc --noEmit
npm run build # compile to dist/
npm run check # biome lint + format check
npm run docs # generate the Markdown API reference into docs/api/API reference
Every export carries JSDoc. Generate a full Markdown API reference with TypeDoc + the Markdown plugin:
npm run docs # writes docs/api/ (git-ignored)The docs are not committed; CI builds them and publishes them to this
repository's GitHub Wiki on every push to main.
Commits follow Conventional Commits and
drive automated releases: pushes to main run the Test workflow, and on
success the Release workflow runs semantic-release
to version, changelog, publish to npm, and tag a GitHub release.
See TODO.md for deferred work.
