Authorization primitives for Effect programs, backed by CASL's rule engine.
This package models authorization checks as Effect values and keeps failures
in the typed error channel, while preserving the familiar CASL rule model:
actions, subjects, fields, conditions, allow rules, and deny rules.
- TypeScript projects should use strict type checking to get the full public API guarantees.
effectand@casl/abilityare peer dependencies.- CASL APIs are not re-exported. Import official CASL helpers directly from
@casl/ability.
yarn add @nmnmcc/ability effect @casl/abilityThe main entry point is the following import:
import {Ability, AbilityExtra} from "@nmnmcc/ability"| Concept | Description |
|---|---|
Ability |
An immutable, ordered set of authorization rules. |
Rule |
A yieldable allow or deny rule recorded by Ability.define. |
Ability.define |
Synchronously builds an Ability from a generator DSL. |
Ability.check |
The authorization decision API. It succeeds with void or fails through the Effect error channel. |
Ability.subject |
Wraps a plain object with an explicit subject name without mutating the object. |
AbilityExtra |
Helpers for compact rule transport and rule-to-query transforms. |
Important behavior:
- Last matching rule wins.
managemeans any action.allmeans any subject.- Checks do not return booleans. Use Effect combinators to derive booleans when needed.
Define your domain subjects as a type-level map:
import {Effect} from "effect"
import {Ability} from "@nmnmcc/ability"
interface Post {
readonly id: string
readonly authorId: string
readonly published: boolean
readonly title: string
readonly body: string
}
type Subjects = {
readonly Post: Post
}Define an ability synchronously:
const ability = Ability.define<Subjects>()(function* (ability) {
yield* ability.allow("read", "Post")
yield* ability.allow("update", "Post", {
fields: ["title", "body"],
conditions: {authorId: "u1"},
reason: "Authors can edit their own content"
})
yield* ability.deny("delete", "Post", {
conditions: {published: true},
reason: "Published posts cannot be deleted"
})
})Check permissions inside an Effect program:
const post: Post = {
id: "p1",
authorId: "u1",
published: true,
title: "Hello",
body: "World"
}
const program = Effect.gen(function* () {
yield* Ability.check(ability, {
action: "update",
subject: "Post",
value: post,
field: "title"
})
const deleteResult = yield* Ability.check(ability, {
action: "delete",
subject: "Post",
value: post
}).pipe(
Effect.match({
onFailure: (error) =>
error._tag === "AuthorizationError" ? error.reason : "unexpected failure",
onSuccess: () => "deleted"
})
)
return deleteResult
})Syntax
Ability.define<Subjects>()(
function* (ability) {
yield* ability.allow(action, subject, options)
yield* ability.deny(action, subject, options)
},
options
)Ability.define returns an Ability directly. It is not wrapped in Effect.
The yielded Rule values are collected in order and stored immutably.
Example (Multiple Actions)
const ability = Ability.define<Subjects>()(function* (ability) {
yield* ability.allow(["read", "update"], "Post", {
fields: ["title", "body"],
conditions: {authorId: "u1"}
})
})Example (manage and all)
const ability = Ability.define<Subjects>()(function* (ability) {
yield* ability.allow("manage", "all")
yield* ability.deny("delete", "Post", {
conditions: {published: true},
reason: "Published posts cannot be deleted"
})
})Rules are resolved from the end of the rule list. In the example above, the
later deny("delete", "Post") can override the earlier allow("manage", "all")
when its condition matches.
Syntax
Ability.check(ability, request)
ability.pipe(Ability.check(request))Ability.check returns:
Effect.Effect<void, Ability.AuthorizationError | Ability.ConditionError | Ability.SubjectDetectionError>- Success means the request is authorized.
AuthorizationErrormeans no allow rule matched, or the winning rule was a deny rule.ConditionErrormeans condition matching threw while evaluating a rule.SubjectDetectionErrormeans the request did not provide a subject and the subject could not be detected from the value.
Example (Boolean Derived From Effect)
const canUpdateTitle = yield* Ability.check(ability, {
action: "update",
subject: "Post",
value: post,
field: "title"
}).pipe(
Effect.match({
onFailure: () => false,
onSuccess: () => true
})
)There is no synchronous can, cannot, or allows API in this package.
Conditions use CASL's Mongo-style matcher and MongoQuery type.
Example (Mongo-Style Conditions)
interface Comment {
readonly authorId: string
readonly body: string
}
interface PostWithComments extends Post {
readonly comments: ReadonlyArray<Comment>
}
type Subjects = {
readonly Post: PostWithComments
}
const ability = Ability.define<Subjects>()(function* (ability) {
yield* ability.allow("read", "Post", {
conditions: {
published: {$eq: false},
comments: {$elemMatch: {authorId: "u1"}}
}
})
})Fields are typed as dot paths, with bounded recursion for predictable type-checking. CASL-style field patterns are also accepted.
Example (Field Restrictions)
interface Address {
readonly city: string
readonly street: string
}
interface PostWithAddress extends Post {
readonly address: Address
}
type Subjects = {
readonly Post: PostWithAddress
}
const ability = Ability.define<Subjects>()(function* (ability) {
yield* ability.allow("update", "Post", {
fields: ["title", "address.**"],
conditions: {authorId: "u1"}
})
})
const post = {} as PostWithAddress
yield* Ability.check(ability, {
action: "update",
subject: "Post",
value: post,
field: "address.city"
})When you check a request without value, conditional allow rules can still
authorize the action for the subject type. Pass value when you need object
conditions to be evaluated for a specific resource. Conditional deny rules also
need a value before their conditions can match; unconditional deny rules still
apply without a value.
Use Ability.permittedFields to derive the request fields allowed by matching
rules. Allow rules add fields; deny rules remove fields.
Syntax
Ability.permittedFields(ability, request, {
fieldsFrom: (rule) => rule.fields ?? fallbackFields
})Example
const fields = yield* Ability.permittedFields(
ability,
{
action: "update",
subject: "Post",
value: post
},
{
fieldsFrom: (rule) => rule.fields ?? ["id", "authorId", "published", "title", "body"]
}
)fieldsFrom is used when a matching rule does not declare explicit fields.
A check request can provide the subject name explicitly:
yield* Ability.check(ability, {
action: "read",
subject: "Post",
value: post
})You can also wrap a value with a subject name:
const wrapped = Ability.subject("Post", post)
yield* Ability.check(ability, {
action: "read",
value: wrapped
})
const original = Ability.unwrapSubject(wrapped)Ability.subject is pure and does not mutate the wrapped object.
If neither subject nor a typed subject wrapper is provided, configure subject
detection:
const ability = Ability.define<Subjects>()(
function* (ability) {
yield* ability.allow("read", "Post")
},
{
detectSubjectType: (value) => (value as {readonly __typename: "Post"}).__typename
}
)
yield* Ability.check(ability, {
action: "read",
value: {
...post,
__typename: "Post"
}
})Without a custom detector, subject detection falls back to constructor metadata when available.
Action aliases expand one rule action into additional checkable actions.
Example
const ability = Ability.define<Subjects>()(
function* (ability) {
yield* ability.allow("modify", "Post", {
fields: ["title", "body"],
conditions: {authorId: "u1"}
})
},
{
actionAliases: {
modify: ["update", "delete"]
} as const
}
)
yield* Ability.check(ability, {
action: "update",
subject: "Post",
value: post,
field: "title"
})Aliases are one-directional. A modify rule matches update and delete, but
separate update and delete rules do not imply modify.
Invalid aliases fail fast:
managecannot be used as an alias name.- Aliases cannot target
manage. - Aliases cannot target an empty list.
- Cyclic aliases are rejected.
Raw rules are JSON-safe data structures.
Syntax
Ability.fromRawRules<Subjects>(rules, options)
Ability.toRawRules(ability)
Ability.update(ability, rules)
ability.pipe(Ability.update(rules))Example
const ability = yield* Ability.fromRawRules<Subjects>([
{
action: "read",
subject: "Post",
conditions: {authorId: "u1"}
},
{
action: "delete",
subject: "Post",
inverted: true,
reason: "No deletes"
}
])
const rules = yield* Ability.toRawRules(ability)
const nextAbility = yield* Ability.update(ability, [
{
action: "read",
subject: "Post"
}
])Ability.update returns a new immutable ability and reuses the current ability
options, including action aliases and subject detection.
Ability.fromRawRules and Ability.update can fail with:
RawRuleErrorfor invalid raw rules.AliasErrorfor invalid action alias configuration.
The introspection helpers are also Effect values and support data-first and data-last usage.
| Function | Behavior |
|---|---|
Ability.possibleRulesFor |
Returns rules that may apply before field and condition checks. |
Ability.rulesFor |
Returns rules that may apply after field checks, but before condition checks. |
Ability.relevantRuleFor |
Returns the winning rule after field and condition checks. |
Ability.actionsFor |
Returns actions that have rules for a subject. |
Example
const rules = yield* Ability.rulesFor(ability, {
action: "update",
subject: "Post",
field: "title"
})
const rule = yield* Ability.relevantRuleFor(ability, {
action: "delete",
subject: "Post",
value: post
})
const actions = yield* ability.pipe(
Ability.actionsFor({
subject: "Post"
})
)possibleRulesFor, rulesFor, and actionsFor can fail with
SubjectDetectionError. relevantRuleFor can also fail with ConditionError.
AbilityExtra works with already-defined abilities and JSON-safe rules.
import {AbilityExtra} from "@nmnmcc/ability"
const packed = AbilityExtra.packRules(rules)
const unpacked = AbilityExtra.unpackRules(packed)packRules converts raw rules into compact tuples. unpackRules restores the
raw rule objects.
rulesToFields extracts scalar condition values from matching allow rules. This
is useful when creating initial values from authorization rules.
const defaults = yield* AbilityExtra.rulesToFields(ability, {
action: "read",
subject: "Post"
})For a rule with {conditions: {authorId: "u1"}}, the returned object includes
{authorId: "u1"}.
Use rulesToCondition when you own the logical query representation:
const condition = yield* AbilityExtra.rulesToCondition(
ability,
{
action: "read",
subject: "Post"
},
(rule) => rule.conditions ?? {},
{
and: (conditions) => ({$and: conditions}),
or: (conditions) => ({$or: conditions}),
not: (condition) => ({$not: condition}),
empty: () => ({})
}
)Use rulesToQuery for the built-in generic logical shape:
const query = yield* ability.pipe(
AbilityExtra.rulesToQuery(
{
action: "read",
subject: "Post"
},
(rule) => rule.conditions ?? {}
)
)Rule conversion callbacks may return plain values or Effects. If a conversion
callback throws, the helper fails with QueryGenerationError.
This package uses the official CASL rule engine for indexing and Mongo-style condition matching, but it does not re-export CASL APIs.
Import CASL APIs from @casl/ability when you need them:
import {AbilityBuilder, createMongoAbility, subject} from "@casl/ability"
const {can, cannot, build} = new AbilityBuilder(createMongoAbility)
can("read", "Post")
cannot("delete", "Post", {published: true})
const caslAbility = build()
const canRead = caslAbility.can("read", subject("Post", post))ORM adapters and accessibleBy-style helpers are not implemented here. Use the
official CASL ecosystem packages for those integrations.