diff --git a/docs/adr/0045-event-sourced-service-module-layout.md b/docs/adr/0045-event-sourced-service-module-layout.md new file mode 100644 index 000000000..e720e416d --- /dev/null +++ b/docs/adr/0045-event-sourced-service-module-layout.md @@ -0,0 +1,196 @@ +--- +number: "0045" +slug: event-sourced-service-module-layout +status: accepted +date: 2026-07-08 +--- + +# ADR#0045: Aggregate-Oriented Module Layout for Event-Sourced Services + +## Context + +Several first-party services implement an event-sourced model: typed commands +decide events, an evolve function folds events into state, snapshots compact that +state, and read-side processors project the stream into runtime effects. +`trogon-scheduler` and `trogon-gateway` both follow this shape. + +[ADR#0002](./0002-rust-crate-boundaries.md) governs package boundaries but deliberately stops short of prescribing +intra-package module structure. `rsworkspace/crates/AGENTS.md` governs value +objects, boundary types, and errors, but describes a flat crate-root layout that +fits value-object and library crates, not an event-sourced service. + +With no rule, two failure modes appeared. First, code was organized by layer at +the crate root: a top-level `commands/` module in a crate that hosts more than +one stream reads as "the crate's commands," when the commands actually belong to +one specific stream among several. Second, aggregates were named after the +mechanism rather than the domain: `CredentialLifecycle*` names describe that the +type participates in an event-sourced lifecycle, which every event-sourced +aggregate does by definition, so the qualifier carries no information and +compensates for the fact that the opposing concern (the secret material) was +never named on the same axis. + +The repository needs one rule for how event-sourced code is organized and named, +and a named reference implementation. + +## Decision + +Organize event-sourced code by aggregate, not by layer. Name aggregates as bare +domain nouns. `trogon-scheduler` is the reference implementation. + +### Organize by stream + +The unit of organization is the stream, not a technical layer. Stream, +aggregate, and workflow name that one unit from three angles: the aggregate is +the consistency boundary that decides commands, the stream is that aggregate's +event log, and the workflow is what the log captures over time. One aggregate, +one stream, one module; wherever this ADR says "stream" or "aggregate" it +means this same unit, never two different module boundaries. There is one +module per stream, named for that stream. +`commands` (with its nested `state`, `snapshot`, and `domain` submodules) and +the read-side `processor` are subdivisions inside a stream, never top-level +buckets that imply the whole crate is a single command model. + +A stream module contains: + +- `commands/`: the write side. One file per command decider, named after the + command; each file holds the command struct and its `Decider` implementation. + The decider's `Event` and `State` associated types are the generated proto + types used directly (see "Proto types are the event and state" below), not + hand-written domain enums. The write-side submodules nest inside `commands/`: + - `commands/state`: `initial_state`, `evolve`, and the decide-time and + evolve-time validators, operating on the proto state type. + - `commands/snapshot`: the snapshot policy only, for example the snapshot + frequency. It does not contain the snapshot codec. + - `commands/domain`: the aggregate's value objects used as command inputs, + one type per file. The event and state shapes are proto, so this module + holds value objects, not event or state definitions. +- `processor` (or a named projection): the read model that consumes the stream. + A processor's own rebuildable checkpoint store nests under that processor and + is its adapter boundary: the store owns the persistence SDK calls, and + projection logic above it stays SDK-free. +- The aggregate's persistence and command handler: the event store, stream and + subject configuration, and the handler that executes commands against the + store. These belong to the aggregate and live inside its module, next to the + commands they serve. They are not hoisted to the crate root. + +A crate that owns exactly one aggregate places that aggregate at the crate root, +so a root-level `commands/` reads correctly (`trogon-scheduler`: the crate is the +schedules aggregate). A crate that hosts multiple aggregates or bounded contexts +nests each aggregate under its own noun module (`trogon-gateway`: the credential +aggregate lives under `credential/`, separate from the webhook-ingress sources +and the secret backends). + +### Name a stream for its workflow, as a noun + +A stream is not always a static entity. More often it captures a workflow, a +process that unfolds over time. Name the stream for that workflow, expressed as a +noun: a workflow made noun. The name states which workflow the stream is, never +that it happens to be event-sourced. + +Do not qualify a stream with mechanism words such as `Lifecycle`, `Manager`, +`Service`, or `Handler`. Every event-sourced stream has a lifecycle and is +handled by something, so those words name the machinery, not the workflow, and +carry no information. `Lifecycle` is the clearest offender: it restates the +pattern. + +Name opposing concerns on the same axis instead of qualifying one of them. A +credential has two: the workflow that provisions and maintains it over time +(request, activate, rotate, revoke), and its secret material. Name them +`Credential` (the stream, event-sourced, holds no secret bytes) and `Secret` +(the material, held by the secret store). Do not name the first +`CredentialLifecycle` to distinguish it from the second; naming the second +`Secret` already does that. + +### Value objects + +Domain value objects for an aggregate live under that aggregate's +`commands/domain` module, one file per type, with the construction and error +rules from `rsworkspace/crates/AGENTS.md`. The flat `src/{type}.rs` placement in +the crate conventions applies to value-object and library crates that own no +aggregate. The read model depending on the aggregate's `commands/domain` is +expected, not a layering violation. + +### Proto types are the event and state; their codec lives in the proto crate + +The decider's `Event` and `State` are the generated proto messages, used +directly (`type Event = v1::CredentialEvent; type State = state_v1::CredentialStateSnapshot`). +Do not wrap them in parallel hand-written domain enums with manual proto-to-domain +conversion; that duplicates the schema ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)) and drags the codec into the +consuming crate. + +The event and snapshot codec, the `EventEncode`, `EventDecode`, `EventType`, +`SnapshotType`, and `SnapshotPayload*` implementations, lives in `trogonai-proto`, +implemented on the proto types. This is forced by the orphan rule: both the trait +(`trogon-decider-runtime`) and the type (generated proto) are foreign to the +consuming crate, so the impl must live in the crate that owns the proto type. + +Register a per-domain cargo feature in `trogonai-proto` for each event-sourced +domain (`schedules`, `gateway`), pulling in `trogon-decider-runtime` and any codec +dependencies, and gating that domain's hand-written support module (codec, payload +error types, oneof re-exports). The consuming crate enables that feature and uses +the proto types as its decider types. `trogon-scheduler` with the `schedules` +feature is the reference; its `commands/snapshot.rs` is only the policy const +because the codec lives in `trogonai-proto`. + +### Domain stays free of infrastructure + +Decision and projection logic in `commands` and `processor` is free of +transport and persistence SDKs. What converts at the boundary per +[ADR#0009](./0009-protocol-buffers-wire-contracts.md) is the transport and +persistence envelope: adapters turn message frames, KV entries, and wire bytes +into the proto event and state messages and the aggregate's value objects, and +back. The decider's event and state values stay the generated proto types +through that conversion; the boundary never reintroduces the parallel domain +enums rejected above. Infrastructure adapters (NATS/JetStream stores, stream and +subject configuration, KV stores) are thin and live inside the module +that owns them, not scattered at the crate root: the event store and command +handler next to the commands they serve, a processor's checkpoint store nested +under that processor. + +## Design Rules + +- One command decider per file. +- The decider `Event`/`State` are proto types used directly; do not add parallel + domain enums or hand-written proto conversion for them ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)). +- The event and snapshot codec and the `trogon-decider-runtime` trait impls live + in `trogonai-proto` on the proto types, behind a registered per-domain feature + that pulls in `trogon-decider-runtime`. The consuming crate's + `commands/snapshot` module holds only the snapshot policy. +- Keep the fold (`evolve`) separate from the snapshot policy. +- Name a stream for the workflow it represents, expressed as a noun (a workflow + made noun), not for the event-sourcing mechanism. Reject `Lifecycle`, + `Manager`, `Service`, `Info`, and similar mechanism or filler qualifiers in + stream, event, state, and command type names. +- Persisted identifiers follow the aggregate noun: proto message names, stream + names, subjects, and KV keys. Because a persisted message's package path or + fully-qualified name is embedded in storage keys ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)), renaming an + aggregate is a migration. Do it before the contract ships; treat it as + storage-breaking afterward. +- Value objects follow `rsworkspace/crates/AGENTS.md`, located under the + aggregate's `commands/domain`. + +## Consequences + +- Event-sourced code is grouped by the thing it models, so a reader sees one + aggregate's full write and read model in one place, and a multi-aggregate crate + does not imply that one aggregate speaks for the whole crate. +- Aggregate names carry domain meaning instead of restating the pattern. The + credential aggregate is `Credential`; the secret material is `Secret`. +- The value-object placement contradiction between + `rsworkspace/crates/AGENTS.md` and real aggregate crates is resolved. +- `rsworkspace/crates/AGENTS.md` gains a pointer to this ADR and to + `trogon-scheduler` as the reference. +- `trogon-gateway` migrates: the credential aggregate consolidates under + `credential/` with `lifecycle` dropped, and its event and snapshot codec moves + out of `credential/commands/` into `trogonai-proto` behind the `gateway` + feature (which gains `trogon-decider-runtime`), so the deciders use the proto + `CredentialEvent`/`CredentialStateSnapshot` types directly and the hand-written + domain-enum conversion is removed. Done before the contract ships. + +## References + +- [ADR#0002: Rust Crate Boundaries](./0002-rust-crate-boundaries.md) +- [ADR#0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md) +- `rsworkspace/crates/AGENTS.md` +- `rsworkspace/crates/trogonai-proto` (per-domain feature + codec convention) +- `rsworkspace/crates/trogon-scheduler` (reference implementation) diff --git a/docs/adr/0046-project-anchored-resource-hierarchy.md b/docs/adr/0046-project-anchored-resource-hierarchy.md new file mode 100644 index 000000000..b0ef74ee3 --- /dev/null +++ b/docs/adr/0046-project-anchored-resource-hierarchy.md @@ -0,0 +1,126 @@ +--- +number: "0046" +slug: project-anchored-resource-hierarchy +status: accepted +date: 2026-08-05 +--- + +# ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform + +## Context + +The credential vault and API key platform needs one canonical owner boundary. +The shipped gateway slice already bakes `CredentialOwnerId` into OpenBao paths +(`trogonai/{owner_id}/credentials/{credential_id}`), generated credential ids +(`openbao:{owner}:{scope}:{kind}`), event stream routing, and the credential +state protos. Whatever "owner" means, changing its meaning later migrates +every one of those surfaces at once, so the boundary must be chosen before the +public management API or the broader domain model hardens around it. + +The candidates were workspace, organization, project, tenant, or user. The +three hyperscalers embody three answers, and their histories matter more than +their marketing: + +- AWS hardened the wall it happened to have. The account predates IAM; the + ecosystem converged on many-accounts-per-company and AWS formalized it + after the fact. Isolation is structural and strong, and every interior + structure is ceremony bolted around a retail-era boundary. +- Azure accreted a layer per business era: the tenant is the enterprise + identity directory, the subscription is a procurement artifact, resource + groups patched subscriptions for deployment lifecycle, management groups + patched governance above them. +- GCP designed top-down. The project was the API-console unit from the + beginning (billing attachment, quota, IAM anchor), and when organizations + and folders arrived years later they slotted in above without renaming a + single resource, because resource names had always anchored at the + project: `projects/{project}/secrets/{secret}/versions/{version}`. + +That last property is the decisive one. GCP resource names anchor at the most +stable container and never embed the hierarchy above it, so reorganizing +companies, teams, or billing never rewrites a stored path. GCP Secret +Manager's model (secret plus per-version enabled/disabled/destroyed states) +is also nearly isomorphic to the credential version lifecycle this platform +already ships, which makes its naming grammar a proven reference rather than +an invention. + +Isolation strength does not force the choice. A hard AWS-style wall and a +GCP-style connected hierarchy converge in achievable capability; they differ +in default posture and in which misconfiguration class bites. Every rung of +the isolation ladder (scoped policies on shared infrastructure, per-owner +OpenBao namespaces and keys, dedicated cells) lives beneath the resource +model and changes no name, path, or stream. + +## Decision + +### 1. Organization over project; the project is the owner boundary + +The hierarchy is two levels: organization, then project. The project is the +unit that owns credential vaults, credentials, integrations, API keyspaces, +and quotas. `CredentialOwnerId` in the shipped slice reads as a project id, +and the broader owner value object is a project id. + +### 2. Names anchor at the project and never embed the organization + +The project id is an identifier in the [ADR#0040](./0040-contract-field-vocabulary.md) +sense: rigid, opaque, minted once, never reused. Human-facing naming is a +`display_name` on the project record, never part of a path. Resource names, +OpenBao paths, event stream routing, and generated credential ids contain the +project id and nothing above it, so re-parenting a project to a different +organization is an IAM and billing event, not a storage migration. + +### 3. Public resource names are parent-scoped + +The public management API adopts AIP-style parent-scoped resource names rooted +at the project: `projects/{project}/credential-vaults/{vault}`, +`projects/{project}/credentials/{credential}`, and so on. Flat, unparented +names such as `/v1/credentials` are superseded. The parent is also an +authorization statement: admission derives the expected project from the +authenticated caller context +([ADR#0050](./0050-signed-first-caller-authentication.md), +[ADR#0051](./0051-fully-bound-request-signing.md)) and rejects a request whose +`{project}` does not match it. + +### 4. Environments are attributes, not hierarchy levels + +Environment (production, preview, development) is a field on vaults and +credentials, the way Vercel scopes environment variables, not a container in +the hierarchy. + +### 5. Isolation is deployment posture beneath the model + +Tenant isolation is implemented under the unchanged resource model, in +rungs: scoped OpenBao policies on shared infrastructure first, per-project or +per-organization OpenBao namespaces, mounts, and encryption keys when a +customer tier demands it, dedicated cells at the top. GCP's own guardrail +retrofits (organization policy constraints, deny policies, service +perimeters) are the reference list for the constraint plane this platform +will eventually place above per-project grants. + +### 6. Deferred layers + +Organizations ship later as a pure IAM and billing plane above projects. +Folder-style nesting is deferred until enterprise demand exists; the naming +rule in section 2 guarantees it can be added without touching stored names. + +## Consequences + +- The shipped OpenBao path convention `trogonai/{owner_id}/credentials/{credential_id}` + is ratified as-is, with owner id understood as project id, so no stored path + migrates. +- The tenant value that [ADR#0027](./0027-decider-multi-tenancy-primitive.md) + threads through decider stream and snapshot resolution carries the project + id for credential aggregates. +- Domain work names the owner value object as a project id rather than + inventing a parallel workspace concept; workspace-shaped fields collapse + into the project. +- Cross-project credential moves are disallowed, matching GCP Secret + Manager; a credential is born in a project and dies there. + +## References + +- [ADR#0027: Tenant Value Object for Decider Stream and Snapshot Resolution](./0027-decider-multi-tenancy-primitive.md) +- [ADR#0040: Contract Field Vocabulary: Identifiers, Handles, and Display Labels](./0040-contract-field-vocabulary.md) +- [ADR#0050: Signed Proof-of-Possession as the Strongly Recommended Caller Authentication](./0050-signed-first-caller-authentication.md) +- [ADR#0051: Fully Bound Per-Request Signing Contract](./0051-fully-bound-request-signing.md) +- Google API Improvement Proposals, resource-oriented design (aip.dev) +- Google Cloud Secret Manager resource model and Vercel environment scoping diff --git a/docs/adr/0047-event-sourced-credential-metadata.md b/docs/adr/0047-event-sourced-credential-metadata.md new file mode 100644 index 000000000..751f90316 --- /dev/null +++ b/docs/adr/0047-event-sourced-credential-metadata.md @@ -0,0 +1,72 @@ +--- +number: "0047" +slug: event-sourced-credential-metadata +status: accepted +date: 2026-08-05 +--- + +# ADR#0047: Event Stream as the Credential Metadata Source of Truth + +## Context + +Which store owns credential metadata is an open question for the credential +platform: Postgres, NATS KV, or an existing control-plane database. The +question predates the shipped slice, and the shipped slice has already +answered half of it by construction: the credential aggregate is an +event-sourced decider on NATS JetStream (the +[ADR#0035](./0035-session-store-decider-aggregate.md) pattern), with protobuf +state snapshots on a fixed frequency, a protobuf NATS KV idempotency ledger, +and checkpointed read-side projections (runtime projection and recovery +worker) whose cursors live in KV buckets. The gateway has no database +dependency. + +Introducing a relational store now would create a second write model beside +the stream and the dual-write hazards that come with it, in exchange for +query capabilities (listing, filtering) that no shipped surface requires yet. +The first product slices need correct command handling, idempotent retries, +and runtime resolution, all of which the stream already provides. + +## Decision + +The event stream is the source of truth for credential metadata. No +relational database enters the platform for the first version. + +- Operational records (idempotency, projection and worker checkpoints, and + operation records once they exist) live in NATS KV as protobuf payloads, + keyed and scoped the way the existing idempotency ledger is. +- Listing and query surfaces are deferred. When the product needs them, they + are built as read-side projections over the same streams. Rebuildability is + bounded by retention: a projection rebuild replays the retained event + range, and events purged below the + [ADR#0029](./0029-decider-retention-and-truncation-watermark.md) watermark + are not recoverable by a rebuild; past truncation, only the aggregate's + own snapshot-carried state survives, and that serves the write side, not + a projection replay. +- A relational store may arrive later only as another projection consumer. + It never becomes a write model, and no command handler ever writes to it + directly. + +## Consequences + +- Persistence for vaults, credentials, versions, operations, idempotency + records, and audit facts is reframed: each is either an aggregate on the + stream, a KV record, or a projection, not a table. +- The public API's first slice ships without list endpoints; single-resource + reads resolve through projections or aggregate replay. List endpoints + arrive with their projections. +- Audit facts are events; an audit query surface is a projection over the + retained stream range. + [ADR#0029](./0029-decider-retention-and-truncation-watermark.md) keeps + truncation an operator-invoked decision, so preserving audit-relevant + history is a retention-policy commitment made before any purge runs. +- Retries and recovery keep exactly one consistency mechanism (stream + position plus scoped idempotency), avoiding cross-store reconciliation + between a database and the stream. +- Accepted limitation: no ad-hoc queries until a projection exists for the + question being asked. + +## References + +- [ADR#0035: Session Store as a Decider Aggregate on NATS JetStream](./0035-session-store-decider-aggregate.md) +- [ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform](./0046-project-anchored-resource-hierarchy.md) +- [ADR#0029: Snapshot-Derived Retention Watermark for Decider Streams](./0029-decider-retention-and-truncation-watermark.md) diff --git a/docs/adr/0048-one-time-plaintext-exposure.md b/docs/adr/0048-one-time-plaintext-exposure.md new file mode 100644 index 000000000..416fc169c --- /dev/null +++ b/docs/adr/0048-one-time-plaintext-exposure.md @@ -0,0 +1,58 @@ +--- +number: "0048" +slug: one-time-plaintext-exposure +status: accepted +date: 2026-08-05 +--- + +# ADR#0048: One-Time Plaintext Exposure Contract + +## Context + +Two open questions gate the credential platform's API contracts: whether an +idempotent replay of a create or reroll may re-serve the one-time plaintext it +returned the first time, and whether any server-side escrow of one-time +material (holding the plaintext briefly so a client can fetch it again) is +allowed at all. Both questions trade user convenience against widening the set +of places and moments where raw secret material exists. + +The platform's response rules already commit to metadata-only reads, and the +scoped idempotency ledger stores response snapshots for replay. Whatever +those snapshots contain is retained for the idempotency TTL and replayed to +anyone who can present the key, so their content decides both questions +mechanically. + +## Decision + +Plaintext appears exactly once, in the direct response to the request that +created it. Everything else is metadata. + +- Create, rotate, resubmit, and API-key reroll responses are the only + surfaces that ever carry generated or submitted plaintext, and only in the + immediate response to the winning request. +- Idempotency response snapshots are metadata-only by construction. A replay + under the same idempotency key returns the same operation, resource ids, + and status, and never the plaintext, even for the caller who originally + received it. +- No server-side escrow of one-time material exists in any form. +- Recovery from a lost one-time response is reroll (Trogonai-issued keys) or + resubmission (provider-supplied secrets), never recovery of the original + value. + +## Consequences + +- The idempotency record shape stays metadata-only by contract. The KV ledger + stays inside security-test and audit coverage, which verifies that no + plaintext ever enters its schema or replay path. +- The UI must state plainly that a value shown once cannot be recovered and + offer reroll or resubmit as the recovery action. +- Retry-safety guidance for API clients: capture the plaintext from the + first successful response; a retry that lands as a replay will not carry + it again. +- Security tests assert that no idempotency snapshot, log, trace, metric, or + event payload contains plaintext. + +## References + +- [ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform](./0046-project-anchored-resource-hierarchy.md) +- [ADR#0023: Secret Management and Key Custody on OpenBao behind a Platform Secrets Service](./0023-secret-management-and-key-custody-direction.md) diff --git a/docs/adr/0049-revocation-latency-target.md b/docs/adr/0049-revocation-latency-target.md new file mode 100644 index 000000000..69fdb0e1a --- /dev/null +++ b/docs/adr/0049-revocation-latency-target.md @@ -0,0 +1,67 @@ +--- +number: "0049" +slug: revocation-latency-target +status: accepted +date: 2026-08-05 +--- + +# ADR#0049: Revocation Propagation Latency Target + +## Context + +Revocation latency has to be measured against a stated target for the +platform's revocation guarantee to mean anything operationally. The +measurement side now exists: the gateway records the +`gateway.credential.revocation.latency` histogram (seconds) from a revocation +event's broker-recorded timestamp to the moment the runtime projection +invalidates the cached credential. Three mechanisms bound staleness today: +event-driven invalidation through the checkpointed projection refresh, +fail-closed resolution for revoked or disabled credentials once the +projection reflects them, and the cache TTL of 300 seconds with up to 30 +seconds of deterministic per-key jitter as the backstop when an invalidation +event is missed. + +A target number is a promise about operational behavior, so it is recorded +here as the platform's working service objective rather than left implicit +in dashboards. + +## Decision + +- Target: p99 revocation-to-invalidation latency at or under 5 seconds under + normal operation, as observed by `gateway.credential.revocation.latency` + evaluated as a rolling 5-minute p99. +- Alerting: page when that p99 exceeds 10 seconds sustained over 5 minutes, + or when the histogram stops reporting while credential traffic continues. + An evaluation window holding fewer than 20 revocation samples is skipped + rather than paged on: a tail percentile over a handful of events is noise, + and the missing-data alert already covers silence. The sample floor is a + working value like the latency numbers. +- The target and alert are all-replica, evaluated per replica. Invalidation + is fan-out, each replica invalidating its own cache through its own + consumer, because queue-group delivery would leave the other replicas + serving a revoked credential + ([ADR#0023](./0023-secret-management-and-key-custody-direction.md)). The + histogram therefore carries its emitting instance's resource identity + ([ADR#0008](./0008-opentelemetry-observability.md)), evaluation groups by + instance, and one replica sustaining a p99 above the alert threshold pages + even when the fleet-wide aggregate looks healthy. The sample floor and the + missing-data alert apply per replica as well. +- The cache TTL plus jitter (at most 330 seconds) is the hard upper bound on + staleness when the event path fails entirely; the alert on the event path + exists precisely so the backstop is never the operative mechanism. +- The numbers are working values. They are revisited once production stream + metrics exist, and any change lands as an amendment to this ADR. + +## Consequences + +- Alert definitions have a concrete threshold to encode. +- Event-driven invalidation through the checkpointed projection refresh is + sized against the 5-second target. +- "What revocation latency is required" is settled as a ratified working value + rather than left open. + +## References + +- [ADR#0008: OpenTelemetry Observability](./0008-opentelemetry-observability.md) +- [ADR#0023: Secret Management and Key Custody on OpenBao behind a Platform Secrets Service](./0023-secret-management-and-key-custody-direction.md) +- [ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform](./0046-project-anchored-resource-hierarchy.md) diff --git a/docs/adr/0050-signed-first-caller-authentication.md b/docs/adr/0050-signed-first-caller-authentication.md new file mode 100644 index 000000000..3ae24174f --- /dev/null +++ b/docs/adr/0050-signed-first-caller-authentication.md @@ -0,0 +1,118 @@ +--- +number: "0050" +slug: signed-first-caller-authentication +status: accepted +date: 2026-08-05 +--- + +# ADR#0050: Signed Proof-of-Possession as the Strongly Recommended Caller Authentication + +## Context + +The API key platform offers two caller authentication modes: verifier-only +bearer keys, where the caller presents a shared secret on every request, and +Coinbase-style signed keys, where the caller holds a private key and signs a +short-lived request token. The initial framing treated bearer as the normal +tier and signed as the high-authority exception, and left the first signed-key +algorithm undecided. + +The two modes are not peers on security properties. With a bearer key, the +raw secret exists in three places: at rest on the caller's side, on the wire +in every request, and once, at issuance, in a response body +([ADR#0048](./0048-one-time-plaintext-exposure.md) bounds that last moment). +A breach of platform storage would still yield nothing (verifier digests +only), but every proxy, log line, and TLS-terminating hop on the request +path sees a replayable credential. With a signed key, the platform stores a +public key, which is not a secret; the wire carries a token bound to one +method, host, and path that expires in minutes and cannot be replayed past +its nonce; rotation is a public-key swap; and every request carries a +signature attributing it to a specific key, an audit property bearer keys +structurally cannot have. + +The industry has already moved this direction for exactly these reasons: +request signing in AWS SigV4, signed service-account JWTs in GCP, DPoP +sender-constrained tokens in OAuth (RFC 9449), and WebAuthn replacing +passwords wholesale. Coinbase's protocol is the closest reference for the +token shape, with one caveat worth designing out: their portal generates the +key pair server-side and hands the private key to the caller as a one-time +download, which reintroduces the plaintext-issuance moment the protocol +exists to eliminate. + +What signed keys cannot do is remove plaintext from flows the platform does +not define. Provider-side material (HMAC webhook signing secrets, bot +tokens, OAuth refresh tokens) is raw material the platform must hold because +the counterparty's protocol demands the actual value. The decision below +therefore draws the deprecation boundary explicitly rather than implying +plaintext can vanish everywhere. + +## Decision + +### 1. Signed is the strongly recommended default + +Wherever the platform offers callers an authentication choice, signed +proof-of-possession is the strongly recommended mode and the default posture +of documentation, UI flows, and SDK examples. Bearer keys are the explicitly +labeled compatibility tier for tooling that cannot sign requests, not a peer +option. + +### 2. Client-generated key pairs only + +The platform never generates, transmits, stores, or displays a private key. +Signed-key registration accepts a public key and nothing else. There is no +server-side generation convenience and no one-time private-key download; +the [ADR#0048](./0048-one-time-plaintext-exposure.md) one-time-display +machinery does not apply to this tier because no secret ever exists on the +platform side. + +### 3. Algorithms: Ed25519 default, ES256 accepted + +Ed25519 (JWS `EdDSA`) is the default and recommended algorithm: deterministic +signatures, no ECDSA nonce-reuse failure mode, small keys, wide library +support. ES256 is also accepted for ecosystem and Coinbase-shaped +compatibility. This closes the open "first signed-key algorithm" decision. + +### 4. Request token contract + +The signed request token is a short-lived JWT binding the request target, +issued-at, expiry, and a nonce. The full binding set (transport-mapped +target, payload digest, validity bounds, replay store, server-nonce +escalation) is specified in +[ADR#0051](./0051-fully-bound-request-signing.md), which amends this +section. + +### 5. Bearer remains, policy-bounded + +Bearer keys keep verifier-only storage and one-time display per +[ADR#0048](./0048-one-time-plaintext-exposure.md). Keyspace policy can +disallow bearer issuance entirely, and root and management keyspaces are +signed-only from the start. Deprecating bearer where it is no longer needed +is a per-keyspace policy action, not a platform migration. + +### 6. The plaintext deprecation boundary + +Plaintext-out (platform-issued secrets) is deprecable: it shrinks as +keyspaces move to signed mode and can reach zero for a given keyspace. +Plaintext-in (provider-defined secrets held for HMAC verification and +provider API calls) is not the platform's to deprecate; it persists as raw +material in OpenBao for as long as providers define shared-secret protocols. +Where a provider offers asymmetric verification (for example Ed25519-signed +webhooks), the platform prefers it per source and stores only the public +verification material. + +## Consequences + +- Signed mode is built as the primary path rather than the advanced option; + bearer verification remains constant-time and verifier-only but is + documented as the compatibility tier. +- The platform takes on a nonce replay cache and clock-skew tolerance; + callers on the recommended tier take on private-key custody. +- Audit and `ApiPrincipal` gain per-request key attribution from signatures. +- An ES256-first implementation default is superseded by Ed25519 default with + ES256 compatibility, closing the signed-key algorithm question. + +## References + +- [ADR#0048: One-Time Plaintext Exposure Contract](./0048-one-time-plaintext-exposure.md) +- [ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform](./0046-project-anchored-resource-hierarchy.md) +- [ADR#0051: Fully Bound Per-Request Signing Contract](./0051-fully-bound-request-signing.md) +- RFC 9449 (OAuth DPoP); RFC 8032 (Ed25519) diff --git a/docs/adr/0051-fully-bound-request-signing.md b/docs/adr/0051-fully-bound-request-signing.md new file mode 100644 index 000000000..049ee28b8 --- /dev/null +++ b/docs/adr/0051-fully-bound-request-signing.md @@ -0,0 +1,141 @@ +--- +number: "0051" +slug: fully-bound-request-signing +status: accepted +date: 2026-08-05 +--- + +# ADR#0051: Fully Bound Per-Request Signing Contract + +## Context + +[ADR#0050](./0050-signed-first-caller-authentication.md) made signed +proof-of-possession the strongly recommended caller authentication and +sketched the request token as a short-lived JWT binding method, host, path, +expiry, and a nonce. Studying the strongest deployed variants of the pattern +(Coinbase's Wallet token, DPoP with server-issued nonces per RFC 9449, and +WIMSE workload proof tokens) showed the sketch leaves real gaps: + +- Without payload binding, a token authorizes any body sent to that endpoint + within its validity window. The nonce stops a second use, but whoever + holds an unspent token can attach it to a different payload. +- Method, host, and path are HTTP grammar. Trogonai's internals ride NATS + (jsonrpc-nats, mcp-nats, the A2A stack), where the addressable target is a + subject, and a request may transit brokers and JetStream persistence, + which means more parties see a request in flight than on a single TLS + hop. Binding must be defined per transport, and payload binding matters + more on NATS, not less. +- A purely client-chosen nonce lets the platform detect reuse but never + demand freshness; DPoP's server-issued nonce closes that for surfaces + that warrant it. + +With full binding, the security statement becomes concrete: a captured +request cannot be altered or reused (wrong body, spent nonce, expired +within a minute or two); the one residual power of an intercepted unspent +token is to deliver the caller's exact request once, racing the legitimate +sender for its single admission. A breach of the signed-key tier yields +public keys only, while provider-held plaintext in OpenBao remains, bounded +separately by [ADR#0050](./0050-signed-first-caller-authentication.md) +section 6. Within the signed tier, the sole remaining secret is the +private key on the client machine. + +## Decision + +### 1. Single-use, fully bound tokens + +Every signed request carries a fresh signature; a token authorizes exactly +one request. There is no reduced-binding or multi-use mode. + +### 2. Required binding claims + +- Request target, transport-mapped: for HTTP, method, host, and canonical + path; for NATS, the subject and operation. One canonical serialization is + defined in the API contracts and shared by both; that definition fixes + case, default-port elision, and percent-encoding for HTTP targets, ships + with canonicalization test vectors for both transports, and lands before + `api_key.verify_signed_request` does. +- Payload digest: SHA-256 over the exact request body, with a defined + constant digest for bodyless requests. Always required. +- Time: issued-at and expiry. The validity window is at most 2 minutes, + 1 minute by default; verifiers tolerate at most 30 seconds of clock skew. +- Uniqueness: a client-generated `jti`, checked and recorded against a + replay store scoped by key id. + +### 3. Server-nonce escalation + +The protocol supports DPoP-style server-issued nonces from the first +version: a verifier may reject with a fresh nonce challenge that the client +must bind into its retry. Whether a surface demands it is keyspace policy; +root and management keyspaces demand it by default. + +### 4. Replay store + +Replay records are keyed by key id and `jti` and live in NATS KV per +[ADR#0047](./0047-event-sourced-credential-metadata.md). A record lives for +the validity ceiling plus twice the clock-skew tolerance: skew is +bidirectional, so a token admitted at the earliest tolerated moment +(issued-at minus skew) stays verifiable until expiry plus skew, and the +record must outlive that whole span. Check-and-record +is a single atomic conditional create of the `(key id, jti)` record, never +a read followed by a write; a key-already-exists result is the replay +rejection. Signed-request verification fails closed when the replay store +cannot be consulted. + +### 5. Inherited posture + +Algorithms and key custody follow [ADR#0050](./0050-signed-first-caller-authentication.md): +Ed25519 default, ES256 accepted, client-generated key pairs only. This +contract applies to end-caller authorization at the gateway and riding over +NATS internally; NATS connection identity remains the native NKeys/JWT +machinery, and event provenance remains +[ADR#0039](./0039-self-authenticating-event-provenance.md). + +### 6. NATS transport and admission-time verification + +Over NATS, the token rides in message headers, the bound target is the +concrete subject the message was published on plus the operation in the +payload envelope, and the payload digest covers the raw payload bytes (a +NATS payload is a single byte slice, so no canonicalization rules are +needed). Subject mapping that rewrites subjects breaks signatures the same +way rewriting proxies do on HTTP, and is unsupported in front of signed +subjects. + +The token is verified once, at admission, by the first service that accepts +the request while the token is fresh. From that point authority and origin +travel as provenance on the resulting events per +[ADR#0039](./0039-self-authenticating-event-provenance.md). Downstream and +JetStream consumers do not re-verify caller tokens as a matter of course; +an expired token inside a persisted message is the expected state of an +already-admitted request, not an error. Guidance rather than a hard rule: +per-request signing is worth carrying over NATS wherever a receiver acts on +end-caller authority that connection identity alone cannot establish (agent +tool invocations, management commands, anything done on behalf of a +customer key); pure infrastructure traffic whose authority is fully decided +by NKeys/JWT connection identity and subject permissions does not need it. + +## Consequences + +- `api_key.verify_signed_request` implements the full binding set from its + first version; there is no partially bound rollout stage to migrate away + from later. +- The platform operates a replay store whose size is bounded by request + rate times the record lifetime, the validity ceiling plus twice the clock + skew (180 seconds as specified), roughly three minutes of traffic. +- Clients must know the complete body before signing; streaming uploads + would need a digest-first design, which is acceptable for a management + API surface. +- Intermediaries that rewrite paths or bodies break signatures by design; + the canonicalization rules in the API contracts are the compatibility + surface, and rewriting proxies are unsupported in front of signed routes. +- Amends the token-contract sketch in + [ADR#0050](./0050-signed-first-caller-authentication.md) section 4; that + section now defers to this contract. + +## References + +- [ADR#0050: Signed Proof-of-Possession as the Strongly Recommended Caller Authentication](./0050-signed-first-caller-authentication.md) +- [ADR#0048: One-Time Plaintext Exposure Contract](./0048-one-time-plaintext-exposure.md) +- [ADR#0047: Event Stream as the Credential Metadata Source of Truth](./0047-event-sourced-credential-metadata.md) +- [ADR#0039: Self-Authenticating Event Provenance](./0039-self-authenticating-event-provenance.md) +- RFC 9449 (OAuth DPoP, server nonce); WIMSE workload proof token draft; + AWS SigV4 signed payload hash; Coinbase Wallet request token diff --git a/docs/adr/0052-cloud-kms-production-seal.md b/docs/adr/0052-cloud-kms-production-seal.md new file mode 100644 index 000000000..afb70538f --- /dev/null +++ b/docs/adr/0052-cloud-kms-production-seal.md @@ -0,0 +1,119 @@ +--- +number: "0052" +slug: cloud-kms-production-seal +status: accepted +date: 2026-08-05 +--- + +# ADR#0052: Cloud KMS Auto-Unseal Is Mandatory for Production OpenBao + +## Context + +Everything OpenBao persists is encrypted by its barrier keyring, the +keyring is encrypted by the root key, and the root key is protected by the +seal. What protects the top of that chain is a seal choice: Shamir quorum +shares (the default: the unseal key protecting the root key is split into +shares, typically 5 with a threshold of 3, held by humans and re-entered on +every restart), auto-unseal against a cloud KMS key (AWS KMS, GCP Cloud +KMS, Azure Key Vault), or a transit seal chained to another OpenBao. + +The cloud KMS option roots the platform's entire encryption chain in +FIPS-validated HSMs: the wrapping key physically never leaves the +provider's hardware, every unwrap is an IAM-gated and audit-logged API +call, and a restart becomes an automated unwrap instead of a quorum of +humans typing shares at whatever hour a node restarts. The dependency +points outward to a third party, so it satisfies +[ADR#0033](./0033-two-tier-key-custody-product-model.md)'s rule that +platform boot keys are deployment-provisioned and never cycle back through +the platform's own services. +[ADR#0023](./0023-secret-management-and-key-custody-direction.md) noted +that OpenBao is not an HSM and reserved the hardware-boundary question as a +new decision. This is that decision. + +## Decision + +**Production OpenBao MUST auto-unseal against a cloud KMS key. The Shamir +quorum seal is prohibited as the routine production seal.** This is not a +default to be weighed per deployment; it is the rule. + +### 1. Providers + +GCP Cloud KMS and AWS KMS are the expected providers; Azure is acceptable +in its HSM-backed form. "Cloud KMS" alone does not guarantee the hardware +posture the chain of trust claims, so the seal key MUST be HSM-backed +wherever the provider distinguishes protection levels: `HSM`, not +`SOFTWARE`, on GCP Cloud KMS; Azure Managed HSM or an HSM-protected Key +Vault key; on AWS KMS, key material generated by KMS itself (`AWS_KMS` +origin), which is created and held in KMS HSMs, never imported material, a +CloudHSM-backed custom key store, or an external key store, which carry a +different chain of trust. The key +lives in a +platform-controlled cloud account, with unwrap access IAM-scoped to the +OpenBao service identity and the provider's key-level audit logging +enabled. Key lifecycle is explicit rather than provider-default: rotation +is enabled deliberately (it is opt-in on AWS), prior key versions are never +destroyed while a root key they wrapped might still need unwrapping, purge +or deletion protection is enabled where the provider offers it, and the +key is multi-region or has a documented recovery path for a KMS outage. + +### 2. The single exception + +A deployment whose network cannot reach any cloud KMS (air-gapped or +restricted-egress environments) may use the Shamir quorum seal. The +exception is recorded per deployment with its custody ceremony documented, +and OpenBao seal migration keeps the exception reversible when the network +constraint lifts. + +### 3. Recovery keys are break-glass only + +Auto-unseal still generates a recovery-key quorum. Those shares exist +solely for break-glass operations under +[ADR#0023](./0023-secret-management-and-key-custody-direction.md)'s +out-of-band ceremony (quorum-held shares, root token revoked after use) and +are never part of routine operation. Recovery keys authorize administrative +operations such as generating a root token or approving a seal migration; +they cannot unseal the cluster or decrypt the root key, so they are not a +fallback unseal path when the KMS is unreachable. + +### 4. Development + +Dev and local environments use the static or single-share dev seal per +[ADR#0023](./0023-secret-management-and-key-custody-direction.md)'s +dev-mode story. The mandate applies to production only. + +### 5. Provisioning + +The seal stanza is deployment configuration under +[ADR#0033](./0033-two-tier-key-custody-product-model.md)'s bootstrap rule, +outside the `SecretStore` and `KeyManagement` ports. No platform code +depends on which seal a deployment uses. + +## Consequences + +- The unseal and key custody runbook has its shape: restarts unseal + automatically; the runbook covers the KMS-outage path (running nodes stay + unsealed, restarts block until KMS returns or the documented recovery is + executed) and the break-glass recovery ceremony. +- The recoverability boundary is stated plainly: seal migration requires + the current seal to still be reachable and takes a brief full-cluster + restart, so migrating away from a failing KMS key is possible only while + that key still unwraps. Permanent loss of the seal key with no surviving + migration path loses the cluster, storage backups included; the + documented recovery path in section 1 exists to prevent exactly that. +- The chain of trust reads end to end: cloud HSM wraps the OpenBao root + key, the barrier keyring encrypts platform storage, the secrets service and + `KeyManagement` route tenant material to managed or customer-managed + backends per [ADR#0030](./0030-customer-controlled-key-backend-routing.md). +- Auto-unseal protects the bootstrap of the chain only. It does not protect + a compromised running process or over-permissive API access; those are + the service auth-method decision (still open) and OpenBao policies. +- A deliberate cloud dependency is accepted for the seal. Deployments that + cannot accept it fall under the section 2 exception, not under a softer + reading of the rule. + +## References + +- [ADR#0023: Secret Management and Key Custody Direction](./0023-secret-management-and-key-custody-direction.md) +- [ADR#0030: Customer-Controlled Key Backend Routing](./0030-customer-controlled-key-backend-routing.md) +- [ADR#0033: Two-Tier Key Custody Product Model](./0033-two-tier-key-custody-product-model.md) +- OpenBao seal configuration and seal migration documentation diff --git a/docs/adr/index.md b/docs/adr/index.md index bf59ba928..6ea74c18c 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -50,3 +50,11 @@ future implementation work. - [ADR#0042: NATS Trace Context and Message Path Tracing (Draft)](./0042-nats-trace-context-and-message-path-tracing.md) - [ADR#0043: Agent Instructions Ownership and Shape (Draft)](./0043-agent-instructions-ownership-and-shape.md) - [ADR#0044: Inbound Media Is Fetched Out of Band by a Dedicated Consumer (Draft)](./0044-inbound-media-fetch-out-of-band.md) +- [ADR#0045: Aggregate-Oriented Module Layout for Event-Sourced Services](./0045-event-sourced-service-module-layout.md) +- [ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform](./0046-project-anchored-resource-hierarchy.md) +- [ADR#0047: Event Stream as the Credential Metadata Source of Truth](./0047-event-sourced-credential-metadata.md) +- [ADR#0048: One-Time Plaintext Exposure Contract](./0048-one-time-plaintext-exposure.md) +- [ADR#0049: Revocation Propagation Latency Target](./0049-revocation-latency-target.md) +- [ADR#0050: Signed Proof-of-Possession as the Strongly Recommended Caller Authentication](./0050-signed-first-caller-authentication.md) +- [ADR#0051: Fully Bound Per-Request Signing Contract](./0051-fully-bound-request-signing.md) +- [ADR#0052: Cloud KMS Auto-Unseal Is Mandatory for Production OpenBao](./0052-cloud-kms-production-seal.md) diff --git a/docs/architecture/key-custody.md b/docs/architecture/key-custody.md index 82322254e..c8a025231 100644 --- a/docs/architecture/key-custody.md +++ b/docs/architecture/key-custody.md @@ -46,9 +46,12 @@ ceremony, or attested identity. material and then under the external manager's key, so neither party alone can decrypt it. - **[OpenBao](../glossary/openbao)** encrypts everything it stores, including - Transit keys, under a barrier key. The barrier key is protected by the - unseal ceremony: quorum-held Shamir shares, or auto-unseal against another - KMS. OpenBao is not an HSM, and unsealed key material exists in server + Transit keys, under its barrier keyring. The keyring is encrypted by the + root key, and the root key is protected by the seal: quorum-held Shamir + shares of the unseal key, or auto-unseal against another KMS. Production + mandates the cloud KMS auto-unseal + ([ADR#0052](../adr/0052-cloud-kms-production-seal.md)). OpenBao is not an + HSM, and unsealed key material exists in server process memory ([ADR#0023](../adr/0023-secret-management-and-key-custody-direction.md)). - **The platform** reaches OpenBao through deployment-attested identity, never @@ -64,7 +67,12 @@ two customer-facing tiers, which differ in who holds the KEK hop of the chain: - With a **[managed key](../glossary/managed-key)**, the KEK is an OpenBao Transit key operated by the platform. The chain terminates in the platform - operator's OpenBao barrier and unseal ceremony. + operator's OpenBao barrier keyring and its seal: in production, mandatory + auto-unseal against a platform-controlled, HSM-backed cloud KMS key + ([ADR#0052](../adr/0052-cloud-kms-production-seal.md)). The Shamir quorum + seal survives only under that ADR's air-gapped exception; auto-unseal still + generates a recovery-key quorum for break-glass administrative operations, + and those shares cannot unseal the cluster. - With a **[customer managed key](../glossary/customer-managed-key)**, the KEK lives in a backend the customer controls: their AWS KMS key, their Google Cloud KMS key, or their own OpenBao. The platform holds only wrapped DEKs, diff --git a/rsworkspace/crates/AGENTS.md b/rsworkspace/crates/AGENTS.md index 10647b98a..9cbbcc8ca 100644 --- a/rsworkspace/crates/AGENTS.md +++ b/rsworkspace/crates/AGENTS.md @@ -22,3 +22,5 @@ For NATS infrastructure and testing, use the `trogon-nats` crate which provides: ## Module conventions Place observability concerns (metrics, tracing spans, logging helpers) under a `telemetry` module within each crate. Example: `acp-nats/src/telemetry/metrics.rs`. This keeps observability code separated from domain logic and provides a consistent location across crates. + +For event-sourced services, organize by stream and follow [ADR#0045](../../docs/adr/0045-event-sourced-service-module-layout.md). Each stream is a module named for the workflow it represents, expressed as a noun (never a mechanism word like `Lifecycle`/`Manager`/`Service`), containing `commands` (one decider per file, with the write-side `state`, `snapshot`, and `domain` submodules nested inside it), the read-side `processor`, and the stream's own persistence and handler. `domain` holds value objects only; events and state are the generated proto types. Value objects for a stream live under that stream's `commands/domain`, not at the crate root. `trogon-scheduler` is the reference implementation.