@dleangen/cage-iam
v0.1.3
Published
Generic Zanzibar-style Identity and Access Management for the CAGE ecosystem — the Actor Model / Model Layer implementation.
Maintainers
Readme
cage-iam
cage-iam is a generic, headless Identity and Access Management (IAM) design for the CAGE ecosystem: a Zanzibar-style authorization engine over a small set of generic building blocks — Actor, Asset, Group, and a relation graph (reader → contributor → editor → admin → owner) — plus event-sourced per-tenant persistence, a delegation model, and GDPR erasure via crypto-shredding.
It has no domain knowledge of any specific business entity. It provides identity and access primitives that any calling system can use to register actors, register assets, and manage the access relationships between them.
Status
Proof of concept — usable, not enterprise-grade. (Revised from this repo's original "design hypothesis" framing per #37's thinking session: every open design question raised along the way is now closed or explicitly, deliberately parked — see that issue for the full resolution.) "Usable" means the Model Layer, the resolution engine, Delegation, and GDPR erasure (including Persona discovery, below) are real, working, and tested — not a sketch. "Not enterprise-grade" means the shipped defaults (SchemaResolutionEngine, and the EventStore/PiiKeyStore examples in docs/examples/) are deliberately lightweight rather than durable; scaling to production load means swapping in a real durable EventStore/PiiKeyStore and, for the resolution engine, SpiceDB or OpenFGA (both free, Apache-2.0, self-hosted) via the existing ResolutionEngine seam — see "Package: the resolution engine implementation" below for why that swap is meant to be cheap, not a rewrite.
The design in docs/design.md is a draft (v0.5.0), spun out of cage-core#29 into its own repo per cage-coordinator#69, since the design's scope is a standalone library rather than a patch to cage-core's existing responsibilities.
Two halves, both implemented now:
- The Model Layer — the modeled vocabulary described in the design doc (§1.4) — lives in
src/(see "Package: the Model Layer implementation" below). - The resolution engine — originally a separate proposal (
docs/resolution-engine-spec.md, merged as #14) — is implemented insrc/engine/(see "Package: the resolution engine implementation" below and that directory's ownREADME.mdfor the phase-by-phase detail). Kept in this repo rather than spun out to its own, per an explicit decision on #16 — that's a location choice, not a statement that the two halves are coupled;ResolutionEngine(src/resolution-engine.ts) is still the seam between them, and a real deployment can still bring an entirely different engine (SpiceDB, OpenFGA, anything that speaks the same three verbs) instead of this one.
Scope
- Identity — who exists. Registering and managing Actors (Personas, Organizations, Agents).
- Access — who can do what. Registering Assets as authorization boundaries, defining relationships (grants), checking permissions.
- Accounts — how you authenticate. Credentials, preferences, configuration. (Future scope, deferred until identity and access are solid.)
Package: the Model Layer implementation
npm install @dleangen/cage-iamimport { ActorModel, entityRef, SchemaResolutionEngine } from '@dleangen/cage-iam'
// Bring your own EventStore and PiiKeyStore — this package assumes both
// already exist; see pii-key-store.ts for why the PII key store must be
// a real, durable store and never the in-memory test double.
import { MyEventStore } from './my-event-store'
import { MyPiiKeyStore } from './my-pii-key-store'
// The ResolutionEngine can be this package's own in-repo implementation
// (src/engine/ — see "Package: the resolution engine implementation"
// below), loaded with the schema this package's own commands need — a
// short excerpt below; docs/schema-reference.md documents the real one
// in full (every namespace this package's commands actually write to).
const resolutionEngine = SchemaResolutionEngine.fromSource(`
organization { relation owner }
confinement { relation owner relation reader }
collection { relation owner relation reader relation collection }
group { relation owner relation member permission access = owner | member }
`)
// ...or any other Zanzibar-compatible service that speaks the same
// four verbs (grant/revoke/check/listTuples) — a SpiceDB/OpenFGA
// client, etc.
// const resolutionEngine = new MyResolutionEngine()
const actorModel = new ActorModel(new MyEventStore(), resolutionEngine, new MyPiiKeyStore())
const org = await actorModel.registerOrganization('acme.com')
const person = await actorModel.registerPerson()
const jane = await actorModel.registerPersona(person.id, '[email protected]', 'Jane Doe')
const support = await actorModel.createGroup(org.id, 'Support Engineers')
await actorModel.addMember(entityRef('persona', jane.id), entityRef('group', support.id))
const vault = await actorModel.createCollection(org.id, 'Vault')
await actorModel.grantAccess(
entityRef('group', support.id),
'reader',
entityRef('confinement', org.confinementId),
)
// true — the Group's own direct grant, resolved by the real schema above,
// not a stub. (This minimal excerpt has no traversal wiring Group
// membership into Confinement access, so checking *jane's* "reader" on
// the Confinement — as opposed to the Group's — would be `false` here;
// the full schema in docs/schema-reference.md is the same story until a
// deployment adds that wiring itself, per its own closing note.)
await actorModel.checkAccess(entityRef('group', support.id), 'reader', entityRef('confinement', org.confinementId))src/testing/ ships InMemoryEventStore, InMemoryResolutionEngine, and InMemoryPiiKeyStore — used by this package's own test suite, and useful for prototyping, but not a substitute for a durable event store or durable key storage. InMemoryResolutionEngine (the testing one) does direct tuple matching only; it does not evaluate a schema or derive permissions — for a real schema-aware engine, even a non-durable one, see SchemaResolutionEngine below, not this stub. InMemoryPiiKeyStore especially must never reach production — losing its keys on a process restart would silently "erase" every Person that was never actually erased.
What's implemented
- §1.2/§1.5 — Person, Persona, Organization (with its Extent, Confinement, and Root), Association, Group, Collection; the fixed five-level relation vocabulary plus
member. - §2 — the automatic registration tuples for Organization, Association, Persona, Group, and Collection creation; the operational grant/revoke/membership tuple patterns, including Association's peer-based (non-hierarchical) membership with who-performed-it tracking.
- §5/§6 — event-sourced persistence for the core aggregates and access operations.
- §1.6 — Patterns as CRUD reference data, and applying one to an Organization.
- §2 — cycle rejection for Group nesting ("Groups must always form a DAG"), checked at write time from the event log — see
src/commands/group-nesting.tsfor why that doesn't need the resolution engine's help. Collection nesting has no equivalent check because it needs none: a Collection's parent is fixed at creation time and this package has no reparent/move operation, so a cycle is structurally unreachable through the current API, not merely unlikely — see that file's doc comment. - §1.4 — Persona
name(required) and optionaldescription, alongsideemail. - §16 — Organization parent hierarchy (resolved in cage-iam#14:
organization { relation parent; permission admin = owner | parent^ }).ActorModel.setOrganizationParent/removeOrganizationParent/getOrganizationParent, with the same event-log-based cycle rejection as Group nesting (src/commands/organization-parent.ts) and an enforced at-most-one-parent invariant (the design doc's "joint ownership needs a separate joint entity" language, Open Question #9, rules out silently allowing a second concurrent parent). - §10 GDPR crypto-shredding — complete: Persona
email,name, anddescriptionare all stored encrypted (emailEnc/nameEnc/descriptionEnc, AES-256-GCM under a per-Person key) rather than in plaintext, closing the exact gap the design doc calls out by name.ActorModel.erasePersonimplements all six steps of the erasure procedure. Steps 1/3 (sole-ownership precondition, cascading tuple revocation) are auto-discovered viaresolutionEngine.listTuples(cage-iam#8's fix (2)) and cover every relation shape this package writes — access relations, Group/Extent membership, and, as of cage-iam#28's fix, Association membership too, viaAssociationMemberRemoved's newreason: 'erasure' | 'governance'discriminant (performedByPersonaIdcorrespondingly made optional — absent for'erasure', required for'governance'). Persona discovery (cage-iam#37 Q3, resolved) —personaIdsis now optional: omit it anderasePersondiscovers every Persona the Person has ever registered itself, via a reverse-index projection (queries/list-personas.ts)PersonaRepository.registermaintains; pass an explicit list only to override.options.assetsToCheckOwnership/grantsToRevokeand an explicitpersonaIdsare all pure supplements now, never requirements. - §1.2 Agent registration (resolved via a live Thinker session, cage-iam#10, CAGE-2026-0265) —
ActorModel.registerAgent(personaId, name), atomic, mirroringregisterPersona's shape. Zero automatic resolution-engine grant: unlike every other aggregate this package registers, a fresh Agent gets noownertuple — registration is never a privilege-escalation event, so an Agent's capability set is always fully traceable to explicit Delegation grants, never partly implicit. Seesrc/repositories/agent-repository.ts. - §9 Delegation (same session as Agent registration, cage-iam#10) —
ActorModel.delegate(delegatorPersonaId, agentId, relation, object, expiresAt). Scope: strictly narrower than the delegator's own level, never equal (mechanical rule over the fixed vocabulary order; delegatingowneritself is impossible — nothing is strictly above it). Expiry: mandatory, tracked at the Model Layer via aDelegationGrantedevent, not by the resolution engine —ActorModel.listExpiredDelegationsdiscovers what's expired,revokeAccessis how a caller actually revokes it (no background sweeper). Depth: one hop only, Persona → Agent — enforced bydelegate's own signature. Supersedes §9's original "composite subject syntax" proposal entirely; seesrc/commands/delegate.tsand design.md's Open Question #6 for the full reasoning. Its correctness depends on cage-iam#21's dispatch fix, which is why that fix landed first. Two threads flagged as real but explicitly non-blocking, same as design.md's own framing: delegated access isn't currently tracked back to the delegator's live state if later revoked, and Agent isn't yet covered by §10 erasure.
What's deferred
- Authorization of who may call
addMember/removeMemberon an Extent — Root's "single hard-coded permission" is an application-level rule, not a resolution-engine tuple; not enforced by this package. - Dynamic schema support — open question in the design document itself (§10 note: partially informed by cage-iam#14's base+overlay schema composition proposal, not fully resolved).
A source-document ambiguity, flagged not silently resolved
The design document is internally inconsistent about tuple direction for Collection nesting: its own "Collection creation" example for nesting under a Confinement puts the child Collection as subject and the parent as object, while its next example (nesting under a parent Collection) and its separate "Collection hierarchy" section both put the parent as subject. This implementation follows the latter, majority form — grant(parent, collection, child) — uniformly for both cases. See the comment above COLLECTION_RELATION in src/repositories/collection-repository.ts. Flagging for the design author to confirm/correct upstream (cage-core#29).
Examples: durable adapters (cage-iam#37 Q1)
docs/examples/ has two illustrative, non-shipped adapters, each with a .smoke.ts script that actually exercises it against real infrastructure (not part of npm test — they need a real Postgres/KMS-compatible endpoint reachable; see each file's own header for how to run it):
postgres-event-store.ts— a durableEventStorebacked by a single Postgres table,(stream_id, seq)as the primary key for real optimistic concurrency. Neither this norTupleStoregets a maintained reference implementation from this package — "bring your own, we specify the interface" stays the permanent position for both; this is a worked example, not a first-party option.kms-pii-key-store.ts— a durablePiiKeyStoreusing KMS envelope encryption (a real KMSEncrypt/Decryptcall is structurally required by the constructor — there is no "just use a local key" fallback to slip into). Treated differently fromEventStoreon purpose: a badEventStoreis merely unreliable, but a badPiiKeyStore— like this package's ownInMemoryPiiKeyStoretest double — fails silently, which is a materially worse failure mode for something GDPR erasure depends on.
Both were verified against real (throwaway, Dockerized) infrastructure before being committed, not just written and trusted — the Postgres example caught a real bug this way (node-postgres returns a JS Date for TIMESTAMPTZ, not the ISO string EventStore expects).
Development
npm install
npm run build
npm test # mocha + chai, then eslint via posttestPackage: the resolution engine implementation
src/engine/ implements docs/resolution-engine-spec.md (merged as #14) end to end: schema grammar and static validation, a tuple store with the bidirectional/by-relation indexing §1.1 requires, the check() resolution algorithm (dispatch, permission derivation, the inherit primitive, cycle-safe caching, a configurable depth bound), composed schemas (base+overlay), and batch grant/revoke/check. SchemaResolutionEngine is the class that ties all of it together into a real ResolutionEngine — see the usage example above, src/engine/README.md for the phase-by-phase detail, and src/engine/schema-resolution-engine.ts's own doc comment for what's still not durable (its default tuple store is in-memory, same tradeoff as everything else under testing/).
One thing flagged rather than silently resolved while building this, since fixed, plus one still open on the issue tracker:
- #21, fixed — a permission and a relation sharing the same name on one type made the permission unreachable (the spec's original dispatch order checked "is this a declared relation" before "is this a declared permission"), which broke
docs/schema-reference.md's intended owner-cascades-down chain foradmin/editor/contributor/reader. A real tension between this package's Model Layer convention and the spec's original dispatch priority, not a typo — fixed by two coordinated pieces insrc/engine/(schema/parse.ts's term disambiguation,resolve.ts's dispatch order), neither requiring a schema or Model Layer change.ownernow correctly cascades all the way down. Seesrc/engine/README.mdanddocs/resolution-engine-spec.md's §2.3/§4.2 revision notes for the full reasoning. - #8, now fully closed — this package answers "who owns this specific object" from its own event log (fix (1)), and "what does this Persona own" from the resolution engine's reverse-by-subject index via
listTuples(fix (2)). Together these are what letActorModel.erasePerson's §10 steps 1/3 auto-discover rather than require caller-supplied candidates — see that command's own doc comment.
CAGE ecosystem
| Package | Description | |---------|-------------| | cage-core | Agent protocol and Agent Bundle contract | | cage-chart | Source Chart schema and compiler | | cage-cli | CLI for scaffolding CAGE-enabled projects | | cage-coordinator | Orchestration methodology and dispatch FSM | | cage-issues | Issue tracker | | cage-git | Git workflow hooks and safe branch landing | | cage-iam | Generic Zanzibar-style Identity and Access Management design (this repo) | | cage-documents | Per-repo document storage with namespace/index convention |
License
Proprietary — © 2026 David Leangen. All rights reserved.
