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:

  1. POST /console/admin/tenants (succeeds but only inserts authority.tenants + shared.tenants)
  2. Manual UPDATE authority.clients SET properties = ... SQL for stellaops-cli (undocumented; lost on stack rebuild)
  3. 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

  1. Auto-grant hook — inside the CreateTenant handler, after the tenant write and shared.tenants propagation 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:

    1. Explicit config (override). If Authority:TenantClientGrants:AutoGrantClientIds is supplied in configuration, the operator-supplied list is honoured verbatim (including the empty-list opt-out).
    2. Declarative source-of-truth (default since Sprint F3 / 2026-05-31). Otherwise the service queries IAuthorityClientStore.ListFirstPartyAsync(), which scans the partial index idx_clients_is_first_party WHERE is_first_party = TRUE on authority.clients and returns every client whose is_first_party flag is TRUE.

    For each matched client, the slug is unioned into properties.tenants, normalized (lower-cased, deduped, sorted-stable), and the document is upserted via UpsertAsync. See src/Authority/StellaOps.Authority/StellaOps.Authority/Tenants/TenantClientGrantService.cs.

  2. Bootstrap re-seed defense — on Authority startup the Standard plugin’s bootstrapper re-runs CreateOrUpdateAsync from standard.yaml. As of Sprint 031 the StandardClientProvisioningStore.ApplyRegistration union-merges the existing-DB properties.tenants with the registration tenants, mirroring the Sprint 20260520_084 MergeAllowedScopes precedent. The re-seed can no longer SHRINK the tenant allowlist; operator-set + auto-granted + manual-SQL-mutated tenants all survive every restart.

  3. Audit trail — the create event (authority.admin.tenants.create) gains the following properties:

    • tenant.client_grants.outcomesuccess / warning / disabled
    • tenant.client_grants.extended — space-separated list of clients that were extended
    • tenant.client_grants.unchanged — clients that already had the slug
    • tenant.client_grants.not_found — configured clients missing from the store
    • tenant.client_grants.failures — clients where the upsert failed
  4. Non-fatal failures — if the grant throws (e.g., client store transient unavailable), the tenant write + shared.tenants propagation already succeeded, so the create returns 201 with a Warning header and an authority.admin.tenants.create.client_grant_failed audit 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:

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

Inclusion rule for first-party clients

A client SHOULD be marked is_first_party = TRUE when ALL of the following are true:

  1. Multi-tenant by design. The client uses Pattern B from Sprint 051 — no singular descriptor.Tenant, all tenant binding lives in the properties.tenants allow-list. Single-tenant clients (e.g. stellaops-advisory-ai-internal which mints default-tenant tokens for internal service-to-service calls only) are EXCLUDED.
  2. 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.
  3. First-party / operator-shipped. Both platform-shipped (cli, cli-automation, release-dispatch) and operator-installed multi-tenant clients qualify.
  4. Not a dev-only harness. Clients with hard-coded change-me secrets 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):

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 CreateTenant handler emits a SEPARATE Warning header for a different failure — 199 StellaOps "tenant propagation partial success; run reconcile-tenants" — when the shared.tenants propagation step (not the client grant) fails. That path records authority.admin.tenants.create.propagation_failed and is recovered with the platform reconcile-tenants flow, 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:

  1. Header/token tenant mismatch. The CLI token was minted for tenant A (the tenant request parameter in step 4) but the API call carries an X-StellaOps-TenantId header 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).
  2. Token never carried the tenant. Auto-grant was disabled or the client id is missing from AutoGrantClientIds, so stellaops-cli is 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';

Source pointers