Console Admin RBAC Architecture

Audience: engineers and operators working with the Authority-backed Console admin surface, and anyone reasoning about Stella Ops scopes and role bundles. Source of truth: the canonical scope catalog is StellaOpsScopes.cs; the seeded role bundles are ConsoleAdminEndpointExtensions.GetDefaultRoles(). Where this doc and the code disagree, the code wins — several rows below are explicitly marked proposed vs seeded.

1. Purpose

2. Scope

Non-goals:

3. Core Architecture

3.1 Authority admin tiers

NOTE (doc-vs-code): an earlier draft described a separate /admin tier gated by mTLS plus an authority.admin scope. Neither exists in source: there is no authority.admin scope constant in StellaOpsScopes.cs, and the Console admin endpoints are not bound to an mTLS-only route group. Authority does support mutual-TLS sender-constrained tokens generally (AuthorityClientCertificateValidator), but the Console admin surface above authorizes on bearer scopes, not client certificates.

3.2 Entities and ownership

Authority remains the source of truth for:

3.3 Fresh-auth

High-risk mutating operations are wired to a fresh-auth gate via RequireFreshAuth() in code:

Authority enforces these operations against the authenticated principal’s auth_time claim. The claim must be a Unix timestamp no more than five minutes old; timestamps more than one minute in the future are also rejected. Missing, invalid, stale, or excessively future authentication returns HTTP 401 with code fresh_auth_required. Read-only admin routes do not apply this filter, so an older session may inspect state but must reauthenticate before changing it. The general Console admin and process-global identity-provider admin surfaces use the same five-minute/one-minute contract.

3.4 Tenant boundary: tenant administrator and global administrator

The authority:* scopes are granted per tenant, so a holder of role/console-admin is a tenant administrator of the tenant its token is bound to. Only a global administrator (a user with is_global_admin, whose tokens carry stellaops:admin=true) administers the installation across tenants. Both facts are read from the authenticated principal only: the caller’s tenant is its stellaops:tenant claim, never the tenant header, a query value or the request body; global-admin status is resolved the same way the identity-provider admin group resolves it.

A tenant administrator:

A global administrator keeps all of these operations and is not subject to the client scope cap. The first global administrator is the bootstrap administrator the Standard plugin creates from STELLAOPS_ADMIN_PASS; that path does not use the Console admin API. Every refusal writes an authority.admin.* audit event with outcome Failure and the refusal code as reason.

3.5 Tenant header (PCD-868)

TenantHeaderFilter validates X-StellaOps-TenantId on the /console, /console/admin, /console/admin/branding, air-gap audit and /api/v1/tenants groups:

Through the gateway the header is always set from the validated principal, so these refusals reach only direct callers.

4. Scope and Role Taxonomy

4.1 Console admin scopes

Admin scopes (Authority-managed, all defined in StellaOpsScopes.cs):

The console Identity Providers route requires ui.admin together with authority:idp.read. Its create, update, delete, enable/disable, test, and apply controls require authority:idp.write and use the same fresh-authentication contract as the Authority mutation endpoints. Legacy platform.idp.read and platform.idp.admin claims do not authorize this Authority-owned surface.

4.2 Scanner scope and role bundles

Scanner scopes and role bundles (all five scopes exist in StellaOpsScopes.cs; the four scanner roles are seeded built-ins in GetDefaultRoles()):

Doc-vs-code (catalog table below): §4.3 augments these scanner bundles with findings:read and aoc:verify. The seeded GetDefaultRoles() catalog does not include those extra scopes on the scanner roles — the built-ins carry only the scanner:* scopes listed here. The §4.3 entries with findings:read/aoc:verify are proposed, not the seeded reality.

Compatibility:

4.3 Module role bundle catalog

Role bundles are grouped by module and map to existing Authority scopes unless noted.

Seeded vs. proposed (doc-vs-code, important). The compiled built-in role bundles are the twelve returned by ConsoleAdminEndpointExtensions.GetDefaultRoles(): role/console-viewer, role/console-admin, role/executive-assurance-viewer, role/release-operations-steward, role/security-risk-manager, role/service-release-contributor, role/scanner-viewer, role/scanner-operator, role/scanner-signed-sbom-writer, role/scanner-admin, role/release-approver, role/decision-signing-enrollee. Every other row in the module table below (Policy, Concelier, Excititor, Notify, Scheduler, Orchestrator, Graph, Vuln Explorer, Export Center, Advisory AI, Signals, Evidence Locker, Observability, Issuer Directory, Task Packs, Airgap, Exceptions, Attestor, Signer, SBOM, Release, Zastava, and the operator/auditor Operator Verify rows) is a proposed/reference bundle: the underlying scopes generally exist in StellaOpsScopes.cs, but the role itself is not seeded as a built-in. Operators can recreate any of these as custom roles via POST /console/admin/roles (which accepts any catalog scope and auto-creates the permission rows). The role IDs marked (new) in the Scheduler rows refer to a no-longer-accurate “scope is new” annotation — see the Missing-scopes note in this section. Where a proposed row’s scopes differ from a seeded built-in (notably the Scanner rows, which the table augments with findings:read/aoc:verify that the seeded scanner roles do not carry), treat the seeded GetDefaultRoles() definition as authoritative.

Human token issuance uses the compiled additive role union for both interactive and password flows, bounded by requested scopes and the actual client ceiling. Unknown/custom roles do not grant product scopes merely because the catalog stores a scope list. role/release-approver grants only release:publish and role/decision-signing-enrollee grants only authority:signing-keys.enroll. Neither changes the base professional bundles or grants gate bypass. Ordinary interactive login excludes release:gate-bypass for all roles. See the human issuance contract for source tests and the proposed, still unprovisioned acceptance identities.

Service-contributor deployment is a scoped release-control case, not an admin case. Use role/service-release-contributor for users who can update or request updates for their own microservice images in allowed environments. That role must be paired with ABAC constraints such as serviceIds, repositoryIds, imageNamespaces, environmentIds, and maxEnvironmentType; do not grant broad release:publish, release:bypass, ui.admin, orch:operate, or policy:approve just to enable self-service updates. Authority also narrows interactive console authorization-code grants for role/service-release-contributor and legacy role/release-developer principals to the self-service allow-list, so a broad UI client request does not mint admin/publisher/approval scopes for a contributor.

Console administrators manage those attributes from Identity & Access > Users. Selecting role/service-release-contributor opens fields for service IDs, repository IDs, image namespaces, environment IDs, maximum environment type, and directUpdateAllowed. The Authority admin API persists these values under user metadata attributes, returns them in user summaries, and the standard/LDAP/AD claim enrichers can project them into issued tokens. The UI blocks save until the administrator explicitly confirms any combination of service-release contribution with broad admin, operator, bypass, publisher, or approver roles. Built-in roles returned by GetDefaultRoles() are valid user-update assignments even when an installation has no matching tenant-local role-table row; their authoritative scope bundles remain the compiled catalog. Tenant-created roles still require a persisted role row before assignment.

The customer-facing default bundles are professional functions rather than job titles:

Customer functionBuilt-in roleScope boundary
Executive assurancerole/executive-assurance-viewerRead release, policy audit, findings, evidence, graph, exports, authority audit, tenant timeline, platform context, (BAZ-3) attestor evidence-transparency / proof-chain via attest:read, and (CCP-057) approval policies via release.policy.read, without release.policy.manage.
Release operationsrole/release-operations-stewardOperate release orchestration and scans, read the tenant timeline, and (CCP-057) read approval policies via release.policy.read, without release.policy.manage, release bypass or platform admin.
Security riskrole/security-risk-managerRead tenant registry image inventory plus the release and integration context used by visible dashboard, Promotions, image, custody, and audit surfaces; investigate findings and vulnerabilities; run policy simulations; read advisory/VEX context, evidence, and the tenant timeline; and (BAZ-3) read the remediation console surface via remediation:read. release:read, registry:read, and integration:read do not grant release mutation, registry administration, or integration write/operate actions, and remediation:read does not grant remediation submit/manage (admin-only).
Service deliveryrole/service-release-contributorRequest or perform ABAC-limited service image updates and read the tenant timeline; no publish, bypass, approval, admin, or broad orchestration operate scopes.
ModuleRole bundleScopes
Consolerole/console-viewerui.read
Consolerole/console-adminui.read, ui.admin, authority:tenants.read, authority:tenants.write, authority:users.read, authority:users.write, authority:roles.read, authority:roles.write, authority:clients.read, authority:clients.write, authority:tokens.read, authority:tokens.revoke, authority.audit.read, authority:branding.read, authority:branding.write, authority:idp.read, authority:idp.write
Consolerole/console-superadmin (proposed; not seeded)ui.read, ui.admin, authority:tenants.*, authority:users.*, authority:roles.*, authority:clients.*, authority:tokens.*, authority.audit.read, authority:branding.* — NB there is no distinct console-superadmin built-in; the seeded role/console-admin already carries the full set. Audit scope is dot-form authority.audit.read.
Operator Verifyoperatoroperator.verify.read, operator.verify.admin
Operator Verifyauditoroperator.verify.read
Scannerrole/scanner-viewerscanner:read, findings:read, aoc:verify
Scannerrole/scanner-operatorscanner:read, scanner:scan, scanner:export, findings:read, aoc:verify
Scannerrole/scanner-signed-sbom-writerscanner.signed-sbom-material.write
Scannerrole/scanner-adminscanner:read, scanner:scan, scanner:export, scanner:write, findings:read, aoc:verify
Policyrole/policy-authorpolicy:read, policy:author, policy:simulate, findings:read
Policyrole/policy-reviewerpolicy:read, policy:review, policy:simulate, findings:read
Policyrole/policy-approverpolicy:read, policy:review, policy:approve, policy:simulate, findings:read
Policyrole/policy-operatorpolicy:read, policy:operate, policy:run, policy:activate, policy:publish, policy:promote, policy:simulate, findings:read
Policyrole/policy-auditorpolicy:read, policy:audit, findings:read
Concelierrole/concelier-readeradvisory:read, aoc:verify
Concelierrole/concelier-ingestadvisory:ingest, advisory:read, aoc:verify
Concelierrole/concelier-operatorconcelier.jobs.trigger, advisory:read, aoc:verify
Concelierrole/concelier-adminconcelier.jobs.trigger, concelier.merge, advisory:read, aoc:verify
Excititorrole/excititor-readervex:read, aoc:verify
Excititorrole/excititor-ingestvex:ingest, vex:read
Notifyrole/notify-viewernotify.viewer
Notifyrole/notify-operatornotify.viewer, notify.operator
Notifyrole/notify-adminnotify.viewer, notify.operator, notify.admin
Schedulerrole/scheduler-viewerscheduler:read
Schedulerrole/scheduler-operatorscheduler:read, scheduler:operate
Schedulerrole/scheduler-adminscheduler:read, scheduler:operate, scheduler:admin
Orchestratorrole/orch-viewerorch:read, findings:read
Orchestratorrole/orch-operatororch:read, orch:operate, findings:read
Orchestratorrole/orch-adminorch:read, orch:operate, orch:quota, orch:backfill, findings:read
Graphrole/graph-viewergraph:read, graph:export
Graphrole/graph-operatorgraph:read, graph:export, graph:simulate
Graphrole/graph-admingraph:read, graph:export, graph:simulate, graph:write, graph:admin
Vuln Explorerrole/vuln-viewervuln:view, findings:read
Vuln Explorerrole/vuln-investigatorvuln:view, vuln:investigate, findings:read
Vuln Explorerrole/vuln-operatorvuln:view, vuln:investigate, vuln:operate, findings:read
Vuln Explorerrole/vuln-auditorvuln:view, vuln:audit, findings:read
Export Centerrole/export-viewerexport.viewer
Export Centerrole/export-operatorexport.viewer, export.operator
Export Centerrole/export-adminexport.viewer, export.operator, export.admin
Advisory AIrole/advisory-ai-vieweradvisory-ai:view, aoc:verify
Advisory AIrole/advisory-ai-operatoradvisory-ai:view, advisory-ai:operate, aoc:verify
Advisory AIrole/advisory-ai-adminadvisory-ai:view, advisory-ai:operate, advisory-ai:admin, aoc:verify
Signalsrole/signals-viewersignals:read, aoc:verify
Signalsrole/signals-uploadersignals:read, signals:write, aoc:verify
Signalsrole/signals-adminsignals:read, signals:write, signals:admin, aoc:verify
Evidence Lockerrole/evidence-readerevidence:read
Evidence Lockerrole/evidence-creatorevidence:read, evidence:create
Evidence Lockerrole/evidence-legalevidence:read, evidence:hold
Observabilityrole/observability-viewerobs:read, timeline:read, attest:read
Observabilityrole/observability-investigatorobs:read, timeline:read, timeline:write, evidence:read, evidence:create, attest:read
Observabilityrole/observability-incident-commanderobs:read, obs:incident, timeline:read, timeline:write, evidence:read, evidence:create, attest:read
Issuer Directoryrole/issuer-directory-viewerissuer-directory:read
Issuer Directoryrole/issuer-directory-operatorissuer-directory:read, issuer-directory:write
Issuer Directoryrole/issuer-directory-adminissuer-directory:read, issuer-directory:write, issuer-directory:admin
Task Packsrole/packs-viewerpacks.read
Task Packsrole/packs-operatorpacks.read, packs.run
Task Packsrole/packs-publisherpacks.read, packs.write
Task Packsrole/packs-approverpacks.read, packs.approve
Airgaprole/airgap-viewerairgap:status:read
Airgaprole/airgap-operatorairgap:status:read, airgap:import
Airgaprole/airgap-adminairgap:status:read, airgap:import, airgap:seal
Exceptionsrole/exceptions-viewerexceptions:read
Exceptionsrole/exceptions-approverexceptions:read, exceptions:approve
Exceptionsrole/exceptions-editorexceptions:read, exceptions:write
Attestorrole/attestor-viewerattest:read, aoc:verify
Attestorrole/attestor-operatorattest:read, attest:create, aoc:verify
Attestorrole/attestor-adminattest:read, attest:create, attest:admin, aoc:verify
Signerrole/signer-viewersigner:read, aoc:verify
Signerrole/signer-operatorsigner:read, signer:sign, aoc:verify
Signerrole/signer-adminsigner:read, signer:sign, signer:rotate, signer:admin, aoc:verify
SBOMrole/sbom-viewersbom:read, aoc:verify
SBOMrole/sbom-creatorsbom:read, sbom:write, aoc:verify
SBOMrole/sbom-attestorsbom:read, sbom:write, sbom:attest, attest:create, aoc:verify
Releaserole/release-viewerrelease:read, policy:read, findings:read
Releaserole/service-release-contributorui.read, ui.preferences.read, ui.preferences.write, release:read, release:write, orch:read, scheduler:read, scanner:read, scanner:scan, sbom:read, policy:read, findings:read, evidence:read, vuln:view, timeline:read, platform.context.read
Releaserole/release-developerLegacy alias only; migrate users to role/service-release-contributor.
Releaserole/release-managerrelease:read, release:write, policy:read, findings:read
Releaserole/release-publisherrelease:read, release:write, release:publish, policy:read, findings:read
Releaserole/release-adminrelease:read, release:write, release:publish, release:bypass, policy:read, findings:read
Zastavarole/zastava-viewerzastava:read
Zastavarole/zastava-operatorzastava:read, zastava:trigger
Zastavarole/zastava-adminzastava:read, zastava:trigger, zastava:admin

Scope availability (reconciled against StellaOpsScopes.cs):

Every scope referenced in the §4.3 catalog is defined as a const in src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs, so the catalog rows describe enforceable scopes — what remains aspirational is the seeding of most module role bundles (see the seeded-vs-proposed note at the top of §4.3), not the scopes themselves.

Confirmed present in the catalog:

Caveat (gateway enforcement vs. scope existence): a scope being defined in StellaOpsScopes.cs only means an issued OpTok may carry it (token issuance filters requested scopes against authority.clients.allowed_scopes; the authority.permissions RBAC catalog is not consulted at issuance — see the class remarks in StellaOpsScopes.cs). It does not by itself guarantee that every downstream service enforces the scope on its endpoints; verify per-service authorization policies against the owning module when that guarantee matters.

5. Console Admin API Surface

5.1 Tenants

Scopes: authority:tenants.read (read/list/operator-compliance read), authority:tenants.write (create/update/suspend/resume and all operator-compliance mutations).

Tenant boundary (section 3.4): create is global-admin only; every {tenantId} route refuses a tenant other than the caller’s own unless the caller is a global administrator.

Suspension has an additional invariant beyond scope and fresh-auth checks: the normalized target tenant must differ from the tenant resolved by TenantHeaderFilter for the authenticated request. Attempting to suspend the active context returns sealed HTTP 409 application/problem+json with code active_tenant_suspend_forbidden and performs no repository update, shared-tenant propagation, or success audit. An operator must switch to a different tenant context before suspending the former tenant.

5.2 Users

Scopes: authority:users.read (list), authority:users.write (create/update/disable/enable).

Only a global administrator sets or changes isGlobalAdmin on create or update (section 3.4).

POST /console/admin/users requires a non-blank password for local users. Missing or blank passwords return HTTP 400 with error=password_required and code=PASSWORD_REQUIRED so QA and clients can assert the exact contract. The created account is hashed with Argon2id and a authority.admin.users.password.set audit row is emitted alongside authority.admin.users.create. DELETE of the last enabled user in a tenant is rejected with error=last_admin_user (HTTP 400) to avoid lockout.

The user is created in the tenant of the request. A user of a tenant other than the Standard provider’s configured tenantId (default) signs in by naming that tenant (tenant on the password grant or the authorize request) through a client assigned to it; the same username may exist in several tenants, and the named tenant selects the account. See Authority § 3.2b.

5.3 Roles and scopes

Scopes: authority:roles.read|write

5.4 Clients

Scopes: authority:clients.read|write

Update and rotate apply the list’s tenant visibility filter to the caller’s own tenant; tenant assignments and requested scopes are bounded as described in section 3.4.

5.5 Tokens and audit

Scopes: authority:tokens.read (list), authority:tokens.revoke (revoke), authority.audit.read (audit list — dot-form scope, not authority:audit.read).

Tenant binding (PCD-876): all three act on the tenant in the caller’s stellaops:tenant claim. A global administrator may select another tenant with the X-StellaOps-TenantId header that TenantHeaderFilter validated, and otherwise gets its claim tenant. No query parameter selects a tenant: the former tenantId query accepted any tenant in the stellaops:allowed_tenants claim, which for a password-grant token is the client’s tenant list rather than the user’s, and was removed. A request that resolves no tenant returns HTTP 400 tenant_required; the revoke route no longer runs unscoped in that case.

Both token endpoints were stubs until 2026-07-21 (the list always returned [] + "implementation pending", the revoke echoed the request count without revoking — Fable audit #12). They now run against the real IAuthorityTokenStore (authority.oidc_tokens, the same store issuance persists into and TokenValidationHandlers consults):

Deprecation: GET /console/admin/audit carries a Sunset header (2027-10-19) and advertises a successor link to the unified Timeline audit store at /api/v1/audit/events?modules=authority (DeprecatedForTimeline(...) in ConsoleAdminEndpointExtensions). New integrations should read audit from Timeline rather than this endpoint.

5.6 Branding

Scopes: authority:branding.read (admin GET / preview), authority:branding.write (PUT). Source: ConsoleBrandingEndpointExtensions.

6. Audit and Observability

7. Offline-first Administration

NOT IMPLEMENTED / roadmap. This section describes a planned signed-bundle import workflow. As of this reconciliation there is no /admin/bundles/apply endpoint (or any /admin/bundles* route) in the Authority source — searching src/** returns no match. The Console admin surface (/console/admin/*, /api/admin/users) operates against a live Authority instance over bearer scopes; there is no offline change-manifest apply path in code yet. Keep the design below as the intended direction, not as shipped behavior.

8. UI Integration Contract

9. References