Issuer Directory Architecture (served by Authority)

Verified against commit 942a18727c on 2026-09-26 (UTC) for the header and sections 1 to 3, 5, 6 and 8. Re-verify: git grep -n "AddFoldedIssuerDirectory\|MapFoldedIssuerDirectory" -- src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs and grep -n "CREATE TABLE" src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/024_authority_issuer_directory_tables.sql. Sections 4 and 7 are carried from the pre-fold revision and were not re-verified on 2026-09-26.

The single operator-key read and Release Orchestrator authentication statements were re-verified at 7e3898fbce (CCP-081, 2026-10-13). Re-run the owner OperatorKeyServiceReadTests, client IssuerDirectoryClientTests and Release Orchestrator IssuerDirectoryServiceIdentityTests through tools/scripts/test-targeted-xunit.ps1; these are isolated source tests, not a live grant.

The standalone issuer-directory service was retired on 2026-09-11 (SPRINT_20260722_016 AUTH-10) after AUTH-9 folded the surface into Authority on 2026-09-08. Its host project is frozen at src/__Obsoleted/Authority/StellaOps.IssuerDirectory.WebService/. Paths, scopes and payloads did not change with the fold; namespaces moved from StellaOps.IssuerDirectory.WebService.* to StellaOps.IssuerDirectory.Api.*. Authority dossier: ../authority/architecture.md.

Source of truth

1. Purpose

Issuer Directory holds trusted VEX/CSAF publisher metadata so that other services can resolve issuer identity, active signing keys and trust weights. It provides tenant-scoped CRUD with an audit trail, key lifecycle management (add, rotate, revoke), tenant trust-weight overrides, and the operator decision-signing key registry (ADR-025).

2. Runtime topology

Clients --> Authority (token, tenant) --> Issuer Directory endpoints --> PostgreSQL stellaops_authority (schema issuer)
                                                   |
                                                   +--> IIssuerAuditSink (issuer.audit)

3. Configuration

KeyDefaultNotes
IssuerDirectory:Fold:EnabledfalseMaps the surface into Authority.
IssuerDirectory:Fold:ConnectionStringemptyEmpty means Authority’s own connection.
IssuerDirectory:Fold:SchemaNameissuerSchema used by the repositories.
IssuerDirectory sectionIssuerDirectoryApiOptions (API options such as the tenant header); SeedCsafPublishers is forced off by the fold.

The standalone host’s IssuerDirectory:Authority:*, IssuerDirectory:Persistence:* and IssuerDirectory:Postgres:* keys belonged to the retired host and are not read by the fold.

4. API surface

All endpoints live under the /issuer-directory/issuers route group. Write/delete operations accept an optional audit reason via the X-StellaOps-Reason header (IssuerDirectoryHeaders.AuditReason); trust PUT also accepts a reason in the body.

Tenancy contract (claim-bound)

Sprint SPRINT_20260712_001 (TEN-3) moved tenant resolution off the wire header and onto the authenticated identity. TenantResolver (WebService/Services/TenantResolver.cs) now applies:

Caller shapeResolution
Token carries stellaops:tenantThe claim is the isolation key. A X-StellaOps-TenantId header that disagrees with it is rejected 400 error_code=tenant_conflict — it is never an override. (Global-admin console tenant switching is unaffected: the shared StellaOpsTenantResolver already folds the canonical header into the claim for stellaops:admin principals, so the two agree by construction.)
No claim + mutation (POST/PUT/DELETE, key rotate/revoke, operator-key enroll/rotate/revoke/approve)Rejected 400 error_code=tenant_missing.
No claim + read (GET)Falls back to the X-StellaOps-TenantId header.
Authority:Enabled=false (anonymous local harness only — IssuerDirectoryAnonymousModeGuard hard-blocks it outside Development/Testing)Header remains the tenancy source for every access mode.

Why reads still honour the header: StellaOps.IssuerDirectory.Client sends the tenant header and no bearer token, and Excititor / Policy Engine / ReleaseOrchestrator resolve issuer keys and trust weights through it over the backend network (Authority:BypassNetworks). Reads are non-destructive; the 2026-07-11 audit finding was that a mutation from that same tokenless bypass shape could rewrite another tenant’s VEX-consensus trust weights. Mutations are therefore claim-bound and a bypass-network caller — which carries no principal, hence no tenant claim — can no longer write into a tenant it merely names.

The failure vocabulary (error_code problem extension: tenant_missing, tenant_conflict, tenant_unknown) mirrors the platform’s StellaOpsTenantEndpointFilter. All tenant failures are 400, matching the platform mapping (not Attestor’s 403 tenant_claim_mismatch).

Authorization policies (IssuerDirectoryPolicies): IssuerDirectory.Reader → read scope, IssuerDirectory.Writer → write scope, IssuerDirectory.Admin → admin scope. Each maps to the configured scope via AddStellaOpsScopePolicy (honors BypassNetworks). When Authority:Enabled=false, all three policies allow any request (anonymous mode).

Issuers

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuersReader / issuer-directory:readQuery includeGlobal (default true). Returns IssuerResponse[].
GET/issuer-directory/issuers/{id}Reader / issuer-directory:readQuery includeGlobal (default true); 404 if not found.
POST/issuer-directory/issuersWriter / issuer-directory:writeBody IssuerUpsertRequest; returns 201 Created with Location: /issuer-directory/issuers/{id}.
PUT/issuer-directory/issuers/{id}Writer / issuer-directory:writeBody IssuerUpsertRequest; route id must equal body Id (400 on mismatch). Returns 200.
DELETE/issuer-directory/issuers/{id}Admin / issuer-directory:adminReturns 204 No Content only when the issuer has no append-only operator decision-signing history; persistence rejects a delete that could cascade into those key rows.

Issuer keys (nested under {issuerId}/keys)

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuers/{issuerId}/keysReader / issuer-directory:readQuery includeGlobal. Returns IssuerKeyResponse[].
POST/issuer-directory/issuers/{issuerId}/keysWriter / issuer-directory:writeBody IssuerKeyCreateRequest; returns 201 Created (Location includes generated key id). Validation failures → 400.
POST/issuer-directory/issuers/{issuerId}/keys/{keyId}/rotateWriter / issuer-directory:writeBody IssuerKeyRotateRequest; retires the active key and creates a replacement atomically. Returns 200.
DELETE/issuer-directory/issuers/{issuerId}/keys/{keyId}Admin / issuer-directory:adminRevokes the key (status → Revoked). Returns 204.

Operator decision-signing keys (ADR-025 / AUTH-OPSIGN-002)

The stable enrolled-key contract consumed by the rest of the operator-signed-decisions program (WS3/WS4/WS5/WS6/WS7) — the frozen field shape, the policy.trusted_keys verification-view delta, and the Authority→Policy pull-by-keyId sync — lives in docs/modules/authority/operator-signing-key-contract.md(AUTH-OPSIGN-006). This section documents the enrolment surface; that doc is the contract.

A separate, account-scoped enrolment surface for operator decision-signing public keys (the operator-decision@v1 predicate). Distinct from the issuer-scoped key endpoints above:

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/operator-signing-keys/{issuerId}/keysauthority:signing-keys.enroll (+ fresh auth)Lists the current operator’s DecisionSigning keys for the issuer, bound to the token sub. Returns public verification material and lifecycle status.
GET/issuer-directory/operator-signing-keys/{issuerId}/keys/subjects/{subjectId}Reader / issuer-directory:readTenant-scoped service/admin lookup for all public DecisionSigning keys belonging to one subject. This is deliberately reader-only: Policy uses it to answer key-presence checks for the authenticated operator without impersonating that operator’s fresh-auth enrolment token. Policy calls it with its own per-tenant policy-engine bearer (issuer-directory:read), and the shared client does not cache the answer, so an enrollment or revocation is visible on the next check. The tenant header remains mandatory and cross-tenant rows are never returned.
GET/issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}Reader / issuer-directory:readService/admin verification read for one DecisionSigning key. Returns 404 when the key is missing or is not a decision-signing key. The shared client reads the owner on every call, so a prior active key does not conceal revocation or an owner refusal. Release Orchestrator supplies its own tenant-bound service bearer; the tenant and issuer IDs returned by the owner may be canonical UUIDs when the request used names.
POST/issuer-directory/operator-signing-keys/{issuerId}/keysauthority:signing-keys.enroll (+ fresh auth)Body OperatorSigningKeyEnrollRequest; binds the key to the token sub with purpose DecisionSigning and, by default, auto-binds the active compliance-profile algorithm/provider. Returns 201 (Active, or Pending if dual-control is on), 400 for a client/profile mismatch, or 503 when the profile/provider cannot be resolved safely.
POST/issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}/rotateauthority:signing-keys.enroll (+ fresh auth)Body OperatorSigningKeyEnrollRequest; applies the same active-profile algorithm/provider guard as enrolment, then retires the prior key and binds the replacement to the same operator. Returns 200; a rejected replacement leaves the active key unchanged.
DELETE/issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}authority:signing-keys.enroll (+ fresh auth)Revokes the key (stays resolvable for historical verification). Returns 204.
POST/issuer-directory/operator-signing-keys/{issuerId}/keys/{keyId}/approveauthority:signing-keys.admin (+ fresh auth)Dual-control: a second approver promotes a Pending key to Active. Returns 200.
POST/issuer-directory/operator-signing-keys/{issuerId}/provider-change-supersedeAny of authority:signing-keys.admin, crypto:admin, crypto:profile:admin, or ops.adminBody OperatorProviderChangeSupersedeRequest contains the stable operationId, initiating originalActorSubject, Authority-resolved exception-granting subjectIds, and active-provider supportedAlgorithmIds. Returns the immutable OperatorProviderChangeSupersedeResponse; exact-request replay returns the original response, conflicting operation-id reuse returns 409.

Scope gotcha (CLAUDE.md §1.5): authority:signing-keys.enroll / authority:signing-keys.admin / decision-signing are JWT scope claim values that double as the ASP.NET policy names (the endpoints register RequireAuthorization(<scope>) with the same literal). The freshness gate is applied by StellaOpsScopeAuthorizationHandler for any token presenting these scopes, so applying the policy is sufficient to require a recent step-up.

Issuer trust (nested under {issuerId}/trust)

MethodRoutePolicy / ScopeNotes
GET/issuer-directory/issuers/{issuerId}/trustReader / issuer-directory:readQuery includeGlobal. Returns IssuerTrustResponse (tenant + global overrides + effective weight).
PUT/issuer-directory/issuers/{issuerId}/trustWriter / issuer-directory:writeBody IssuerTrustSetRequest (Weight, optional Reason). Weight must be in [-10, 10] (else 400). Returns 200 with the recomputed view.
DELETE/issuer-directory/issuers/{issuerId}/trustAdmin / issuer-directory:adminRemoves the tenant override (falls back to global/default weight). Returns 204.

Request/response contracts

Domain enums

Note: Authority migration 024 keeps the legacy string vocabularies: issuer_keys.key_type CHECK (ed25519, x509, dsse, kms, hsm, fido2), issuers.status CHECK (active, revoked, deprecated) and issuer_keys.status CHECK (active, retired, revoked, pending). These string vocabularies are not the same surface as the C# enums above, which are the authoritative API contract.

5. Authentication

Authentication is Authority’s own resource-server stack; the fold adds only the three named policies and the operator signing-key policies. The retired host’s anonymous mode and IssuerDirectory:Authority:* keys do not exist on the folded host.

6. Persistence schema (schema issuer)

Authority migration 024_authority_issuer_directory_tables.sql creates the schema inside stellaops_authority on Authority’s migration chain; the fold registers no migrator of its own. The SQL files under StellaOps.IssuerDirectory.Persistence/Migrations/ (001 to 004) are the retired host’s history and are not the migration authority. Tables:

Indexes cover tenant/status/slug lookups, key issuer/status/tenant, trust tenant, and audit (tenant_id, occurred_at DESC)/issuer. Migration 024 also creates the updated_at triggers on issuers, issuer_keys and trust_overrides, the delete guard trg_operator_decision_signing_key_append_only that preserves operator decision-signing public keys even through the ON DELETE CASCADE from issuers, and the receipt append-only trigger. PostgresOperatorProviderChangeRepository commits each receipt in the same transaction as the corresponding key lifecycle and audit mutations.

The schema isolation from AuthorityDbContext is a deliberate security feature (blast-radius containment; least privilege). See Authority architecture § 21.3.

7. Observability

OpenTelemetry meter StellaOps.IssuerDirectory (IssuerDirectoryMetrics):

The host registers ASP.NET Core + runtime metrics instrumentation and ASP.NET Core + HttpClient tracing.

Audit actions

Issuer-domain audit rows are written with these action values (from the Core services): created, updated, deleted, seeded, key_created, key_enrollment_pending, key_retired, key_rotated, key_revoked, trust_override_set, trust_override_deleted. key_enrollment_pending is written instead of key_created when RequireDualControl is on and the key is held for a second-operator approval (IssuerKeyService.Add.cs:91). Provider-change retirement writes key_retired once per affected key with correlation id operationId; details retain both original_actor_subject and executing_actor_subject. A receipt replay writes no additional audit row.

8. Dependencies and reuse

9. Archived and superseded documentation