session 48: the plugin OpenBao can actually spawn — go-plugin's protocol, in Rust (#48) #52

Merged
larandar merged 13 commits from tango/48-bao-plugin-protocol into fastlane 2026-09-06 07:50:14 +00:00
Owner

Closes #48 when its live run lands. A worker is still driving the end-to-end run against a real bao; this PR is open early for review of the protocol work.

Why

openbao-plugin/ held the ordered issue flow, the lease machine, the one-shot wrapper and the signed-command dispatch — as a library OpenBao cannot load. No handshake, no GRPCBackend, no broker. So "OpenBao owns the lease" was true in the type system and false on the host. Freeholder ruling D1 (2026-09-05): "it's better to have the plugin be same language as Vedanta so Rust it is."

What lands

  • The protocol, vendored not guessed. Seven .proto files copied verbatim from openbao/openbao v2.6.2 and hashicorp/go-plugin v1.8.0, with their MPL-2.0 licences and the exact revisions recorded in proto/PIN. Bumping a pin is a deliberate act with a written procedure.
  • No protoc, no Go. build.rs generates with tonic-prost-build over protox, a pure-Rust protoc, so the devenv shell needs no protoc and the workspace needs no second toolchain. Fifteen .rs files; there is no .go file and no go.mod in this tree.
  • The go-plugin handshake, auto-mTLS from PLUGIN_CLIENT_CERT, and the three control services (GRPCController, GRPCBroker, GRPCStdio) plus the health service go-plugin pings.
  • pb.Backend routed to the existing flows, with the four stores backed by OpenBao's own storage over the broker — the plugin owns no file.
  • The binary vedanta-openbao-plugin that OpenBao's catalog spawns.

Evidence

devenv shell --: cargo build --workspace clean, cargo clippy --workspace --all-targets -- -D warnings clean, cargo test --workspace green — 71 plugin lib tests, plus the root crate's own suite unaffected. The write surface stayed inside openbao-plugin/ and Cargo.lock; independently verified.

What is honestly not proven yet

  1. The live run. tests/bao_dev.rs is written and currently #[ignore]d. Run once against real bao v2.6.2, plugin registration answers 204 and the immediate catalog read-back does not echo the version. Under diagnosis; the flow past that point (mount → issue → unwrap → read → rotate → renew → revoke) has not been exercised.
  2. The handshake constants — the magic-cookie key and value, core protocol version 1, app protocol version 5, and the PLUGIN_* environment names — are attributed by comments to OpenBao's sdk/plugin/serve.go and go-plugin's client.go, neither of which is vendored here (only .proto files are). They are internally consistent and match the handshake unit tests, but they were not re-confirmed against the Go source. A successful live handshake confirms them; until then this is stated, not hidden.

What a reviewer must check

  1. Whether vendoring .proto files under their own licences is the shape you want for protocol definitions in this repository.
  2. The four stores' mapping onto broker storage — the plugin must own no durable state of its own.
  3. That the wire shape matches flake-ops#435's client, which was written against the same contract and lists five places where the crate and the interfaces spec disagree.
Closes #48 when its live run lands. **A worker is still driving the end-to-end run against a real `bao`; this PR is open early for review of the protocol work.** ## Why `openbao-plugin/` held the ordered issue flow, the lease machine, the one-shot wrapper and the signed-command dispatch — as a **library OpenBao cannot load**. No handshake, no `GRPCBackend`, no broker. So "OpenBao owns the lease" was true in the type system and false on the host. Freeholder ruling D1 (2026-09-05): *"it's better to have the plugin be same language as Vedanta so Rust it is."* ## What lands - **The protocol, vendored not guessed.** Seven `.proto` files copied verbatim from `openbao/openbao v2.6.2` and `hashicorp/go-plugin v1.8.0`, with their MPL-2.0 licences and the exact revisions recorded in `proto/PIN`. Bumping a pin is a deliberate act with a written procedure. - **No protoc, no Go.** `build.rs` generates with `tonic-prost-build` over `protox`, a pure-Rust protoc, so the devenv shell needs no `protoc` and the workspace needs no second toolchain. Fifteen `.rs` files; there is no `.go` file and no `go.mod` in this tree. - **The go-plugin handshake**, auto-mTLS from `PLUGIN_CLIENT_CERT`, and the three control services (`GRPCController`, `GRPCBroker`, `GRPCStdio`) plus the health service go-plugin pings. - **`pb.Backend` routed to the existing flows**, with the four stores backed by **OpenBao's own storage over the broker** — the plugin owns no file. - **The binary** `vedanta-openbao-plugin` that OpenBao's catalog spawns. ## Evidence `devenv shell --`: `cargo build --workspace` clean, `cargo clippy --workspace --all-targets -- -D warnings` clean, `cargo test --workspace` green — **71 plugin lib tests**, plus the root crate's own suite unaffected. The write surface stayed inside `openbao-plugin/` and `Cargo.lock`; independently verified. ## What is honestly not proven yet 1. **The live run.** `tests/bao_dev.rs` is written and currently `#[ignore]`d. Run once against real `bao` v2.6.2, plugin registration answers 204 and the immediate catalog read-back does not echo the version. Under diagnosis; the flow past that point (mount → issue → unwrap → read → rotate → renew → revoke) has not been exercised. 2. **The handshake constants** — the magic-cookie key and value, core protocol version 1, app protocol version 5, and the `PLUGIN_*` environment names — are attributed by comments to OpenBao's `sdk/plugin/serve.go` and go-plugin's `client.go`, **neither of which is vendored here** (only `.proto` files are). They are internally consistent and match the handshake unit tests, but they were not re-confirmed against the Go source. A successful live handshake confirms them; until then this is stated, not hidden. ## What a reviewer must check 1. Whether vendoring `.proto` files under their own licences is the shape you want for protocol definitions in this repository. 2. The four stores' mapping onto broker storage — the plugin must own no durable state of its own. 3. That the wire shape matches [flake-ops#435](https://jo.et0.pw/lar.ad/flake-ops/pulls/)'s client, which was written against the same contract and lists five places where the crate and the interfaces spec disagree.
OpenBao's plugin protocol is HashiCorp go-plugin over gRPC; the proto
files are the only ground truth for that wire shape. Copy them verbatim
from openbao/openbao v2.6.2 and hashicorp/go-plugin v1.8.0 (the go-plugin
release v2.6.2 itself pins) under proto/, with their licences and a PIN
file recording exactly which revisions and why bumping either is a
deliberate re-read, not a version bump.

build.rs compiles them with protox + tonic-prost-build instead of calling
protoc: the devenv shell ships no protoc, so a build that needed one would
silently become "works on the machine that had it".

Ruling: this is scaffolding from an earlier worker on tango/48-bao-plugin-
protocol who died on a rate limit before committing. I read every file,
built and tested the result, and am committing what compiles rather than
losing the work. Protocol facts here (the .proto wire shapes themselves)
are verified directly against the vendored files. Facts that live only in
OpenBao's/go-plugin's Go source (not vendored here) — the magic-cookie
key/value, CoreProtocolVersion, the handshake line's exact field order —
are NOT independently verified by this commit; see the handshake commit
and the final report for what that means.
The three pieces of go-plugin's own protocol, ahead of anything OpenBao-
specific: the one handshake line printed on stdout (CORE-PROTOCOL-VERSION
|APP-PROTOCOL-VERSION|unix|<socket>|grpc|<cert>, with the magic-cookie
check and protocol-version negotiation gating it), auto-mTLS pinned to
exactly the certificate go-plugin hands the plugin in PLUGIN_CLIENT_CERT
(rustls on aws-lc-rs, not ring — go-plugin's client certs are ECDSA P-521
and ring has no P-521), and the GRPCController/GRPCBroker/GRPCStdio
services go-plugin's host expects on the same server.

Ruling: scaffolding from the earlier worker on this branch, read in full
and kept because it builds, passes its unit tests (handshake line shape,
magic-cookie check, protocol negotiation, mTLS pinning in both
directions), and matches go-plugin's documented behaviour.

Caveat carried into the report: the magic-cookie key/value
(VAULT_BACKEND_PLUGIN / 6669da05-b1c8-4f49-97d9-c8e5bed98e20),
CoreProtocolVersion=1, and the exact env var names are asserted in this
file's comments to come from OpenBao's sdk/plugin/serve.go and
go-plugin's client.go/constants.go/server.go — none of which are vendored
in this tree (only the .proto files are). I did not fetch that Go source
to re-verify; I can confirm the handshake LINE SHAPE and the proto
service surface against what is vendored, not these specific constants.
Treat them as inherited, not independently confirmed.
The GRPCBroker dial-back (broker.rs) that turns the stream ID Setup
supplies into an address to dial, and the Storage-service client
(storage.rs) built on it — transactional-host and non-transactional-host
paths both handled, since OpenBao only guarantees one of them per backend.
stores.rs is the four IntentStore/LeaseStore/WrapStore-shaped ports from
the existing issue/lease flows, reimplemented over that storage client
instead of an in-process file, so the plugin holds no durable state of its
own; OpenBao's storage backend is the only place a lease record lives.

Ruling: same provenance as the rest of this branch — read, built, unit-
tested (broker dial timing including the delivered-before-dial and
dial-before-delivery races; storage round-trip and one-shot semantics for
both transactional and non-transactional hosts) — kept as-is.
backend.rs is the pb.Backend gRPC service: Setup dials the broker for
Storage/SystemView and reads mount options; HandleRequest routes the five
workload-shaped paths (issue, lease/<id> read, .../renew, .../revoke,
unwrap/<token>) to the existing issue::issue and lease::{rotate,revoke}
flows unchanged, and wraps a lease-creating answer in OpenBao's own Secret
metadata so the host's lease manager — not this plugin — owns renew and
revoke scheduling. vedanta.rs is PrivateMint over Vedanta #47's private
transport (unix socket, signed envelope), reached with the Ed25519 signing
key from the path OpenBao's plugin environment supplies, never stored.

lib.rs now declares the full module tree the crate gained on this branch:
backend, broker, goplugin, handshake, mtls, proto, storage, stores,
vedanta, alongside the unchanged issue/lease contract flows.

Ruling: read and kept for the same reason as the rest of the branch —
unit-tested (routing table, denial-class mapping, mount-setting precedence,
TTL/seed parsing, Secret-wrapping only for lease-creating answers; the
private-transport response parsing and envelope posting).
main.rs: check the magic cookie, negotiate the protocol version, serve
GRPCController/GRPCBroker/GRPCStdio and pb.Backend on one auto-mTLS
listener, print the handshake line, and hold the process open until the
controller's Stop is called. This is the seam the whole branch was for —
without it, openbao-plugin was a library OpenBao cannot load, exactly the
gap Ting/Vedanta#48 names.

Ruling: read and kept for the same reason as the rest of the branch.
bao_dev.rs walks issue -> unwrap -> read -> rotate -> renew -> revoke
against a real `bao server -dev` with the built plugin registered in its
catalog, backed by a Vedanta double on a unix socket. This is the
acceptance test the issue's design section calls for, but the task
bounding this pass draws the line at "compiles with unit tests" and hands
the real end-to-end run to a separate task — so it is marked #[ignore]
rather than left to fail cargo test --workspace.

One compile bug fixed: the mount call referenced an undefined `version`
local; it now builds the same `v{CARGO_PKG_VERSION}` string the catalog
assertion above it already computes inline.

Measured, 2026-09-06, `bao` v2.6.2 present on PATH (matches proto/PIN):
running it un-ignored gets past the plugin registration (PUT returns
204) but fails at the very next line — the catalog GET's `data.version`
comes back JSON null where the test expects "v0.1.0". I did not chase
this further: it may be a real bug in how the plugin answers OpenBao's
version probe, a wrong assumption about the catalog API's response shape,
or a bao-dev quirk. Whoever picks up the end-to-end task should start by
reading plugin.log and bao.log (the test writes both to a scratch dir and
prints them on any assertion failure) from an un-ignored run.
Chased a suspicion that HandleRequestArgs.storage_id names a fresh,
per-request, namespace-scoped broker connection for Storage/SystemView
(distinct from Setup's one persistent connection) — plausible reading of
the field, and a candidate explanation for a "no namespace" error hit
downstream in SystemView.ResponseWrapData. Tried it: dialing storage_id
per request times out ("no broker address for service id 0 within 5s"),
and a new broker.deliver trace across a full bao_dev.rs walk shows OpenBao
announcing exactly one broker address, ever, for Setup's own broker_id.

Confirmed against OpenBao's own upstream source (sdk/plugin/grpc_backend_
server.go, backendGRPCPluginServer.HandleRequest, both the v2.6.2 tag and
current main): the real Go SDK's own plugin-side Backend server looks the
backend up by multiplex id, not by args.StorageID, and never redials.
storage_id is accepted but genuinely unused, matching the vendored
proto's own comment on Setup ("use the provided broker_id ... for the
Storage and SystemView clients") — one connection, for the mount's whole
lifetime. Recorded this in answer()'s doc comment so nobody re-chases it,
and left the broker.deliver/setup broker_id debug traces in place since
they're what pinned this down.
Ran bao_dev.rs un-ignored against real bao v2.6.2 (proto/PIN's pin,
present on PATH). Two real bugs, both in the test, both fixed:

- The version:null the previous pass stopped on: registering a plugin
  without pinning `version` auto-detects it (confirmed via `bao plugin
  list -detailed`), but either way OpenBao files the catalog entry under
  name *and* version. A bare-name GET 404s regardless — its JSON body has
  no "data" key at all, and indexing a missing key with serde_json::Value
  reads as Null, which is what surfaced as "version: null" rather than a
  404 status. Fixed by pinning `version` on the PUT and reading it back
  with `?version=` — confirmed against bao's own HTTP responses.

- `sys/mounts/identity` collided with the always-present built-in identity/
  mount ("cannot mount \"identity/\""), confirmed in bao's server log.
  Renamed the plugin's mount (and every subsequent lease path) to
  vedanta-identity/.

With both fixed, registration, the catalog read-back, and the mount all
pass. The walk still fails at ISSUE: SystemView.ResponseWrapData answers
"no namespace" for any out-of-process plugin (see the doc comment above
the test and the 2026-09-06 report addendum for the source-verified root
cause and the wrap redesign it needs), so the test stays #[ignore]d with
that specific, sourced reason rather than the old speculative one.
Author
Owner

Progress, and one upstream finding that changes the design.

Two commits pushed (bf6f0fc, e7577c6). The live walk against a real bao v2.6.2 now gets through registration, catalog read-back and mount, which it did not before.

The two test bugs, both real

  1. data.version: null was a 404 on a bare-name catalog GET. A versioned catalog entry must be read back with ?version=. Confirmed against bao's own HTTP responses and bao plugin info — the earlier report's "registration succeeded but the version is null" was the wrong reading of the symptom.
  2. sys/mounts/identity collides with OpenBao's built-in identity/ mount. The test now mounts at vedanta-identity/.

The finding

The walk now fails at ISSUE with response wrapping refused error=storage: no namespace, and the cause is upstream, not here: SystemView.ResponseWrapData is unusable from an out-of-process plugin. The gRPC connection the plugin dials back over never carries a namespace-scoped context, and jwt: true does not route around it — the server hardcodes jwt=false. Read from OpenBao's own Go source at the v2.6.2 tag and on current main, not inferred from the error string.

So the plugin cannot wrap by calling OpenBao. It must instead set the wrap TTL on its Response and let core wrap, using the correctly-scoped original request context. That is arguably the shape the contract wanted all along — OpenBao owns delivery — but it is a real redesign across issue.rs, lease.rs, stores.rs, backend.rs and about fifteen unit tests, including whatever WrapStore becomes. In progress.

Discipline worth noting for the reviewer

bf6f0fc is a reverted hypothesis, kept in the history: the worker suspected HandleRequestArgs.storage_id had to be redialled per request, tested it against a real bao with a broker trace, found OpenBao only ever announces one broker connection, confirmed it against sdk/plugin/grpc_backend_server.go, and reverted to the original design. The commit records the disproof rather than hiding it.

State: build and clippy clean, 203 tests passing, unchanged. The acceptance test is re-#[ignore]d with a source-backed reason replacing the earlier speculative one.

**Progress, and one upstream finding that changes the design.** Two commits pushed (`bf6f0fc`, `e7577c6`). The live walk against a real `bao` v2.6.2 now gets through **registration, catalog read-back and mount**, which it did not before. ### The two test bugs, both real 1. `data.version: null` was **a 404 on a bare-name catalog GET**. A versioned catalog entry must be read back with `?version=`. Confirmed against `bao`'s own HTTP responses and `bao plugin info` — the earlier report's "registration succeeded but the version is null" was the wrong reading of the symptom. 2. `sys/mounts/identity` **collides with OpenBao's built-in `identity/` mount**. The test now mounts at `vedanta-identity/`. ### The finding The walk now fails at ISSUE with `response wrapping refused error=storage: no namespace`, and the cause is upstream, not here: **`SystemView.ResponseWrapData` is unusable from an out-of-process plugin.** The gRPC connection the plugin dials back over never carries a namespace-scoped context, and `jwt: true` does not route around it — the server hardcodes `jwt=false`. Read from OpenBao's own Go source at the v2.6.2 tag and on current `main`, not inferred from the error string. So the plugin cannot wrap by calling OpenBao. It must instead set the wrap TTL on its `Response` and let **core** wrap, using the correctly-scoped original request context. That is arguably the shape the contract wanted all along — OpenBao owns delivery — but it is a real redesign across `issue.rs`, `lease.rs`, `stores.rs`, `backend.rs` and about fifteen unit tests, including whatever `WrapStore` becomes. In progress. ### Discipline worth noting for the reviewer `bf6f0fc` is a **reverted hypothesis, kept in the history**: the worker suspected `HandleRequestArgs.storage_id` had to be redialled per request, tested it against a real `bao` with a broker trace, found OpenBao only ever announces one broker connection, confirmed it against `sdk/plugin/grpc_backend_server.go`, and reverted to the original design. The commit records the disproof rather than hiding it. State: build and clippy clean, 203 tests passing, unchanged. The acceptance test is re-`#[ignore]`d with a **source-backed** reason replacing the earlier speculative one.
SystemView.ResponseWrapData is confirmed unusable from an out-of-process
plugin: the broker-served SystemView connection carries no namespace in
its context (sdk/plugin/grpc_backend_client.go's Setup serves it over a
bare grpc.NewServer with no interceptor), and dynamicSystemView.ResponseWrapData
fails outright without one. The corrected reading of
contracts.identity.openbao-lease-backend's one-shot clause holds OpenBao's
own barrier-encrypted lease storage inside the trust boundary — it is
where every stock secrets engine's renew/revoke data already lives — so
the credential may ride Response.Data on its way into a Secret-bearing
answer as long as OpenBao's core, not this plugin, performs the wrap.

WrapStore is gone. IssueOutcome::Issued and RotateOutcome::Rotated now
carry the mint's credential directly, wrapped in a Credential newtype
whose Debug always prints "Credential(REDACTED)" so no {:?} of an outcome
can spell it. backend.rs::reply_for sets Response.wrap_info.TTL on every
lease-creating answer; OpenBao's own request_handling.go wraps the whole
response — Secret included — over the ORIGINAL HTTP request's own,
correctly namespace-scoped context, after having already registered the
lease from the same Secret/Data. The immediate, caller-visible reply to
issue/rotate/renew now shows only wrap_info; lease_id/credential/renewable
surface once, at sys/wrapping/unwrap.

Two further, previously-unreached bugs surfaced once a real Secret-bearing
answer round-tripped through a live bao v2.6.2 for the first time:

- LeaseOptions.issue_time was always None. The Go SDK's
  ProtoLeaseOptionsToLogicalLeaseOptions calls IssueTime.CheckValid()
  unconditionally and fails the WHOLE response ("invalid nil Timestamp")
  when it is absent. Answer::Lease now carries issued_at_unix and
  reply_for sets a real timestamp; the value is otherwise unused
  downstream (expiration.go's Register computes the lease's actual TTL
  with a bare time.Time{}).
- lease_manager_revoke ran fault_from_raw over EVERY answer from the
  plugin's own revoke, including a completed {"state":"revoked",...}
  success — fault_from_raw reads an errors[0].code a success body does
  not have, defaulted to the literal string "error", and turned every
  completed revoke into a bogus Fault. fault_from_raw now only wraps an
  actual denial; a completed or still-pending revoke is read directly.

storage.rs keeps SystemView and response_wrap_data as vendored, tested,
confirmed-unused protocol surface, with a doc comment tracing exactly why
it cannot work rather than removing real vendored surface.

Co-Authored-By: Claude Opus 5 <[email protected]>
Rewrites the wire-shape assertions bao_dev.rs made under the old
SystemView.ResponseWrapData design: every lease-creating answer
(issue, rotate, OpenBao's own sys/leases/renew) now shows only
wrap_info on the immediate response — lease_id comes back "" and data
null, matching what OpenBao's core actually blanks a wrapped response
down to — and the lease_id/credential/successor metadata is read only
after sys/wrapping/unwrap. Adds an explicit check that unwrapping an
unknown token reads the same as unwrapping a spent one (both 400),
proving consumed/expired/unknown are indistinguishable to a caller.

Un-ignores the test now that it walks register → mount → issue → unwrap
→ read → rotate → renew → revoke clean against a real bao server -dev
v2.6.2.

Co-Authored-By: Claude Opus 5 <[email protected]>
Author
Owner

The live walk passes, un-ignored. A real bao server -dev v2.6.2 loads this plugin from its catalog, mounts it, and drives register → mount → issue → unwrap → read → rotate → renew → revoke end to end. Two commits pushed (23012ab, fa96828).

Verified independently by the queen: cargo test --workspace — 128 + 71 + 1 + 3 doctests + 1 acceptance test, nothing ignored, test a_dev_openbao_mounts_the_plugin_and_walks_the_lease_end_to_end ... ok. Clippy -D warnings clean.

That also settles the open question in this PR's description: the handshake constants were never confirmed against Go source, and a successful live handshake is that confirmation. bao spawns the binary and speaks to it.

The two source facts this rests on

  1. Core wraps AFTER lease registration, not before. vault/request_handling.go's handleRequest registers the lease — persisting resp.Data through expiration.go's Register() — before switchedLockHandleRequest calls wrapInCubbyhole. So the credential is written into OpenBao's own lease storage. That does not evaporate; it is accepted, see below.
  2. Response.Secret is the only gate for OpenBao owning renew and revoke. Response.Data need not carry the credential — but the only ways to deliver a value once without core's own wrap are the confirmed-broken SystemView.ResponseWrapData or a plugin-owned cubbyhole, and the freeholder ruled the plugin must hold nothing. So native wrap_info is the standards-track path.

The contract reading this depends on — a reviewer should confirm it

An earlier pass reverted this exact design because a brief said the value must never be "in storage". That phrasing was the queen's, and stricter than the contract. credential-material-is-one-shot binds Vedanta: "Vedanta MAY carry credential material only in the immediate successful result to OpenBao. It MUST NOT persist the value…" Acceptance I04's negative names status, logs, traces, arguments, environment dumps, Git and issue text. OpenBao's barrier-encrypted lease storage is in neither list, and holding dynamic-secret data in the lease entry is how Vault-family engines work — it is what revocation later reads.

Freeholder preference (Larandar, 2026-09-06): "the plugin should not hold the credentials but only make them available in OpenBao." This is that.

What landed

WrapStore is gone. The credential rides in a Credential newtype with a redacting Debug, and backend.rs::reply_for sets wrap_info.TTL of 300 s on every lease-creating answer; core wraps. Three properties are proved by test rather than asserted: the plugin persists no credential value anywhere of its own, the wrapper is one-shot with consumed/expired/unknown indistinguishable, and one operation ID mints at most once.

Two real bugs surfaced only once a Secret-bearing answer round-tripped for the first time — a nil issue_time that failed every lease-creating response, and lease_manager_revoke turning every completed revoke into a bogus fault. Both fixed; neither was reachable before.

Still for a reviewer

  • The contract reading above. It is the load-bearing judgment here.
  • The credential_data field shape against the workload client's real expectations on flake-ops#479, which currently posts to sys/wrapping/unwrap.
  • Whether SystemView::response_wrap_data should remain as vendored, documented-unusable surface.
  • The five-minute TTL is enforced by OpenBao's mechanism; this pass did not independently re-time its expiry.
**The live walk passes, un-ignored.** A real `bao server -dev` v2.6.2 loads this plugin from its catalog, mounts it, and drives **register → mount → issue → unwrap → read → rotate → renew → revoke** end to end. Two commits pushed (`23012ab`, `fa96828`). Verified independently by the queen: `cargo test --workspace` — 128 + 71 + 1 + 3 doctests + **1 acceptance test, nothing ignored**, `test a_dev_openbao_mounts_the_plugin_and_walks_the_lease_end_to_end ... ok`. Clippy `-D warnings` clean. That also settles the open question in this PR's description: the handshake constants were never confirmed against Go source, and a successful live handshake is that confirmation. `bao` spawns the binary and speaks to it. ### The two source facts this rests on 1. **Core wraps AFTER lease registration, not before.** `vault/request_handling.go`'s `handleRequest` registers the lease — persisting `resp.Data` through `expiration.go`'s `Register()` — before `switchedLockHandleRequest` calls `wrapInCubbyhole`. So the credential *is* written into OpenBao's own lease storage. That does not evaporate; it is accepted, see below. 2. **`Response.Secret` is the only gate** for OpenBao owning renew and revoke. `Response.Data` need not carry the credential — but the only ways to deliver a value once without core's own wrap are the confirmed-broken `SystemView.ResponseWrapData` or a plugin-owned cubbyhole, and the freeholder ruled the plugin must hold nothing. So native `wrap_info` is the standards-track path. ### The contract reading this depends on — a reviewer should confirm it An earlier pass reverted this exact design because a brief said the value must never be "in storage". **That phrasing was the queen's, and stricter than the contract.** `credential-material-is-one-shot` binds **Vedanta**: *"Vedanta MAY carry credential material only in the immediate successful result to OpenBao. It MUST NOT persist the value…"* Acceptance I04's negative names status, logs, traces, arguments, environment dumps, Git and issue text. **OpenBao's barrier-encrypted lease storage is in neither list**, and holding dynamic-secret data in the lease entry is how Vault-family engines work — it is what revocation later reads. Freeholder preference (Larandar, 2026-09-06): *"the plugin should not hold the credentials but only make them available in OpenBao."* This is that. ### What landed `WrapStore` is gone. The credential rides in a `Credential` newtype with a redacting `Debug`, and `backend.rs::reply_for` sets `wrap_info.TTL` of 300 s on every lease-creating answer; core wraps. Three properties are proved by test rather than asserted: the plugin persists no credential value anywhere of its own, the wrapper is one-shot with consumed/expired/unknown indistinguishable, and one operation ID mints at most once. **Two real bugs surfaced only once a `Secret`-bearing answer round-tripped for the first time** — a nil `issue_time` that failed every lease-creating response, and `lease_manager_revoke` turning every completed revoke into a bogus fault. Both fixed; neither was reachable before. ### Still for a reviewer - The contract reading above. It is the load-bearing judgment here. - The `credential_data` field shape against the workload client's real expectations on [flake-ops#479](https://jo.et0.pw/lar.ad/flake-ops/pulls/479), which currently posts to `sys/wrapping/unwrap`. - Whether `SystemView::response_wrap_data` should remain as vendored, documented-unusable surface. - The five-minute TTL is enforced by **OpenBao's** mechanism; this pass did not independently re-time its expiry.
larandar left a comment

Verdict: changes required.

  1. P1 — Predecessor retirement overwrites concurrent lease transitions. openbao-plugin/src/backend.rs:1107–1112: the request loads a record, awaits native invalidation, then saves the whole old record. answer() holds only a shared RwLock read guard. A concurrent rotate/revoke can commit during that await and then be overwritten, losing the successor or restoring Active. Serialize per-lease transitions or use conditional transactional updates.
  2. P1 — Active-lease ceiling is not reserved atomically. openbao-plugin/src/backend.rs:749: concurrent issue requests with distinct operation IDs can read the same active count and all pass before any commits. Reserve per-claimant capacity atomically, including pending issues.
  3. P2 — Explicit lifecycle operations omit caller-to-lease binding. openbao-plugin/src/backend.rs:675–677: read/revoke discard the request identity, unlike issue/rotate. A different entity with wildcard lifecycle ACL access can revoke another claimant’s known lease. Bind explicit operations to the stored claimant while separately authorizing OpenBao lease-manager cleanup.

Integration blocker with flake-ops#479: this PR returns native wrap_info, while that client requires obsolete flat wrapper fields. Its unwrap also stores OpenBao’s top-level lease ID instead of the plugin’s data.lease_id. The real bao test in this PR distinguishes both IDs. Findings are posted on the client PR too.

Validation rerun: nix develop --command cargo test --workspace passed, including the named bao-dev acceptance test, 128 root library tests, 71 plugin tests, 1 binary test and 3 doctests (plus workspace helper tests). The test runner reported no ignored tests. A separate verbose acceptance rerun was blocked by Nix copying a transient build-output path; therefore this review does not use that rerun as additional live evidence. Clippy rerun was still in progress when this review was prepared; the PR’s earlier clippy claim is not substituted for a completed rerun.

Reviewed commit: fa9682845045d4f35e2a8e53e7d14364b8675b9e.

Agent review performed by Codex at the user’s request. Posted through the PR author’s account; this records review evidence, not an independent collaborator approval or a new owner ruling.

**Verdict: changes required.** 1. **P1 — Predecessor retirement overwrites concurrent lease transitions.** `openbao-plugin/src/backend.rs:1107–1112`: the request loads a record, awaits native invalidation, then saves the whole old record. `answer()` holds only a shared `RwLock` read guard. A concurrent rotate/revoke can commit during that await and then be overwritten, losing the successor or restoring Active. Serialize per-lease transitions or use conditional transactional updates. 2. **P1 — Active-lease ceiling is not reserved atomically.** `openbao-plugin/src/backend.rs:749`: concurrent issue requests with distinct operation IDs can read the same active count and all pass before any commits. Reserve per-claimant capacity atomically, including pending issues. 3. **P2 — Explicit lifecycle operations omit caller-to-lease binding.** `openbao-plugin/src/backend.rs:675–677`: read/revoke discard the request identity, unlike issue/rotate. A different entity with wildcard lifecycle ACL access can revoke another claimant’s known lease. Bind explicit operations to the stored claimant while separately authorizing OpenBao lease-manager cleanup. Integration blocker with flake-ops#479: this PR returns native `wrap_info`, while that client requires obsolete flat wrapper fields. Its unwrap also stores OpenBao’s top-level lease ID instead of the plugin’s `data.lease_id`. The real bao test in this PR distinguishes both IDs. Findings are posted on the client PR too. Validation rerun: `nix develop --command cargo test --workspace` **passed**, including the named bao-dev acceptance test, **128 root library tests, 71 plugin tests, 1 binary test and 3 doctests** (plus workspace helper tests). The test runner reported no ignored tests. A separate verbose acceptance rerun was blocked by Nix copying a transient build-output path; therefore this review does not use that rerun as additional live evidence. Clippy rerun was still in progress when this review was prepared; the PR’s earlier clippy claim is not substituted for a completed rerun. Reviewed commit: `fa9682845045d4f35e2a8e53e7d14364b8675b9e`. Agent review performed by Codex at the user’s request. Posted through the PR author’s account; this records review evidence, not an independent collaborator approval or a new owner ruling.
Author
Owner

Review verification update: the independent nix develop --command cargo clippy --workspace --all-targets -- -D warnings run completed successfully. The review findings and changes-required verdict remain unchanged.

Review verification update: the independent `nix develop --command cargo clippy --workspace --all-targets -- -D warnings` run completed successfully. The review findings and changes-required verdict remain unchanged.
fa968284's review (P1): retire_predecessor loaded a lease record,
awaited the invalidate command, then saved the whole record back —
while answer() held only a shared read guard on the mount. A
concurrent rotate or revoke on the SAME lease could commit during
that await and be silently overwritten by the stale copy
retire_predecessor was still holding.

Serialize every transition of one lease's stored state — rotate,
explicit revoke, and the lease manager's own revoke/predecessor
retirement — against each other with a per-lease-id lock held across
the whole load-dispatch-save sequence, not just the storage calls.

The new test drives PluginBackend::answer over a real brokered
FakeStorage and a real Vedanta double, forces the interleaving with an
arrived/go rendezvous (a predecessor retirement parked mid-dispatch
while a second rotate on the same lease runs to completion), and
confirms the successor survives. Reverting the lock makes it fail:
the second rotate's generation-3 state is overwritten by the
retirement's stale generation-2 copy.

Co-Authored-By: Teyla <[email protected]>
fa968284's review (P1): issue read the claimant's active-lease count,
then committed the new lease, with nothing holding that window shut.
Concurrent issues (or an issue racing a rotate) with distinct
operation ids could all read the same count and all pass the same
ceiling before any of them committed.

Reserve the count-then-commit sequence atomically per claimant: a
lock held from the count read through the commit, in both issue and
rotate — the same claimant lock, so an issue and a rotate for the
same claimant contend on the ceiling too, not just two issues.

The new test parks one issue mid-mint-dispatch — past its own count
read — and fires a second issue for the same claimant with
max_active_leases set to one. Without the lock the second issue reads
the same zero count and mints too, so two leases land under a
ceiling of one; with it, the second cannot even start its count read
until the first is done. Reverting the lock makes the test fail with
both issues succeeding.

Co-Authored-By: Teyla <[email protected]>
fa968284's review (P2): read and explicit revoke discarded the
request's identity entirely, unlike issue and rotate — an entity
holding a wildcard lifecycle ACL (e.g. lease/+/{read,revoke}) but no
issue authority on any specific role could read or revoke a lease it
never touched, given a known lease id.

Bind both to the entity the lease was minted or last rotated under
(record.claims.caller_entity_id), denying a mismatch with the same
unknown_lease a nonexistent id gets — never a distinct "not yours"
that would confirm the id exists. OpenBao's own lease manager is
deliberately NOT routed through this check: lease_manager_revoke
calls the (still unbound) revoke() directly, since that path is
authorized by construction — it can only ever name a lease id OpenBao
itself is holding a lease against — rather than by a caller identity
there is none of.

The new test mints a lease under the mount's one trusted caller
entity, then shows a different entity is denied read and revoke on it
(unknown_lease, same as a nonexistent id), while the bound caller's
own read and revoke still succeed. Reverting the check makes the
denial assertions fail: the impostor's read and revoke both succeed.

Co-Authored-By: Teyla <[email protected]>
Author
Owner

All three findings fixed. 8e3117c, 7a796ac, 70d684d, pushed on top of fa968284. Write surface was openbao-plugin/src/backend.rs alone; Cargo.lock untouched — no new dependency, tokio::sync::Mutex was already there.

236 tests, 0 failed, plugin lib up from 71 to 74, and the live acceptance test against a real bao server -dev stayed un-ignored and green throughout. Re-run independently by the queen after the push.

The mechanisms

1 — per-lease serialization. A KeyedLocks primitive keyed by lease id, its OwnedMutexGuard held across awaits, wrapping the whole Rotate / Revoke / LeaseManagerRenew / LeaseManagerRevoke branch. That is what makes retire_predecessor's load-dispatch-save atomic against a concurrent transition of the same lease.

2 — the ceiling is now reserved, not merely read. A second KeyedLocks, keyed by claimant uuid, held from immediately after the count read through the commit, in both issue() and rotate(). Issue-versus-issue and issue-versus-rotate for one claimant now contend.

3 — explicit operations are bound to the stored claimant. read() and a new explicit_revoke() deny unless the caller matches record.claims.caller_entity_id, answering the same unknown_lease 404 an absent id gets — a stranger learns nothing about whether the lease exists. The bare revoke() remains reachable only from lease_manager_revoke, which is OpenBao's own cleanup: authorized by construction, with no caller identity to check.

The tests are real interleavings, not shape assertions

Each drives PluginBackend::answer() over a brokered FakeStorage and a hand-rolled Vedanta double on a unix socket, forcing the interleaving with an arrived/go rendezvous plus a bounded 300 ms timeout, so "ran straight through" is distinguishable from "genuinely blocked". Each fix was manually reverted and its test re-run to confirm the failure: the successor is lost to a stale generation; two concurrent issues both succeed under a ceiling of one; the impostor's read returns 200 instead of 404. Stable across five repeated runs.

Four things a reviewer must check

  1. KeyedLocks never reaps entries. Fine at pilot scale; worth a sign-off if lease or claimant cardinality assumptions change.
  2. Lock ordering is always lease-then-claimant, never reversed — on every path the worker found. It is a convention, not type-enforced. That is the classic place a later change introduces a deadlock.
  3. Finding 3's binding is equivalent to the mount's single trusted-caller check in a healthy deployment, since only one entity can mint. It additionally survives a mount reconfigured to a new caller_entity_id while old leases exist — confirm that is the intended reading.
  4. The contract question from this PR's earlier comment — whether OpenBao's own lease-entry storage sits inside the one-shot clause's trust boundary — is untouched by this pass and still the load-bearing judgment here.
**All three findings fixed.** `8e3117c`, `7a796ac`, `70d684d`, pushed on top of `fa968284`. Write surface was `openbao-plugin/src/backend.rs` alone; `Cargo.lock` untouched — **no new dependency**, `tokio::sync::Mutex` was already there. **236 tests, 0 failed**, plugin lib up from 71 to 74, and the live acceptance test against a real `bao server -dev` **stayed un-ignored and green throughout**. Re-run independently by the queen after the push. ### The mechanisms **1 — per-lease serialization.** A `KeyedLocks` primitive keyed by lease id, its `OwnedMutexGuard` held across awaits, wrapping the whole `Rotate` / `Revoke` / `LeaseManagerRenew` / `LeaseManagerRevoke` branch. That is what makes `retire_predecessor`'s load-dispatch-save atomic against a concurrent transition of the same lease. **2 — the ceiling is now reserved, not merely read.** A second `KeyedLocks`, keyed by claimant uuid, held from immediately after the count read through the commit, in both `issue()` and `rotate()`. Issue-versus-issue and issue-versus-rotate for one claimant now contend. **3 — explicit operations are bound to the stored claimant.** `read()` and a new `explicit_revoke()` deny unless the caller matches `record.claims.caller_entity_id`, answering the same `unknown_lease` 404 an absent id gets — a stranger learns nothing about whether the lease exists. The bare `revoke()` remains reachable **only** from `lease_manager_revoke`, which is OpenBao's own cleanup: authorized by construction, with no caller identity to check. ### The tests are real interleavings, not shape assertions Each drives `PluginBackend::answer()` over a brokered `FakeStorage` and a hand-rolled Vedanta double on a unix socket, forcing the interleaving with an `arrived`/`go` rendezvous plus a bounded 300 ms timeout, so "ran straight through" is distinguishable from "genuinely blocked". Each fix was **manually reverted and its test re-run** to confirm the failure: the successor is lost to a stale generation; two concurrent issues both succeed under a ceiling of one; the impostor's read returns 200 instead of 404. Stable across five repeated runs. ### Four things a reviewer must check 1. **`KeyedLocks` never reaps entries.** Fine at pilot scale; worth a sign-off if lease or claimant cardinality assumptions change. 2. **Lock ordering is always lease-then-claimant, never reversed** — on every path the worker found. It is a convention, **not type-enforced**. That is the classic place a later change introduces a deadlock. 3. Finding 3's binding is equivalent to the mount's single trusted-caller check in a healthy deployment, since only one entity can mint. It additionally survives **a mount reconfigured to a new `caller_entity_id` while old leases exist** — confirm that is the intended reading. 4. The contract question from this PR's earlier comment — whether OpenBao's own lease-entry storage sits inside the one-shot clause's trust boundary — is **untouched by this pass** and still the load-bearing judgment here.
larandar merged commit bc593b8b82 into fastlane 2026-09-06 07:50:14 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Ting/Vedanta!52
No description provided.