@tdreyno/he-said
v0.11.1
Published
Functional auth rules for JavaScript and TypeScript applications.
Readme
@tdreyno/he-said
Type-safe authorization rule algebra for TypeScript.
he-said lets you model authorization as composable, typed rules instead of scattered conditionals.
Use it to express RBAC, ABAC, ACL, and graph-style access checks with one consistent API.
Why he-said
- Type-safe terms and relations with strong TypeScript inference
- Composable logic operators for readable, testable policies
- In-memory evaluation for local development and unit tests
- Postgres planning and execution support for production data
- Proof-oriented evaluation APIs for debugging policy behavior
Install
npm install @tdreyno/he-saidAgent Skills (skills.sh)
This repo ships first-party skills to help consumer agents use he-said correctly:
he-said-guide
Install from this repository with skills.sh:
npx skills add tdreyno/he-saidThen use npx skills list to confirm they were installed for your agent environment.
Quick Start
import {
and,
createInMemoryAdapter,
evaluator,
relation,
term,
} from "@tdreyno/he-said"
type User = { id: string; suspended: boolean }
type Document = { id: string }
const actor = term<User>()
const document = term<Document>()
const userOwnsDocument = relation<User, Document>()
const activeActor = actor.is(value => !value.suspended)
const canReadDocument = and(userOwnsDocument(activeActor, document))
const u1 = { id: "u1", suspended: false }
const u2 = { id: "u2", suspended: true }
const d1 = { id: "d1" }
const instance = evaluator(
createInMemoryAdapter({
relations: [
{
relation: userOwnsDocument,
pairs: [
[u1, d1],
[u2, d1],
],
},
],
domain: [u1, u2, d1],
}),
{ evaluatorContext: null },
)
const allowed = await instance.evaluate(canReadDocument, {
[actor]: u1,
[document]: d1,
})
const blocked = await instance.evaluate(canReadDocument, {
[actor]: u2,
[document]: d1,
})What You Can Model
- RBAC: role and scope checks
- ABAC: attribute and context predicates
- ACL: explicit grant relations
- ReBAC-style checks: graph and relationship constraints
Core API
Core constructors:
term<T>()term<T>().is(jsPredicateOrExpression)attr(term, "column")relation<Left, Right>()exists(term)eq(leftTerm, rightTermOrValue)eq(attr(...), attr(...) | value)ne,gt,ge,lt,le,isNull,isNotNull
Composition operators:
and(...constraints)or(...constraints)not(constraint)implies(premise, consequence)oneOf(term, values)atLeast(count, ...constraints)atMost(count, ...constraints)exactly(count, ...constraints)through(term).to(relation, term)forAll(term, constraint)select(...terms)(constraint)distinct(constraint)letRule(name, constraint)ref(name)
Evaluator:
evaluator(adapter, { evaluatorContext })instance.evaluate(rule, environment)instance.evaluateWithProof(rule, environment)- deny proofs include
proof.failingwith deterministicpath,kind, andreason
- deny proofs include
instance.filter(rule, { environment, term, candidates? })instance.prepare({ environment?, preload?, facts? })
Prepared evaluators let you bind request-scoped actor facts once and evaluate many rules/resources without rebuilding that actor context on every call.
Common Building Blocks
- Deny rule:
not(...) - Action families:
oneOf(action, ["read", "download"]) - Cardinality constraints:
atLeast,atMost,exactly - Reusable subrules:
letRule(name, rule)andref(name) - Quantified constraints:
forAll(term, constraint) - Fluent relation chains:
through(term).to(relation, term)
For fail-closed admin bypass rules that must still deny missing target rows, use
exists(term):
const canAdminRead = and(factIsTrue(isAppAdmin), exists(document))Adapters
In-Memory Adapter
Use for local evaluation, unit tests, and rule debugging.
createInMemoryAdapter({ relations, domain? })validateStratifiedNegation(rule)
Postgres Adapter
Use when relation facts are persisted in SQL tables.
createPostgresAdapter({ relationMappings, queryExecutor, ... })createPostgresAdapter({ relationMappings, queryExecutor, getEvaluatorContext?, ... })planPostgresRule(rule, { relationMappings, environment, ... })createPostgresAdaptersupportsincludeFailingNodeSql(defaultfalse) to include parameterized failing-node SQL inproof.failing
planPostgresPredicate(rule, { relationMappings, environment, bindings?, ... })
relationMappings[].source supports both legacy staticFilters and typed predicates:
{
kind: "join-table",
table: "team_members",
leftColumn: "user_id",
rightColumn: "team_id",
predicates: [{ column: "role", op: "ge", value: "editor" }],
orderings: [
{ column: "role", order: { viewer: 10, editor: 20, admin: 30, owner: 40 } },
],
}Typed predicates are compiled to parameterized SQL (eq, in, gt, ge, lt, le).
planPostgresRule can produce diagnostics for join-table index hints, domain coverage for forAll, and other planner guidance.
planPostgresPredicate returns a parameterized EXISTS(...) fragment for composing authorization constraints into caller-owned WHERE clauses.
For SQL pushdown with term.is(...) expressions, configure termDomains with columns mappings so attr(term, "column") can resolve to mapped SQL columns.
RBAC Package
For role-based access control, use the dedicated RBAC package:
import { enforcer, resource, role, policy } from "@tdreyno/he-said/rbac"
// Define resources
const document = resource<"document">()
// Define roles with permissions
const viewer = role().permission("read", document)
const editor = role().permission("read", document).permission("write", document)
const admin = role()
.permission("read", document)
.permission("write", document)
.permission("delete", document)
// Create enforcer
const rbac = enforcer(policy([viewer, editor, admin], []))
// Assign roles
await rbac.roles(editor.id).grant("alice")
// Check permissions
const can = await rbac.enforce("alice", document, "write")
console.log(can.allowed) // true
// Query and manage
const alicePerms = await rbac.users("alice").permissions()
await rbac.roles(editor.id).addPermission("archive", document)The RBAC package provides a fluent, method-based API perfect for role-based patterns. See the RBAC Guide and API Reference for complete documentation.
ACL Package
For explicit allow/deny style access control, use @tdreyno/he-said/acl:
import {
action,
actionIs,
allow,
deny,
enforcer,
eq,
failure,
policy,
} from "@tdreyno/he-said/acl"
type User = { id: string; suspended: boolean }
type Document = { id: string; ownerId: string }
const READ = action("read")
const denySuspended = deny(
eq((subject: User) => subject.suspended, true),
{
failure: failure("Suspended users cannot access documents."),
priority: 100,
},
)
const allowOwnerRead = allow([
actionIs(READ),
eq(
(subject: User) => subject.id,
(resource: Document) => resource.ownerId,
),
])
const acl = enforcer(policy(denySuspended, allowOwnerRead))
const can = await acl.can(READ, {
subject: user,
resource: document,
})
console.log(can.allowed)The ACL package provides ACL-native allow/deny semantics with deterministic deny-first precedence and composable algebra-backed rules. See the ACL Guide and ACL API Reference for full details.
ABAC Package
For attribute-based access control, use @tdreyno/he-said/abac:
import {
action,
actionIs,
approve,
deny,
enforcer,
eq,
eqEnv,
failure,
ge,
policy,
} from "@tdreyno/he-said/abac"
type User = {
id: string
department: string
clearance: number
suspended: boolean
}
type Document = {
ownerId: string
department: string
sensitivity: number
}
type Environment = {
isBusinessHours: boolean
}
const READ = action("read")
const RULE_DENY_SUSPENDED = failure("Suspended users cannot access documents.")
const denySuspended = deny(
eq((user: User) => user.suspended, true),
{
failure: RULE_DENY_SUSPENDED,
},
)
const approveRead = approve([
actionIs<User, Document, Environment>(READ),
eq(
(user: User) => user.department,
(resource: Document) => resource.department,
),
ge(
(user: User) => user.clearance,
(resource: Document) => resource.sensitivity,
),
])
const abac = enforcer(policy(denySuspended, approveRead))
const can = await abac.can(READ, {
user,
resource: document,
environment,
})
console.log(can.allowed)The ABAC package provides a rule-focused API with action identity tokens, deny/approve precedence, reusable failure tokens, and traceable rule references.
ReBAC Package
For relationship-scoped roles (for example, "editor on team A"), use
@tdreyno/he-said/rebac:
import { grant, roleTiers, scopedPolicy, through } from "@tdreyno/he-said/rebac"
import { relation, term } from "@tdreyno/he-said"
type User = { id: string }
type Team = { id: string }
type Document = { id: string }
const actor = term<User>()
const team = term<Team>()
const memberOfTeam = relation<User, Team>()
const documentInTeam = relation<Document, Team>()
const policy = scopedPolicy({
actor,
scope: team,
membership: {
relation: memberOfTeam,
roleColumn: "role",
tiers: roleTiers("viewer", "editor", "owner"),
},
resources: {
Document: through(documentInTeam),
},
grants: {
read: grant.atLeast("viewer"),
update: grant.atLeast("editor"),
manage: grant.deny(), // bypass-only action
},
})Use /rebac when roles are held on objects (teams, projects, folders) rather
than globally on the actor.
Documentation
- Getting Started
- Core Concepts
- API Documentation
- In-Memory Evaluation
- Type Safety and Terms
- RBAC Guide
- RBAC API Reference
- ACL Guide
- ACL API Reference
- ABAC Guide
- ABAC API Reference
- ABAC on Postgres
- ReBAC Guide
- CASL Comparison
- CASL Migration Guide
- RBAC Example
- ACL Example
- ABAC Example
- FAQ
When To Use
Use he-said when you want policy logic to be explicit, typed, and reusable across services, route handlers, and tests.
If your authorization model is small today but expected to grow, starting with composable rules helps avoid hard-to-maintain permission conditionals later.
Development
npm run lint
npm run typecheck
npm run build
npm run test:ci
npm run test:typesLicense
MIT
