Skip to content

feat(permissions): named, module-owned principals — async-resolved identity facts for sync conditions #459

Closed

Description

@Shinrai

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:

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:

  1. Resolve in the app and stuff it into context (context.run({ roleCtx }, fn)). The key is module-writable unless every establishment site remembers scope({ protect }); nothing ties the value to a trusted resolver (any code that can call scope() 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.
  2. 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).
  3. 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 / invalidate live under slothlet.permissions.control.principal.**, deny-by-default for modules (the existing built-in slothlet.permissions.control.** deny). The host grants registration to the modules that should define principals, exactly like addRule.
  • 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/owners guard 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 operationHistory and replayed on reload (same as addRule). A scoped api.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 require principal P only if M may read slothlet.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 under deny).

Resolution — lazy, per principal, sync fast path

Each principal keeps its own cache keyed by its own key(ctx), with its own epoch.

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 discards roles(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/invalidate under control.principal.**, deny-by-default for modules; name ownership enforced; replay across reload; removal with owner.
  • requires on rules; principals passed to function conditions alongside args/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.md cross-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

  1. added
    semver: minorThis release adds new functionality in a backwards-compatible way
    priority: mediumShould be addressed in the normal course of development
    type: feature requestA request to add new functionality that does not currently exist
    area: coreTouches core library / runtime source code
    status: not startedNot implemented yet — no code exists for this
    on Sep 27, 2026
  2. added theissue type on Sep 27, 2026
  3. Shinrai commented on Sep 27, 2026

    @Shinrai
    ContributorAuthor

    Design decisions settled before implementation:

    1. Thenable-from-sync-condition → deny ships in the same PR as principals (currently permission-manager.mjs #singleConditionMatches does !!conditionEntry(ctx, callMeta), so a returned promise fails open on allow rules).
    2. Sync-only targets (construct, read-gating, event resolution) whose required principal is stale: the rule is a non-match (falls to defaultPolicy) and a diagnostic is emitted. No eager establishment-time resolve.
    3. API surface: api.slothlet.permissions.principal.register / unregister / invalidate, sitting beside addRule, deny-by-default for modules via a new built-in rule on those paths.
    4. Consumption gate: a rule may require its own module's principals freely; requiring another module's principal needs a host-granted allow on slothlet.permissions.principal.<name>. Without it the rule does not match.
    5. Delivered as one PR (feature + safety fix + docs).
  4. added
    status: in progressPartially implemented — actively being built
    and removed
    status: not startedNot implemented yet — no code exists for this
    on Sep 27, 2026
  5. Shinrai commented on Sep 27, 2026

    @Shinrai
    ContributorAuthor

    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 require any 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.

  6. added
    status: implementedBuilt and deployed, but not yet fully tested/verified
    and removed
    status: in progressPartially implemented — actively being built
    on Sep 27, 2026
  7. Shinrai commented on Sep 27, 2026

    @Shinrai
    ContributorAuthor

    Implemented in #469 (merged to next as 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. an initialize routine) since self is 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.

  8. cldmv-bot commented on Sep 28, 2026

    @cldmv-bot
    Contributor

    Closed in v3.21.0 — resolved by #469.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreTouches core library / runtime source codepriority: mediumShould be addressed in the normal course of developmentsemver: minorThis release adds new functionality in a backwards-compatible waystatus: implementedBuilt and deployed, but not yet fully tested/verifiedtype: feature requestA request to add new functionality that does not currently exist

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions