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 pluggablePermissionResolverSPI, 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 +InboxAuthzbuilt; the Simple-Auth modulesvance-addon-shared-simpleauth(Resolver R2–R7 + Grant-Storage + Bootstrap + Migration) andvance-addon-brain-simpleauth(Admin-REST + LLM-Tools + Web-UI- Area) built and tested; the federated Grant management UI + the genericaddon.html-host + the dynamic landing tiles invance-faceare live (pnpm-Build green). The landing tile metadata comes from one source (Addon ManifestMETA-INF/vance-addon.yamltile:) and appears in/face/addonsin both Dev (vite-Middleware) and Prod (AddonManifestRegistry→AddonDto.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— SPIboolean isAllowed(SecurityContext, Resource, Action). Never throws; returnsfalsefor missing data (fail-closed). Evaluates user policy exclusively —WriteReasonis not part of the SPI (see R1 / §5a).PermissionService—check(...)/enforce(...)(throwsPermissionDeniedException→ REST 403, WS-Error-Frame). First enforces the framework trust boundary (SYSTEM-Subject orWriteReason.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 thename-foreign-keys of their parents, never Mongo-id.Action—READ, WRITE, CREATE, DELETE, START, EXECUTE, ADMIN, IMPERSONATE.SecurityContext—record(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-*-simpleauthmodules along the layer naming convention (clear separation instead of one module with context conditionals):vance-addon-shared-simpleauth(Spring-Library,vance-sharedonly) — Mongo-EntityPermissionGrantDocument+ Repository +PermissionGrantService,MongoPermissionResolver(R2–R7; R1-SYSTEM-Trust is in the framework’sPermissionService),PermissionBootstrap-Impl, Migration,@AutoConfiguration. Context-neutral, loads into both hosts (Brain + anus), because both persist + check grants. Packagede.mhus.vance.simpleauth. No client → does not follow thevance-addon-brain-*-federation convention.vance-addon-brain-simpleauth(+vance-brain, withclient/-federation) — Admin-REST under/brain/{tenant}/admin/permission-grants, LLM-Grant-Tools (permission_grant_set/_list/_remove), Web-UI-Area. Brain only. Packagede.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 thesubjectisSecurityContext.SYSTEMor theWriteActorcarries server-builtWriteReason.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.PermissionServicedraws this boundary before delegation: the provider never sees SYSTEM and only evaluates real user policy (R2–R7);WriteReasonthus 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 READof 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.SYSTEMis 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 insubject) and is short-circuited inPermissionServicebefore the resolver._vanceis 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 inDocumentService. 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)._vanceis consistently a path prefix here, not a project name —_vancedoes not exist as a projectId (the Tenant-Scope is called_tenant); the system-Tenant_vanceis unaffected. Enforce-vs-internal: the WRITE reservation applies at the enforcement points (LLM-Tools/REST/WebDAV/Script-API); internal services write logs/recipes directly viaDocumentService(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;_tenantis 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 additiveWriteReason.SYSTEMchannel 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 resource → Provider (Roles/Grants/R2–R7). The SYSTEM-Trust (R1) sits before it in the framework.
reason-Triage (strict):
WriteReason.SYSTEMonly 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.SYSTEMor 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.USERfor 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, viaSecurityContextFactory.forToolSubject→null userId⇒SecurityContext.SYSTEMfor 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: trueis seeded toDocumentDocument.privilegedupon creation (analogous to$meta.lockedForInitial).runAsis honored for execution only if the persisted source document isprivileged. The Ursa-Loaders silently drop arunAsfrom 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 hostaddon.html?addon=simpleauthinvance-face(AddonHostApploads./area, presence-gated via/face/addons). The landing tile is dynamically rendered from the declarativetile:block of the Addon-META-INF/vance-addon.yaml(single source, delivered in/face/addonsby the vite-middleware in Dev or theAddonManifestRegistryin Prod) and appears only if the addon is loaded andWebUiLevelis 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-simpleauthcontributes the spring-shell-commandspermission 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.
onUserDeletedruns before the document is removed, and an exception aborts the deletion (a grant without a subject is worse than an undeleted account).onUserCreatedruns 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.