@api-policy/core
v1.0.1
Published
Universal policy engine for authorization. Framework-agnostic, 4KB gzipped, zero dependencies.
Downloads
97
Maintainers
Readme
@api-policy/core
Universal policy engine for Node.js authorization. Framework-agnostic, 4KB gzipped, zero dependencies.
npm install @api-policy/coreOverview
Build authorization policies as composable AST expressions, then evaluate them against a subject (user) and resource.
import { and, or, role, perm, owner, evaluate } from '@api-policy/core'
const WRITE = 1 << 1
// admin OR (has WRITE permission AND owns the resource)
const policy = or(
role('admin'),
and(perm(WRITE), owner('authorId'))
)
const allowed = evaluate(policy, {
subject: { id: 'user-1', role: 'editor', permissions: WRITE },
resource: { authorId: 'user-1' },
})
// → trueBuilders
Build policies by composing these primitives:
| Builder | Description |
|---------|-------------|
| role(name) | subject.role === name |
| perm(mask) | (subject.permissions & mask) === mask — ALL bits must match |
| owner(field?) | resource[field] === subject.id — field defaults to 'ownerId' |
| sameTenant() | subject.tenantId === resource.tenantId |
| inTenant(id) | resource.tenantId === id |
| custom(fn) | Arbitrary predicate (ctx: PolicyContext) => boolean |
| and(...nodes) | All must pass |
| or(...nodes) | At least one must pass |
| not(node) | Negation |
API
evaluate(policy, ctx)
Evaluates a policy against a context. Returns boolean.
import { evaluate } from '@api-policy/core'
const allowed = evaluate(policy, {
subject: {
id: 'user-123',
role: 'editor',
permissions: 0b0011, // READ | WRITE
tenantId: 'tenant-abc',
},
resource: {
authorId: 'user-123',
tenantId: 'tenant-abc',
},
})PolicyContext shape:
interface PolicyContext {
subject: {
id: string
role?: string
permissions?: number
tenantId?: string
[key: string]: unknown
}
resource?: {
[key: string]: unknown
}
}explain(policy, ctx)
Same as evaluate, but returns a detailed trace — useful for debugging why a policy allowed or denied.
import { explain } from '@api-policy/core'
const result = explain(policy, { subject, resource })
console.log(result)
// {
// allowed: true,
// policyString: "or(role('admin'), and(owner('authorId'), perm(2)))",
// steps: [
// { description: "or(...) [2 children]", result: true },
// { description: "role('admin')", result: false, details: "user.role = editor" },
// { description: "and(...) [2 children]", result: true },
// { description: "owner('authorId')", result: true, details: "resource.authorId = user-1, user.id = user-1" },
// { description: "perm(2)", result: true, details: "user.permissions = 3, required = 2" },
// ],
// evaluationTime: 0.021
// }compileToBranches(policy) + checkCompiled(compiled, ctx)
Compile a policy to DNF (Disjunctive Normal Form) for repeated evaluation. Useful when the same policy is checked many times.
import { compileToBranches, checkCompiled } from '@api-policy/core'
const compiled = compileToBranches(policy) // compile once
// check many times
const allowed = checkCompiled(compiled, ctx)Compilation is capped at 64 branches. If the policy would exceed that, compiled.isFallback = true and evaluation falls back to evaluate().
normalizePolicy(policy)
Reduce a policy to canonical form: flattens nested and/or, applies De Morgan's laws to not, and sorts children deterministically.
import { normalizePolicy } from '@api-policy/core'
// not(and(A, B)) → or(not(A), not(B))
const normalized = normalizePolicy(policy)policyToString(policy) / policiesEqual(a, b)
Canonical string representation and structural equality check.
import { policyToString, policiesEqual } from '@api-policy/core'
policyToString(or(role('admin'), perm(2)))
// → "or(perm(2), role('admin'))"
policiesEqual(and(role('admin'), perm(1)), and(perm(1), role('admin')))
// → true (order-independent)Examples
RBAC
const adminOnly = role('admin')
const editorOrAdmin = or(role('editor'), role('admin'))Permission bitmask
const READ = 1 << 0 // 1
const WRITE = 1 << 1 // 2
const DELETE = 1 << 2 // 4
const canEdit = perm(WRITE)
const canDelete = perm(DELETE)
const canReadAndWrite = perm(READ | WRITE) // both bits must be setOwner-only access
// User can only access their own resources
const ownResourceOnly = owner('ownerId')
// Custom owner field
const ownPost = owner('authorId')Multi-tenant isolation
// Must be in the same tenant as the resource
const sameTenantPolicy = and(perm(READ), sameTenant())Complex policies
// admin can do anything; editors can write if they own the resource
const editPolicy = or(
role('admin'),
and(role('editor'), perm(WRITE), owner('authorId'))
)
// Public read, owner or admin can write
const resourcePolicy = or(
perm(READ),
role('admin'),
and(perm(WRITE), owner())
)Custom predicates
const approvedOnly = and(
perm(READ),
custom(ctx => ctx.resource?.status === 'approved')
)Permission bitmask convention
Permissions are checked with exact-match bitwise AND: (subject.permissions & mask) === mask.
This means all bits in the mask must be set. A user with permissions = READ | WRITE passes perm(READ), perm(WRITE), and perm(READ | WRITE) — but not perm(DELETE).
Recommended constants:
export const PERM = Object.freeze({
READ: 1 << 0, // 1
WRITE: 1 << 1, // 2
DELETE: 1 << 2, // 4
APPROVE: 1 << 3, // 8
EXECUTE: 1 << 4, // 16
})Use with @api-policy/server
@api-policy/core uses subject (single role, flat permissions). @api-policy/server uses UserContext (multiple roles, per-resource permission map). Bridge them with toSubject():
import { evaluate, or, role, owner } from '@api-policy/core'
import { toSubject } from '@api-policy/server'
const allowed = evaluate(
or(role('admin'), owner('authorId')),
{
subject: toSubject(ctx.user, 'post'), // picks user.perms['post']
resource: post,
}
)License
MIT
