Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
e0fa631
Add the Foundry spec for this flight
ericmann Sep 24, 2026
f4c21f3
plan: derive build plan from SPEC
ericmann Sep 24, 2026
9f72663
chore: start implementation run
ericmann Sep 24, 2026
0e770f5
P1-01: Add the examples PHPUnit harness and the Vault test helper
ericmann Sep 24, 2026
a6247ae
progress: P1-01 done
ericmann Sep 24, 2026
314a8a6
P1-02: Add the `examples` CI job with a Vault service container
ericmann Sep 24, 2026
a888546
progress: P1-02 done
ericmann Sep 24, 2026
6e34962
P1-03: Push phase 1 and record the manual checks
ericmann Sep 24, 2026
82b25eb
progress: P1-03 done
ericmann Sep 24, 2026
9224e40
P2-01: Add the Vault KV v2 provider skeleton with path mapping, HTTP …
ericmann Sep 24, 2026
8017c56
progress: P2-01 done
ericmann Sep 24, 2026
edb78d0
P2-02: Implement set(), retire_previous(), and a minimal list_secrets…
ericmann Sep 24, 2026
3f906a3
progress: P2-02 done
ericmann Sep 24, 2026
c6931ac
P2-03: Push phase 2 and record the manual checks
ericmann Sep 24, 2026
c696428
progress: P2-03 done
ericmann Sep 24, 2026
df7e762
P3-01: Prove strict N-1 and destroy-on-retire against the live server
ericmann Sep 24, 2026
12abfd2
progress: P3-01 done
ericmann Sep 24, 2026
cd9ecd7
chore: pipeline friction (implement)
ericmann Sep 24, 2026
e68982c
P3-02: Push phase 3 and record the manual checks
ericmann Sep 24, 2026
dfdeb21
progress: P3-02 done
ericmann Sep 24, 2026
b58fe16
P4-01: Store needs_rotation in custom_metadata and fill in listing me…
ericmann Sep 24, 2026
deb22d8
progress: P4-01 done
ericmann Sep 24, 2026
8e8e2ae
P4-02: Multisite isolation, sealed-or-unreachable behaviour, and the …
ericmann Sep 24, 2026
f14a32f
progress: P4-02 done
ericmann Sep 24, 2026
78c104f
P4-03: Push phase 4 and record the manual checks
ericmann Sep 24, 2026
8b8b290
progress: P4-03 done
ericmann Sep 24, 2026
f8ed035
P5-01: Map AWS site scope to wp/site/<blog_id>/<name> and test it by …
ericmann Sep 24, 2026
bd5782d
progress: P5-01 done
ericmann Sep 24, 2026
7e24067
P5-02: Push phase 5 and record the manual checks
ericmann Sep 24, 2026
189e6c0
progress: P5-02 done
ericmann Sep 24, 2026
ef92129
P6-01: Write the Vault example README and update the example index, r…
ericmann Sep 24, 2026
faac1a4
progress: P6-01 done
ericmann Sep 24, 2026
d9ee7f3
P6-02: Add ADR 0009 and update the spec pages' "As built" sections
ericmann Sep 24, 2026
2679def
progress: P6-02 done
ericmann Sep 24, 2026
14b05e7
P6-03: Update the journal tracking pages, write the journal entry, an…
ericmann Sep 24, 2026
c5d39ce
progress: P6-03 done
ericmann Sep 24, 2026
1728561
P6-04: Push phase 6, remove the Vault container, and record the manua…
ericmann Sep 24, 2026
253640a
progress: P6-04 done
ericmann Sep 24, 2026
58d3aba
chore: handoff for review
ericmann Sep 24, 2026
3be654e
chore: round 0 implemented
ericmann Sep 24, 2026
3bc74a6
chore: pipeline friction (review)
ericmann Sep 24, 2026
370f09d
review: round 1
ericmann Sep 24, 2026
d7a0d21
chore: start review-fix round 1
ericmann Sep 24, 2026
c2cee99
R1-01: Preserve custom_metadata on rotation-flag writes, clean docblocks
ericmann Sep 24, 2026
c0f5817
progress: R1-01 done
ericmann Sep 24, 2026
46eec67
R1-02: Fail loudly in Vault_Test_Server when Vault is unreachable
ericmann Sep 24, 2026
d88a92a
progress: R1-02 done
ericmann Sep 24, 2026
daa6292
R1-03: Fix Vault docs to match code, ban Foundry task IDs from shippe…
ericmann Sep 24, 2026
9743d76
progress: R1-03 done
ericmann Sep 24, 2026
75117b3
chore: handoff for review
ericmann Sep 24, 2026
3d9d3c4
chore: round 1 implemented
ericmann Sep 24, 2026
e91a8bd
review: round 2 approved
ericmann Sep 24, 2026
728eaf8
chore: pipeline friction (summarize)
ericmann Sep 24, 2026
ea12a2a
chore: build summary
ericmann Sep 24, 2026
dd80cba
Remove the Foundry build bookkeeping
ericmann Sep 25, 2026
b6be691
Date the journal tracking pages this branch changed
ericmann Sep 25, 2026
24116c2
Merge main into build/vault-provider
ericmann Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,20 @@ jobs:

- run: make test-ms

# Outside `make ci` because it needs a service container (Moto, an AWS
# emulator) that the other jobs and the no-Docker local path do not provide.
# The examples under examples/ stay unlinted -- they are single files a host
# copies out, not part of this plugin's own coding-standard surface.
# Runs the platform examples (examples/*/secrets.php) against a Moto
# server (an AWS emulator, for the KMS and Secrets Manager examples) and a
# Vault dev server, single site then multisite. It sits outside `make ci`
# because the other jobs and the no-Docker local path provide no service
# containers. The examples stay unlinted: they are single files a host copies
# out, not part of this plugin's own coding-standard surface.
examples:
name: Examples (Moto)
name: Examples (Moto, Vault)
needs: static
runs-on: ubuntu-latest
env:
WP_SECRETS_TEST_AWS_ENDPOINT: http://127.0.0.1:5000
VAULT_ADDR: http://127.0.0.1:8200
VAULT_TOKEN: dev-root
services:
mysql:
image: mysql:8.0
Expand All @@ -213,6 +217,19 @@ jobs:
image: motoserver/moto@sha256:91fd602a21f49cf9eb82fdf474015a3c131d40104c8297ea6a2ca920708ae32c
ports:
- 5000:5000
# Pinned by the same digest as the Makefile comment above test-examples.
vault:
image: hashicorp/vault@sha256:47f14a6acb98f48d798a07df7c83f23a6e636e1cf724c5f8ff165cb32667a1e2
env:
VAULT_DEV_ROOT_TOKEN_ID: dev-root
ports:
- 8200:8200
options: >-
--cap-add=IPC_LOCK
--health-cmd="wget -qO- http://127.0.0.1:8200/v1/sys/health"
--health-interval=5s
--health-timeout=3s
--health-retries=10
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/phpcs.xml
/phpunit.xml
/phpunit-multisite.xml
/phpunit-examples.xml
/phpstan.neon
.DS_Store

Expand Down
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ test: ## Run the single-site suite.
test-ms: ## Run the multisite suite.
WP_MULTISITE=1 $(VENDOR_BIN)/phpunit -c phpunit-multisite.xml.dist

test-examples: ## Run the platform examples suite against emulators. Needs Moto (see examples/README.md); not part of make ci.
$(VENDOR_BIN)/phpunit -c phpunit-examples.xml.dist

coverage: ## Run the single-site suite with coverage.
$(VENDOR_BIN)/phpunit --coverage-html coverage --coverage-text

Expand All @@ -59,5 +56,14 @@ reference-check: ## Fail if docs/reference/ is stale relative to the source.

ci: lint compat analyse reference-check test test-ms ## Everything CI runs.

# Local Vault dev server for the vault-provider example (pinned digest):
# docker run -d --name secrets-api-vault -p 8201:8200 -e VAULT_DEV_ROOT_TOKEN_ID=dev-root --cap-add=IPC_LOCK hashicorp/vault@sha256:47f14a6acb98f48d798a07df7c83f23a6e636e1cf724c5f8ff165cb32667a1e2
# Then, from the repository root, inside wp-env (see README.md):
# npx @wordpress/env run --env-cwd="wp-content/plugins/$(basename "$PWD")" tests-cli env VAULT_ADDR=http://host.docker.internal:8201 VAULT_TOKEN=dev-root vendor/bin/phpunit -c phpunit-examples.xml.dist
# npx @wordpress/env run --env-cwd="wp-content/plugins/$(basename "$PWD")" tests-cli env WP_MULTISITE=1 VAULT_ADDR=http://host.docker.internal:8201 VAULT_TOKEN=dev-root vendor/bin/phpunit -c phpunit-examples.xml.dist
test-examples: ## Run the platform examples suite, single site then multisite. Needs Moto and Vault (see examples/README.md); not part of make ci.
$(VENDOR_BIN)/phpunit -c phpunit-examples.xml.dist
WP_MULTISITE=1 $(VENDOR_BIN)/phpunit -c phpunit-examples.xml.dist

clean: ## Remove generated artefacts.
rm -rf vendor coverage .phpunit.result.cache .phpcs.cache
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ KMS, Google Cloud KMS) is a `WP_Secrets_Keyring` and takes three methods, while
(Secrets Manager, Parameter Store) is a `WP_Secrets_Provider` and takes eight. People routinely
pick the wrong one and pay for it in per-operation API calls. Start from
[`examples/aws-kms-keyring/`](examples/aws-kms-keyring/) — it is the smaller interface, and it is
what most hosts are actually after: key custody moves to the KMS and nothing else changes.
what most hosts are actually after: key custody moves to the KMS and nothing else changes. Two
`WP_Secrets_Provider` examples ship alongside it, AWS Secrets Manager and HashiCorp Vault KV v2.
`make test-examples` runs all three against Moto and a Vault dev server.

## Contributing

Expand All @@ -165,7 +167,8 @@ stores credentials, and a flaw in it is a flaw in the thing protecting everythin

CI (`.github/workflows/ci.yml`) is a thin wrapper around the `make` targets above, running on
github.com's hosted runners: static analysis gates a PHP 7.4/8.0/8.3 × WordPress latest/trunk
matrix plus a multisite job. See [`docs/reference/ci.md`](docs/reference/ci.md).
matrix plus a multisite job, plus an `examples` job that runs the platform bindings against a
Vault service container. See [`docs/reference/ci.md`](docs/reference/ci.md).

## License

Expand Down
64 changes: 64 additions & 0 deletions docs/decisions/0010-cap-a-many-version-backend-to-two-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
title: "ADR 0010: Cap a many-version backend to two slots"
description: "The Vault KV v2 provider example sets max_versions: 2 on every secret it creates and defines PREVIOUS as strictly version N-1, so a backend that keeps ten versions still behaves like the API's two-slot model."
---

# ADR 0010: Cap a many-version backend to two slots

| | |
|---|---|
| **Number** | 0010 |
| **Date** | 2026-09-24 |
| **Status** | Accepted. |

## Context

[The detailed spec for the Vault provider example](../../examples/vault-provider/SPEC.md) built
the first provider whose backend does not already share the API's two-slot shape. HashiCorp
Vault's KV v2 secrets engine numbers versions 1, 2, 3, and so on, and keeps up to `max_versions`
of them — 10 by default. The Secrets API exposes exactly two: `WP_Secret_Version::CURRENT` and
`::PREVIOUS`.

Two problems follow from that mismatch. First, any version older than N-1 that Vault still keeps
is readable to anyone holding a Vault token, even though nothing in WordPress can see or retire
it — the value is protected by Vault, but the API's model of "there is a current value and a
previous one, and nothing else" no longer describes what actually exists. Second, "previous" has
no obvious definition once there are more than two versions: is it N-1, or the newest version that
has not been deleted or destroyed? Those two readings differ the moment a version other than N-1
goes missing, and only one of them is safe to expose through
[`wp_retire_secret_version()`](../../examples/vault-provider/README.md#1-what-previous-is), whose
whole purpose is to make a compromised credential unreachable.

## Decision

`Vault_KV2_Provider` in [`examples/vault-provider/secrets.php`](../../examples/vault-provider/secrets.php)
makes Vault a two-slot store rather than teaching the API about N versions:

- On creating a secret, it sets `max_versions: 2` in the secret's metadata, so Vault itself stops
keeping anything older than the current pair.
- `PREVIOUS` is defined as strictly version N-1. If N-1 is missing, soft-deleted, or destroyed,
the result is `null` — never the newest surviving version below N. `previous_version()` is the
one place this rule lives.
- `retire_previous()` destroys version N-1 outright rather than soft-deleting it, because a
soft-deleted version can still be undeleted and retiring is meant to make the value gone for
good.

This amends nothing in [ADR 0008](0008-the-trac-ticket-replaces-thread-confirmation.md); it
records what that ADR's Vault example turned up.

## Consequences

- A secret created outside this provider — by `vault kv put` directly, by an older policy, or by
a different tool against the same mount — keeps whatever `max_versions` it already has, and may
still hold versions WordPress cannot see or retire. The provider only enforces the cap on
secrets it creates itself.
- Retiring can leave no previous version at all. That is by design: a backend with nothing to
promote into `PREVIOUS` is the correct outcome of "make the compromised value unreachable," not
a bug to work around.
- The interface docblocks for `get()` and `retire_previous()` do not yet say what "previous" means
on a backend with more than two versions. That gap is recorded in
[`docs/journal/open-questions.md`](../journal/open-questions.md) and goes to the Trac ticket
description as a docblock clarification, per
[ADR 0008](0008-the-trac-ticket-replaces-thread-confirmation.md)'s review path.
- If capping at two slots turns out to be wrong in practice — for example, if a real deployment
needs the versions Vault would otherwise have kept — this is the record to amend.
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ directory holds everything longer than that.
- [`0007-fail-closed-on-a-broken-drop-in.md`](decisions/0007-fail-closed-on-a-broken-drop-in.md) — one provider per request, and a broken drop-in never falls back to the default.
- [`0008-the-trac-ticket-replaces-thread-confirmation.md`](decisions/0008-the-trac-ticket-replaces-thread-confirmation.md) — additions are reviewed on the Trac ticket, after two more examples and a CLI smoke test.
- [`0009-root-key-cached-for-the-request.md`](decisions/0009-root-key-cached-for-the-request.md) — the key manager unwraps the root key once per request instead of once per secret.
- [`0010-cap-a-many-version-backend-to-two-slots.md`](decisions/0010-cap-a-many-version-backend-to-two-slots.md) — the Vault example caps `max_versions` at 2 and defines `PREVIOUS` as strictly N-1.

### journal/
- [`2026-09-04-0-1-0-is-public.md`](journal/2026-09-04-0-1-0-is-public.md) — devlog: what 0.1.0 shipped, what it left out, and the road to 7.2.
- [`2026-09-24-a-kms-keyring.md`](journal/2026-09-24-a-kms-keyring.md) — devlog: the first real `WP_Secrets_Keyring`, the root-key cache and `rotate --from` it drove, and what it found.
- [`2026-09-24-a-vault-provider.md`](journal/2026-09-24-a-vault-provider.md) — devlog: the Vault KV v2 example, the AWS site-scope bug it found, and what stayed open.
- [`open-questions.md`](journal/open-questions.md) — what is still deliberately undecided.
- [`proposal-questions.md`](journal/proposal-questions.md) — the five questions the proposal asked, and the answers so far.
- [`test-coverage-gaps.md`](journal/test-coverage-gaps.md) — paths the suite cannot reach and what was verified by hand.
78 changes: 78 additions & 0 deletions docs/journal/2026-09-24-a-vault-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: "A Vault provider"
description: "Building a HashiCorp Vault KV v2 provider example, the first backend whose versioning does not already match the API's two slots, and the AWS site-scope bug it turned up along the way."
date: 2026-09-24
---

# A Vault provider

[ADR 0008](../decisions/0008-the-trac-ticket-replaces-thread-confirmation.md) named this as one of
three pieces of work to finish before the Trac ticket opens: a Vault provider is the first backend
whose version model does not already share `WP_Secret_Version::CURRENT`/`PREVIOUS`'s shape, which
makes it the test of the design most likely to be wrong in a way nobody had pointed out yet.

## What was built

`examples/vault-provider/secrets.php` — a single-file `WP_Secrets_Provider` drop-in against
Vault's KV v2 HTTP API, no Composer, no SDK. Alongside it, the harness half of the work: a
`Vault_Test_Server` test helper, a `WP_Secrets_Provider_Conformance` subclass run against a real
Vault dev server, and provider-specific tests covering retirement, the two-slot cap, the rotation
flag, multisite isolation, and sealed/unreachable Vault behaviour. CI gained an `examples` job
running that suite against a pinned `hashicorp/vault` dev-mode service container, single site and
multisite. And a third, separate commit: `examples/aws-secrets-manager/`'s site-scope naming fix
(below), found while working out Vault's own paths.

## What it found

**A real defect, in the AWS example rather than the interface.**
`AWS_Secrets_Manager_Provider::aws_name()` mapped site scope to `wp/<name>` with no blog ID, so on
a network every site read and wrote the *same* AWS secret for a given name. The shipped provider
keeps site scope per site; the example was wrong, not the interface. Fixed to
`wp/site/<blog_id>/<name>`, matching what Vault does, with a README note that this is a rename on
AWS's side for anyone running an earlier copy.

**The conformance suite passed against Vault unchanged.** No new skip, no adapted assertion beyond
what the suite already allows for a read-only provider. That is decent evidence the interface
itself does not assume a two-slot backend, only that a provider can present one.

**The interface leaves "previous" undefined past two versions.** KV v2 keeps up to 10 versions by
default; this API exposes exactly two. The provider answers by setting `max_versions: 2` on every
secret it creates and defining `PREVIOUS` as strictly version N-1 — never the newest surviving
version below N, since promoting an older survivor into that slot would let
`wp_retire_secret_version()` bring back a version it was supposed to make unreachable. Recorded as
[ADR 0010](../decisions/0010-cap-a-many-version-backend-to-two-slots.md) and as an open question
for the Trac ticket, since `get()` and `retire_previous()`'s own docblocks don't say this.
`test_previous_is_strictly_n_minus_1_even_when_older_versions_survive` is the test that pins it
down.

**A provider outside the WordPress boundary still needs local key material.** Fingerprints derive
from the site master key regardless of where the value itself lives, so a `BOUNDARY_PROVIDER`
provider still depends on a working keyring and root key for one feature. This was already true of
the AWS example; Vault inherits it rather than introduces it, and it stays as a written-down
question rather than a promise.

**A small, deliberate inconsistency left alone.** The AWS example fingerprints network secrets
under the `'site'` master key, while the shipped provider — and now the Vault example — use
`'network'`. Pre-existing, outside this work's scope, and noted here rather than silently
diverging further.

## What was left out

Vault auth methods other than a static token — AppRole and Kubernetes auth are named in the README
as the production path, not implemented. Check-and-set (`cas`) on writes, the answer to two
writers racing on the same secret, named but not built. KV v1 and the dynamic-secret engines,
which don't fit a stored-secret API at all. A compatibility read for the AWS rename — before 1.0,
a read that fell back to the old flat name would silently share secrets across blogs again, which
is the exact bug this work just fixed. And no change to anything under `src/`: everything here is
interface-level evidence, recorded in the journal, not a signature change.

## What it means for the Trac patch

Two items for the ticket description: `get()` and `retire_previous()` should document what
"previous" means on a backend that keeps more than two versions (the strict-N-1 answer, now
proven against a real one), and the provider contract should note that a `BOUNDARY_PROVIDER`
implementation may still depend on local key material for fingerprinting. Neither changes a
function signature.

See [`examples/vault-provider/README.md`](../../examples/vault-provider/README.md) for the
operator-facing detail and the four questions in full.
35 changes: 35 additions & 0 deletions docs/journal/open-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,41 @@ change).
interfaces, and no host has built against `WP_Secrets_Provider` or `WP_Secrets_Keyring`
independently.

**What the Vault example added:** a second provider, `examples/vault-provider/`, this time against
a backend whose versioning does not already match — Vault's KV v2 engine numbers versions 1, 2, 3,
and so on, rather than keeping two named slots. The conformance suite now runs against it
automatically in `make test-examples`, on a real Vault dev server, rather than being described
only in a README. It found one real defect, in the AWS example rather than the interface:
`AWS_Secrets_Manager_Provider::aws_name()` mapped site scope to a flat name with no blog ID, so
every site on a network shared one AWS secret for a given name. Past that, the conformance suite
passed against Vault unchanged, and the two-slot model held once the provider capped Vault at
`max_versions: 2` — evidence for question 2 in `proposal-questions.md`.

---

## What "previous" means on a backend with more than two versions

🟡 Needs an answer before the core patch.

The Vault example made a conservative choice rather than waiting for one: `PREVIOUS` is strictly
version N-1, and `null` when N-1 is missing, soft-deleted, or destroyed — never the newest
surviving version below N. The code is `Vault_KV2_Provider::previous_version()` in
`examples/vault-provider/secrets.php`. The interface docblocks for `get()` and `retire_previous()`
do not themselves define what "previous" means once a backend keeps more than two versions, which
is fine for the shipped provider and the AWS example (neither has this problem) but was undefined
before Vault. Resolution belongs on the Trac ticket description as a docblock clarification. See
[ADR 0010](../decisions/0010-cap-a-many-version-backend-to-two-slots.md).

## A provider outside the WordPress boundary still needs a root key

🟢 Tracking only.

Fingerprints derive from the site master key, so a site whose values live entirely in a provider
reporting `BOUNDARY_PROVIDER` still depends on a working keyring and root key for one feature. This
is inherited from the AWS example rather than introduced by Vault, and stays as-is. Where the code
is: `AWS_Secrets_Manager_Provider::build_secret()` in `examples/aws-secrets-manager/secrets.php`,
and `Vault_KV2_Provider::build_secret()` in `examples/vault-provider/secrets.php`.

---

## Testability smells
Expand Down
8 changes: 7 additions & 1 deletion docs/journal/proposal-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ absorbed into an assumption.
its `AWSCURRENT`/`AWSPREVIOUS` staging labels are the same two slots, so the model needed no
emulation there. `'v' => 1` leaves room to change this, but see
[ADR 0006](../decisions/0006-record-format-v2-not-read-compatible.md) for what a format bump
would mean.
would mean. The Vault KV v2 example needed a translation rather than a match: Vault numbers
versions 1, 2, 3, and so on, with no built-in concept of "current" and "previous". Setting
`max_versions: 2` on create made it a two-slot store, and the conformance suite passed
unchanged. The one thing the model did not define was what "previous" means once a backend
keeps more than two versions; the strict N-1 answer this example adopted is recorded in
[open-questions.md](open-questions.md#what-previous-means-on-a-backend-with-more-than-two-versions)
for the Trac ticket.
3. **Does `wp_import_option_as_secret()` fit actual plugin migration workflows?**
— no objections raised, and no plugin outside this project has used it yet.
4. **Which WP-CLI commands most need this surface, and in what priority order?** — no objections
Expand Down
Loading
Loading