diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3108f8..69409d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index e6b37d4..cf57298 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /phpcs.xml /phpunit.xml /phpunit-multisite.xml +/phpunit-examples.xml /phpstan.neon .DS_Store diff --git a/Makefile b/Makefile index 596395f..a1a96cd 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/README.md b/README.md index b2e8969..26a4602 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/decisions/0010-cap-a-many-version-backend-to-two-slots.md b/docs/decisions/0010-cap-a-many-version-backend-to-two-slots.md new file mode 100644 index 0000000..286c7bf --- /dev/null +++ b/docs/decisions/0010-cap-a-many-version-backend-to-two-slots.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 968b647..f9eaeec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/journal/2026-09-24-a-vault-provider.md b/docs/journal/2026-09-24-a-vault-provider.md new file mode 100644 index 0000000..14ee712 --- /dev/null +++ b/docs/journal/2026-09-24-a-vault-provider.md @@ -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/` 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//`, 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. diff --git a/docs/journal/open-questions.md b/docs/journal/open-questions.md index 0ae3204..65db87a 100644 --- a/docs/journal/open-questions.md +++ b/docs/journal/open-questions.md @@ -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 diff --git a/docs/journal/proposal-questions.md b/docs/journal/proposal-questions.md index 0fc367f..3bc60fb 100644 --- a/docs/journal/proposal-questions.md +++ b/docs/journal/proposal-questions.md @@ -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 diff --git a/docs/journal/test-coverage-gaps.md b/docs/journal/test-coverage-gaps.md index 4dc88f1..612b4bb 100644 --- a/docs/journal/test-coverage-gaps.md +++ b/docs/journal/test-coverage-gaps.md @@ -140,3 +140,17 @@ machine. Moto does not verify SigV4 signatures or IAM permissions the way real A signing bug that happens to produce a request Moto accepts anyway, or a policy missing a permission the example actually needs, is invisible to this suite. The live run against real AWS is the manual step named in `examples/aws-kms-keyring/README.md` and has not been run yet. + +--- + +## 🟢 The Vault example's failure paths are simulated + +`Tests_Vault_Provider`'s sealed-Vault (503) and failed-flag-write cases are produced with +`pre_http_request`, not a real sealed server — sealing and unsealing a Vault dev container inside +the test run was judged not worth the added CI time. The unreachable case is real: the test points +the provider at a closed local port (`http://127.0.0.1:1`), and the connection is really refused. +OpenBao is not run in CI at all; the README says it implements the same KV v2 API, and one manual +run against it is a human check whose result is recorded in a commit message rather than an +automated one. Only the pinned Vault digest named in the Makefile comment and `ci.yml` is tested — +a different Vault version, or a real OpenBao build, could behave differently and nothing here +would catch it. diff --git a/docs/reference/ci.md b/docs/reference/ci.md index 063159d..ecdb0cd 100644 --- a/docs/reference/ci.md +++ b/docs/reference/ci.md @@ -78,8 +78,9 @@ hosted pipeline. latest / trunk matrix, plus a multisite job. `shivammathur/setup-php` provides the interpreter and asks for the `sodium` extension by name. The whole API is built on libsodium, so relying on whatever the runner image happens to ship wasn't good enough. The `examples` job is the only one -with a non-database service container: a pinned Moto instance the AWS Secrets Manager provider -conformance run and the AWS KMS keyring conformance and integration tests both run against. +with service containers beyond the database: a pinned Moto instance, which the AWS Secrets Manager +conformance run and the AWS KMS keyring tests run against, and a pinned Vault dev server for the +Vault example. It runs `make test-examples` single site and multisite. The workflow declares `permissions: contents: read`. Nothing in it writes to the repository, publishes anything, or needs a token beyond reading the code under test. @@ -106,6 +107,7 @@ person pasted. | `test-multisite` | 8.3 | latest | Multisite suite | | `examples` | 8.3 | latest | `make test-examples` against a Moto (AWS emulator) service container, pinned by digest. Not part of `make ci`. | | `reference-docs` | 8.3 | — | `bin/gen-reference.php --check`: the committed docs/reference/ matches the source. No Composer install. | +| `examples` | 8.3 | latest | `make test-examples` against a Vault dev-mode service container, single site and multisite. Outside `make ci` because it needs the container. | The 7.4 leg is not optional. Core's floor is 7.4 and `src/` must run there; PHPCompatibilityWP catches syntax statically, but only a running 7.4 catches runtime behaviour differences. diff --git a/docs/spec/extension-points.md b/docs/spec/extension-points.md index 22fb2cc..d659928 100644 --- a/docs/spec/extension-points.md +++ b/docs/spec/extension-points.md @@ -62,7 +62,9 @@ read-only really does refuse writes with `secret_provider_read_only`. Where the variation it adapts: a read-only provider is never asked to round-trip a value, and the skipped checks are reported as skipped rather than passing quietly. The suite lives in `tests/includes/class-wp-secrets-provider-conformance.php` and runs against the shipped provider, -so there is a known-good subject to compare failures against. +so there is a known-good subject to compare failures against. It also runs against the Vault +provider example on a real dev server in `make test-examples`, so there is a second known-good +subject whose backend does not share the two-slot shape. **The two inner interfaces.** The store and keyring are the internals of the shipped provider, and either can still be replaced on its own. A host who wants their own key custody but is happy diff --git a/docs/spec/providers-and-keyrings.md b/docs/spec/providers-and-keyrings.md index 825e15b..ba4f391 100644 --- a/docs/spec/providers-and-keyrings.md +++ b/docs/spec/providers-and-keyrings.md @@ -78,7 +78,9 @@ common host integration is a keyring alone: three methods. `src/wp-includes/secrets.php`. `WP_SECRETS_ERROR_PROVIDER_READ_ONLY` for writes a provider refuses. A conformance suite, `WP_Secrets_Provider_Conformance` in `tests/includes/class-wp-secrets-provider-conformance.php`, runs against the shipped provider and -can be extended for a third-party one. +can be extended for a third-party one. Two provider examples exist, +`examples/aws-secrets-manager/` and `examples/vault-provider/`, and `make test-examples` runs the +conformance suite against the Vault one on a real server. **Plugin-only detail.** `secrets-api.php` sets `$GLOBALS['wp_secrets_store']` to a `Secrets_API_Prototype_Fallback_Store` wrapping `WP_Secrets_Option_Store` before the drop-in diff --git a/docs/spec/rotation.md b/docs/spec/rotation.md index 1841080..db7a02d 100644 --- a/docs/spec/rotation.md +++ b/docs/spec/rotation.md @@ -31,7 +31,9 @@ old value. `retire_previous()`, which removes the `previous` slot, writes the record, and fires `wp_secret_changed` with `retired`. When there is no previous slot, or no secret, it returns `true`: the requested state already holds. `wp secret retire [--yes]` in -`cli/class-wp-cli-secret-command.php` wraps it. +`cli/class-wp-cli-secret-command.php` wraps it. `examples/vault-provider/secrets.php` implements +`retire_previous()` as a Vault `destroy` of exactly version N-1, rather than a soft delete, since a +soft-deleted version can still be undeleted and retiring is meant to make it gone for good. **Rotating the site key.** `wp secret rotate [--from=] [--yes]` in `cli/class-wp-cli-secret-command.php` calls `WP_Secrets_Key_Manager::rotate_site_key()` in diff --git a/docs/spec/versioning.md b/docs/spec/versioning.md index da40fda..72b7194 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -47,6 +47,13 @@ record format version, stored as `v` in every record and checked by two value slots. `docs/decisions/0006-record-format-v2-not-read-compatible.md` covers what a future bump would mean. +**A backend with more than two versions.** `examples/vault-provider/secrets.php` is the first +provider whose backend keeps more than two versions of its own: Vault's KV v2 engine numbers +versions 1, 2, 3, and so on. `Vault_KV2_Provider` translates that into the two-slot shape by +setting `max_versions: 2` on every secret it creates and by defining `PREVIOUS` as strictly +version N-1 in `previous_version()`, never the newest surviving version below N. See +[ADR 0010](../decisions/0010-cap-a-many-version-backend-to-two-slots.md). + ## Why [proposal]: https://make.wordpress.org/core/2026/08/25/proposal-a-secrets-api-for-wordpress-7-2/ diff --git a/examples/README.md b/examples/README.md index 289926e..35551b5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,12 +26,20 @@ and the two map to different interfaces: | **AWS Secrets Manager** | secrets | `WP_Secrets_Provider` | 8 methods | | **Google Secret Manager** | secrets | `WP_Secrets_Provider` | 8 methods | | **AWS Parameter Store** | secrets | `WP_Secrets_Provider` | 8 methods | +| **HashiCorp Vault KV v2** | secrets | `WP_Secrets_Provider` | 8 methods | The mistake to avoid is reaching for KMS and writing a `WP_Secrets_Provider`. You'll make one KMS call per secret read, hit the 4,096-byte payload ceiling on anything bigger than a token, and pay per operation for work WordPress already does locally. AWS says as much in its own `Encrypt` documentation: *"You don't need to use the `Encrypt` operation to encrypt a data key."* +## In this directory + +- [`aws-secrets-manager/`](aws-secrets-manager/README.md) — a `WP_Secrets_Provider` for AWS + Secrets Manager. +- [`vault-provider/`](vault-provider/README.md) — a `WP_Secrets_Provider` for HashiCorp Vault's + KV v2 secrets engine. + ## Start with a KMS keyring The root key is 32 bytes, and it's the only wrapped value on the site. That makes a KMS keyring diff --git a/examples/aws-secrets-manager/README.md b/examples/aws-secrets-manager/README.md index f49ff44..5fa050e 100644 --- a/examples/aws-secrets-manager/README.md +++ b/examples/aws-secrets-manager/README.md @@ -82,9 +82,20 @@ them. ## Naming -WordPress names map across unchanged, under a scope prefix: `acme/stripe-key` becomes -`wp/acme/stripe-key`, and network-scope secrets use `wp-network/`. Secrets Manager allows -alphanumerics plus `/_+=.@-`, so no escaping is needed. +WordPress names map across unchanged, under a scope prefix. Site-scope secrets are per site: +`acme/stripe-key` becomes `wp/site/1/acme/stripe-key` on a single site, or on blog 1 of a network; +on another blog it becomes `wp/site//acme/stripe-key`. Network-scope secrets are +unchanged: `wp-network/acme/stripe-key`. Secrets Manager allows alphanumerics plus `/_+=.@-`, so no +escaping is needed. The IAM resource pattern below (`secret:wp/*`) still matches both shapes. + +### Upgrading from an earlier copy of this example + +Before this change, site-scope secrets lived at `wp/`, with no blog ID — so on a network, +every site read and wrote the *same* AWS secret for a given name. They now live at +`wp/site/1/` (site 1) and `wp/site//` elsewhere. This is a rename on AWS's +side: create the new secret from the old value, then delete the old one. The example ships no +compatibility read before 1.0, because a read that fell back to the flat name would silently share +secrets across blogs again. ## The part worth pointing at diff --git a/examples/aws-secrets-manager/secrets.php b/examples/aws-secrets-manager/secrets.php index 2c69998..c0572fd 100644 --- a/examples/aws-secrets-manager/secrets.php +++ b/examples/aws-secrets-manager/secrets.php @@ -320,6 +320,23 @@ private function build_secret( $name, $value ) { return new WP_Secret( $name, $value, $fingerprint ); } + /** + * The scope prefix for the current request: `wp-network/` for network + * scope, or `wp/site//` for site scope. Site scope is per site + * because the shipped provider's option store is per site; a flat `wp/` + * prefix would make every blog on a network share one secret. + * + * Read at call time (not cached), so a `switch_to_blog()` mid-request is + * honoured. + * + * @param bool $network Whether this is network scope. + * + * @return string + */ + private function scope_prefix( $network ) { + return $network ? 'wp-network/' : 'wp/site/' . get_current_blog_id() . '/'; + } + /** * Secrets Manager names allow alphanumerics and /_+=.@- so a namespaced * WordPress name maps across unchanged. Network-scope secrets get a prefix so @@ -331,7 +348,7 @@ private function build_secret( $name, $value ) { * @return string */ private function aws_name( $name, $network ) { - return ( $network ? 'wp-network/' : 'wp/' ) . $name; + return $this->scope_prefix( $network ) . $name; } /** @@ -343,7 +360,7 @@ private function aws_name( $name, $network ) { * @return string|null */ private function wp_name( $aws_name, $network ) { - $prefix = $network ? 'wp-network/' : 'wp/'; + $prefix = $this->scope_prefix( $network ); if ( 0 !== strpos( $aws_name, $prefix ) ) { return null; diff --git a/examples/aws-secrets-manager/tests/test-aws-secrets-manager-naming.php b/examples/aws-secrets-manager/tests/test-aws-secrets-manager-naming.php new file mode 100644 index 0000000..e3b5605 --- /dev/null +++ b/examples/aws-secrets-manager/tests/test-aws-secrets-manager-naming.php @@ -0,0 +1,145 @@ +requests = array(); + $this->queue = array(); + + add_filter( 'pre_http_request', array( $this, 'fake_request' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'fake_request' ), 10 ); + + parent::tear_down(); + } + + public function fake_request( $preempt, $parsed_args, $url ) { + $this->requests[] = array( + 'url' => $url, + 'args' => $parsed_args, + ); + + if ( ! empty( $this->queue ) ) { + return array_shift( $this->queue ); + } + + return $this->fake_response( 200, array() ); + } + + private function fake_response( $code, array $body ) { + return array( + 'headers' => array(), + 'body' => wp_json_encode( $body ), + 'response' => array( + 'code' => $code, + 'message' => '', + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + private function queue_response( $code, array $body ) { + $this->queue[] = $this->fake_response( $code, $body ); + } + + private function provider() { + return new AWS_Secrets_Manager_Provider( 'us-east-1', 'test-key', 'test-secret' ); + } + + private function secret_id( $index = 0 ) { + $body = json_decode( $this->requests[ $index ]['args']['body'], true ); + + return isset( $body['SecretId'] ) ? $body['SecretId'] : null; + } + + public function test_site_scope_names_include_the_blog_id() { + $this->queue_response( 200, array( 'SecretString' => 'v' ) ); + + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertSame( 'wp/site/1/acme/key', $this->secret_id() ); + $this->assertSame( 'secretsmanager.GetSecretValue', $this->requests[0]['args']['headers']['X-Amz-Target'] ); + } + + public function test_network_scope_names_are_unchanged() { + $this->queue_response( 200, array( 'SecretString' => 'v' ) ); + + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT, true ); + + $this->assertSame( 'wp-network/acme/key', $this->secret_id() ); + } + + public function test_set_uses_the_same_site_scoped_name() { + $this->queue_response( 200, array() ); + + $this->provider()->set( 'acme/key', 'v' ); + + $this->assertSame( 'wp/site/1/acme/key', $this->secret_id() ); + } + + public function test_listing_maps_site_scoped_names_back_and_ignores_the_rest() { + $this->queue_response( + 200, + array( + 'SecretList' => array( + array( 'Name' => 'wp/site/1/acme/key' ), + array( 'Name' => 'wp/site/2/acme/key' ), + array( 'Name' => 'wp/acme/legacy' ), + array( 'Name' => 'wp-network/acme/key' ), + ), + ) + ); + + $names = wp_list_pluck( $this->provider()->list_secrets(), 'name' ); + $this->assertSame( array( 'acme/key' ), $names ); + + $this->queue_response( + 200, + array( + 'SecretList' => array( + array( 'Name' => 'wp/site/1/acme/key' ), + array( 'Name' => 'wp/site/2/acme/key' ), + array( 'Name' => 'wp/acme/legacy' ), + array( 'Name' => 'wp-network/acme/key' ), + ), + ) + ); + + $names = wp_list_pluck( $this->provider()->list_secrets( '', true ), 'name' ); + $this->assertSame( array( 'acme/key' ), $names ); + } + + public function test_the_blog_id_is_read_at_call_time_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + + $blog = self::factory()->blog->create(); + switch_to_blog( $blog ); + + $this->queue_response( 200, array( 'SecretString' => 'v' ) ); + + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertSame( "wp/site/{$blog}/acme/key", $this->secret_id() ); + + restore_current_blog(); + } +} diff --git a/examples/vault-provider/README.md b/examples/vault-provider/README.md new file mode 100644 index 0000000..3012f68 --- /dev/null +++ b/examples/vault-provider/README.md @@ -0,0 +1,190 @@ +# HashiCorp Vault KV v2 provider + +A `wp-content/secrets.php` drop-in that makes a Vault KV v2 secrets engine the system of record for +a site's credentials. WordPress becomes a consumer rather than a custodian, and `wp secret dropin` +reports `Encryption boundary: the provider (outside WordPress)`. + +**No Composer, no Vault SDK.** One `wp_remote_request()` call per operation against Vault's HTTP +API, in a single file you can read end to end. + +## Where the credentials go + +**Not `.wp-env.json`** — that file is committed. Use `.wp-env.override.json`, which wp-env merges +on top and which this repo git-ignores: + +```jsonc +// .wp-env.override.json (repo root, git-ignored) +{ + "config": { + "WP_SECRETS_VAULT_ADDR": "http://host.docker.internal:8201", + "WP_SECRETS_VAULT_TOKEN": "dev-root", + "WP_SECRETS_VAULT_MOUNT": "secret", + "WP_SECRETS_VAULT_NAMESPACE": "" + } +} +``` + +Anything under `config` becomes a PHP constant in `wp-config.php`. `WP_SECRETS_VAULT_MOUNT` +defaults to `secret` and `WP_SECRETS_VAULT_NAMESPACE` is optional (Vault Enterprise / HCP only) — +both can be omitted from the override file entirely. From inside wp-env, the dev server started +per "Run the tests" below is reachable at `http://host.docker.internal:8201`, not `127.0.0.1:8200` +— that address is the *host's* view of the container, not the WordPress container's. + +```sh +npx @wordpress/env start # re-reads the config and rewrites wp-config.php +``` + +On a real site these are ordinary `wp-config.php` constants, backed by a token from a real auth +method — see "Known limits" below. + +## Install the drop-in + +```sh +CID=$(docker ps --format '{{.Names}}' | grep -- '-cli-1' | grep -v tests) +docker cp examples/vault-provider/secrets.php "$CID":/var/www/html/wp-content/secrets.php +docker exec "$CID" wp secret dropin +``` + +Expected once the constants are set: + +``` +Drop-in active: yes +Provider: Vault_KV2_Provider +Protected by: HashiCorp Vault (http://host.docker.internal:8201, mount secret) +Encryption boundary: the provider (outside WordPress) +Accepts writes: yes +``` + +To take it back out — **and do this before running the test suite**: + +```sh +for c in $(docker ps --format '{{.Names}}' | grep -E 'cli-1|wordpress-1'); do + docker exec "$c" rm -f /var/www/html/wp-content/secrets.php +done +``` + +**The gotcha:** wp-env's dev and tests environments see the same `wp-content`, so an installed +drop-in is in front of PHPUnit too. A drop-in that cannot reach Vault will fail most of the suite, +which looks alarming and is not a code problem. Remove it, re-run, and it is green again. Removing +it from a single container is not enough — the loop above covers all four. + +## Vault policy + +The smallest policy that runs everything below: + +```hcl +path "secret/data/wp/*" { + capabilities = ["create", "update", "read", "delete"] +} +path "secret/metadata/wp/*" { + capabilities = ["create", "update", "read", "delete", "list"] +} +path "secret/destroy/wp/*" { + capabilities = ["update"] +} +``` + +`is_writable()` returns `true` regardless of what the token can actually do — a token without +write policy is not detected in advance. It surfaces the first time `set()`, `delete()`, or +`retire_previous()` runs and Vault returns 403, which the provider maps to `WP_Error`, same as a +sealed Vault. + +## Naming + +| Scope | Vault path under the mount | +|---|---| +| Site | `wp/site///` | +| Network | `wp/network//` | + +Site scope includes the blog ID because the shipped provider's site scope is per site — the option +store writes through `get_option()`, which reads the current blog's table. `acme/stripe-key` +becomes `wp/site/1/acme/stripe-key` on a single site or blog 1, and `wp/site//acme/...` +elsewhere on a network. WordPress names are `namespace/key`, one slash, both segments matching +`[a-z0-9_-]`, so they map to Vault paths unchanged. + +## The four questions + +### 1. What "previous" is + +Strictly version N-1, never "the newest surviving version below N." Retiring destroys N-1 and +never promotes N-2 into its place — if it did, `wp_retire_secret_version()`, meant to make a +compromised credential unreachable, would instead bring back an even older one. +`test_previous_is_strictly_n_minus_1_even_when_older_versions_survive` proves it: `max_versions` +is raised to 10 through the test helper first, so pruning cannot be what's producing the result, +three versions are written, `retire_previous()` runs through the provider, `PREVIOUS` reads as +`null`, and version 1 — the older survivor — still reads `200` directly against Vault. + +### 2. The versions the API cannot see + +KV v2 keeps up to 10 versions by default; the interface exposes exactly two. This provider sets +`max_versions: 2` on every secret it creates, which makes Vault a two-slot store from its first +write. A secret created outside the provider — by `vault kv put` directly, or by an older policy — +keeps whatever `max_versions` it already had, so a pre-existing Vault secret can still hold +versions WordPress cannot see or retire. If that turns out to matter in practice it is a finding +about the version model, not a bug in this example, and goes on the Trac ticket. See +[ADR 0010](../../docs/decisions/0010-cap-a-many-version-backend-to-two-slots.md). + +### 3. Where `needs_rotation` lives + +In `custom_metadata.needs_rotation`, as the string `"1"` (set) or `"0"` (cleared) — never omitted, +because Vault replaces `custom_metadata` wholesale on every write. The flag write merges the +existing `custom_metadata` (read first, in the same request cycle) with the new flag value before +posting, so other keys a different tool set survive, and "no flag" and "flag cleared" still have +to be the same write rather than an omitted key. This needs Vault 1.9 or later. The value +write and the metadata write are two separate requests, not a transaction: if the value lands and +the flag write fails, `set()` returns `WP_Error` when the caller asked for the flag (the value is +stored, but the flag is not, and the interface says a provider must not report an unhonoured flag +as honoured), and silently logs and ignores a failed *clear*. + +### 4. What `list_secrets()` costs + +One `LIST` to enumerate namespaces under the scope, one `LIST` per namespace to enumerate secrets, +and one metadata `GET` per secret found — never a data read. Fingerprints come back blank, the +same choice the AWS example makes, because fingerprinting every entry would mean a value read per +secret; `wp secret get` reports the real fingerprint for one secret at a time. + +## Known limits + +- **Static token only.** Fine for a dev server; a production deployment should use AppRole or + Kubernetes auth instead, with a short-lived token refreshed outside this file. +- **No `cas` (check-and-set) on writes.** The answer to two writers racing on the same secret, not + implemented here. +- **KV v1 and the dynamic-secret engines are out of scope.** Dynamic database credentials do not + fit a stored-secret API, and trying to make them fit is how an example turns into a product. +- **Caching is request-scoped only**, deliberately. Never put a plaintext in the persistent object + cache: `WP_Secret` cannot round-trip one through `wp_cache_set()`, and caching the raw value + beside it would quietly undo that. +- **Fingerprints still need this site's own root key**, even though the protection boundary is + Vault. That is inherited from the AWS example rather than fixed here, and is written down as a + question rather than a promise: a provider reporting `BOUNDARY_PROVIDER` still depends on local + key material for one feature. +- **`wp_secret_changed` carries blank fingerprints** for the same reason listing does — this + provider never fingerprints without a value already in hand. + +## OpenBao + +[OpenBao](https://openbao.org/) is the Linux Foundation fork of Vault, and implements the same +KV v2 HTTP API this provider speaks — nothing here is Vault-specific beyond the path shapes above. +Vault has been under the Business Source License since 1.15, so it is not itself open source; CI +tests Vault because it is the name hosts will search for, and one manual run against OpenBao is a +human check whose result is recorded in a commit message rather than run in CI. + +## Run the tests + +```sh +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: + +```sh +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 +``` + +`VAULT_ADDR` and `VAULT_TOKEN` tell the test harness (`Vault_Test_Server`) which server to run the +conformance suite and the provider-specific tests against; both default to +`127.0.0.1:8200` / `dev-root` if unset, which only works when the test runner and Vault are on the +same host network. A host with its own PHPUnit setup outside wp-env can instead run +`make test-examples`, which runs the same suite against `VAULT_ADDR`/`VAULT_TOKEN` from its own +environment, single site then multisite. diff --git a/examples/vault-provider/secrets.php b/examples/vault-provider/secrets.php new file mode 100644 index 0000000..316f86e --- /dev/null +++ b/examples/vault-provider/secrets.php @@ -0,0 +1,688 @@ + + */ + private $memo = array(); + + /** + * @param string $addr Vault address, e.g. 'https://vault.example.com:8200'. + * @param string $token Vault token. + * @param string $mount KV v2 mount point. Default 'secret'. + * @param string $namespace Vault Enterprise namespace, or '' for none. + */ + public function __construct( $addr, $token, $mount = 'secret', $namespace = '' ) { + $this->addr = rtrim( $addr, '/' ); + $this->token = $token; + $this->mount = trim( $mount, '/' ); + $this->namespace = $namespace; + } + + // -- the provider contract ------------------------------------------------- + + /** + * @param string $name Secret name. + * @param string $version A WP_Secret_Version constant. + * @param bool $network Whether this is a network-scope secret. + * + * @return WP_Secret|null|WP_Error + */ + public function get( $name, $version, $network = false ) { + $vault_path = $this->vault_path( $name, $network ); + $memo_key = $vault_path . '#' . $version; + + if ( isset( $this->memo[ $memo_key ] ) ) { + return $this->build_secret( $name, $this->memo[ $memo_key ], $network ); + } + + if ( WP_Secret_Version::PREVIOUS === $version ) { + $meta = $this->read_metadata( $name, $network ); + + if ( is_wp_error( $meta ) ) { + return $meta; + } + + if ( null === $meta ) { + return null; + } + + $previous = $this->previous_version( $meta ); + + if ( null === $previous ) { + return null; + } + + $data = $this->request( 'GET', $this->url( 'data', $vault_path, array( 'version' => $previous ) ) ); + } else { + $data = $this->request( 'GET', $this->url( 'data', $vault_path ) ); + } + + if ( is_wp_error( $data ) || null === $data ) { + return $data; + } + + if ( ! isset( $data['data']['value'] ) || ! is_string( $data['data']['value'] ) ) { + return new WP_Error( + WP_SECRETS_ERROR_RECORD_MALFORMED, + 'Vault returned a secret without a string "value" field.' + ); + } + + $this->memo[ $memo_key ] = $data['data']['value']; + + return $this->build_secret( $name, $data['data']['value'], $network ); + } + + /** + * Creates or updates a secret. On first write, max_versions is set to + * self::MAX_VERSIONS before the value is written, so Vault is a two-slot + * store from its very first version. A secret created outside this + * provider keeps whatever max_versions it already has -- see ADR 0010. + * The rotation flag is written in a separate metadata request. + * + * @param string $name Secret name. + * @param string $value Plaintext value. + * @param bool $network Whether this is a network-scope secret. + * @param bool $needs_rotation Mark the stored secret as needing rotation. + * @param string|null $action Overrides the action reported to wp_secret_changed. + * + * @return true|WP_Error + */ + public function set( $name, $value, $network = false, $needs_rotation = false, $action = null ) { + $vault_path = $this->vault_path( $name, $network ); + $meta = $this->read_metadata( $name, $network ); + + if ( is_wp_error( $meta ) ) { + return $meta; + } + + $created = ( null === $meta ); + + if ( $created ) { + $result = $this->request( + 'POST', + $this->url( 'metadata', $vault_path ), + array( 'max_versions' => self::MAX_VERSIONS ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + } + + $result = $this->request( 'POST', $this->url( 'data', $vault_path ), array( 'data' => array( 'value' => $value ) ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $this->memo = array(); + + /** + * Fires whenever a secret is created, updated, deleted, or imported. + * + * Providers own firing this -- see WP_Secrets_Provider::set(). + */ + do_action( + 'wp_secret_changed', + $name, + null !== $action ? $action : ( $created ? 'created' : 'updated' ), + get_current_user_id(), + time(), + '', + '' + ); + + // The value and the rotation flag are two requests, not a transaction + // (see the file docblock): the action above already fired for the + // value write, and a flag failure is reported or logged separately + // rather than undoing what already landed. + $wanted = (bool) $needs_rotation; + $had = $this->flag_is_set( $meta ); + + if ( $wanted !== $had ) { + $flag = $this->write_flag( $vault_path, $wanted, $meta ); + + if ( is_wp_error( $flag ) ) { + if ( $wanted ) { + return new WP_Error( + WP_SECRETS_ERROR_STORE_UNAVAILABLE, + sprintf( + 'The value was stored but Vault refused to record the rotation flag: %s', + $flag->get_error_message() + ) + ); + } + + error_log( + sprintf( + 'Vault_KV2_Provider: could not clear %s on %s: %s', + self::ROTATION_FLAG, + $vault_path, + $flag->get_error_message() + ) + ); + } + } + + return true; + } + + /** + * @param string $name Secret name. + * @param bool $network Whether this is a network-scope secret. + * + * @return true|WP_Error + */ + public function delete( $name, $network = false ) { + $vault_path = $this->vault_path( $name, $network ); + $result = $this->request( 'DELETE', $this->url( 'metadata', $vault_path ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $this->memo = array(); + + /** + * Fires whenever a secret is created, updated, deleted, or imported. + * + * Providers own firing this -- see WP_Secrets_Provider::set(). + */ + do_action( 'wp_secret_changed', $name, 'deleted', get_current_user_id(), time(), '', '' ); + + return true; + } + + /** + * Destroys the secret's version N-1, so a retired value can never be + * un-deleted. + * + * @param string $name Secret name. + * @param bool $network Whether this is a network-scope secret. + * + * @return true|WP_Error + */ + public function retire_previous( $name, $network = false ) { + $vault_path = $this->vault_path( $name, $network ); + $meta = $this->read_metadata( $name, $network ); + + if ( is_wp_error( $meta ) ) { + return $meta; + } + + if ( null === $meta ) { + return true; + } + + $previous = $this->previous_version( $meta ); + + if ( null === $previous ) { + return true; + } + + $result = $this->request( 'POST', $this->url( 'destroy', $vault_path ), array( 'versions' => array( $previous ) ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $this->memo = array(); + + do_action( 'wp_secret_changed', $name, 'retired', get_current_user_id(), time(), '', '' ); + + return true; + } + + /** + * Lists secret names and metadata under a namespace, never values. + * + * @param string $name_prefix Restrict to names beginning with this prefix. + * @param bool $network Whether to list network-scope secrets. + * + * @return array|WP_Error + */ + public function list_secrets( $name_prefix = '', $network = false ) { + $base = $this->scope_prefix( $network ); + + if ( '' !== $name_prefix ) { + $namespaces = array( $name_prefix ); + } else { + $keys = $this->list_keys( $this->url( 'metadata', $base, array( 'list' => 'true' ) ) ); + + if ( is_wp_error( $keys ) ) { + return $keys; + } + + if ( null === $keys ) { + return array(); + } + + $namespaces = array(); + + foreach ( $keys as $key ) { + if ( '/' === substr( $key, -1 ) ) { + $namespaces[] = rtrim( $key, '/' ); + } + } + } + + $entries = array(); + + foreach ( $namespaces as $ns ) { + $keys = $this->list_keys( $this->url( 'metadata', "{$base}{$ns}/", array( 'list' => 'true' ) ) ); + + if ( is_wp_error( $keys ) ) { + return $keys; + } + + if ( null === $keys ) { + continue; + } + + foreach ( $keys as $key ) { + if ( '/' === substr( $key, -1 ) ) { + continue; + } + + $secret_meta = $this->request( 'GET', $this->url( 'metadata', "{$base}{$ns}/{$key}" ) ); + + if ( is_wp_error( $secret_meta ) ) { + return $secret_meta; + } + + // A secret deleted between the LIST and this GET is simply + // omitted, the same way a name that never existed would be. + if ( null === $secret_meta ) { + continue; + } + + $created = 0; + + if ( ! empty( $secret_meta['created_time'] ) ) { + $created = (int) strtotime( preg_replace( '/\.\d+Z$/', 'Z', $secret_meta['created_time'] ) ); + } + + $entries[] = array( + 'name' => "{$ns}/{$key}", + // No data reads: fingerprinting every entry would mean a + // read per secret. See README.md question 4. + 'fingerprint' => '', + 'created' => $created, + 'has_previous' => null !== $this->previous_version( $secret_meta ), + 'needs_rotation' => $this->flag_is_set( $secret_meta ), + ); + } + } + + return $entries; + } + + /** + * @return string + */ + public function get_label() { + return sprintf( 'HashiCorp Vault (%s, mount %s)', $this->addr, $this->mount ); + } + + /** + * @return string + */ + public function get_protection_boundary() { + return self::BOUNDARY_PROVIDER; + } + + /** + * Always true: a token without write policy is not detected in advance, + * only surfaced as WP_Error from set() when the write is actually refused. + * + * @return bool + */ + public function is_writable() { + return true; + } + + // -- internals ------------------------------------------------------------- + + /** + * @param bool $network Whether this is a network-scope secret. + * + * @return string + */ + private function scope_prefix( $network ) { + return $network ? 'wp/network/' : 'wp/site/' . get_current_blog_id() . '/'; + } + + /** + * @param string $name Secret name. + * @param bool $network Whether this is a network-scope secret. + * + * @return string + */ + private function vault_path( $name, $network ) { + return $this->scope_prefix( $network ) . $name; + } + + /** + * @param string $kind 'data', 'metadata', 'destroy', or 'delete'. + * @param string $vault_path Path under the mount. + * @param array $query Query args. + * + * @return string + */ + private function url( $kind, $vault_path, array $query = array() ) { + $url = "{$this->addr}/v1/{$this->mount}/{$kind}/{$vault_path}"; + + if ( ! empty( $query ) ) { + $url .= '?' . http_build_query( $query ); + } + + return $url; + } + + /** + * Sends one Vault request and maps the response. + * + * Absence (404) is null. A transport failure or any non-2xx response, + * including 403 (permission denied) and 503 (sealed), is WP_Error with code + * WP_SECRETS_ERROR_STORE_UNAVAILABLE -- both read as "the store cannot + * answer right now," which is the correct state for a caller that must + * never confuse "sealed" with "the secret was deleted." + * + * @param string $method HTTP method. + * @param string $url Full request URL. + * @param array|null $body Request body, encoded as JSON when non-null. + * + * @return array|null|WP_Error Decoded 'data' array on 2xx (empty array for + * 204), null on 404, WP_Error otherwise. + */ + private function request( $method, $url, $body = null ) { + $headers = array( + 'X-Vault-Token' => $this->token, + 'X-Vault-Request' => 'true', + 'Content-Type' => 'application/json', + ); + + if ( '' !== $this->namespace ) { + $headers['X-Vault-Namespace'] = $this->namespace; + } + + $response = wp_remote_request( + $url, + array( + 'method' => $method, + 'timeout' => self::REQUEST_TIMEOUT, + 'headers' => $headers, + 'body' => null === $body ? null : wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + WP_SECRETS_ERROR_STORE_UNAVAILABLE, + sprintf( 'Vault unreachable: %s', $response->get_error_message() ) + ); + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( 404 === $code ) { + return null; + } + + $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $code >= 200 && $code < 300 ) { + return is_array( $decoded ) && isset( $decoded['data'] ) && is_array( $decoded['data'] ) + ? $decoded['data'] + : array(); + } + + $raw_body = wp_remote_retrieve_body( $response ); + $errors = isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ? $decoded['errors'] : array(); + $detail = ! empty( $errors ) ? implode( '; ', $errors ) : $raw_body; + + return new WP_Error( + WP_SECRETS_ERROR_STORE_UNAVAILABLE, + sprintf( 'Vault error (HTTP %d): %s', $code, $detail ) + ); + } + + /** + * @param string $name Secret name. + * @param bool $network Whether this is a network-scope secret. + * + * @return array|null|WP_Error + */ + private function read_metadata( $name, $network ) { + return $this->request( 'GET', $this->url( 'metadata', $this->vault_path( $name, $network ) ) ); + } + + /** + * Runs a Vault LIST (GET ...?list=true) and returns just the keys. + * Isolated so the per-secret metadata read in list_secrets() does not + * restructure this helper. + * + * @param string $url Full LIST URL, including ?list=true. + * + * @return string[]|null|WP_Error + */ + private function list_keys( $url ) { + $result = $this->request( 'GET', $url ); + + if ( is_wp_error( $result ) || null === $result ) { + return $result; + } + + return isset( $result['keys'] ) && is_array( $result['keys'] ) ? $result['keys'] : array(); + } + + /** + * The version this provider calls "previous": strictly N-1, and only when + * N-1 is itself readable. Never the newest surviving version below N -- + * retiring must never resurrect an older version by promoting it into the + * previous slot. + * + * @param array $meta Decoded metadata (the 'data' object from + * GET secret/metadata/). + * + * @return int|null + */ + private function previous_version( array $meta ) { + $current = isset( $meta['current_version'] ) ? (int) $meta['current_version'] : 0; + + if ( $current < 2 ) { + return null; + } + + $previous = $current - 1; + $key = (string) $previous; + + if ( ! isset( $meta['versions'][ $key ] ) ) { + return null; + } + + $version_meta = $meta['versions'][ $key ]; + + if ( ! empty( $version_meta['deletion_time'] ) || ! empty( $version_meta['destroyed'] ) ) { + return null; + } + + return $previous; + } + + /** + * Whether the rotation flag is set. Requires Vault 1.9+, which is when + * custom_metadata shipped. Reads as set only when the value is exactly + * "1" -- see write_flag() for why a clear writes "0" rather than removing + * the key. + * + * @param array|null $meta Decoded metadata, or null. + * + * @return bool + */ + private function flag_is_set( $meta ) { + return null !== $meta + && isset( $meta['custom_metadata'][ self::ROTATION_FLAG ] ) + && '1' === $meta['custom_metadata'][ self::ROTATION_FLAG ]; + } + + /** + * Writes the rotation flag. Vault replaces custom_metadata wholesale on + * every POST, so this merges the flag into the custom_metadata already + * read by the caller and posts the merged map -- preserving any other + * custom_metadata keys a different tool may have set. Clearing the flag + * writes "0" rather than omitting the key, since dropping it would also + * mean re-deriving the rest of the map correctly on every write. This + * read-then-write is not atomic (see the file docblock): a change made + * by another tool between the read and this write can be overwritten. + * + * @param string $vault_path Path under the mount. + * @param bool $set Whether to set (true) or clear (false). + * @param array|null $meta Metadata already read by the caller (the + * 'data' object from GET secret/metadata/). + * + * @return true|WP_Error + */ + private function write_flag( $vault_path, $set, $meta ) { + $existing = ( null !== $meta && isset( $meta['custom_metadata'] ) && is_array( $meta['custom_metadata'] ) ) + ? $meta['custom_metadata'] + : array(); + + $result = $this->request( + 'POST', + $this->url( 'metadata', $vault_path ), + array( 'custom_metadata' => array_merge( $existing, array( self::ROTATION_FLAG => $set ? '1' : '0' ) ) ) + ); + + return is_wp_error( $result ) ? $result : true; + } + + /** + * Wraps a plaintext into a WP_Secret, fingerprinted with this site's own + * master key so fingerprints stay comparable with every other provider. + * + * @param string $name Secret name. + * @param string $value Plaintext. + * @param bool $network Whether this is a network-scope secret. + * + * @return WP_Secret|WP_Error + */ + private function build_secret( $name, $value, $network ) { + $master_key = _wp_secrets_get_key_manager()->get_master_key( + $network ? 'network' : 'site', + $network ? null : get_current_blog_id() + ); + + if ( is_wp_error( $master_key ) ) { + return $master_key; + } + + $fingerprint = ( new WP_Secrets_Cipher() )->fingerprint( $master_key, $value ); + + wp_secrets_memzero( $master_key ); + + if ( is_wp_error( $fingerprint ) ) { + return $fingerprint; + } + + return new WP_Secret( $name, $value, $fingerprint ); + } +} + +/* + * Install it, but only with the address and token actually filled in. + * + * Checked for emptiness rather than just defined(): a config file with the + * constants present but blank -- the state a freshly-copied override file is + * in -- would otherwise install a provider that fails every single call. + * Falling back to WordPress's own provider means an unpopulated config is + * just a normal site. + */ +if ( defined( 'WP_SECRETS_VAULT_ADDR' ) && defined( 'WP_SECRETS_VAULT_TOKEN' ) + && '' !== trim( (string) WP_SECRETS_VAULT_ADDR ) + && '' !== trim( (string) WP_SECRETS_VAULT_TOKEN ) +) { + $mount = ( defined( 'WP_SECRETS_VAULT_MOUNT' ) && '' !== trim( (string) WP_SECRETS_VAULT_MOUNT ) ) + ? WP_SECRETS_VAULT_MOUNT + : 'secret'; + $namespace = defined( 'WP_SECRETS_VAULT_NAMESPACE' ) ? WP_SECRETS_VAULT_NAMESPACE : ''; + + $GLOBALS['wp_secrets_provider'] = new Vault_KV2_Provider( + WP_SECRETS_VAULT_ADDR, + WP_SECRETS_VAULT_TOKEN, + $mount, + $namespace + ); +} diff --git a/examples/vault-provider/tests/includes/class-vault-test-server.php b/examples/vault-provider/tests/includes/class-vault-test-server.php new file mode 100644 index 0000000..1278c76 --- /dev/null +++ b/examples/vault-provider/tests/includes/class-vault-test-server.php @@ -0,0 +1,228 @@ +addr = rtrim( $addr ? $addr : 'http://127.0.0.1:8200', '/' ); + $token = getenv( 'VAULT_TOKEN' ); + $this->token = $token ? $token : 'dev-root'; + $this->mount = 'secret'; + } + + public function addr() { + return $this->addr; + } + + public function token() { + return $this->token; + } + + public function mount() { + return $this->mount; + } + + /** + * @return Vault_KV2_Provider + */ + public function provider() { + return new Vault_KV2_Provider( $this->addr(), $this->token(), $this->mount() ); + } + + /** + * @param string $method HTTP method. + * @param string $path Path under /v1/, e.g. 'secret/data/wp/site/1/acme/key'. + * @param array|null $body Request body, encoded as JSON when non-null. + * + * @return array{code:int,body:array|null} + */ + public function request( $method, $path, $body = null ) { + $response = wp_remote_request( + "{$this->addr}/v1/{$path}", + array( + 'method' => $method, + 'timeout' => 10, + 'headers' => array( + 'X-Vault-Token' => $this->token, + 'X-Vault-Request' => 'true', + 'Content-Type' => 'application/json', + ), + 'body' => null === $body ? null : wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + PHPUnit\Framework\Assert::fail( + sprintf( + '%s %s/v1/%s: %s', + $method, + $this->addr, + $path, + $response->get_error_message() + ) + ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); + + return array( + 'code' => $code, + 'body' => is_array( $decoded ) ? $decoded : null, + ); + } + + /** + * @return array + */ + public function health() { + return $this->request( 'GET', 'sys/health' )['body']; + } + + /** + * @param string $vault_path Path under the mount, e.g. 'wp/site/1/acme/key'. + * + * @return array|null + */ + public function metadata( $vault_path ) { + $result = $this->request( 'GET', "{$this->mount}/metadata/{$vault_path}" ); + + if ( 404 === $result['code'] ) { + return null; + } + + if ( 200 !== $result['code'] ) { + PHPUnit\Framework\Assert::fail( + sprintf( 'GET %s/v1/%s/metadata/%s: unexpected HTTP %d', $this->addr, $this->mount, $vault_path, $result['code'] ) + ); + } + + return isset( $result['body']['data'] ) ? $result['body']['data'] : null; + } + + /** + * @param string $vault_path Path under the mount. + * @param int $version Version number. + * + * @return int HTTP response code. + */ + public function read_version( $vault_path, $version ) { + $result = $this->request( 'GET', "{$this->mount}/data/{$vault_path}?version={$version}" ); + + return $result['code']; + } + + /** + * @param string $vault_path Path under the mount. + * @param int $max_versions Value for max_versions. + * + * @return array{code:int,body:array|null} + */ + public function create_metadata( $vault_path, $max_versions ) { + return $this->request( + 'POST', + "{$this->mount}/metadata/{$vault_path}", + array( 'max_versions' => $max_versions ) + ); + } + + /** + * @param string $vault_path Path under the mount. + * @param int[] $versions Version numbers to soft-delete. + * + * @return array{code:int,body:array|null} + */ + public function soft_delete_versions( $vault_path, array $versions ) { + return $this->request( + 'POST', + "{$this->mount}/delete/{$vault_path}", + array( 'versions' => $versions ) + ); + } + + /** + * @param string $vault_path Path under the mount to list, e.g. 'wp/'. + * + * @return string[] + */ + public function list_keys( $vault_path ) { + $result = $this->request( 'GET', "{$this->mount}/metadata/{$vault_path}?list=true" ); + + if ( 404 === $result['code'] ) { + return array(); + } + + if ( 200 !== $result['code'] ) { + PHPUnit\Framework\Assert::fail( + sprintf( 'GET %s/v1/%s/metadata/%s?list=true: unexpected HTTP %d', $this->addr, $this->mount, $vault_path, $result['code'] ) + ); + } + + return isset( $result['body']['data']['keys'] ) ? $result['body']['data']['keys'] : array(); + } + + /** + * Deletes every secret's metadata (and therefore all its versions) under + * secret/metadata/wp/, recursively. Called in set_up() by every Vault test + * class: WP_UnitTestCase's database rollback does not reach Vault, and the + * conformance suite reuses the same secret name across test methods. + * + * @return void + */ + public function wipe() { + $this->wipe_recursive( 'wp/' ); + } + + /** + * @param string $vault_path Directory path under the mount, ending in '/'. + * + * @return void + */ + private function wipe_recursive( $vault_path ) { + foreach ( $this->list_keys( $vault_path ) as $key ) { + $full = $vault_path . $key; + + if ( '/' === substr( $key, -1 ) ) { + $this->wipe_recursive( $full ); + continue; + } + + $result = $this->request( 'DELETE', "{$this->mount}/metadata/{$full}" ); + + if ( 204 !== $result['code'] ) { + PHPUnit\Framework\Assert::fail( + sprintf( 'DELETE %s/v1/%s/metadata/%s: unexpected HTTP %d', $this->addr, $this->mount, $full, $result['code'] ) + ); + } + } + } +} diff --git a/examples/vault-provider/tests/test-vault-harness.php b/examples/vault-provider/tests/test-vault-harness.php new file mode 100644 index 0000000..7a43f74 --- /dev/null +++ b/examples/vault-provider/tests/test-vault-harness.php @@ -0,0 +1,62 @@ +server = new Vault_Test_Server(); + } + + public function test_the_dev_server_is_reachable_and_unsealed() { + $health = $this->server->health(); + + $this->assertTrue( $health['initialized'] ); + $this->assertFalse( $health['sealed'] ); + } + + public function test_kv_v2_is_mounted_at_secret() { + $result = $this->server->request( 'GET', 'sys/mounts' ); + + $this->assertSame( 200, $result['code'] ); + $this->assertSame( '2', $result['body']['secret/']['options']['version'] ); + $this->assertSame( '2', $result['body']['data']['secret/']['options']['version'] ); + } + + public function test_wipe_removes_everything_under_wp() { + $this->server->request( 'POST', 'secret/data/wp/site/1/harness/one', array( 'data' => array( 'value' => 'one' ) ) ); + $this->server->request( 'POST', 'secret/data/wp/network/harness/two', array( 'data' => array( 'value' => 'two' ) ) ); + + $this->server->wipe(); + + $this->assertSame( array(), $this->server->list_keys( 'wp/' ) ); + $this->assertNull( $this->server->metadata( 'wp/site/1/harness/one' ) ); + } + + public function test_the_helper_fails_loudly_when_vault_is_unreachable() { + $helper = new Vault_Test_Server( 'http://127.0.0.1:1' ); + + $this->expectException( PHPUnit\Framework\AssertionFailedError::class ); + + $helper->metadata( 'wp/site/1/acme/key' ); + } + + public function test_wipe_fails_loudly_when_vault_is_unreachable() { + $helper = new Vault_Test_Server( 'http://127.0.0.1:1' ); + + $this->expectException( PHPUnit\Framework\AssertionFailedError::class ); + + $helper->wipe(); + } +} diff --git a/examples/vault-provider/tests/test-vault-provider-conformance.php b/examples/vault-provider/tests/test-vault-provider-conformance.php new file mode 100644 index 0000000..2e7372f --- /dev/null +++ b/examples/vault-provider/tests/test-vault-provider-conformance.php @@ -0,0 +1,23 @@ +server = new Vault_Test_Server(); + $this->server->wipe(); + } + + protected function provider() { + return $this->server->provider(); + } +} diff --git a/examples/vault-provider/tests/test-vault-provider-multisite.php b/examples/vault-provider/tests/test-vault-provider-multisite.php new file mode 100644 index 0000000..01dbd0e --- /dev/null +++ b/examples/vault-provider/tests/test-vault-provider-multisite.php @@ -0,0 +1,87 @@ +markTestSkipped( 'Multisite only.' ); + } + + $this->server = new Vault_Test_Server(); + $this->server->wipe(); + $this->provider = $this->server->provider(); + } + + public function tear_down() { + if ( ms_is_switched() ) { + restore_current_blog(); + } + + parent::tear_down(); + } + + public function test_site_scope_is_isolated_per_blog() { + $this->provider->set( 'acme/key', 'blog-one' ); + + $blog = self::factory()->blog->create(); + switch_to_blog( $blog ); + + $this->assertNull( $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT ) ); + + $this->provider->set( 'acme/key', 'blog-two' ); + + $this->assertSame( 'blog-two', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + + $names = wp_list_pluck( $this->provider->list_secrets(), 'name' ); + $this->assertSame( array( 'acme/key' ), $names ); + + restore_current_blog(); + + $this->assertSame( 'blog-one', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + + $this->assertNotNull( $this->server->metadata( 'wp/site/1/acme/key' ) ); + $this->assertNotNull( $this->server->metadata( "wp/site/{$blog}/acme/key" ) ); + } + + public function test_network_scope_is_shared_across_blogs() { + $this->provider->set( 'acme/key', 'net', true ); + + $blog = self::factory()->blog->create(); + switch_to_blog( $blog ); + + $this->assertSame( 'net', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT, true )->reveal() ); + + restore_current_blog(); + + $this->assertNotNull( $this->server->metadata( 'wp/network/acme/key' ) ); + $this->assertNull( $this->server->metadata( "wp/site/{$blog}/acme/key" ) ); + } + + public function test_deleting_on_one_blog_leaves_the_other() { + $this->provider->set( 'acme/key', 'blog-one' ); + + $blog = self::factory()->blog->create(); + switch_to_blog( $blog ); + $this->provider->set( 'acme/key', 'blog-two' ); + + $this->assertTrue( $this->provider->delete( 'acme/key' ) ); + + restore_current_blog(); + + $this->assertSame( 'blog-one', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + } +} diff --git a/examples/vault-provider/tests/test-vault-provider-paths.php b/examples/vault-provider/tests/test-vault-provider-paths.php new file mode 100644 index 0000000..9eeecb2 --- /dev/null +++ b/examples/vault-provider/tests/test-vault-provider-paths.php @@ -0,0 +1,249 @@ +requests = array(); + $this->queue = array(); + + add_filter( 'pre_http_request', array( $this, 'fake_request' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'fake_request' ), 10 ); + + parent::tear_down(); + } + + public function fake_request( $preempt, $parsed_args, $url ) { + $this->requests[] = array( + 'url' => $url, + 'args' => $parsed_args, + ); + + if ( ! empty( $this->queue ) ) { + return array_shift( $this->queue ); + } + + return $this->fake_response( 404, array( 'errors' => array() ) ); + } + + private function fake_response( $code, array $body ) { + return array( + 'headers' => array(), + 'body' => wp_json_encode( $body ), + 'response' => array( + 'code' => $code, + 'message' => '', + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + private function queue_response( $code, array $body ) { + $this->queue[] = $this->fake_response( $code, $body ); + } + + private function provider( $mount = 'secret', $namespace = '' ) { + return new Vault_KV2_Provider( 'http://vault.test:8200', 'test-token', $mount, $namespace ); + } + + public function test_site_scope_maps_to_wp_site_blog_id_namespace_key() { + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertSame( 'http://vault.test:8200/v1/secret/data/wp/site/1/acme/key', $this->requests[0]['url'] ); + } + + public function test_network_scope_maps_to_wp_network_namespace_key() { + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT, true ); + + $this->assertSame( 'http://vault.test:8200/v1/secret/data/wp/network/acme/key', $this->requests[0]['url'] ); + } + + public function test_a_custom_mount_and_namespace_are_used() { + $this->provider( 'kv', 'team-a' )->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertStringContainsString( '/v1/kv/data/', $this->requests[0]['url'] ); + $this->assertSame( 'team-a', $this->requests[0]['args']['headers']['X-Vault-Namespace'] ); + + $this->requests = array(); + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertArrayNotHasKey( 'X-Vault-Namespace', $this->requests[0]['args']['headers'] ); + } + + public function test_the_token_header_is_sent_and_the_timeout_is_the_constant() { + $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertSame( 'test-token', $this->requests[0]['args']['headers']['X-Vault-Token'] ); + $this->assertSame( Vault_KV2_Provider::REQUEST_TIMEOUT, $this->requests[0]['args']['timeout'] ); + } + + public function test_a_404_on_current_is_null() { + $this->queue_response( 404, array( 'errors' => array() ) ); + + $this->assertNull( $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ) ); + } + + public function test_a_403_is_store_unavailable_with_vaults_message() { + $this->queue_response( 403, array( 'errors' => array( 'permission denied' ) ) ); + + $result = $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringContainsString( 'permission denied', $result->get_error_message() ); + } + + public function test_a_sealed_vault_is_store_unavailable_not_null() { + $this->queue_response( 503, array( 'errors' => array( 'Vault is sealed' ) ) ); + + $result = $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertWPError( $result ); + $this->assertStringContainsString( 'Vault is sealed', $result->get_error_message() ); + } + + public function test_a_transport_failure_is_store_unavailable() { + remove_filter( 'pre_http_request', array( $this, 'fake_request' ), 10 ); + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'http_request_failed', 'cURL error 7' ); + } + ); + + $result = $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + } + + public function test_a_missing_value_field_is_record_malformed() { + $this->queue_response( 200, array( 'data' => array( 'data' => array() ) ) ); + + $result = $this->provider()->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_RECORD_MALFORMED, $result->get_error_code() ); + } + + public function test_current_reveals_the_value_and_is_memoised() { + $this->queue_response( 200, array( 'data' => array( 'data' => array( 'value' => 'sk_live_x' ) ) ) ); + + $provider = $this->provider(); + $secret = $provider->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertInstanceOf( 'WP_Secret', $secret ); + $this->assertSame( 'sk_live_x', $secret->reveal() ); + + $count = count( $this->requests ); + $provider->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertCount( $count, $this->requests ); + } + + public function test_previous_with_one_version_is_null_without_a_data_read() { + $this->queue_response( + 200, + array( + 'data' => array( + 'current_version' => 1, + 'versions' => array( + '1' => array(), + ), + ), + ) + ); + + $this->assertNull( $this->provider()->get( 'acme/key', WP_Secret_Version::PREVIOUS ) ); + $this->assertCount( 1, $this->requests ); + } + + public function test_previous_skips_a_destroyed_n_minus_1_rather_than_falling_back() { + $this->queue_response( + 200, + array( + 'data' => array( + 'current_version' => 3, + 'versions' => array( + '1' => array(), + '2' => array( 'destroyed' => true ), + '3' => array(), + ), + ), + ) + ); + + $this->assertNull( $this->provider()->get( 'acme/key', WP_Secret_Version::PREVIOUS ) ); + $this->assertCount( 1, $this->requests ); + } + + public function test_previous_reads_exactly_n_minus_1() { + $this->queue_response( + 200, + array( + 'data' => array( + 'current_version' => 3, + 'versions' => array( + '2' => array(), + '3' => array(), + ), + ), + ) + ); + $this->queue_response( 200, array( 'data' => array( 'data' => array( 'value' => 'v2' ) ) ) ); + + $secret = $this->provider()->get( 'acme/key', WP_Secret_Version::PREVIOUS ); + + $this->assertStringEndsWith( '?version=2', $this->requests[1]['url'] ); + $this->assertSame( 'v2', $secret->reveal() ); + } + + public function test_delete_returns_true_on_204_and_fires_deleted() { + $this->queue_response( 204, array() ); + + $fired = array(); + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $this->assertTrue( $this->provider()->delete( 'acme/key' ) ); + $this->assertSame( array( 'deleted' ), $fired ); + } + + public function test_delete_on_a_sealed_vault_is_an_error_not_success() { + $this->queue_response( 503, array( 'errors' => array( 'Vault is sealed' ) ) ); + + $this->assertWPError( $this->provider()->delete( 'acme/key' ) ); + } + + public function test_declarations() { + $provider = $this->provider(); + + $this->assertSame( 'HashiCorp Vault (http://vault.test:8200, mount secret)', $provider->get_label() ); + $this->assertSame( WP_Secrets_Provider::BOUNDARY_PROVIDER, $provider->get_protection_boundary() ); + $this->assertTrue( $provider->is_writable() ); + } +} diff --git a/examples/vault-provider/tests/test-vault-provider.php b/examples/vault-provider/tests/test-vault-provider.php new file mode 100644 index 0000000..72c076d --- /dev/null +++ b/examples/vault-provider/tests/test-vault-provider.php @@ -0,0 +1,552 @@ +server = new Vault_Test_Server(); + $this->server->wipe(); + $this->provider = $this->server->provider(); + } + + public function tear_down() { + if ( false !== $this->original_error_log ) { + ini_set( 'error_log', $this->original_error_log ); + $this->original_error_log = false; + } + + parent::tear_down(); + } + + public function test_create_sets_max_versions_to_two_in_vault_itself() { + $this->assertTrue( $this->provider->set( 'acme/key', 'v1' ) ); + + $meta = $this->server->metadata( 'wp/site/1/acme/key' ); + + $this->assertSame( Vault_KV2_Provider::MAX_VERSIONS, $meta['max_versions'] ); + } + + public function test_first_write_fires_created_and_second_fires_updated() { + $fired = array(); + + add_action( + 'wp_secret_changed', + static function ( ...$args ) use ( &$fired ) { + $fired[] = $args; + }, + 10, + 6 + ); + + $this->provider->set( 'acme/key', 'canary-1' ); + $this->provider->set( 'acme/key', 'canary-2' ); + + $this->assertSame( array( 'created', 'updated' ), array( $fired[0][1], $fired[1][1] ) ); + $this->assertStringNotContainsString( 'canary-1', wp_json_encode( $fired ) ); + $this->assertStringNotContainsString( 'canary-2', wp_json_encode( $fired ) ); + } + + public function test_an_explicit_action_overrides_created_or_updated() { + $fired = array(); + + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $this->provider->set( 'acme/key', 'v1', false, false, 'imported' ); + + $this->assertSame( array( 'imported' ), $fired ); + } + + public function test_update_does_not_reassert_max_versions() { + $this->server->create_metadata( 'wp/site/1/acme/key', 10 ); + + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + + $this->assertSame( 10, $this->server->metadata( 'wp/site/1/acme/key' )['max_versions'] ); + } + + public function test_retire_destroys_exactly_n_minus_1_and_fires_retired() { + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + + $fired = array(); + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $this->assertTrue( $this->provider->retire_previous( 'acme/key' ) ); + + $meta = $this->server->metadata( 'wp/site/1/acme/key' ); + + $this->assertTrue( $meta['versions']['1']['destroyed'] ); + $this->assertSame( 200, $this->server->read_version( 'wp/site/1/acme/key', 2 ) ); + $this->assertSame( array( 'retired' ), $fired ); + } + + public function test_retire_with_nothing_to_retire_fires_nothing() { + $this->provider->set( 'acme/key', 'v1' ); + + $fired = array(); + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $this->assertTrue( $this->provider->retire_previous( 'acme/key' ) ); + $this->assertSame( array(), $fired ); + } + + public function test_list_returns_names_across_namespaces_and_never_a_value() { + $canary = 'canary-listing-value-9d2c'; + + $this->provider->set( 'alpha/one', $canary ); + $this->provider->set( 'alpha/two', $canary ); + $this->provider->set( 'beta/three', $canary ); + + $names = wp_list_pluck( $this->provider->list_secrets(), 'name' ); + sort( $names ); + + $this->assertSame( array( 'alpha/one', 'alpha/two', 'beta/three' ), $names ); + $this->assertStringNotContainsString( $canary, wp_json_encode( $this->provider->list_secrets() ) ); + + $beta_names = wp_list_pluck( $this->provider->list_secrets( 'beta' ), 'name' ); + + $this->assertSame( array( 'beta/three' ), $beta_names ); + } + + public function test_list_on_an_empty_mount_is_an_empty_array() { + $this->assertSame( array(), $this->provider->list_secrets() ); + } + + public function test_retiring_does_not_resurrect_an_older_version() { + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + $this->provider->set( 'acme/key', 'v3' ); + + $this->provider->retire_previous( 'acme/key' ); + + $previous = $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS ); + $this->assertNotWPError( $previous ); + $this->assertNull( $previous ); + + $this->provider->set( 'acme/key', 'v4' ); + + $this->assertSame( 'v3', $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS )->reveal() ); + $this->assertSame( 'v4', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + } + + public function test_only_two_versions_are_kept_in_vault_itself() { + $path = 'wp/site/1/acme/key'; + + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + $this->provider->set( 'acme/key', 'v3' ); + + $this->assertSame( 404, $this->server->read_version( $path, 1 ) ); + + $meta = $this->server->metadata( $path ); + + $this->assertArrayNotHasKey( '1', $meta['versions'] ); + $this->assertSame( 2, $meta['oldest_version'] ); + $this->assertSame( 200, $this->server->read_version( $path, 2 ) ); + $this->assertSame( 200, $this->server->read_version( $path, 3 ) ); + } + + public function test_previous_is_strictly_n_minus_1_even_when_older_versions_survive() { + $path = 'wp/site/1/acme/key'; + + $this->server->create_metadata( $path, 10 ); + + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + $this->provider->set( 'acme/key', 'v3' ); + + $this->provider->retire_previous( 'acme/key' ); + + $this->assertNull( $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS ) ); + $this->assertSame( 200, $this->server->read_version( $path, 1 ) ); + } + + public function test_a_soft_deleted_n_minus_1_reads_as_absent() { + $path = 'wp/site/1/acme/key'; + + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + + $this->server->soft_delete_versions( $path, array( 1 ) ); + + $this->assertNull( $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS ) ); + $this->assertSame( 'v2', $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + } + + public function test_a_soft_deleted_current_reads_as_absent_not_error() { + $path = 'wp/site/1/acme/key'; + + $this->provider->set( 'acme/key', 'v1' ); + $this->server->soft_delete_versions( $path, array( 1 ) ); + + $result = $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT ); + + $this->assertNull( $result ); + $this->assertNotWPError( $result ); + } + + public function test_retire_clears_the_memo() { + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + + $this->assertSame( 'v1', $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS )->reveal() ); + + $this->provider->retire_previous( 'acme/key' ); + + $this->assertNull( $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS ) ); + } + + public function test_retire_is_idempotent() { + $this->provider->set( 'acme/key', 'v1' ); + $this->provider->set( 'acme/key', 'v2' ); + + $fired = array(); + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $this->assertTrue( $this->provider->retire_previous( 'acme/key' ) ); + $this->assertTrue( $this->provider->retire_previous( 'acme/key' ) ); + + $this->assertSame( array( 'retired' ), $fired ); + } + + public function test_needs_rotation_round_trips_through_custom_metadata() { + $path = 'wp/site/1/acme/key'; + + $this->assertTrue( $this->provider->set( 'acme/key', 'v', false, true ) ); + + $this->assertSame( '1', $this->server->metadata( $path )['custom_metadata']['needs_rotation'] ); + + $listing = $this->provider->list_secrets(); + $this->assertTrue( $listing[0]['needs_rotation'] ); + } + + public function test_a_set_without_the_flag_clears_it() { + $path = 'wp/site/1/acme/key'; + + $this->provider->set( 'acme/key', 'v1', false, true ); + $this->provider->set( 'acme/key', 'v2' ); + + $this->assertSame( '0', $this->server->metadata( $path )['custom_metadata']['needs_rotation'] ); + + $listing = $this->provider->list_secrets(); + $this->assertFalse( $listing[0]['needs_rotation'] ); + } + + public function test_setting_and_clearing_the_flag_preserves_other_custom_metadata() { + $path = 'wp/site/1/acme/key'; + + $this->provider->set( 'acme/key', 'v1' ); + + $this->server->request( + 'POST', + "secret/metadata/{$path}", + array( 'custom_metadata' => array( 'owner' => 'ops' ) ) + ); + + $this->assertTrue( $this->provider->set( 'acme/key', 'v2', false, true ) ); + + $custom_metadata = $this->server->metadata( $path )['custom_metadata']; + $this->assertSame( 'ops', $custom_metadata['owner'] ); + $this->assertSame( '1', $custom_metadata['needs_rotation'] ); + + $this->assertTrue( $this->provider->set( 'acme/key', 'v3' ) ); + + $metadata = $this->server->metadata( $path ); + $custom_metadata = $metadata['custom_metadata']; + $this->assertSame( 'ops', $custom_metadata['owner'] ); + $this->assertSame( '0', $custom_metadata['needs_rotation'] ); + $this->assertSame( Vault_KV2_Provider::MAX_VERSIONS, $metadata['max_versions'] ); + } + + public function test_the_flag_is_written_on_create_when_requested() { + $seen = array(); + + add_filter( + 'pre_http_request', + static function ( $preempt, $args, $url ) use ( &$seen ) { + if ( 'POST' === $args['method'] && false !== strpos( $url, '/metadata/' ) + && false !== strpos( (string) $args['body'], 'custom_metadata' ) + ) { + $seen[] = $url; + } + + return $preempt; + }, + 10, + 3 + ); + + $this->provider->set( 'acme/key', 'v', false, true ); + + $this->assertCount( 1, $seen ); + } + + public function test_a_set_with_an_unchanged_flag_makes_no_metadata_write() { + $this->provider->set( 'acme/key', 'v1' ); + + $seen = array(); + + add_filter( + 'pre_http_request', + static function ( $preempt, $args, $url ) use ( &$seen ) { + if ( 'POST' === $args['method'] && false !== strpos( $url, '/metadata/' ) + && false !== strpos( (string) $args['body'], 'custom_metadata' ) + ) { + $seen[] = $url; + } + + return $preempt; + }, + 10, + 3 + ); + + $this->provider->set( 'acme/key', 'v2' ); + + $this->assertSame( array(), $seen ); + } + + public function test_a_failed_flag_write_that_was_requested_is_an_error_after_the_value_landed() { + $canary = 'CANARY-flag-fail-7e2a'; + + add_filter( + 'pre_http_request', + static function ( $preempt, $args, $url ) { + if ( 'POST' === $args['method'] && false !== strpos( $url, '/metadata/' ) + && false !== strpos( (string) $args['body'], 'custom_metadata' ) + ) { + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'errors' => array( 'Vault is sealed' ) ) ), + 'response' => array( 'code' => 503, 'message' => '' ), + 'cookies' => array(), + 'filename' => null, + ); + } + + return $preempt; + }, + 10, + 3 + ); + + $fired = array(); + add_action( + 'wp_secret_changed', + static function ( $name, $action ) use ( &$fired ) { + $fired[] = $action; + }, + 10, + 2 + ); + + $result = $this->provider->set( 'acme/key', $canary, false, true ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringNotContainsString( $canary, $result->get_error_message() ); + + remove_all_filters( 'pre_http_request' ); + + $this->assertSame( $canary, $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT )->reveal() ); + $this->assertSame( array( 'created' ), $fired ); + } + + public function test_a_failed_clear_is_logged_without_the_value_and_ignored() { + $path = 'wp/site/1/acme/key'; + $canary = 'CANARY-clear-9c1d'; + + $this->provider->set( 'acme/key', 'v1', false, true ); + + $log_file = get_temp_dir() . 'vault-provider-test-' . wp_generate_password( 8, false ) . '.log'; + $this->original_error_log = ini_get( 'error_log' ); + ini_set( 'error_log', $log_file ); + + add_filter( + 'pre_http_request', + static function ( $preempt, $args, $url ) { + if ( 'POST' === $args['method'] && false !== strpos( $url, '/metadata/' ) + && false !== strpos( (string) $args['body'], 'custom_metadata' ) + ) { + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'errors' => array( 'Vault is sealed' ) ) ), + 'response' => array( 'code' => 503, 'message' => '' ), + 'cookies' => array(), + 'filename' => null, + ); + } + + return $preempt; + }, + 10, + 3 + ); + + $this->assertTrue( $this->provider->set( 'acme/key', $canary ) ); + + remove_all_filters( 'pre_http_request' ); + + $log = file_exists( $log_file ) ? file_get_contents( $log_file ) : ''; + + if ( file_exists( $log_file ) ) { + unlink( $log_file ); + } + + $this->assertStringContainsString( 'could not clear needs_rotation', $log ); + $this->assertStringNotContainsString( $canary, $log ); + $this->assertSame( '1', $this->server->metadata( $path )['custom_metadata']['needs_rotation'] ); + } + + public function test_list_reports_created_and_has_previous() { + $this->provider->set( 'acme/key', 'v1' ); + + $listing = $this->provider->list_secrets(); + $this->assertLessThan( 300, abs( time() - $listing[0]['created'] ) ); + $this->assertFalse( $listing[0]['has_previous'] ); + + $this->provider->set( 'acme/key', 'v2' ); + $listing = $this->provider->list_secrets(); + $this->assertTrue( $listing[0]['has_previous'] ); + + $this->provider->retire_previous( 'acme/key' ); + $listing = $this->provider->list_secrets(); + $this->assertFalse( $listing[0]['has_previous'] ); + } + + public function test_list_omits_a_secret_deleted_between_list_and_metadata_read() { + $this->provider->set( 'acme/one', 'v1' ); + $this->provider->set( 'acme/two', 'v1' ); + + add_filter( + 'pre_http_request', + static function ( $preempt, $args, $url ) { + if ( 'GET' === $args['method'] && false !== strpos( $url, '/metadata/wp/site/1/acme/one' ) ) { + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'errors' => array() ) ), + 'response' => array( 'code' => 404, 'message' => '' ), + 'cookies' => array(), + 'filename' => null, + ); + } + + return $preempt; + }, + 10, + 3 + ); + + $names = wp_list_pluck( $this->provider->list_secrets(), 'name' ); + + $this->assertSame( array( 'acme/two' ), $names ); + } + + public function test_a_sealed_vault_is_an_error_from_every_method() { + $this->provider->set( 'acme/key', 'v1' ); + + add_filter( + 'pre_http_request', + static function () { + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'errors' => array( 'Vault is sealed' ) ) ), + 'response' => array( 'code' => 503, 'message' => '' ), + 'cookies' => array(), + 'filename' => null, + ); + } + ); + + $results = array( + $this->provider->get( 'acme/key', WP_Secret_Version::CURRENT ), + $this->provider->get( 'acme/key', WP_Secret_Version::PREVIOUS ), + $this->provider->set( 'acme/key', 'v2' ), + $this->provider->delete( 'acme/key' ), + $this->provider->retire_previous( 'acme/key' ), + $this->provider->list_secrets(), + ); + + foreach ( $results as $result ) { + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringContainsString( 'Vault is sealed', $result->get_error_message() ); + } + } + + public function test_an_unreachable_vault_is_an_error_not_absence() { + $provider = new Vault_KV2_Provider( 'http://127.0.0.1:1', 'x' ); + + $get = $provider->get( 'acme/key', WP_Secret_Version::CURRENT ); + $this->assertWPError( $get ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $get->get_error_code() ); + + $list = $provider->list_secrets(); + $this->assertWPError( $list ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $list->get_error_code() ); + + $delete = $provider->delete( 'acme/key' ); + $this->assertWPError( $delete ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $delete->get_error_code() ); + } + + public function test_a_permission_denied_write_is_an_error_from_set() { + $this->provider->set( 'acme/key', 'v1' ); + + $bad_provider = new Vault_KV2_Provider( $this->server->addr(), 'not-a-real-token', $this->server->mount() ); + + $result = $bad_provider->set( 'acme/key', 'v2' ); + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringContainsString( 'permission denied', $result->get_error_message() ); + + $get_result = $bad_provider->get( 'acme/key', WP_Secret_Version::CURRENT ); + $this->assertWPError( $get_result ); + } +} diff --git a/tests/bootstrap-examples.php b/tests/bootstrap-examples.php index 5e75989..a97bbc7 100644 --- a/tests/bootstrap-examples.php +++ b/tests/bootstrap-examples.php @@ -4,7 +4,8 @@ * * Bootstraps WordPress and the plugin exactly as tests/bootstrap.php does, then * requires every examples/*\/secrets.php found on disk so their classes are - * available to construct directly in a test. The install block at the bottom of + * available to construct directly in a test. Shared test helpers under + * examples/*\/tests/includes/ load first. The install block at the bottom of * each example file is guarded on wp-config.php constants (WP_SECRETS_AWS_REGION * and friends) that are never defined in this process, so requiring the file * installs nothing as a provider or keyring global -- it only makes the class @@ -17,6 +18,10 @@ require_once __DIR__ . '/bootstrap.php'; +foreach ( glob( dirname( __DIR__ ) . '/examples/*/tests/includes/*.php' ) as $helper ) { + require_once $helper; +} + foreach ( glob( dirname( __DIR__ ) . '/examples/*/secrets.php' ) as $example ) { require_once $example; }