Permission System — Authorization via Pluggable Providers

Vancetope authorizes every access via a narrow, abstract interface (PermissionService.enforce(SecurityContext, Resource, Action)). The decision logic resides behind a pluggable PermissionResolver SPI, provided by a Provider Addon — either the included Simple-Auth (role-based, Grants in MongoDB) or a commercial Governor (rights from an external system). Exactly one provider is mandatory: otherwise, the Brain (and the anus-Context) will not boot. Simple-Auth is the only included provider — there is intentionally no separate “Allow-All” provider; an open installation simply grants broad permissions.

Guiding Principle: minimal, not enterprise. Roles (READER/WRITER/ADMIN) on two Scopes (TENANT, PROJECT); everything deeper (Session/Process/Document/Inbox) inherits from the Project or is handled by a few code rules. No per-object ACLs.

Status: Interface + “exactly one provider” guard + all enforcement points + $meta.privileged-runAs-gate + InboxAuthz built; the Simple-Auth modules vance-addon-shared-simpleauth (Resolver R2–R7 + Grant-Storage + Bootstrap + Migration) and vance-addon-brain-simpleauth (Admin-REST + LLM-Tools + Web-UI- Area) built and tested; the federated Grant management UI + the generic addon.html-host + the dynamic landing tiles in vance-face are live (pnpm-Build green). The landing tile metadata comes from one source (Addon Manifest META-INF/vance-addon.yaml tile:) and appears in /face/addons in both Dev (vite-Middleware) and Prod (AddonManifestRegistryAddonDto.tile). vance-addon-anus-simpleauth (Grant-CRUD-Shell-Commands + Setup-Wizard-Admin-Seed) built. Implementation plan/ history: planning/permission-system-concept.md.

1. Architecture: Interface ⇥ Implementation

Three strictly separated layers:

Layer Knows Task
Enforcement Point (Controller, WS-Handler, Tools, Loader) only Action + own data asks enforce(ctx, Resource, Action) — “is the subject allowed to do this?”
Grant Admin Surface (UI/Tools/anus — Simple-Auth only) GrantRole assigns roles
Provider / Resolver (Addon) maps Action → internal (role or Governor logic) answers

Strict Rule: No enforcement point ever reasons about roles or grants — no if (user.isAdmin()) at a call site. Roles are the model of the default provider, not of the interface. This keeps the provider pluggable without touching a single enforcement point.

Core Types (vance-shared/.../permission/):

  • PermissionResolver — SPI boolean isAllowed(SecurityContext, Resource, Action). Never throws; returns false for missing data (fail-closed). Evaluates user policy exclusivelyWriteReason is not part of the SPI (see R1 / §5a).
  • PermissionServicecheck(...)/enforce(...) (throws PermissionDeniedException → REST 403, WS-Error-Frame). First enforces the framework trust boundary (SYSTEM-Subject or WriteReason.SYSTEM → allowed, without asking the provider), then delegates actual user writes to the single resolver. This prevents any provider from breaking internal plumbing and eliminates the need for any provider to reimplement SYSTEM trust.
  • Resource (sealed) — Tenant, Project, Document, Setting, Session, ThinkProcess, Team, User, InboxItem. Records carry the name-foreign-keys of their parents, never Mongo-id.
  • ActionREAD, WRITE, CREATE, DELETE, START, EXECUTE, ADMIN, IMPERSONATE.
  • SecurityContextrecord(subjectType, subjectId, tenantId, teams[]). SYSTEM-singleton for internal callers.

2. Providers are Mandatory — and Live in Addons

PermissionService injects exactly one PermissionResolver. 0 or >1 → Boot-Fail with a clear message (no silent default, no ambiguous choice). Since PermissionService is in vance-shared, the guard applies in every context that scans shared — Brain and anus.

A provider addon registers classpath-based via @AutoConfiguration + META-INF/spring/...AutoConfiguration.imports — JAR on classpath ⇒ provider active, JAR removed ⇒ gone. Two providers:

  • Simple-Auth — the included role-based implementation, intentionally divided into three vance-addon-*-simpleauth modules along the layer naming convention (clear separation instead of one module with context conditionals):
    • vance-addon-shared-simpleauth (Spring-Library, vance-shared only) — Mongo-Entity PermissionGrantDocument + Repository + PermissionGrantService, MongoPermissionResolver (R2–R7; R1-SYSTEM-Trust is in the framework’s PermissionService), PermissionBootstrap-Impl, Migration, @AutoConfiguration. Context-neutral, loads into both hosts (Brain + anus), because both persist + check grants. Package de.mhus.vance.simpleauth. No client → does not follow the vance-addon-brain-*-federation convention.
    • vance-addon-brain-simpleauth (+ vance-brain, with client/-federation) — Admin-REST under /brain/{tenant}/admin/permission-grants, LLM-Grant-Tools (permission_grant_set/_list/_remove), Web-UI-Area. Brain only. Package de.mhus.vance.simpleauth.brain.
    • vance-addon-anus-simpleauth (+ spring-shell) — Grant-CRUD-Commands. Anus context only.
  • EE-Governor (commercial) — its own PermissionResolver, fetches rights externally; does not implement the Vancetope grant surfaces.

There is no shipped “Allow-All” provider — Simple-Auth is the only included production provider. Dev/IDE-bundles (vance-brain-all1/all2) load Simple-Auth; the Acme bootstrap seeds marvin.acme as TENANT-ADMIN, so that real role-based enforcement is also active in Dev. A deliberately open installation grants broad permissions (e.g., a Team-WRITER grant tenant-wide).

Test Exception: qa/ai-test boots a bare VanceBrainApplication without a provider addon on the classpath and would fail at the guard. The E2E tests check AI flows, not authorization — therefore, an @AutoConfiguration only on the test classpath (AitestAllowAllPermissionConfig + Test-AutoConfiguration.imports) registers a permissive PermissionResolver that satisfies the guard with exactly one provider, without enforcing. Purely test-scoped — Prod/Dev remain Simple-Auth.

The vance-addon-shared-simpleauth core only depends on vance-shared (MongoPermissionResolver only needs PermissionGrantService/TeamService), so it loads identically in Brain and anus; the Brain and anus surfaces live in their own modules with their own dependencies. The package root de.mhus.vance.simpleauth is intentionally outside the component scan bases of the Brain (de.mhus.vance.brain/.shared), so that beans are registered exclusively via @AutoConfiguration and not duplicated.

3. Subject & Membership Model

  • USER — authenticated human (JWT). subjectId = UserDocument.name.
  • TEAM — not a separate SubjectType, but an aggregation: Grants can be attached to a Team; team membership travels with SecurityContext.teams() and is combined with user grants in the Resolver (max role wins). Teams are organizational — the Team↔Project association (ProjectDocument.teamIds) states “which team works on the project” and controls Inbox routing, but is not the authorization source.
  • SYSTEM — internal callers (Scheduler, Lifecycle-Listener, Engine-System-Writes, Migrations). SecurityContext.SYSTEM, always allowed.

4. Roles, Grants & Cascade (Default Provider)

Roles are totally ordered — READER < WRITER < ADMIN — and map to Actions:

READ                                    → READER
WRITE, CREATE, DELETE, START, EXECUTE   → WRITER
ADMIN, IMPERSONATE                      → ADMIN

Grants live in their own permission_grants collection (in the Simple-Auth Addon, not in ProjectDocument), key (tenantId, scopeType, scopeId, subjectType, subjectId) → exactly one role grant per subject/scope. Two scopes: TENANT and PROJECT. A Tenant-Grant covers every project of the tenant; a Project-Grant only its own. Intentionally no deny grants (additive only, max role wins).

effectiveRole(subject, tenant, project) = max over (a) direct user grants, (b) team grants of the subject’s teams, (c) tenant grant. Grant lookups are short-TTL cached per scope (Caffeine, ~30–60 s; Multi-Pod TTL-only, no cross-pod invalidation).

5. Code Rules (R2–R7 in Resolver, R1 in Framework)

  • R1 SYSTEM (Framework Trust Boundary, in PermissionService — not in Resolver) — a write is unconditionally allowed if the subject is SecurityContext.SYSTEM or the WriteActor carries server-built WriteReason.SYSTEM (covers migrations, bootstrap, lifecycle logs, Slart Recipes, Kit Install, Scheduler-/OAuth-Controller). Trust lies in the character of the write, which the call site honestly declares (§5a) — it cannot be falsified from user input. PermissionService draws this boundary before delegation: the provider never sees SYSTEM and only evaluates real user policy (R2–R7); WriteReason thus does not reach the resolver. This prevents any new provider from breaking internal plumbing and eliminates the need for any provider to reimplement SYSTEM trust. Not a carte blanche for user-driven writes that merely “conveniently” set SYSTEM.
  • R2 Tenant-READ implicit — every JWT-authenticated user has Tenant READ of their own tenant; actual visibility filters per-project.
  • R3 Project Inheritance — Session/ThinkProcess/Setting/Document inherit: effectiveRole >= minRole(action).
  • R4 Reserved-Prefix — Writes to _vance/… require ADMIN: a normal user actor (READER/WRITER) is read-only here; an ADMIN (Project-ADMIN or Tenant-ADMIN via R3 inheritance) may write _vance/… directly. WriteReason.SYSTEM is the additive elevation channel — a vouched SYSTEM action is allowed regardless of role (thus also for non-admins, “under special observation”; the actual actor remains for audit in subject) and is short-circuited in PermissionService before the resolver. _vance is server-owned system config; dedicated authoring tools (§5a) check ADMIN themselves and then write as SYSTEM (defense-in-depth). The single _vance/-prefix subsumes all config namespaces (recipes _vance/recipes/, hooks _vance/hooks/, events _vance/events/, scheduler _vance/scheduler/, model, setting-forms, wizards, templates, manuals, logs …); a $meta.privileged/runAs-document additionally carries its own ADMIN-gate in DocumentService. Foreign _user_<x>/…-hubs are covered by R7. READ on _vance/… remains open (follows the project role or is readable for every tenant member in the _tenant-project — the cascade resolves here for all). _vance is consistently a path prefix here, not a project name — _vance does not exist as a projectId (the Tenant-Scope is called _tenant); the system-Tenant _vance is unaffected. Enforce-vs-internal: the WRITE reservation applies at the enforcement points (LLM-Tools/REST/WebDAV/Script-API); internal services write logs/recipes directly via DocumentService (no enforce) or as SYSTEM (framework bypass) and are unaffected.
  • R5 Inbox-Assignee — an item is accessible if and only if the user is its assignee or shares a team with the assignee. REST and WS share the same semantics via the InboxAuthz-helper.
  • R6 Impersonation — see §6.
  • R7 Podless-Owner — the user <login> implicitly has ADMIN on their _user_<login>-hub project; _tenant is readable for tenant members and writable by Tenant-ADMIN (Settings cascade) — this is the scope under which server-owned system config (_vance/…-paths) resides, plus the additive WriteReason.SYSTEM channel for internal services. Any other _-project (foreign _user_<x>-hubs) requires Tenant-ADMIN.

Tenant boundary is strict: a USER never acts cross-tenant.

5a. Write-Actor Contract: Input vs. Policy

Every DocumentService-write takes a WriteActor = (subject, reason). Authorization itself belongs exclusively to the provider (§1, §5) — the call site does not authorize. But the call site has a non-delegable duty: to provide honest authorization input. This is not policy, but a fact about the call context that only the call site knows.

  • Who acts (subject) + honest character of the write (reason) → Call Site.
  • Is this subject allowed this action on this resourceProvider (Roles/Grants/R2–R7). The SYSTEM-Trust (R1) sits before it in the framework.

reason-Triage (strict):

  • WriteReason.SYSTEM only for truly internal plumbing where the path is code-determined — Migration, Bootstrap, Lifecycle-Logs (_vance/logs/…), Slart-Recipe-Persist, Kit-Install, Scheduler-/OAuth-Controller. The setter knows exactly which file is being written; there is no freely selectable target path. The Framework (PermissionService) allows this before the provider (R1: SecurityContext.SYSTEM or the server-built SYSTEM-Reason) — so the provider does not see the SYSTEM-Trust at all. Only server code can build a SYSTEM-Reason-Actor — the trust signal cannot be falsified from user input.
  • WriteReason.USER for every write whose target path originates from the caller — LLM-Tools, REST, WebDAV, Script-API (vance.documents.*), Template-Apply with user-chosen target, Addon-Editors. The Actor carries the actual Subject (User + Teams, via SecurityContextFactory.forToolSubjectnull userIdSecurityContext.SYSTEM for headless/Scheduler-runs), and the provider applies normal checks (R3/R4 — _vance/ requires ADMIN). A normal WRITER therefore cannot user-driven write to _vance/; an ADMIN may do so directly, and a dedicated tool that owns the policy itself and sets SYSTEM writes independently of the role.

Dedicated Authoring of a _vance/-Namespace (Pattern): the scheduler_set/hook_set/event_set-tools and the scheduler-/hook-REST-controllers write auto-executing config under _vance/{scheduler,hooks,events}/. A normal WriteReason.USER-write there fails for any non-ADMIN at the choke point (R4). These surfaces are nevertheless dedicated authoring tools that own the policy themselves: they explicitly enforce Project-ADMIN and then write WriteActor.system(subject) (retaining the actual subject for audit). This remains useful as defense-in-depth and because the SYSTEM-write works role-independently (does not depend on the caller’s ADMIN binding) — not a convenient bypass, but its own, tighter authorization before the SYSTEM-write.

Anti-Pattern (was the cause of several findings in Review-2): a user-driven write that passes WriteActor.SYSTEM without its own authorization because it’s “convenient” — this bypasses R4/Lock/privileged fail-open. Criterion when writing write code: Is the target path code-fixed or caller-controlled? Caller-controlled ⇒ USER, unless the surface is a dedicated _vance/-authoring tool with its own ADMIN check (then SYSTEM after the check).

No Pre-Provider-Policy-Check. Reserved-Prefix, roles, etc., are not hardcoded at call sites — that would decouple policy from the pluggable provider. The call site only provides honest input; the installed provider decides. The only pre-provider check is the SYSTEM-Trust boundary (R1) — and that is intentionally not a policy, but the definition of the trust boundary (“the server trusts its own internal actor”), which no provider may override.

SYSTEM-Trust in Framework (implemented). SYSTEM-Trust has been moved from the provider to the framework: PermissionService intercepts both SYSTEM-Subject and WriteReason.SYSTEM before delegation, so the provider only sees actual user writes and internal plumbing can never be broken. WriteReason is therefore no longer an SPI concept — the PermissionResolver-SPI is purely 3-arg (subject, resource, action). Open (target hardening): an SPI conformance test kit that pins the minimum guarantees of each provider (default-deny, Reserved-Prefix protection, Tenant-isolation).

6. Privileged Documents & runAs

Scheduler, Hook, and Event documents can carry a runAs: <user>, so that the spawned process runs under a different identity — a privilege escalation via configuration. Secured by a persistent document flag:

  • $meta.privileged: true is seeded to DocumentDocument.privileged upon creation (analogous to $meta.lockedForInitial).
  • runAs is honored for execution only if the persisted source document is privileged. The Ursa-Loaders silently drop a runAs from a non-privileged document. (Validation/Preview and bundled/in-code sources retain the raw value — they do not execute anything or are trustworthy.)
  • Who may write such a document is governed by R4: Scheduler/Hook/Event-YAMLs are under reserved prefixes ⇒ ADMIN. A normal WRITER cannot even create them.

Distinction: code-internal runAs in Java (trusted server code) is the SecurityContext.SYSTEM-mechanism (R1) — no check, no flag.

The action IMPERSONATE is the abstract verb for this (Default-Provider maps it to ADMIN); it is intentionally separate from ADMIN so that a Governor can map Impersonation independently.

7. Enforcement Points

Inbound layers call enforce, services trust their callers. Wired are, among others: Document-REST (per-Doc READ/WRITE/CREATE), Project-/Tenant-Admin, Damogran-Compose (Project WRITE/READ), Office (Document WRITE before token issuance), Execution (Cross-Project-Scope-Guard in ExecutionRouter + tail/kill), Cross-Project-Spawn (Project START), Trillian-Session-Send (Session EXECUTE), Kit-Tools (Project WRITE/READ on the target project), Tool-Template-Apply (Project ADMIN on the target project — tool_template_apply takes projectId as a tool parameter, writes _vance/server-tools/-documents and encrypted settings, and is thus symmetrically gated to its REST twin ToolTemplatesAdminController), WebDAV (CREATE before lock-null materialization, per-child check during recursive DELETE), Inbox REST+WS (InboxItem READ/WRITE, R5), and the central ToolDispatcher (EXECUTE on the deepest scope resource, with team-resolved subject).

The tool path resolves team memberships via a short-TTL cache (ToolDispatcher), cross-scope tool actions via SecurityContextFactory.forToolSubject(...).

8. Bootstrap & Admin Surfaces

Initial rights come via the shared-SPI PermissionBootstrap (grantTenantAdmin/grantProjectAdmin/grantProjectTeamWriter, intent-named, no role leak). Consumers inject ObjectProvider<PermissionBootstrap> and call ifAvailable(...) — present (Simple-Auth loaded) ⇒ seed, absent ⇒ No-op:

  • Project Creator → PROJECT-ADMIN (ProjectLifecycleService.create).
  • Tenant-Bootstrap-Admin → TENANT-ADMIN (BootstrapBrainService / anus-Setup-Wizard).
  • _user_<login>-Hub → Owner rule R7 (no grant needed).

Grant Management (only if Simple-Auth is loaded):

  • Web-UI (production) — a federated Addon-Area (@vance-addon/simpleauth, expose ./area = PermissionsArea.vue: list/grant/revoke grants on a TENANT or PROJECT scope), accessed via the generic host addon.html?addon=simpleauth in vance-face (AddonHostApp loads ./area, presence-gated via /face/addons). The landing tile is dynamically rendered from the declarative tile: block of the Addon-META-INF/vance-addon.yaml (single source, delivered in /face/addons by the vite-middleware in Dev or the AddonManifestRegistry in Prod) and appears only if the addon is loaded and WebUiLevel is sufficient (admin). Double gate: addon presence + UI level; the REST additionally enforces ADMIN on the scope.
  • anus (production) — the Setup-Wizard seeds the provisioned user as TENANT-ADMIN via the shared-SPI (PermissionBootstrap, ifAvailable; the wizard only creates admins, all other users later via tooling); vance-addon-anus-simpleauth contributes the spring-shell-commands permission grant list/set/remove (operator-god-mode, cross-tenant, without per-scope enforce).
  • LLM-Tools permission_grant_set/_remove/_list (ADMIN-gated).

8a. Account Lifecycle: Grants Follow Name, Not Document

A grant is keyed on (tenant, **username**), not the Mongo-Id — so it does not disappear with the user document. And names return: service accounts follow a schema (_trillian-void-a7f3, _daemon-prod-01) and are created and deleted hourly; human logins are reused. A leftover TENANT-ADMIN grant will thus be silently inherited by the next account under the same name.

The seam for this is UserLifecycleListener (vance-shared, de.mhus.vance.shared.user) — onUserCreated(UserDocument) / onUserDeleted(tenantId, name), both default-No-op, resolved via ObjectProvider (no listener is the normal case). UserService fires; whoever holds name-keyed state listens. Intentionally not PermissionBootstrap called directly from UserService as before: the SPI seeds initial authority, and besides grants, there is other state tied to the name — coupling to exactly one subsystem would have made every other holder invisible.

Simple-Auth implements this in SimpleAuthUserLifecycleListener (vance-addon-shared-simpleauth, registered solely by the component scan of the addon package) and calls revokeAll in both cases — grants gone, permission requests on the name expired.

Three properties that support the contract:

  • Both ends, not just delete. Deletion is the obvious half; creation is the second line of defense against the same danger. Whoever cleans up on create makes inheritance impossible, without having to prove that the delete path ever ran — also for legacy data created before this listener.
  • The two halves fail differently. onUserDeleted runs before the document is removed, and an exception aborts the deletion (a grant without a subject is worse than an undeleted account). onUserCreated runs after the insert, where there is nothing left to abort — an error is logged, the remaining listeners continue.
  • Create first, then grant. Because the create listener cleans up, a seed before the account would be wiped. All four seeding paths (BootstrapBrainService, ProjectLifecycleService, TrillianSessionBootstrapper, anus-Setup-Wizard) grant after creation — this remains so.

PermissionBootstrap.revokeAll remains for cleaners without a user document: TrillianSessionLifecycleHook revokes before account deletion, so grants are removed even if the account (archived session, second run) is already gone. Idempotence is therefore a requirement, not a courtesy.

9. Non-Goals

Deliberately excluded: per-document ACLs, field/attribute-level rights, ABAC, deny grants, custom roles, cross-tenant sharing, grant expiry, session-/process- granular grants, a generic provider plugin framework for the UI. The document-lock (lockedFor) remains orthogonal — soft edit protection, not authorization.