Tenant Onboarding — Auto-Grant CLI Client Access
Status: GA as of Sprint 031 (2026-05-28). Audience: platform operators onboarding a new customer/environment tenant. Prerequisites: dev or production install with admin credentials and the authority:tenants.write + ui.admin scopes.
When to use this
When you need to onboard a new tenant (internal, partner, platform-builds-runtime, etc.) and have CLI access work immediately — no manual SQL mutation, no operator-after-the-fact knowledge required.
As of Sprint 031, the POST /console/admin/tenants endpoint auto-grants the configured CLI/automation clients (Sprint 031 shipped default: stellaops-cli; widened by Sprint F3 on 2026-05-31 to ["stellaops-cli", "stellaops-cli-automation", "stellaops-release-dispatch"] after the third instance of the identity-gap pattern) access to the newly created tenant by extending their properties.tenants allowlist atomically. The grant is idempotent and union-merges with existing operator-set tenants — it never clobbers what was already there.
Current first-party multi-tenant clients are stellaops-cli, stellaops-cli-automation, stellaops-scanner-worker, and stellaops-release-dispatch; the scanner-worker entry is required for tenant-scoped SBOM/reachability jobs to resolve registry credentials without anonymous fallback.
Before Sprint 031 this required:
POST /console/admin/tenants(succeeds but only insertsauthority.tenants+shared.tenants)- Manual
UPDATE authority.clients SET properties = ...SQL forstellaops-cli(undocumented; lost on stack rebuild) - Restart authority (which on a buggy bootstrap path could then overwrite the manual mutation)
That entire chain is now collapsed into step 1.
How it works
Auto-grant hook — inside the
CreateTenanthandler, after the tenant write andshared.tenantspropagation succeed and before the 201 is returned,ITenantClientGrantService.GrantAsync(slug)runs. The service resolves its target client-id set at grant time using a two-tier precedence:- Explicit config (override). If
Authority:TenantClientGrants:AutoGrantClientIdsis supplied in configuration, the operator-supplied list is honoured verbatim (including the empty-list opt-out). - Declarative source-of-truth (default since Sprint F3 / 2026-05-31). Otherwise the service queries
IAuthorityClientStore.ListFirstPartyAsync(), which scans the partial indexidx_clients_is_first_party WHERE is_first_party = TRUEonauthority.clientsand returns every client whoseis_first_partyflag isTRUE.
For each matched client, the slug is unioned into
properties.tenants, normalized (lower-cased, deduped, sorted-stable), and the document is upserted viaUpsertAsync. Seesrc/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantClientGrantService.cs.- Explicit config (override). If
Bootstrap re-seed defense — on Authority startup the Standard plugin’s bootstrapper re-runs
CreateOrUpdateAsyncfromstandard.yaml. As of Sprint 031 theStandardClientProvisioningStore.ApplyRegistrationunion-merges the existing-DBproperties.tenantswith the registration tenants, mirroring the Sprint 20260520_084MergeAllowedScopesprecedent. The re-seed can no longer SHRINK the tenant allowlist; operator-set + auto-granted + manual-SQL-mutated tenants all survive every restart.Audit trail — the create event (
authority.admin.tenants.create) gains the following properties:tenant.client_grants.outcome—success/warning/disabledtenant.client_grants.extended— space-separated list of clients that were extendedtenant.client_grants.unchanged— clients that already had the slugtenant.client_grants.not_found— configured clients missing from the storetenant.client_grants.failures— clients where the upsert failed
Non-fatal failures — if the grant throws (e.g., client store transient unavailable), the tenant write +
shared.tenantspropagation already succeeded, so the create returns 201 with aWarningheader and anauthority.admin.tenants.create.client_grant_failedaudit event. Operators can re-apply via the manual fallback (see below).
Configuration
The auto-grant has two configuration surfaces since Sprint F3 / 2026-05-31:
Surface 1 (recommended) — declarative is_first_party flag on the client row
Mark a client first-party via SQL UPDATE (or via a forward seed migration when shipping a new platform client):
UPDATE authority.clients SET is_first_party = TRUE
WHERE client_id IN ('stellaops-cli', 'stellaops-cli-automation',
'stellaops-scanner-worker',
'stellaops-release-dispatch', 'my-org-custom-client');
The column is added by migration S032_clients_is_first_party_column.sql, which also backfills is_first_party = TRUE for the platform-shipped first-party multi-tenant clients (cli + cli-automation + scanner-worker + release-dispatch). Operators MAY mark their own custom clients first-party at any time without a service restart — the next POST /console/admin/tenants will pick them up automatically (queries the partial index idx_clients_is_first_party WHERE is_first_party = TRUE).
This is the preferred operator extension point because:
- No service restart required.
- No
appsettings.jsonedit required. - No code change required (even when shipping a new platform-default client — a single
S###_*.sqlrow in a new migration is enough). - The decision lives next to the client definition.
Surface 2 (override) — explicit AutoGrantClientIds config
When supplied, the explicit list is honoured verbatim and the declarative is_first_party flag is ignored. This is the override path for test harnesses, pinned deployments, or operators who want to lock the auto-grant set to a known subset.
{
"Authority": {
"TenantClientGrants": {
// Default: true. Set to false to disable auto-grant globally.
"Enabled": true,
// OPTIONAL. When ABSENT (the standard production posture), the service
// falls back to the declarative is_first_party=TRUE scan (Surface 1).
// When PRESENT, the explicit list is the authoritative target set;
// is_first_party is ignored.
"AutoGrantClientIds": [
"stellaops-cli",
"stellaops-cli-automation",
"stellaops-scanner-worker",
"stellaops-release-dispatch"
]
}
}
}
Opt-out variants
- Globally:
Authority:TenantClientGrants:Enabled = false. - Empty allowlist via explicit config:
Authority:TenantClientGrants:AutoGrantClientIds = [](key present, expands to zero entries) — bypassesis_first_partyentirely. - Per-client (declarative):
UPDATE authority.clients SET is_first_party = FALSE WHERE client_id = '...';. The EF UPSERTis_first_party = authority.clients.is_first_party OR EXCLUDED.is_first_partyclause ensures Standard plugin re-seeds never silently re-flip the operator’s choice. - Per-tenant: NOT supported in v1. Operators who want a tenant-specific opt-out can post-process via the manual fallback after the create.
Inclusion rule for first-party clients
A client SHOULD be marked is_first_party = TRUE when ALL of the following are true:
- Multi-tenant by design. The client uses Pattern B from Sprint 051 — no singular
descriptor.Tenant, all tenant binding lives in theproperties.tenantsallow-list. Single-tenant clients (e.g.stellaops-advisory-ai-internalwhich mintsdefault-tenant tokens for internal service-to-service calls only) are EXCLUDED. - Operator-facing. The client serves an operator workflow (CLI, automation script, scanner-worker for tenant-scoped SBOM/reachability jobs, release-dispatch). Internal service-to-service credentials are EXCLUDED.
- First-party / operator-shipped. Both platform-shipped (cli, cli-automation, release-dispatch) and operator-installed multi-tenant clients qualify.
- Not a dev-only harness. Clients with hard-coded
change-mesecrets intended for local dev only (findings-ledger-dev,scanner-poe-dev) are EXCLUDED.
When shipping a new PLATFORM-DEFAULT first-party client, add a forward S-migration that BOTH creates/updates the client row AND flips is_first_party = TRUE in the same migration. No code change is required in TenantClientGrantService — the runtime scan picks it up on the next Authority startup.
Adding a custom operator-installed client to the auto-grant list
When an operator installs a new “needs tenant routing” client (e.g., a custom SCM connector that calls /api per-tenant):
UPDATE authority.clients SET is_first_party = TRUE
WHERE client_id = 'my-org-scm-connector';
No Authority restart required. The next POST /console/admin/tenants will extend the new tenant onto BOTH my-org-scm-connector AND every other is_first_party = TRUE client in a single grant.
Standard happy-path
# 1. Mint an admin token (with ui.admin + authority:tenants.write + offline_access).
TOKEN=$(curl -sk -X POST https://stella-ops.local/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&client_id=stellaops-cli&scope=openid offline_access ui.admin authority:tenants.read authority:tenants.write&username=admin&password=${STELLAOPS_ADMIN_PASS}" \
| jq -r .access_token)
# 2. Create the tenant.
# NOTE: the console-admin tenant header is X-StellaOps-TenantId
# (AuthorityHttpHeaders.Tenant), NOT X-StellaOps-Tenant. When the header
# is present, TenantHeaderFilter validates it against the principal's
# tenant claim / allowed-tenants list (mismatch => 403). When it is MISSING
# the filter defers (calls next); the CreateTenant handler's own
# string.IsNullOrWhiteSpace defense-in-depth check is what rejects a
# header-less request. (A global-admin principal carrying stellaops:admin=true
# bypasses the allowed-tenants / claim-equality checks — Sprint 20260530_032 —
# which is why an admin can pass X-StellaOps-TenantId: default freely.)
# The /console/admin group enforces ui.admin plus the tenant-header filter.
# Mutation routes add RequireFreshAuth() individually; read-only routes do not.
TENANT="my-new-tenant"
curl -sk -X POST https://stella-ops.local/console/admin/tenants \
-H "Authorization: Bearer $TOKEN" \
-H "X-StellaOps-TenantId: default" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$TENANT\",\"displayName\":\"My New Tenant\"}"
# Expected: 201 Created, NO Warning header.
# 3. Verify the auto-grant landed (optional sanity check).
docker exec stellaops-postgres psql -U stellaops -d stellaops_authority -t -A -c \
"SELECT properties->>'tenants' FROM authority.clients WHERE client_id = 'stellaops-cli'"
# Expected: list including "$TENANT" (sorted-stable, deduped).
# 4. Mint a tenant-scoped CLI token.
# The token endpoint resolves the requested tenant from the `tenant`
# REQUEST PARAMETER (AuthorityOpenIddictConstants.TenantParameterName,
# PasswordGrantHandlers.cs), NOT from a header. The resulting OpTok carries
# the tenant in its stellaops:tenant claim. (stellaops-cli must already be
# allow-listed for $TENANT — step 2's auto-grant ensures this.)
TENANT_TOKEN=$(curl -sk -X POST https://stella-ops.local/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&client_id=stellaops-cli&tenant=$TENANT&scope=openid offline_access integration:read&username=admin&password=${STELLAOPS_ADMIN_PASS}" \
| jq -r .access_token)
# 5. Call any tenant-routed API; must return 200, never 403 tenant_override_forbidden.
# The gateway derives the effective tenant from the token's stellaops:tenant
# claim (IdentityHeaderPolicyMiddleware) and injects X-StellaOps-TenantId
# downstream itself. Per-request tenant override via header is OFF by default
# (EnableTenantOverride=false), so any client-supplied tenant header MUST match
# the token's tenant or the request is rejected with 403 tenant_override_forbidden.
curl -sk -i https://stella-ops.local/api/v1/integrations \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-StellaOps-TenantId: $TENANT"
Failure modes
Warning header 199 StellaOps "tenant-client auto-grant ..."
The tenant was created + propagated to shared.tenants, but the auto-grant did not fully succeed. The CreateTenant handler emits one of two 199 StellaOps Warning headers (see ApplyTenantClientGrantAsync in ConsoleAdminEndpointExtensions.cs):
199 StellaOps "tenant-client auto-grant partial success; check authority logs and re-run if needed"— at least one configured client failed to upsert (outcome.Failuresnon-empty) but others may have succeeded.199 StellaOps "tenant-client auto-grant failed; check authority logs and re-run if needed"— the grant call itself threw before completing.
In both cases the audit event authority.admin.tenants.create.client_grant_failed (outcome warning for the partial case, failed for the exception case) records the root cause. Re-run the manual mutation as the fallback:
Note: the same
CreateTenanthandler emits a SEPARATE Warning header for a different failure —199 StellaOps "tenant propagation partial success; run reconcile-tenants"— when theshared.tenantspropagation step (not the client grant) fails. That path recordsauthority.admin.tenants.create.propagation_failedand is recovered with the platformreconcile-tenantsflow, not the SQL fallback below.
UPDATE authority.clients
SET properties = jsonb_set(
properties,
'{tenants}',
to_jsonb(
(
SELECT string_agg(DISTINCT t, ' ' ORDER BY t)
FROM unnest(
string_to_array(properties->>'tenants', ' ') || ARRAY['my-new-tenant']
) t
WHERE t <> ''
)
)
)
WHERE client_id = 'stellaops-cli';
This mutation survives subsequent Authority restarts because the bootstrap re-seed defensive merge (Sprint 031) union-preserves operator-added tenants.
403 tenant_override_forbidden on tenant-scoped API call
This error is raised by the gateway’s IdentityHeaderPolicyMiddleware when a client supplies a tenant header (X-StellaOps-TenantId / X-StellaOps-Tenant / X-Stella-Tenant / X-Tenant-Id) whose value does NOT match the tenant resolved from the token’s stellaops:tenant claim, while per-request override is disabled (EnableTenantOverride = false, the default). Two common root causes:
- Header/token tenant mismatch. The CLI token was minted for tenant A (the
tenantrequest parameter in step 4) but the API call carries anX-StellaOps-TenantIdheader for tenant B. Mint the token for the same tenant you call, or drop the redundant header (the gateway derives the tenant from the token claim regardless). - Token never carried the tenant. Auto-grant was disabled or the client id is missing from
AutoGrantClientIds, sostellaops-cliis not allow-listed for the tenant and the password grant could not select it. Check:
docker exec stellaops-authority cat /app/appsettings.json | jq '.Authority.TenantClientGrants'
Then either fix the config + restart Authority, or apply the manual fallback SQL above.
Tenant deletion
There is no DELETE /console/admin/tenants/{id} endpoint today; only POST /console/admin/tenants/{id}/suspend and /resume. Suspend preserves the auto-grant — resuming the tenant restores exactly the same posture. If a true delete endpoint is added in the future, it MUST invoke ITenantClientGrantService.RevokeAsync(slug) to keep properties.tenants clean.
To manually remove a tenant from the allowlist today:
UPDATE authority.clients
SET properties = jsonb_set(
properties,
'{tenants}',
to_jsonb(
(
SELECT string_agg(t, ' ' ORDER BY t)
FROM unnest(string_to_array(properties->>'tenants', ' ')) t
WHERE t <> 'the-tenant-to-remove'
)
)
)
WHERE client_id = 'stellaops-cli';
Related sprints + sprints’ references
- Sprint F3 (
docs/implplan/SPRINT_20260531_F3_Authority_first_party_clients_auto_onboard.md) — systemic-fix capstone for the identity-gap pattern: extended the Sprint 031 default fromstellaops-clito the full first-party multi-tenant operator set, plus an S031 backfill migration so existing dev volumes converge on upgrade. Triggered by the third instance of the pattern (a customer dev-lab tenant + Concelier/api/v1/learn/sbomreturning400 tenant_requiredbecause cli-automation’s allow-list was never extended). - Sprint 031 (archived under
docs-archive/implplan/SPRINT_20260528_031_*) — the strategic fix that delivered the auto-grant + bootstrap defensive merge. - Sprint 029 FIX-001 (S011 idempotency) — defensive conditional-CASE in the
S011_tester_harness_export_tenant.sqlseed migration that preserves operator mutations on manual re-runs. Still load-bearing for any future S011 re-runs. - Sprint 029 FIX-003 STOP — the live evidence that triggered Sprint 031: the WS-7 re-run discovered that manual
properties.tenantsmutations were silently overwritten on stack rebuild. - Sprint 028 RP-028-020 — first discovered that seed migrations are manual-only; explains why pre-Sprint-031 manual SQL mutations did not survive a
compose_postgres-datawipe. - Sprint 030 S030-001 (S024 client scope grant) — the
standard.yaml+ bootstrap reconcile pattern that works for GENERIC scope additions; that pattern does NOT apply to tenant lists (which are operator-specific), which is why Sprint 031 took the auto-grant approach instead of baking customer tenants intostandard.yaml.
Source pointers
src/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantClientGrantService.cs— the grant service.src/Authority/StellaOps.Authority/StellaOps.Authority/Console/Admin/ConsoleAdminEndpointExtensions.cs—CreateTenanthandler +ApplyTenantClientGrantAsynchelper.src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Storage/StandardClientProvisioningStore.cs—MergeTenants+SplitTenantsProperty(bootstrap re-seed defensive merge).src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Tenants/TenantClientGrantServiceTests.cs— regression suite.src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardClientProvisioningStoreTests.cs—CreateOrUpdateAsync_MergesExistingTenants_DoesNotShrinkregression guard.
