Issuer Directory Architecture

Owned by the Authority domain (Sprint 216, 2026-03-04). The IssuerDirectory source tree moved under src/Authority/. Schema and DbContext ownership are documented canonically in the Authority architecture dossier (sections 21.1–21.4); this document describes the IssuerDirectory service’s own runtime, API, persistence, and observability surface as implemented.

Source of truth

1. Purpose

Issuer Directory centralises trusted VEX/CSAF publisher metadata so downstream Stella Ops services (VEX Lens, Excititor, Policy Engine) can resolve issuer identity, active signing keys, and trust weights. It provides tenant-scoped CRUD APIs with an audit trail, key lifecycle management (add/rotate/revoke), tenant trust-weight overrides, and a bootstrap import for CSAF publishers.

This dossier is for engineers and operators integrating with the Issuer Directory service; it documents the runtime, API surface, persistence schema, and observability as implemented.

2. Runtime topology

Clients ──> Authority (JWT) ──> IssuerDirectory WebService ──> PostgreSQL (schema: issuer)
                                       │
                                       ├─> IIssuerAuditSink (PostgreSQL audit table)
                                       └─> Unified audit emission ──> Timeline service

3. Configuration

Configuration binds to IssuerDirectoryWebServiceOptions (section name IssuerDirectory, env prefix ISSUERDIRECTORY_). Verified keys:

KeyDefaultNotes
IssuerDirectory:Telemetry:MinimumLogLevelInformationSerilog minimum level.
IssuerDirectory:Authority:EnabledtrueWhen false, anonymous mode (Development/Testing only — startup throws otherwise, see § 5).
IssuerDirectory:Authority:Issuer""Required when enabled.
IssuerDirectory:Authority:Audiences[]Required (≥1) when enabled.
IssuerDirectory:Authority:RequireHttpsMetadatatrue
IssuerDirectory:Authority:ReadScopeissuer-directory:readScope bound to the Reader policy.
IssuerDirectory:Authority:WriteScopeissuer-directory:writeScope bound to the Writer policy.
IssuerDirectory:Authority:AdminScopeissuer-directory:adminScope bound to the Admin policy.
IssuerDirectory:Authority:Scopes[read, write, admin]Advertised scope set.
IssuerDirectory:Authority:ClientScopes[]
IssuerDirectory:Authority:BypassNetworks[]CIDR networks that receive a synthetic principal carrying the required scope. Populated from ISSUERDIRECTORY__AUTHORITY__BYPASSNETWORKS__N; explicitly copied into StellaOpsResourceServerOptions.BypassNetworks (SPRINT_20260512_010 FU-1).
IssuerDirectory:TenantHeaderX-StellaOps-TenantIdWire tenant header. Since TEN-3 it is not the isolation key when the caller is authenticated — it is conflict-checked against the stellaops:tenant claim, and is only a fallback on the read path (see §4 “Tenancy contract”).
IssuerDirectory:SeedCsafPublisherstrueEnables CSAF bootstrap seeding.
IssuerDirectory:CsafSeedPathcsaf-publishers.jsonRelative paths resolved against the content root.
IssuerDirectory:Persistence:ProviderPostgresPostgres or InMemory (InMemory only valid in Testing).
IssuerDirectory:Persistence:PostgresConnectionString""Required when provider is Postgres.
IssuerDirectory:Persistence:SchemaNameissuerEffective schema; falls back to IssuerDirectory:Postgres:Schema, then issuer.
IssuerDirectory:Postgres:ConnectionString""Legacy connection-string fallback.
IssuerDirectory:Postgres:SchemaissuerLegacy schema fallback.
IssuerDirectory:Postgres:CommandTimeoutSeconds30Legacy.

Auto-migration is wired: IssuerDirectoryPersistenceRuntime calls AddStartupMigrations<IssuerDirectoryWebServiceOptions> (module IssuerDirectory.Persistence, migrations assembly = IssuerDirectoryDataSource.Assembly) so a fresh database converges to the configured schema on startup.

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. 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.
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: the startup SQL migration’s key_type CHECK constraint lists the legacy lower-case token set (ed25519, x509, dsse, kms, hsm, fido2) and the issuers.status CHECK lists (active, revoked, deprecated). The issuer_keys.status CHECK was relaxed by migration 002_operator_signing_key_dual_control.sql to admit pending alongside active/retired/revoked. These string vocabularies are not the same surface as the C# enums above, which are the authoritative API contract.

5. Authentication and anonymous mode

6. Persistence schema (default schema issuer)

Startup migration Migrations/001_initial_schema.sql (embedded; applied by the shared migration runner under the configured search_path) creates:

Indexes cover tenant/status/slug lookups, key issuer/status/tenant, trust tenant, and audit (tenant_id, occurred_at DESC)/issuer. BEFORE UPDATE triggers maintain updated_at on issuers, issuer_keys, and trust_overrides. Startup migration 003_operator_signing_key_append_only_guard.sql adds the issuer_keys delete guard that preserves operator decision-signing public keys even when an issuer delete would otherwise cascade. Startup migration 004_operator_provider_change_receipts.sql makes provider-change retry receipts durable and append-only; 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 / superseded documentation