ADR-012: Per-tenant system-account client_credentials clients auto-seeded during tenant onboarding (Pattern C)
Status: Accepted Date: 2026-05-29 Sprint: SPRINT_20260529_053_Authority_per_tenant_client_autoseed_onboarding.md (S053-001) Related ADRs: ADR-005 (single-operator multi-environment tenancy), ADR-006 (execution plugin framework), ADR-007 (KEK lifecycle and rotation — same Vault substrate), ADR-010 (shared tenant resolver migration) Related sprints: Sprint 028 RP-028-008 (HttpVaultKvReader — wire-level Vault HTTP client). Sprint 031 (archived; tenant-onboarding ITenantClientGrantService + bootstrap merge of properties.tenants). Sprint 048 S048-002 (commit c1bef51ab5; YAML + idempotent SQL backstop seed-template). Sprint 051 (Pattern B; lab/dev multi-tenant client — coexists with Pattern C during prod rollout). Related code: src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs:315-431 (CreateTenant handler — the Pattern C insertion point), src/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantClientGrantService.cs (Sprint 031 auto-grant primitive — Pattern C reuses the call-site, not the merge semantics), src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs:1841-1862 (the singular-tenant binding handler — Pattern C clients USE this binding deliberately), src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs:100-211 (ApplyRegistration — Pattern C clients are NOT in standard.yaml; the bootstrap reseed never touches them), src/__Libraries/StellaOps.Cryptography.CredentialStore.Vault/HttpVaultKvReader.cs (existing read surface; Pattern C extends with a write surface), src/Scanner/StellaOps.Scanner.Worker/Processing/ConcelierLearnTokenHandler.cs (per-tenant token cache shape Pattern C generalises).
In one line: at prod scale, give every Stella Ops tenant its own auto-seeded Authority
client_credentialsidentity for system-account services (scanner-worker first), with the secret in HashiCorp Vault — so a leaked secret can never mint tokens for any other tenant.Audience: Authority and platform engineers implementing tenant onboarding, and operators reasoning about per-tenant blast-radius containment. Builds on ADR-005 (tenancy) and ADR-007 (the Vault substrate).
Context
Stella Ops is moving from a few hand-onboarded tenants (lab + lab-like dev) to prod-scale tenant onboarding where each new customer tenant is created via an admin endpoint (Sprint 031’s POST /console/admin/tenants). The scanner-worker (and other “system-account” services that fan-out per-tenant work — Concelier learn, integrations resolver, attestor, etc.) needs an Authority client_credentials identity per tenant in order to mint tenant-scoped tokens that resource servers gate on (stellaops:tenant claim).
Sprint 046 + 019 surfaced the friction: the single seeded stellaops-cli-automation client inherited tenantId: "default" from etc/authority/plugins/standard.yaml:4 and the ClientCredentialsHandlers.cs:1855 guard rejected any other selectedTenant. Three patterns were considered post-H4 architectural analysis (2026-05-29):
- Pattern A — per-tenant manually seeded clients. One client per tenant, each seeded by hand into
standard.yaml+ a per-tenant Authority migration. Rejected as a scaling smell: every new tenant becomes a YAML edit + migration + restart cycle for the operator, with no automation surface and an unbounded seed-config file. - Pattern B — multi-tenant single client with
properties.tenantsallowlist + request-time selection. Reuses existingallowedTenantsinfrastructure atClientCredentialsHandlers.cs:378+1673. Cheap to land (one YAML edit + one idempotent SQL backstop), scales operationally (extending the allowlist is a config change), but single secret = blast radius across all allowed tenants. Acceptable for lab/dev; inappropriate for prod where a leaked secret would mean attacker can act as every allowed tenant. - Pattern C (this ADR) — per-tenant
stellaops-scanner-worker-<tenant>clients auto-seeded during tenant onboarding, secret in Vault. Per-tenant secret means per-tenant blast-radius containment. Auto-seed during onboarding means zero manual operator step. Singulardescriptor.Tenantbinding (deliberately) means the strongest possible Authority-layer tenant gate (theClientCredentialsHandlers.cs:1855guard now becomes a feature, not a bug — a leaked per-tenant secret CANNOT mint tokens for another tenant even withselectedTenantrequest manipulation).
Operator decision 2026-05-29 (post-H4): “chain 2 → go B+C”. Pattern B (Sprint 051) ships immediately as the tactical Sprint 046/019 unblock for lab/e2e-lab. Pattern C (this ADR) is the prod-scale long-term path that retires the multi-tenant blast-radius concern via per-tenant containment.
This ADR is the architectural design for Pattern C. The execution roadmap (S053-002) and per-phase execution sprints follow when prod-scale tenant onboarding becomes a real driver (currently lab/e2e-only; Pattern B suffices).
Existing infrastructure Pattern C builds on
| Surface | File | Role for Pattern C |
|---|---|---|
| Tenant-onboarding endpoint | ConsoleAdminEndpointExtensions.cs:315-431 | Insertion point: extend the CreateTenant handler to atomically issue + Vault-store per-tenant scanner-worker client alongside tenantRepository.CreateAsync + PropagateSharedTenantAsync + tenantClientGrantService.GrantAsync. |
| Tenant-client grant primitive | Tenants/TenantClientGrantService.cs (Sprint 031) | Reuse the call-site pattern (post-propagation, non-fatal-on-failure, audit-property capture). Pattern C does NOT extend the Sprint 031 primitive — it adds a sibling primitive IPerTenantSystemAccountProvisioner because the semantics are different (issue a new client, not merge into an existing one). |
| Singular tenant binding | ClientCredentialsHandlers.cs:1841-1862 | Pattern C clients SET descriptor.Tenant = <tenant> deliberately. The guard at line 1855 becomes the per-tenant containment fence: any token request with a selectedTenant other than the descriptor’s tenant is rejected with invalid_client. Pattern B suppresses this binding; Pattern C requires it. |
| Vault KV read surface | HttpVaultKvReader.cs (Sprint 028 RP-028-008) | Reused verbatim by scanner-worker secret resolution. The reader handles KV v2, three auth modes (token / approle / kubernetes), 80% lease re-auth, and VaultPermanent vs VaultUnavailable exception split. |
| Vault KV write surface | (does not exist yet) | New: IVaultKvWriter interface + HttpVaultKvWriter implementation in the same library (StellaOps.Cryptography.CredentialStore.Vault). Mirrors the reader’s wire-level approach (no VaultSharp dependency) — single endpoint POST /v1/<mount>/data/<path>. Required by Authority for secret-write on onboarding + rotation; required by tenant-decommission for secret-delete. |
| Token-cache-per-tenant shape | ConcelierLearnTokenHandler.cs (Sprint 022 002) | Generalise ConcurrentDictionary<string,CachedToken> keyed by tenant. Pattern C extends to (tenant, audience) key + adds Vault-resolved secret lookup before RequestClientCredentialsTokenAsync so each per-tenant client’s secret enters the token request from Vault, not from process-resident config. |
IAuthorityClientStore | StellaOps.Authority.Persistence/Stores/IAuthorityClientStore.cs | Reused for the actual client document write. Pattern C adds a deterministic clientId = stellaops-scanner-worker-<tenant> + per-tenant secret hash (via AuthoritySecretHasher.ComputeHash); the secret plaintext goes to Vault, never to authority.clients.client_secret. |
Why Pattern B and Pattern C must coexist (not replace each other)
Pattern B retires per-environment as Pattern C lands in that environment. Lab + dev keep Pattern B during the prod-scale rollout because (a) Pattern B has zero per-tenant operational cost and lab tenants come and go too frequently to amortise per-tenant Vault writes, and (b) the lab’s blast-radius tolerance is fundamentally higher than prod’s. The migration from Pattern B to Pattern C in a given environment is a single operator step (Phase 5 below): remove the multi-tenant stellaops-cli-automation client’s tenants plural, run onboarding for each existing tenant. No data migration; no token-cache flush in the consuming services (they observe both client_ids during the cutover).
Decision
Adopt Pattern C. Extend the tenant-onboarding workflow (Sprint 031’s CreateTenant) to atomically issue a per-tenant stellaops-scanner-worker-<tenant> client_credentials client with a Vault-stored per-tenant secret. The scanner-worker (and future system-account services) resolves the per-tenant secret from Vault at token-acquire time, with a per-(tenant, audience) token cache and fail-closed semantics when Vault is unreachable for the tenant.
Per-tenant client shape (target)
clientId = "stellaops-scanner-worker-<tenant-slug>"
displayName = "Stella Ops Scanner Worker — <tenant-slug>"
confidential = true
clientSecret = <generated at onboarding; 32-byte CSRNG → base64url, ≥256 bits entropy>
secretHash = AuthoritySecretHasher.ComputeHash(clientSecret) // persisted to authority.clients
allowedGrantTypes = ["client_credentials"]
allowedAudiences = ["api://integrations", "stellaops"] // matches Sprint 048 S025
allowedScopes = [<scanner-worker subset; see "Scope set" below>]
tenant = "<tenant-slug>" // singular, deliberate (Pattern C contract)
properties.tenants = "<tenant-slug>" // singular, mirrors tenant
properties.system_account = "scanner-worker" // tag for inventory + rotation policy lookup
properties.provisioned_by = "tenant-onboarding-v1" // provenance for migrate-from-Pattern-B detection
properties.vault_secret_path = "stellaops/tenants/<tenant-slug>/system-accounts/scanner-worker"
tenant-slug constraint. Pattern C reuses the tenant-onboarding regex (ConsoleAdminEndpointExtensions.cs:332 — lowercase letters, digits, hyphens only). Slugs are bounded ≤ 63 chars (matches shared.tenants.slug); the derived clientId is therefore bounded ≤ len("stellaops-scanner-worker-") + 63 = 88 chars, well under any authority.clients.client_id column limit.
Scope set. Minimum scanner-worker subset (intentionally narrower than stellaops-cli-automation’s full operator scope set):
aoc:verify advisory:ingest advisory:read concelier.jobs.trigger concelier.merge
graph:read integration:read integration:write integration:operate
registry.admin scanner:read scanner:scan scanner:write scanner:export
sbom:read vex:read vexhub:read findings:read
Operator extensibility: the scope set is captured in a single config key (Authority:PerTenantSystemAccounts:ScannerWorker:AllowedScopes — see “Config shape” below) so future scanner-worker scope additions (e.g., a new learn:operate scope) propagate to every tenant on the next Authority restart without per-tenant migration.
Vault path scheme (canonical, on-prem-first)
mount = secret # KV v2 default mount; operator-overridable
path = stellaops/tenants/<tenant-slug>/system-accounts/<account-name> # account-name = "scanner-worker" today; "attestor", "advisory-ai", … future
field = client_secret # opaque base64url string
Why this scheme:
- Tenant-scoped path prefix (
stellaops/tenants/<tenant>/) matches the ADR-005 single-operator-multi-environment model — operator can write a Vault policy that allow-lists onlystellaops/tenants/<tenant>/*for the scanner-worker process bound to that tenant (per-process scoping when scanner-worker is sharded per-tenant; aggregate scoping when one scanner-worker fans out across tenants — the per-process policy then allow-listsstellaops/tenants/+/system-accounts/scanner-worker, the Vault glob). - Account-name segment (
system-accounts/<account>) generalises to future per-tenant system accounts without a path-scheme migration. Pattern C is named for scanner-worker because that’s the driver, but the design covers every “fan-out per tenant” service. - Separation from KEK path (
stellaops/kek). ADR-007 ownssecret/data/stellaops/kek. Pattern C lives undersecret/data/stellaops/tenants/<tenant>/…. Different Vault policies; different operator approval flows; same Vault server.
Operator-overridable. Like the KEK source (ADR-007 → Crypto:Kek:Vault:Path), Pattern C’s path template is configurable (Authority:PerTenantSystemAccounts:Vault:PathTemplate = "stellaops/tenants/{tenant}/system-accounts/{account}") for operators with existing path conventions. The template uses named placeholders, not positional, so the order can’t drift.
Vault write surface (new — IVaultKvWriter / HttpVaultKvWriter)
Add to StellaOps.Cryptography.CredentialStore.Vault. Wire-level KV v2 write (POST /v1/<mount>/data/<path>), same auth-mode + lease + exception split as HttpVaultKvReader. Returns the Vault version number on success (for audit + idempotency: if a write fails mid-rotation, the operator can verify whether the new version landed).
public interface IVaultKvWriter
{
Task<int> WriteFieldAsync(string mount, string path, string field, string value, CancellationToken ct);
Task DeleteAsync(string mount, string path, CancellationToken ct); // KV v2 metadata destroy (full removal, not soft-delete)
}
Two consumers in the Pattern C design:
- Authority (during onboarding/rotation): writes the freshly generated secret atomically inside the onboarding transaction.
- Authority (during tenant-decommission): hard-deletes the Vault entry via KV v2 metadata destroy so the secret cannot be recovered from Vault version history.
Scanner-worker has read-only Vault access (the existing IVaultKvReader). The write surface lives in Authority’s network footprint exclusively.
Atomic onboarding (rollback semantics)
Extend CreateTenant (ConsoleAdminEndpointExtensions.cs:315-431) with a new step between PropagateSharedTenantAsync (line 380) and the existing tenantClientGrantService.GrantAsync (line 395):
[existing] tenantRepository.CreateAsync // step 1: authority.tenants row
[existing] PropagateSharedTenantAsync // step 2: shared.tenants upsert
[NEW] perTenantSystemAccountProvisioner.ProvisionAsync // step 3: per-tenant client + Vault secret (atomic)
[existing] tenantClientGrantService.GrantAsync // step 4: extend stellaops-cli + others (Sprint 031)
[existing] WriteAdminAuditAsync // step 5: audit
IPerTenantSystemAccountProvisioner.ProvisionAsync(tenantSlug, ct) is the atomic unit. It performs:
- Generate secret in-process (32 bytes CSRNG → base64url).
- Vault write (
IVaultKvWriter.WriteFieldAsync) — secret lands first; this is the “cannot fully undo” step from Authority’s perspective. If it fails: throw; nothing downstream has happened; tenant create proceeds with a non-fatal warning audit (see “Failure modes” below). - Authority client write (
IAuthorityClientStore.UpsertAsync) withsecretHashcomputed viaAuthoritySecretHasher.ComputeHash. If it fails: Vault entry is orphan — best-effortIVaultKvWriter.DeleteAsyncrollback in the catch handler; if THAT fails too, log atenant.system_account.orphan_vault_secretevent with the path so the operator can clean up. The orphan is harmless (no client references it) but operator-visible. - Audit-property capture — record
system_account.provisioned,system_account.vault_path,system_account.client_id,system_account.outcomeon the tenant-create audit event. NO secret material in any audit field.
Why Vault write FIRST, Authority client write SECOND. The reverse ordering would leave a client with a hash that no Vault entry can satisfy → scanner-worker token-acquire fails fail-closed → noisy but recoverable. The chosen ordering leaves at worst an orphan Vault entry (no client references it; no client can use it) → silent and equally recoverable. The chosen ordering is the cleaner default because the Authority secret_hash is the harder-to-rotate side: rewriting Vault is a single API call; rewriting authority.clients.secret_hash requires a new client-update endpoint OR a forward-only migration. Keeping the harder side last shrinks the abort window.
Non-fatal on provisioning failure. Mirroring PropagateSharedTenantAsync’s posture (Sprint 031’s “tenant exists; propagation is the post-fix”): if ProvisionAsync throws, the tenant-create still returns 201 Created with a Warning response header Warning: 199 - "scanner-worker system account provisioning failed; run \stella system provision-account. The tenant is operational for everything that doesn't need scanner-worker; the operator gets a clear remediation path. This is the same posture Sprint 031 chose forITenantClientGrantService(ConsoleAdminEndpointExtensions.cs:395-403` comment).
Scanner-worker per-tenant secret resolution
Generalise ConcelierLearnTokenHandler.cs into a reusable PerTenantSystemAccountTokenHandler in StellaOps.Auth.Client. Key shape change vs. Sprint 022’s handler:
// Today: ConcurrentDictionary<string, CachedToken> keyed by tenant
// New: ConcurrentDictionary<(string Tenant, string Audience), CachedToken>
SendAsync resolves the per-tenant clientId (stellaops-scanner-worker-<tenant>) + Vault-resolved secret (IVaultKvReader.ReadFieldAsync("secret", $"stellaops/tenants/{tenant}/system-accounts/scanner-worker", "client_secret", ct)), then calls RequestClientCredentialsTokenAsync with the resolved secret. Per-(tenant, audience) cache. Tokens are cached for 0.8 × expires_in (matching the existing 30-second RefreshSkew).
Fail-closed on Vault unreachable for a tenant. If IVaultKvReader.ReadFieldAsync throws VaultUnavailableException, the handler does NOT fall back to any other secret source — it returns 503 to the caller (or fails the per-tenant scan job, depending on the dispatch path) with failure_mode = "vault_unreachable". If it throws VaultPermanentException (404, 403, malformed payload), it returns 503 with failure_mode = "vault_permanent". Both failure modes are operator-visible per-tenant; one tenant’s Vault outage does NOT degrade the others (the cache + per-tenant resolution scope keeps blast radius bounded).
Secret-rotation path
Two cadences:
Scheduled rotation (90-day default; operator-configurable per-tenant via
Authority:PerTenantSystemAccounts:ScannerWorker:RotationCadenceDays). A new admin endpointPOST /console/admin/tenants/{tenantId}/system-accounts/{account}/rotate(or the equivalentstella system rotate-account <tenant> <account>CLI command):- Generate new secret in-process.
- Vault write (new version; KV v2 keeps the prior version for grace-window read-back if needed).
- Authority client update (replace
secretHash). - Audit event
authority.system_account.rotatedwithtenant,account,kek_version_n_minus_1for forensic trail. - Scanner-worker token cache invalidation is not required — the cache holds tokens, not secrets; the next cache miss (≤ token TTL) re-reads Vault. Operators who want zero-grace-window rotation can call the future
IPerTenantSystemAccountTokenHandler.InvalidateAsync(tenant)API (deferred to a follow-up sprint; not required for the 90-day cadence).
Emergency rotation (suspected leak). Same endpoint with
?reason=leakflag → audit captures the operator-supplied reason; otherwise identical to scheduled rotation. The fact that the token cache lives in scanner-worker process memory means the worst-case “stolen secret” exposure window is(Vault write time) + (worker token TTL ≤ 1h). Operators who need sub-TTL invalidation use the future cache-invalidation API.
No double-write window. Authority does not support two valid secrets per client simultaneously (the schema has a single secret_hash column). Rotation is therefore a replace, not an add-then-remove. Acceptable because: (a) scanner-worker re-reads Vault on cache miss (so the new secret propagates ≤ token TTL after rotation), and (b) the only consumer is the per-tenant scanner-worker process, with no external integrations holding the secret.
Tenant-decommission path
There is NO DELETE /tenants/{id} endpoint today (verified at ConsoleAdminEndpointExtensions.cs:104 — only the operator-compliance-approver subresource has MapDelete). When the future tenant-delete endpoint lands, it MUST invoke (in this order):
IPerTenantSystemAccountProvisioner.DecommissionAsync(tenantSlug, ct):IAuthorityClientStore.DeleteAsync(clientId)— removes the client row + secret hash from Authority.IVaultKvWriter.DeleteAsync(mount, path, ct)— KV v2 metadata destroy (full removal, not soft-delete; the leaked-secret hypothesis means version history must not be recoverable).PerTenantSystemAccountTokenHandler.InvalidateAsync(tenant)on the broadcast — any in-flight scanner-worker token cache entry for the decommissioned tenant is dropped.
tenantClientGrantService.RevokeAsync(tenantSlug)— Sprint 031 primitive; removes the tenant fromstellaops-cli+ other auto-grant clients.sharedTenantsPropagator.RemoveAsync(TBD when the propagator gains a remove surface).tenantRepository.DeleteAsync.- Audit
authority.admin.tenants.deletewithsystem_account.decommissioned,system_account.vault_deleted,system_account.client_deletedproperties.
Until that endpoint exists, a tenant suspended via the existing suspend endpoint MUST preserve its system-account client + Vault entry (resume must restore exactly the same posture; matching Sprint 031’s grant-survives-restart property).
Config shape
New configuration section:
Authority:
PerTenantSystemAccounts:
Enabled: true # master switch; default true; false = behaviour-preserving (no provisioning, no error)
Vault:
Address: "https://vault.internal:8200" # reuses ADR-007's Vault server by default; per-section override allowed
Mount: "secret"
PathTemplate: "stellaops/tenants/{tenant}/system-accounts/{account}"
Field: "client_secret"
Auth: # mirrors VaultAuthOptions; same modes (token / approle / kubernetes)
Mode: "approle"
RoleId: "<approle role id for Authority writes>"
SecretId: "<approle secret id for Authority writes>"
WriteRetryWindow: "00:01:00" # transient Vault unavail → retry up to 1 minute before failing onboarding step
Accounts:
ScannerWorker:
ClientIdPrefix: "stellaops-scanner-worker-"
AllowedAudiences: ["api://integrations", "stellaops"]
AllowedScopes: [<see "Scope set" above>]
RotationCadenceDays: 90
SecretEntropyBits: 256 # CSRNG → 32 bytes → base64url
# future: Attestor, AdvisoryAI, etc. — same shape, different ClientIdPrefix + scope set
The Accounts.<Account> shape is open: adding a per-tenant Attestor client to the design is a config-only change (Phase 6 — out of scope for this ADR). The scanner-worker shape is the only one this ADR mandates.
Failure modes (summary table)
| Mode | What happens | Tenant-create outcome | Operator surface |
|---|---|---|---|
| Vault unreachable at onboarding | IVaultKvWriter.WriteFieldAsync throws VaultUnavailableException after WriteRetryWindow | 201 Created + Warning: 199 header | stella system provision-account <tenant> remediation; authority.system_account.provision_failed audit |
| Vault permanent error (403/404/payload) at onboarding | Throws VaultPermanentException | 201 Created + Warning: 199 header | Same remediation; audit captures failure_mode = "vault_permanent" |
| Authority client write fails after Vault wrote | Best-effort IVaultKvWriter.DeleteAsync rollback | 201 Created + Warning: 199 header | Same remediation; if rollback also fails, authority.system_account.orphan_vault_secret audit captures the path |
| Vault unreachable at token-acquire | Scanner-worker returns 503 for that tenant | (not a tenant-create event) | Per-tenant scan jobs fail-closed until Vault recovers; other tenants unaffected |
| Vault permanent error at token-acquire | Scanner-worker returns 503 for that tenant | (not a tenant-create event) | failure_mode = "vault_permanent" — usually a Vault policy drift; operator restores read capability on stellaops/tenants/<tenant>/system-accounts/scanner-worker |
| Authority client deleted but Vault entry survives | Token-acquire returns invalid_client from Authority | (not a tenant-create event) | Same operator surface as decommission cleanup; orphan Vault entry harmless |
Sentinel-leak posture
The provisioning code path generates the secret in-process, writes it once to Vault and once (hashed) to Authority, and discards the plaintext before any audit/log/HTTP-response emission. Sentinel-leak test (mandatory per the Sprint 031 precedent): inject a known-distinguishable secret pattern (SENTINEL_S053_***) into the CSRNG seam, exercise ProvisionAsync, assert the pattern does not appear in any captured log/audit/HTTP-response payload. The same posture applies to rotation.
Consequences
Positive
- Per-tenant blast-radius containment. Leaked per-tenant secret cannot mint tokens for any other tenant — the
ClientCredentialsHandlers.cs:1855guard becomes a security feature, not the friction it was in Sprint 046. - Zero manual operator step at onboarding. Tenant create = client issued + secret in Vault + cache primed (next token-acquire). The friction Sprint 031 removed for
stellaops-cliis now removed for the scanner-worker class of system accounts. - On-prem-first. No cloud-managed secret manager defaults. Vault is the flagship; the same Vault server already runs in prod for ADR-007 KEK source.
- Generalisable to other system accounts. The
Accounts.<Account>config shape covers future per-tenant Attestor, AdvisoryAI, etc. clients without re-deriving the design. - Coexists with Pattern B. Lab/dev keep Pattern B (cheap; high blast-radius tolerance); prod runs Pattern C (per-tenant containment). Migration is per-environment, not per-tenant.
Tradeoffs
- Vault hard dependency at onboarding. A new tenant cannot get a system account unless Vault is reachable. Mitigation: onboarding is non-fatal on provisioning failure (tenant exists; operator runs
stella system provision-account <tenant>post-recovery). Acceptable because Pattern C is a prod-scale concern, and prod operators already run Vault for ADR-007 KEK. - Per-tenant rotation operational cost. N tenants × 90-day cadence = N/90 rotations/day. Mitigation: scheduled rotation is automated (cron-driven via the same admin endpoint); operator surface is “approve the rotation report”, not “rotate each tenant by hand”.
authority.clientsrow growth. N tenants → N+M system-account clients (where M is the per-account multiplier — 1 today for scanner-worker; will grow as the Accounts list expands). Acceptable: row count grows linearly with prod tenants, well within Postgres scale envelopes (10k tenants = 10k+ rows; trivial).- Vault entry growth. Same as above; KV v2 with versioning means each rotation adds a version. Operators on tight Vault storage tune Vault’s
max_versionsper mount (default 10). - Migration churn at the Pattern B → Pattern C cutover. Removing the multi-tenant
stellaops-cli-automationclient’stenantsplural while standing up per-tenant clients is a window where both code paths are live in the scanner-worker. Mitigation: scanner-worker observes both client_ids during the cutover via aAuthority:PerTenantSystemAccounts:ScannerWorker:CompatibilityClientIdconfig (set tostellaops-cli-automationduring migration; cleared after); a per-tenant scan job tries the per-tenant client first, falls back to the compatibility client only if the per-tenant client returnsinvalid_client. - Bootstrap reseed semantics differ from
stellaops-cli. Pattern C clients are NOT instandard.yaml. The bootstrap reseed (StandardClientProvisioningStore.ApplyRegistration) therefore never touches them. They are owned end-to-end by the runtime tenant-onboarding workflow. This is the correct ownership boundary: operator-created tenants → operator-managed via the runtime workflow → not part of the YAML seed surface.
Alternatives Considered
Pattern A — per-tenant manually seeded clients in standard.yaml
Rejected. Pros: no Vault dependency; secret hashes live in Authority alongside every other client. Cons: every new tenant becomes a YAML edit + Authority migration + service restart. Scales sub-linearly with tenant count: each new tenant is a manual operator step with no automation surface. Operator-confirmed rejection 2026-05-29 as a “scaling smell” in the H4 architectural analysis.
Pattern B (alone) — multi-tenant single client + properties.tenants allowlist
Rejected for prod. Pros: cheapest possible; existing infrastructure; no new code beyond a YAML edit. Cons: single secret = blast-radius across every allowed tenant; leak = attacker-as-every-allowed-tenant. Acceptable for lab/dev (Sprint 051 ships it). Inappropriate for prod where per-tenant blast-radius containment is the table-stakes security property.
Per-tenant clients with secret in Postgres (no Vault)
Rejected. Pros: no new infrastructure dependency. Cons: violates feedback_on_prem_first_no_cloud_defaults’s Vault-flagship principle (the spirit of the rule is “secrets at rest live in Vault, not in service databases”); puts plaintext-equivalent material (the hash + the rotation cadence + the operator-visible audit) in the same database as the tenant-onboarding workflow that emits it (no defence-in-depth); makes secret rotation a multi-table transaction instead of a single Vault API call. The Vault path is the architecturally correct location for per-tenant secret material in a Stella Ops on-prem deployment.
Per-tenant clients with secret in a Stella-Ops-managed credential store (not Vault)
Rejected. Same reasoning as the Postgres alternative + adds a new credential-store substrate to maintain. Vault already exists; reuse the existing substrate.
Auto-seed at Authority startup (not at tenant onboarding)
Rejected. Pros: removes the Vault dependency from the onboarding critical path. Cons: a tenant created mid-day would have to wait for the next Authority restart to get a system account → defeats the “zero manual operator step” goal. Onboarding-time provisioning is the right insertion point because the tenant is by definition new (no pre-existing system account to reconcile against) and the operator is already mid-flow.
Migration steps (handed off to S053-002)
Sprint 053 S053-002 (below in the sprint dossier) sequences this design into per-phase execution sprints with acceptance gates. Each phase is a separate executable sprint, dispatched when prod-scale tenant onboarding becomes a real driver.
References
docs/implplan/SPRINT_20260529_053_Authority_per_tenant_client_autoseed_onboarding.md— this ADR’s source sprint; S053-002 execution roadmap.docs/implplan/SPRINT_20260529_051_Authority_per_tenant_scanner_worker_client.md— Pattern B (lab/dev complement; coexists with Pattern C).docs-archive/implplan/SPRINT_20260528_031_Authority_tenant_onboarding_cli_access.md— Sprint 031; the auto-grant primitive Pattern C extends the call-site of.- ADR-005 — single-operator multi-environment tenancy (the tenant-slug surface Pattern C inherits).
- ADR-006 — execution plugin framework (orthogonal; both inform multi-tenant auth model).
- ADR-007 — KEK lifecycle and rotation (the Vault substrate Pattern C extends with a write surface).
- ADR-010 — shared tenant resolver migration (the slug-or-Guid → canonical tenant UUID seam Pattern C uses for tenant-slug normalisation).
src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs:315-431— the insertion point.src/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantClientGrantService.cs— Sprint 031 sibling primitive (NOT the merge semantics; the call-site pattern).src/Authority/StellaOps.Authority/StellaOps.Authority/OpenIddict/Handlers/ClientCredentialsHandlers.cs:1841-1862— the singular-tenant binding handler that Pattern C clients USE.src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs:100-211— the bootstrap reseed that Pattern C clients are NOT subject to (they live entirely outside the YAML seed surface).src/__Libraries/StellaOps.Cryptography.CredentialStore.Vault/HttpVaultKvReader.cs— the read surface scanner-worker reuses verbatim.src/__Libraries/StellaOps.Cryptography.CredentialStore.Vault/VaultKekSource.cs— wire-level Vault reuse exemplar.src/Scanner/StellaOps.Scanner.Worker/Processing/ConcelierLearnTokenHandler.cs— the per-tenant token cache shape Pattern C generalises to(tenant, audience).feedback_on_prem_first_no_cloud_defaults— Vault-flagship requirement; no cloud-managed secret manager defaults.- H4 architectural analysis 2026-05-29 — the dig that distinguished Patterns A/B/C and produced the “go B+C” operator decision.
