Reported symptoms
reporting-worker reads its configuration from a key value version
two mount on bao.example.com and has done so for two years. Release
6.2 added regional reporting, which meant the worker had to stop
reading one hardcoded secret and start discovering which regional
secrets exist before reading them. The policy attached to its role had
not been touched in eighteen months.
The incident channel filled up faster than the facts did:
- 14:06, release 6.2 rolls out. Every worker aborts during startup. The queue depth begins climbing immediately.
- 14:10, the release is rolled back. Service recovers in four minutes, which everybody reads as confirmation that the fault is in the new code.
- 14:11, an operator reads the known secret path by hand to check the policy. It returns the record. The channel notes that the policy is verified and moves on.
- 14:14, somebody spots that the release notes mention a change to the mount syntax in the client configuration, and the next twenty minutes go into the mount.
- 14:31, an engineer opens the audit device output and searches for the worker token accessor.
The policy was never verified at 14:11. An operator read a path that the policy grants, using a different token, in a different form, and concluded something about two paths that the policy does not grant. That one minute of work cost the incident twenty-five.
Evidence provided
# reporting-worker instance 2, startup log
2026-08-26T14:06:41.118Z level=info component=secrets step=discovery
prefix=app action=list
2026-08-26T14:06:41.164Z level=error component=secrets step=discovery
status=403 body="* permission denied"
2026-08-26T14:06:41.164Z level=fatal component=worker
cause="discovery failed, refusing to start with an unknown secret set"
$ bao kv list -mount=kv appError listing kv/metadata/app: Error making API request.
URL: GET https://bao.example.com:8200/v1/kv/metadata/app?list=true
Code: 403. Errors:
* permission deniedIllustrative output
$ bao kv get -mount=kv app/config$ bao kv get -mount=kv app/otherError reading kv/data/app/other: Error making API request.
URL: GET https://bao.example.com:8200/v1/kv/data/app/other
Code: 403. Errors:
* permission deniedIllustrative output
Three requests, one token, three different outcomes. Two of them are refused and the two refusals name different API paths. Neither of those paths resembles what was typed.
# /etc/openbao/policies/app-read.hcl, unchanged since February 2025
path "kv/data/app/config" {
capabilities = ["read"]
}
That is the entire policy. One stanza, one capability, one path.
{"time":"2026-08-26T14:35:02.555636512Z","type":"response",
"auth":{"client_token":"hmac-sha256:da33377eba1c...","accessor":"hmac-sha256:5eb6ce9e...",
"policies":["app-read","default"],"policy_results":{"allowed":false}},
"request":{"operation":"read","mount_point":"kv/","mount_type":"kv",
"path":"kv/data/app/other","remote_address":"198.51.100.62"},
"response":{"data":{"error":"hmac-sha256:b0a0f532..."}},
"error":"1 error occurred:\n\t* permission denied\n\n"}
This record is the decisive piece of evidence and it is worth reading
field by field. The auth object is populated, and it names the
policies the caller carries, which means the token authenticated
successfully and the server knew who it was. policy_results says
allowed is false, which means the request reached policy
evaluation and lost there. The path is the API path, not the path
anybody typed. A token that had expired would not have produced this
record at all, because there would have been no policy set to
evaluate.
# release 6.2 diff, secrets client (abridged)
+ regions := secrets.List("app") // new: enumerate before reading
cfg := secrets.Read("app/config")
+ other := secrets.Read("app/other") // new: shared regional settings
Work the evidence before reading on
The trap in this incident is that one of the three requests succeeds, and the successful one is the request an operator will naturally reach for when asked whether the policy is correct.
- Write down the three API paths the errors and the successful read actually reach. Now compare them, character by character, against the single path in the policy file. How many of the three appear there?
- The command that succeeded named
kv/app/configand the policy nameskv/data/app/config. Something inserted a path segment. What, and what else might it be inserting elsewhere? - The failing enumeration names a path under
metadata, which the policy does not mention at any capability. If you addedlistto the existing stanza, which of the three requests would change? - The audit record contains a populated policies array and a policy result of not allowed. What does each of those two facts independently rule out?
Before continuing: name the two separate grants this policy is missing, and say why granting one of them on the path the application reads from would achieve nothing.
Root cause
The policy grants one leaf, and policies are deny by default
The stanza names kv/data/app/config and grants read. Every other
path in the system is refused, and refused silently: there is no
warning at write time that a policy is narrower than the workload, and
no diagnostic at request time distinguishing a path that was denied
from a path that was never mentioned. Release 6.2 added a read of
kv/data/app/other, a sibling of the one granted path, and it was
refused for the ordinary reason that the policy does not contain it.
This is the easy half of the incident and the half that was never
reached, because the worker aborted before it got that far.
The list capability lives on a different prefix entirely
Version two of the key value engine splits its API. Secret values are
read and written under a data prefix; version history, deletion
state and enumeration live under a metadata prefix. Enumeration is
only ever evaluated against the metadata path, so the worker
enumerating app produces a request for kv/metadata/app. Granting
list on kv/data/app/config, or even on kv/data/app/*, would not
have made the slightest difference, because no client ever asks to
list that path. The two prefixes require two stanzas, and no amount of
editing one of them produces the other.
The command line interface hides the split from the person reviewing the policy
Operators type kv/app/config and the client inserts data. Policies
are written against the API, so they say kv/data/app/config. The two
strings look similar enough to pass review and different enough that
nobody can compare them mechanically, and the mismatch only becomes
visible in an error message or an audit record, both of which print
the real path. That is why the 14:11 check appeared to verify a policy
it had not tested, and why the incident spent twenty minutes on the
mount configuration named in the release notes.
Resolution
- Take the list of API paths from the audit device, not from the source code. The client library inserts path segments that appear nowhere in the application, which is the entire reason this policy passed review, and reading the code will reproduce the original mistake exactly.
- Add the enumeration grant as its own stanza on the metadata path for the prefix being listed, with the
listcapability and nothing else. It cannot be folded into the data stanza, because the two prefixes are different paths and the client requests only one of them for each operation. - Extend the read grants to cover the data paths the release actually reads. Name them explicitly where the set is small and known. If a prefix is genuinely required, remember the glob is meaningful only as the final character of a path and matches by prefix, so
kv/data/app/*still leaveskv/metadata/appuntouched. - Do not grant
deleteon any metadata path to an application. Delete on a data path soft-deletes the latest version; delete on the matching metadata path destroys every version and the metadata with it. In a diff those two lines are almost identical, so state the rule rather than relying on review to catch it. - Check every other policy attached to this role for an explicit deny covering these paths. An explicit deny always takes precedence over any other capability, including administrative ones, so a correct grant can still produce an identical 403 and an identical audit record.
- Deploy the policy, then re-authenticate the workers so that the policy set in force on their tokens is unambiguous rather than inferred. Record the new accessors so the verification step has something specific to search the audit stream for.
- Roll release 6.2 forward again rather than patching the rolled-back release. The application code was never wrong, and carrying a divergent build forward guarantees that the next person to deploy 6.2 reproduces the incident.
- Write the two new stanzas into the policy repository with a comment beside each explaining which application operation requires it. A stanza with no stated purpose is a stanza that survives every future least-privilege review by default.
Verification
- Re-run the discovery step from an application host, as the application, with the application token. Verifying a policy with an operator token in an operator shell is what produced the false clearance at 14:11, and repeating it now would produce the same worthless result.
- Read the audit record for the metadata path and confirm the denial is gone: no permission denied error, and no result recording the request as not allowed. This is the server stating what it decided, which is independent of whatever the client reports.
- Repeat that audit check for each data path the release reads, including the sibling secret that the worker never reached during the incident because discovery aborted first.
- Run the negative case. Request a path deliberately outside the intended grant with the application token and confirm it is still refused and still recorded as not allowed. A change that fixes the failure and quietly opens a prefix passes every positive test that will ever be written against it.
- Let a worker complete a full job cycle rather than stopping at a successful startup. Discovery and the sibling read are two separate operations added by the same release, and only the first one aborts the process.
- Confirm the audit device is currently recording by locating a request you made yourself within the last minute. A policy verified against a stream that stopped writing days ago has been verified against nothing.
- Compare the deployed policy against the repository copy after the incident closes, to confirm no emergency edit was left in place on the server that the repository does not know about.
Prevention
- Write policies against API paths and publish the translation. Keep a table in the policy repository mapping each command line form to the path it becomes. Reviewers who compare a ticket against a policy are otherwise comparing two different vocabularies.
- Test both halves in continuous integration. For every role, assert the paths that must be permitted and the paths that must be refused. A suite with only positive cases cannot detect the over-granting that a rushed incident fix introduces.
- Gate secret-path changes on a policy review. Any release that adds, moves or enumerates a secret path attaches the audit extract showing the paths it actually calls, and the policy diff is reviewed against that extract rather than against the description.
- Alert on denials. One request recorded as not allowed for a production role warns, five within five minutes pages. The evidence that resolved this incident existed at 14:06 and was first read at 14:31.
- Forbid delete on metadata prefixes for applications. Treat any such grant as a finding with an owner, not a style preference. The blast radius is every version of every secret under the prefix.
- Review roles against what they actually called. Once a quarter, compare each role grant list against ninety days of audit history and remove grants with no matching requests. Policies grow when incidents are fixed and never shrink unless somebody schedules it.
- Verify as the caller, never as an operator. Record in the runbook that a policy is only tested when the request is made from the workload host, with the workload token, in the form the client library produces. The check at 14:11 met none of those three conditions, and it cost the incident twenty-five minutes and a rollback that was never necessary.