Repository navigation
feat(permissions): named, module-owned principals — async-resolved identity facts for sync conditions #459
Description
Summary
Add principals to the permission system: named, module-owned, runtime-registered resolvers that turn a caller identity into authorization facts (roles, plan, group membership, …), resolved asynchronously and cached per identity, and handed read-only to synchronous rule conditions that declare which principals they need.
This separates two jobs that currently have no clean home:
- Resolver — facts about an identity. Async, cached, invalidatable. Example: "U's per-project roles".
- Condition — the per-call decision. Sync, sees
args/target(feat(permissions): pass the call args + target to condition functions (resource-scoped gates) #455). Example: "may Ureadfiles.listonproj-42".
Problem
#455 made resource-scoped conditions possible (condition(ctx, { args, target })), but real per-resource gates need facts that are only available asynchronously (a DB/role lookup), and conditions are synchronous — permission-manager.mjs #singleConditionMatches evaluates !!conditionEntry(ctx, callMeta) with no await.
The workarounds are all unsatisfactory:
- Resolve in the app and stuff it into
context(context.run({ roleCtx }, fn)). The key is module-writable unless every establishment site remembersscope({ protect }); nothing ties the value to a trusted resolver (any code that can callscope()can plant one); and nothing invalidates it — a long-lived extent (a vine link, a background job, a timer) keeps a stale authorization snapshot after a role is revoked. - Async conditions everywhere. Every gated call awaits an authorization resolve, and the model breaks for targets that must be answered synchronously (
construct, read-gating, event fan-out). - A single, instance-config resolver. Contradicts slothlet's runtime-mutable model (modules are added/removed/reloaded; rules are runtime-mutable, owned, and replayed), and forces one notion of "who the caller is" onto every module.
Proposed design
Registration — runtime, owned, named
// the roles module registers its principal (it owns the name "roles")
api.slothlet.permissions.principal.register("roles", {
key: (ctx) => ctx.caller?.sub, // how THIS principal keys identity
resolve: async (identity) => resolveRoleCtx(identity),
maxAge: 30_000, // optional TTL
});
// another module registers a different principal, keyed differently
api.slothlet.permissions.principal.register("billing", {
key: (ctx) => ctx.caller?.orgId,
resolve: async (orgId) => fetchPlan(orgId),
});- Modelled on rules:
register/unregister/invalidatelive underslothlet.permissions.control.principal.**, deny-by-default for modules (the existing built-inslothlet.permissions.control.**deny). The host grants registration to the modules that should define principals, exactly likeaddRule. - Name ownership: the first registrant owns the name; only the owner (or the host) may re-register, unregister, or invalidate it. A second module claiming an owned name throws (analogous to
CONTEXT_KEY_OWNED,protect/ownersguard only the top-level context key, not nested values #207). This prevents a module from replacing another module's resolver with a permissive one. - Lifecycle-bound: registrations are recorded in
operationHistoryand replayed on reload (same asaddRule). A scopedapi.reload(owner)re-registers and bumps that principal's epoch;api.remove(owner)removes the principal. control.seal()also freezes principal registration — opt-in, host's choice, never the default.
Rules declare the principals they need
{ caller: "**", target: "project.files.list", effect: "allow",
requires: ["roles"],
condition: (ctx, { args, target, principals }) => decide(principals.roles, args[0], "read") }
{ caller: "**", target: "reports.export", effect: "allow",
requires: ["roles", "billing"],
condition: (ctx, { args, principals }) =>
principals.billing.plan !== "free" && decide(principals.roles, args[0], "read") }requires is declarative data so slothlet knows, before evaluating the (sync) condition, which principals must be current for the matching rules.
- Reading a principal is itself permission-gated: a rule owned by module M may
requireprincipal P only if M may readslothlet.permissions.principal.P(allowed for P's owner by default; configurable otherwise). - A rule whose required principal is absent (never registered, or its owner was removed) does not match → the call falls to
defaultPolicy(fail-closed underdeny).
Resolution — lazy, per principal, sync fast path
Each principal keeps its own cache keyed by its own key(ctx), with its own epoch.
- At enforcement, collect
requiresfrom the matching conditional rules. If every required principal for the current identity is fresh → synchronous fast path, unchanged cost. - If any required principal is missing, past
maxAge, or its epoch was bumped → promote that call only to the async pipeline (reusing the per-call dispatch promotion introduced for async hooks, Sync/async-transparent hook dispatch — allow any mix of sync/async targets and handlers #253): resolve just the stale principals, then evaluate conditions as usual. - Conditions receive a read-only view of each principal (the recursive protected view from
protect/ownersguard only the top-level context key, not nested values #207), so neither a condition nor a module can mutate the shared cached value; only slothlet replaces it on re-resolve.
Invalidation
principal.invalidate(name, identityKey?)— owner/host only; per identity or whole principal.- Automatic epoch bump on: owner re-register/unregister, owner reload/remove, full instance reload.
- Principals are independent: invalidating
billing(O)never discardsroles(U).
Worked example (identity U, org O, cold cache)
self.project.files.list("proj-42")
requires {roles} → roles(U) missing → async: resolve roles only → decide → allowed
(billing never resolved)
self.reports.export("proj-42")
requires {roles, billing} → roles(U) fresh, billing(O) missing → async: resolve billing only
self.project.files.list("proj-42") → roles(U) fresh → sync fast path
api.slothlet.api.reload("roles") → roles epoch bump (resolver replayed)
self.project.files.list("proj-42") → stale → async re-resolve with the reloaded resolver
roles revokes U on proj-42 → principal.invalidate("roles", U)
self.project.files.list("proj-42") → re-resolve → not a member → default deny
Different decisions from the same facts
No new mechanism: two modules require: ["roles"] and write different conditions. A separate principal is only needed when the facts differ (e.g. DB roles vs. identity-provider group membership).
Safety invariant (should land with or before this)
A sync condition that returns a thenable currently evaluates as truthy (!!Promise === true), so on an allow rule it fails open. The sync path should treat a thenable result as a non-match (deny) and emit a diagnostic. This is independent of principals but is the failure mode principals exist to avoid, so the feature should not ship without it.
Non-targets / constraints
- Targets that must be answered synchronously (
construct, read-gating, event-level resolution): a rule on such a target whose required principal is stale cannot be promoted — define the behaviour explicitly (resolve eagerly at establishment if known, otherwise treat as non-match), and document it. - Boundary-agnostic. slothlet knows nothing about browser/server. Trust comes from where enforcement runs: a principal resolved in an untrusted runtime (a browser/live runtime) gears the UI cooperatively and is never authoritative. slothlet-vine must never forward a principal across a link; the trusted (serve) side resolves its own for its verified identity.
- In-process code that is actively hostile can still monkey-patch — the guarantee is against modules operating through slothlet's API, like the rest of the permission system.
Acceptance
- Runtime
register/unregister/invalidateundercontrol.principal.**, deny-by-default for modules; name ownership enforced; replay across reload; removal with owner. requireson rules;principalspassed to function conditions alongsideargs/target; read gating on principal names.- Per-principal key/cache/epoch/
maxAge; sync fast path when fresh; per-call async promotion only when a required principal is stale. - Read-only principal views; fail-closed on missing principals; thenable-from-sync-condition → deny.
- Docs:
PERMISSIONS.md(principals section + worked example),CONTEXT-PROPAGATION.mdcross-reference.
Downstream
Blocks CLDMV/rummage#78 — relocating app gates (a GATE map + _leaf on 6 extensions; in-body require* on ~54 extensions) into declarative slothlet conditions. Follows #455 (condition args).
Activity
Shinrai commentedon Sep 27, 2026
Design decisions settled before implementation:
permission-manager.mjs#singleConditionMatchesdoes!!conditionEntry(ctx, callMeta), so a returned promise fails open onallowrules).construct, read-gating, event resolution) whose required principal is stale: the rule is a non-match (falls todefaultPolicy) and a diagnostic is emitted. No eager establishment-time resolve.api.slothlet.permissions.principal.register / unregister / invalidate, sitting besideaddRule, deny-by-default for modules via a new built-in rule on those paths.requireits own module's principals freely; requiring another module's principal needs a host-granted allow onslothlet.permissions.principal.<name>. Without it the rule does not match.Shinrai commentedon Sep 27, 2026
Revision to decision 4: no consumption gate. Principals carry authorization facts (roles, plan, membership), not secrets, and any module could derive the same facts itself, so gating reads adds a mechanism without adding protection. Any rule may
requireany registered principal. The "read gating on principal names" acceptance item is dropped. Registration, replacement, and invalidation stay owner/host-only and deny-by-default for modules.Shinrai commentedon Sep 27, 2026
Implemented in #469 (merged to
nextas bf63f38; feature commit 935ec24), shipping in v3.21.0. Includes the thenable-from-sync-condition → non-match safety fix. Behaviour notes vs. the original spec: modules register principals from host-called code (e.g. aninitializeroutine) sinceselfis unavailable at module top level (#460); an owner's scoped reload leaves its principals dormant until it registers again, and a full reload replays host registrations and reserves module ones.release: v3.21.0 - named, module-owned principals for sync conditions
cldmv-bot commentedon Sep 28, 2026
Closed in v3.21.0 — resolved by #469.