Web Gateway Tenant RBAC Contract

Contract ID: CONTRACT-GATEWAY-RBAC-001 Status: APPROVED (reconciled against implementation 2026-05-30) Effective Date: 2025-12-07 Owners: Gateway Guild, Authority Guild, Web UI Guild

Reconciliation note (2026-05-30): This contract was authored ahead of implementation and has been reconciled against the shipping gateway and Authority code. Sections that described a planned org/tenant/project role hierarchy and OAuth scope-inheritance model that were never built are now marked NOT IMPLEMENTED with the actual behaviour documented in their place. Ground-truth sources: src/Router/StellaOps.Gateway.WebService (gateway authz/identity middleware), src/Authority (Authority scopes/roles), and the canonical scope catalog src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs.

Overview

This contract defines the tenant isolation and role-based access control (RBAC) model for the StellaOps Web Gateway, ensuring consistent authorization across all API endpoints and UI components.

The shipping authorization model is flat, scope-based, and tenant-scoped:

Tenant Model

Tenant Hierarchy

NOT IMPLEMENTED (aspirational). The code has no Organization (Org) entity and no Project hierarchy enforced at the gateway. Tenants are a flat namespace. A single optional stellaops:project claim exists for in-tenant project scoping (StellaOpsClaimTypes.Project), but there is no Org layer above tenants. The diagram below is retained as a future/roadmap target only.

(roadmap target — not enforced)
Organization (Org)
├── Tenant A
│   ├── Project 1
│   │   └── Resources...
│   └── Project 2
│       └── Resources...
└── Tenant B
    └── Project 3
        └── Resources...

Tenant Identification

Tenants are resolved exclusively from validated JWT claims. Client-supplied tenant headers (X-StellaOps-TenantId, X-StellaOps-Tenant, X-Stella-Tenant, X-Tenant-Id) are stripped at gateway ingress to prevent spoofing and are not honoured as a tenancy source (IdentityHeaderPolicyMiddleware.ReservedHeaders, StellaOpsTenantResolver remarks: “this resolver reads the tenant identity exclusively from envelope-bound JWT claims”).

The tenant claim is read in this priority order (StellaOpsTenantResolver.TryResolveTenant):

  1. Canonical claim: stellaops:tenant
  2. Alias claim: tenant_id
  3. Alias claim: tenant
  4. Legacy claim: tid

If none is present, resolution fails with the machine-readable error tenant_missing.

Service-to-service / header note: After the gateway resolves identity it writes the downstream headers X-StellaOps-TenantId and X-Tenant-Id for backend consumption, but backends do not treat those headers as an authoritative tenancy source — they re-resolve from the signed identity envelope claims. There is no /tenants/{tenantId}/... path-parameter tenant resolution in the gateway; path-based tenant selection is NOT IMPLEMENTED.

Tenant Override (per-request tenant selection)

For principals whose token carries an allow-list of tenants (stellaops:allowed_tenants), the gateway can honour a client-requested tenant header override only when explicitly enabled (IdentityHeaderPolicyOptions.EnableTenantOverride, default false). The requested tenant must be present in the principal’s stellaops:allowed_tenants allow-list; otherwise the gateway returns 403 with body { "error": "tenant_override_forbidden", ... } (IdentityHeaderPolicyMiddleware.TryApplyTenantOverride).

Role Definitions

NOT IMPLEMENTED as written. The fixed role catalog and role hierarchy below (org:admin, tenant:admin, project:*, policy:admin, scanner:operator, airgap:admin, …) do not exist in the codebase. None of these role names appear in src/Authority. They are retained below only as a strikethrough record of the original design; the Actual role model subsection that follows describes shipping behaviour.

Actual role model (implemented)

Roles in StellaOps are arbitrary named, tenant-scoped entities persisted in Authority (RoleEntity, managed via IRoleRepository). A role is simply a named bundle of scope permissions assigned to it; there is no fixed tier hierarchy and no inheritance between roles.

Original (NOT IMPLEMENTED) built-in role catalog and hierarchy — retained for reference

Built-in Roles

RoleDescriptionScope
org:adminOrganization administratorOrg-wide
org:readerOrganization read-only accessOrg-wide
tenant:adminTenant administratorSingle tenant
tenant:operatorCan modify resources within tenantSingle tenant
tenant:viewerRead-only access to tenantSingle tenant
project:adminProject administratorSingle project
project:contributorCan modify project resourcesSingle project
project:viewerRead-only project accessSingle project
policy:adminPolicy managementTenant-wide
scanner:operatorScanner operationsTenant-wide
airgap:adminAir-gap operationsTenant-wide

Role Hierarchy (not enforced):

(NOT IMPLEMENTED — no role hierarchy exists in code)
org:admin
├── org:reader
├── tenant:admin
│   ├── tenant:operator
│   │   └── tenant:viewer
│   ├── policy:admin
│   ├── scanner:operator
│   └── airgap:admin
└── project:admin
    ├── project:contributor
    └── project:viewer

Scopes

OAuth 2.0 Scopes

The canonical scope catalog is src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs. Only scopes registered there can appear in an issued token’s scope claim (token issuance filters requested scopes against authority.clients.allowed_scopes). The table below lists the scopes most relevant to this contract, using their actual catalog names. Scopes are not tied to a “required role” in code — authorization is a flat scope match, so the former “Required Role” column has been removed.

Scope (catalog const)Description
policy:read (PolicyRead)Read policy metadata
policy:edit (PolicyEdit)Edit policy configurations
policy:write (PolicyWrite)Create/edit policy drafts
policy:activate (PolicyActivate)Activate policies
scanner:read (ScannerRead)View scan results and metadata
scanner:scan (ScannerScan)Trigger Scanner scan operations
scanner:export (ScannerExport)Export Scanner results (SBOM, reports)
airgap:seal (AirgapSeal)Seal/unseal an installation (air-gapped mode)
airgap:import (AirgapImport)Import offline bundles while sealed
airgap:status:read (AirgapStatusRead)Read air-gap status / sealing state
export.viewer (ExportViewer)Read export center runs and bundles
export.operator (ExportOperator)Operate export scheduling and run execution
export.admin (ExportAdmin)Administer export retention, keys, scheduling
authority:users.read (AuthorityUsersRead)Read Authority user management
authority:users.write (AuthorityUsersWrite)Write Authority user management
authority:roles.read / authority:roles.writeRead / write Authority role management
ui.admin (UiAdmin)Console Admin UI and workflows

Corrections vs. the original draft (NOT IN CATALOG): the original table listed scanner:execute, airgap:verify, export:read, export:create, admin:users, and admin:settings. None of those scope names exist in StellaOpsScopes.cs. Their nearest real equivalents are scanner:scan, (no airgap:verify — air-gap surface is airgap:seal / airgap:import / airgap:status:read), export.viewer / export.operator, and the authority:users.read / authority:users.write / ui.admin scopes respectively. policy:read, policy:edit, policy:activate, scanner:read, airgap:seal, and airgap:status:read are correct as written.

Scope Inheritance

NOT IMPLEMENTED. There is no scope-inheritance / parent-child scope mechanism in the codebase. The gateway performs an exact-match scope check per required scope (AuthorizationMiddleware.HasRequiredScope — a scope is granted only if the resolved scope set contains it verbatim or the space-separated scope/scp claim contains it verbatim). No scope_inheritance, HasInheritedScope, or implied-scope lookup exists. A token must carry every scope it needs; granting policy:edit does not implicitly grant policy:read. The YAML below is retained only as a record of the original (unimplemented) design.

# NOT IMPLEMENTED — no parent→child scope expansion exists in code.
scope_inheritance:
  "policy:edit": ["policy:read"]
  "policy:activate": ["policy:read", "policy:edit"]
  "scanner:execute": ["scanner:read"]   # note: scanner:execute is also not a real scope
  "export:create": ["export:read"]      # note: neither scope name exists
  "admin:users": ["admin:settings"]     # note: neither scope name exists

One related real mechanism (different from inheritance): the gateway does perform a small, hard-coded coarse→fine scope expansion for a handful of legacy scopes (IdentityHeaderPolicyMiddleware.ExpandCoarseScopes): scheduler:readscheduler.schedules.read + scheduler.runs.read; scheduler:operatescheduler.schedules.write + scheduler.runs.write + scheduler.runs.preview + scheduler.runs.manage; and orch:quotaquota.read + quota.admin. This is a fixed bridging map, not a generic read/write inheritance model.

Resource Authorization

Resource Types

Resource TypeTenant ScopedProject ScopedDescription
risk_profileYesNoRisk scoring profiles
policy_packYesNoPolicy bundles
scan_resultYesYesScan outputs
exportYesYesExport jobs
findingYesYesVulnerability findings
vex_documentYesYesVEX statements
sealed_modeYesNoAir-gap state
userYesNoTenant users
projectYesNoProjects

Authorization Rules

Illustrative, NOT a loaded artifact. There is no authorization-rules.yaml file consumed by the gateway. Per-endpoint claim requirements are supplied by each service’s endpoint metadata and resolved at request time by IEffectiveClaimsStore.GetEffectiveClaims(...); the gateway then matches them with an exact scope check. The YAML below illustrates intent only. Note the corrections: required_scopes: [scanner:execute] should read [scanner:scan] (and Scanner has no per-resource delete scope in the catalog), and require_role: is NOT enforced — the gateway never checks role names, only scopes. tenant_isolation and project_isolation keys below are descriptive: tenancy is enforced by each service via the resolved tenant claim, not by gateway-level rule keys.

# ILLUSTRATIVE ONLY — not loaded by the gateway; require_role is not enforced.
rules:
  - resource: risk_profile
    actions:
      read:
        required_scopes: [policy:read]
        tenant_isolation: strict
      create:
        required_scopes: [policy:edit]
        tenant_isolation: strict
      update:
        required_scopes: [policy:edit]
        tenant_isolation: strict
      activate:
        required_scopes: [policy:activate]
        tenant_isolation: strict
      delete:
        required_scopes: [policy:edit]
        tenant_isolation: strict
        # require_role: policy:admin   # NOT ENFORCED — no role check in gateway

  - resource: scan_result
    actions:
      read:
        required_scopes: [scanner:read]
        tenant_isolation: strict
        project_isolation: optional
      create:
        required_scopes: [scanner:scan]   # corrected from non-existent scanner:execute
        tenant_isolation: strict
      delete:
        required_scopes: [scanner:scan]   # corrected; no dedicated scanner delete scope exists
        tenant_isolation: strict
        # require_role: scanner:operator  # NOT ENFORCED — no role check in gateway

  - resource: sealed_mode
    actions:
      read:
        required_scopes: [airgap:status:read]
        tenant_isolation: strict
      seal:
        required_scopes: [airgap:seal]
        tenant_isolation: strict
        # require_role: airgap:admin     # NOT ENFORCED — no role check in gateway
        audit: required
      unseal:
        required_scopes: [airgap:seal]   # seal scope also gates unseal (no separate scope)
        tenant_isolation: strict
        # require_role: airgap:admin     # NOT ENFORCED — no role check in gateway
        audit: required

Tenant Isolation

Strict Isolation

All data access is tenant-scoped by default:

-- Example: All queries include tenant filter
SELECT * FROM findings
WHERE tenant_id = @current_tenant_id
  AND deleted_at IS NULL;

Cross-Tenant Access

A token is bound to the single tenant in its stellaops:tenant claim. The only cross-tenant mechanism that exists in code is the stellaops:allowed_tenants allow-list combined with the gateway tenant-override feature:

NOT IMPLEMENTED: there is no Organization-admin “access all tenants in their org” capability, no cross_tenant scope, and no org:reader aggregation role — none of these exist in the codebase. Cross-tenant reach is governed solely by the stellaops:allowed_tenants allow-list above.

Isolation Enforcement Points

LayerEnforcement
GatewayResolves tenant from validated JWT claim; strips client tenant headers and re-issues X-StellaOps-TenantId / X-Tenant-Id + signed identity envelope (IdentityHeaderPolicyMiddleware)
ServiceRe-resolves tenant from envelope claims (StellaOpsTenantResolver) and applies a tenant filter to queries; RequireTenant() endpoint filters reject requests with no resolved tenant
DatabaseRow-level security (RLS) policies on schemas that opt in (e.g. Authority initial schema, Attestor trust verdicts); not universally applied across all module schemas
CacheTenant-prefixed cache keys (per-service convention)

JWT Claims

Representative Claims

Claim names below are grounded in StellaOpsClaimTypes. Roles are emitted as standard ASP.NET role claims (ClaimTypes.Role), not under a stellaops:roles claim. The tenant is normally a slug/identifier (e.g. default), not necessarily a UUID.

{
  "sub": "user-uuid",
  "aud": ["stellaops-api"],
  "iss": "https://stella-ops.local",
  "exp": 1701936000,
  "iat": 1701932400,
  "stellaops:tenant": "default",
  "stellaops:allowed_tenants": "default tenant-golden-local",
  "stellaops:project": "*",
  "role": ["admin"],
  "scope": "policy:read policy:edit scanner:read"
}

Custom Claims

Claim (StellaOpsClaimTypes)TypeDescription
stellaops:tenant (Tenant)stringCurrent tenant identifier (canonical tenancy claim)
stellaops:allowed_tenants (AllowedTenants)stringSpace/comma/semicolon-delimited allow-list of selectable tenants (enables tenant override)
stellaops:project (Project)stringOptional in-tenant project scope (* = any project, StellaOpsTenancyDefaults.AnyProject)
scope (Scope) / scp (ScopeItem)string / string[]Space-separated scope list / individual scope items
client_id (ClientId)stringOAuth2 client identifier
stellaops:idp (IdentityProvider)stringIdentity provider hint for downstream services

NOT IMPLEMENTED claims: the original draft listed stellaops:org, stellaops:roles, stellaops:projects, and stellaops:tier. None of these claim types exist in StellaOpsClaimTypes. There is no organization claim; roles are carried via the standard role claim; project scoping is a single stellaops:project value (not an array); and there is no stellaops:tier rate-limit-tier claim. (For other genuine claim types — operator-reason/ticket, policy publish/promote attestation claims, vuln ABAC filters stellaops:attr:env|owner|business_tier, sid, jti, cnf — see StellaOpsClaimTypes.)

Gateway Implementation

The snippets below reflect the actual middleware classes shipped in src/Router/StellaOps.Gateway.WebService. (There is no TenantAuthorizationMiddleware or ScopeAuthorization.RequireScope / HasInheritedScope — those names in the original draft were placeholders.)

Identity + Tenant Resolution (IdentityHeaderPolicyMiddleware)

IdentityHeaderPolicyMiddleware strips spoofable identity headers, extracts the validated principal, resolves the tenant from claims, optionally applies a tenant override against the stellaops:allowed_tenants allow-list, then writes downstream identity headers and a signed identity envelope:

// IdentityHeaderPolicyMiddleware.InvokeAsync (abridged)
StripReservedHeaders(context, ShouldPreserveAuthHeaders(context.Request.Path));
var identity = ExtractIdentity(context);   // tenant from stellaops:tenant / tenant_id / tenant / tid

if (!identity.IsAnonymous && requestedTenant != identity.Tenant &&
    !TryApplyTenantOverride(context, identity, requestedTenant))   // checks stellaops:allowed_tenants
{
    await context.Response.WriteAsJsonAsync(new { error = "tenant_override_forbidden", ... });
    return;
}

StoreIdentityContext(context, identity);   // Gateway.TenantId, Gateway.Scopes, ...
WriteDownstreamHeaders(context, identity);  // X-StellaOps-TenantId, X-Tenant-Id, X-StellaOps-Scopes, envelope

Scope Authorization (AuthorizationMiddleware)

AuthorizationMiddleware resolves per-endpoint claim requirements (IEffectiveClaimsStore) and enforces them with an exact scope match — no inheritance:

// AuthorizationMiddleware.HasRequiredScope (abridged) — exact match only
private static bool HasRequiredScope(IEnumerable<Claim> userClaims,
    ISet<string>? resolvedScopes, string requiredScope)
{
    if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope))
        return true;

    // scope/scp claim may be space-separated (RFC 6749 §3.3) or individual claims
    return userClaims.Any(c =>
        (c.Type is "scope" or "scp") &&
        (c.Value == requiredScope ||
         c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
                .Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal))));
}

On failure the gateway returns 403 (or 401 when unauthenticated) with the AuthorizationFailureResponse body described under Error Responses below. Note the asymmetric-scope guard: a state-changing method (POST/PUT/PATCH/DELETE) that declares no claim requirements of its own inherits the union of its sibling read-method scope requirements so an unauthorized write returns 403 rather than reaching body validation (AuthorizationMiddleware.InvokeAsync).

Web UI Integration

The Angular console gates UI access on scopes, not role names (src/Web/StellaOps.Web/src/app/core/auth/). AuthService exposes hasScope(scope), hasAnyScope(scopes), and hasAllScopes(scopes) over the session’s current scope set (AuthService.scopes()), with the special admin scope treated as a superuser short-circuit (hasScope returns true when the user holds admin; see scopes.ts). There is no hasAnyRole / role-based guard — the original draft’s role-keyed guard does not exist.

Route Guards (scope-based)

// Real pattern: guards/UI gates use scope checks (AuthService.hasScope / hasAnyScope).
// e.g. AuthService convenience getters:
//   canOperateOrchestrator() => hasAdminPrivilege() || hasScope(ORCH_OPERATE)
//   canEditPolicy()          => hasScope(POLICY_EDIT)
export const ScopeGuard: CanActivateFn = (route) => {
  const auth = inject(AuthService);
  const requiredScopes = route.data['scopes'] as StellaOpsScope[];

  if (!auth.hasAnyScope(requiredScopes)) {
    return inject(Router).createUrlTree(['/unauthorized']);
  }
  return true;
};

// Usage in routes (scope-keyed, NOT role-keyed)
{
  path: 'policy/studio',
  component: PolicyStudioComponent,
  canActivate: [ScopeGuard],
  data: { scopes: ['policy:author', 'policy:edit'] }
}

Scope-Based UI Elements

The console hides/show elements based on AuthService.hasScope(...). (The exact structural-directive name is illustrative; the load-bearing call is the scope check.)

// Illustrative directive — gates on a real scope via AuthService.hasScope
@Directive({ selector: '[requireScope]' })
export class RequireScopeDirective {
  @Input() set requireScope(scope: StellaOpsScope) {
    this.viewContainer.clear();
    if (this.auth.hasScope(scope)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    }
  }
}

// Usage in templates
<button *requireScope="'policy:activate'">Activate Policy</button>

Audit Trail

Illustrative shape. The JSON below is an example audit envelope, not a verbatim schema emitted by a single component. roles here would be the principal’s role-claim values (e.g. ["admin"]), not the nonexistent policy:admin role.

Audited Operations

Write operations are logged with an envelope along these lines:

{
  "timestamp": "2025-12-07T10:30:00Z",
  "actor": {
    "userId": "user-uuid",
    "tenantId": "default",
    "roles": ["admin"],
    "ipAddress": "192.168.1.100"
  },
  "action": "policy.activate",
  "resource": {
    "type": "policy_pack",
    "id": "pack-123",
    "version": 5
  },
  "outcome": "success",
  "details": {
    "previousStatus": "approved",
    "newStatus": "active"
  }
}

Sensitive Operations

These operations require enhanced audit logging:

Configuration

Authority role / scope configuration (actual)

NOT IMPLEMENTED: there is no gateway/rbac.yamlfile and no roleBindings / defaultRole / allowCrossTenantForOrgAdmin config in the gateway. Roles and their scope grants are configured in Authority, not the gateway. The original RBAC-yaml block (with tenant:admin / policy:admin roles and the scanner:execute / export:read / export:create / admin:users / admin:settings scopes) referenced entities that do not exist.

Role/scope binding is defined per-client/bootstrap in etc/authority/plugins/standard.yaml. For example, the default admin bootstrap declares roles: ["admin"], and clients declare an allowedScopes list that caps which catalog scopes the issued token may carry. A representative slice (real scope names):

# etc/authority/plugins/standard.yaml (excerpt — real shape)
tenantId: "default"
defaultAdminBootstrap:
  username: "admin"
  passwordEnvironmentVariable: "STELLAOPS_ADMIN_PASS"
  roles:
    - "admin"            # the only auto-seeded role; granted StellaOpsScopes.All

clients:
  - clientId: "..."
    tenants: "default tenant-golden-local"
    allowedScopes: >-
      ui.read ui.admin authority:users.read authority:users.write
      authority:roles.read authority:roles.write policy:read policy:edit
      scanner:read scanner:write export.viewer export.operator export.admin
      airgap:status:read ...

Authorization is then enforced at the gateway purely on the scopes present in the issued token (no role binding is consulted at request time; authority.clients.allowed_scopes is the issuance-time filter — see StellaOpsScopes.cs remarks).

Error Responses

The gateway authz failure body is AuthorizationFailureResponse (camelCase), not an RFC 7807 problems/* document. The fields are error, message, requiredClaimType, requiredClaimValue, service, version (AuthorizationMiddleware.WriteUnauthorizedAsync / WriteForbiddenAsync). The original draft’s type/title/detail/requiredScope/currentScopes shape was not implemented at the gateway. (Downstream service bodies — e.g. RouteDispatchMiddleware / AspNetRouterRequestDispatcher error mapping — may emit RFC 7807 problem documents for 404/500, but the gateway authz responses use the shape below.)

401 Unauthorized (gateway authz)

{
  "error": "unauthorized",
  "message": "Authentication required",
  "requiredClaimType": "",
  "requiredClaimValue": null,
  "service": "<service-name>",
  "version": "<endpoint-version>"
}

403 Forbidden (gateway authz)

{
  "error": "forbidden",
  "message": "Authorization failed: missing required claim",
  "requiredClaimType": "scope",
  "requiredClaimValue": "policy:activate",
  "service": "<service-name>",
  "version": "<endpoint-version>"
}

For a rejected per-request tenant override the gateway returns 403 with:

{
  "error": "tenant_override_forbidden",
  "message": "Requested tenant override is not permitted for this principal."
}

404 Not Found

The dispatcher returns 404 when no matching backend endpoint is found (AspNetRouterRequestDispatcher.CreateNotFoundResponse).

Caveat (not verified in code): the original claim that “404 is returned instead of 403 for resources in other tenants to prevent enumeration attacks” could not be confirmed as a gateway-level behaviour — the gateway returns 403 on a failed scope/claim check, and tenant isolation is applied per-service at query time. Any 404-for-cross-tenant masking would be a per-service convention, not a gateway guarantee. Treat this as unverified / aspirational until a service implements it explicitly.

Changelog

DateVersionChange
2025-12-071.0.0Initial contract definition
2026-05-301.1.0Reconciled against shipping implementation. Marked the org/tenant/project role hierarchy, scope inheritance, require_role rules, gateway/rbac.yaml, and stellaops:org/stellaops:roles/stellaops:projects/stellaops:tier claims as NOT IMPLEMENTED. Corrected scope names (scanner:executescanner:scan, export:read/createexport.viewer/operator, admin:users/settingsauthority:users.*/ui.admin). Documented the real flat scope-based authz (AuthorizationMiddleware), claim-only tenant resolution + stellaops:allowed_tenants override (IdentityHeaderPolicyMiddleware/StellaOpsTenantResolver), scope-based UI guards, and the actual AuthorizationFailureResponse error shape.

References