Secrets, PKI & CertificatesXII · Secret Management PlatformsSecretManagers
Policies and least privilege: path rules, capabilities and the KV v2 split
What you'll learn
- Write a path rule with the smallest capability list that lets an application work
- Explain why an explicit deny overrides every grant, including sudo
- Apply the prefix and single-segment wildcard rules correctly
- Diagnose the KV version 2 data and metadata split from a 403 response URL
Prerequisites
Practice
Verified against OpenSSL 3.5.x teaching target; 3.0+ minimum · OpenSSH 10.x teaching target; 8.2+ minimum for certificate workflows · OpenBao 2.6.x · Smallstep step-ca 0.30.x · Certbot / Pebble Certbot current release; Pebble 2.10.x ACME test server · Kubernetes (cross-course target) 1.36.x · PostgreSQL 17.x · 2026-08-26
Least privilege in a secret manager is not a slogan; it is a file. A policy is a list of path rules, each carrying a capability list, and the manager merges every policy attached to the requesting session and evaluates the result against the path and operation being asked for. Nothing else grants anything. An empty policy grants no permission in the system, because policies are deny by default.
Rules are written against API paths, not against commands
The unit of authorisation is the path the HTTP request goes to, which is often not the path the command line shows you. This is the reason most policies fail on first contact.
# Read one path. Nothing else.
path "kv/data/app/config" {
capabilities = ["read"]
}
That rule permits an application to read a single configuration object and does nothing else whatsoever. Two design properties follow. First, it is additive: to allow more, add rules, and anything you did not write remains refused. Second, it is mechanical: there is no notion of an owner, a team or a seniority level anywhere in the evaluation, only paths and operations.
The capability list, and the parts people get wrong
Seven ordinary capabilities exist in v2.6.2, plus two special ones.
| Capability | HTTP verb | Grants |
|---|---|---|
create | POST or PUT | Writing where nothing exists |
read | GET | Retrieving the value |
update | POST or PUT | Overwriting an existing value |
patch | PATCH | Partial modification |
delete | DELETE | Removal at that path |
list | LIST | Enumerating child keys |
scan | SCAN | Recursive enumeration below the path |
scan is an OpenBao capability with no HashiCorp Vault
equivalent, and it is routinely missing from capability lists
copied out of Vault documentation. It allows recursively
listing values at the given path, which makes it a
significantly broader grant than list and one to give
deliberately. In the other direction, subscribe and recover
do not exist in OpenBao at all; a policy naming them is
describing a product you are not running.
The two special capabilities behave differently from the rest.
sudo unlocks a small set of root-protected endpoints and is
not a general escalation. deny disallows access and always
takes precedence regardless of any other defined capabilities,
including sudo.
That precedence is the practical difference between an unset
path and a denied one. An unset path simply grants nothing, so
attaching a second policy can open it. A denied path cannot be
reopened by any other policy attached to the same session,
which makes deny the containment primitive during an
incident: attach a narrow deny policy to the affected identity
and the path shuts immediately, without deleting the grant that
will be needed again once the incident closes.
Wildcards, and the two that are not interchangeable
flowchart TD
R["Request: path plus operation"] --> M{"Any rule matches\nthe path?"}
M -- "no" --> D1["Denied: nothing grants it"]
M -- "yes" --> P{"Merged rules\ncontain deny?"}
P -- "yes" --> D2["Denied: deny always wins"]
P -- "no" --> C{"Operation in the\ncapability list?"}
C -- "no" --> D3["403 permission denied"]
C -- "yes" --> A["Permitted, request reaches the engine"]
Evaluation runs in that order on every request. The path is matched against the rules, the merged capability set for the best-matching rule is assembled, an explicit deny short circuits everything, and only a capability that is present in the list permits the operation. The three refusal outcomes are indistinguishable from the client’s point of view, which is deliberate: a detailed refusal would be an oracle for discovering paths.
Two wildcards exist and they are not equivalent. The * glob
is supported only as the final character of a path and matches
as a prefix, so kv/data/team-a/* covers both
kv/data/team-a/db and kv/data/team-a/db/replica. The +
wildcard matches any number of characters within a single path
segment, so kv/data/+/db matches kv/data/team-a/db and does
not reach across a slash. Writing kv/*/db and expecting a
mid-path match is the most common syntax error in this file
format, and it fails silently by granting nothing.
The evidence: one read grant, three refusals
The single-path read policy above was uploaded and a token bound to it was issued. The permitted read works and returns the data. Everything else refuses, and the refusals are more instructive than the success.
$ bao kv get kv/app/other
Error reading kv/data/app/other: Error making API request.
URL: GET http://127.0.0.1:8200/v1/kv/data/app/other
Code: 403. Errors:
* 1 error occurred:
* permission denied
$ bao kv put kv/app/config x=y
Error writing data to kv/data/app/config: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/kv/data/app/config
Code: 403. Errors:
* 1 error occurred:
* permission denied
$ bao kv list kv/app
Error listing kv/metadata/app: Error making API request.
URL: GET http://127.0.0.1:8200/v1/kv/metadata/app?list=true
Code: 403. Errors:
* 1 error occurred:
* permission denied
Read the URLs, not the commands. The first refusal is an
ordinary unset path. The second refuses a write to the very
path the token may read, because read and update are
separate capabilities and granting one never implies the other.
The third is the one that catches experienced operators: the
command names kv/app, but the request went to
kv/metadata/app, a different prefix that the policy says
nothing about at all.
| Operation attempted | Path the request used | Result |
|---|---|---|
bao kv get kv/app/config | kv/data/app/config | Permitted |
bao kv get kv/app/other | kv/data/app/other | 403, path not granted |
bao kv put kv/app/config x=y | kv/data/app/config | 403, no update |
bao kv list kv/app | kv/metadata/app | 403, wrong prefix entirely |
Granting enumeration therefore means adding a separate rule against the metadata tree, and it means being careful about which capability you add there.
# Read the values.
path "kv/data/app/*" {
capabilities = ["read"]
}
# Enumerate the keys. list only, never delete.
path "kv/metadata/app/*" {
capabilities = ["list"]
}
The comment on the second rule is load bearing. delete on a
metadata path removes every version and the metadata itself,
permanently, for everything the rule matches. Operators
importing habits from filesystem permissions read delete as
the mild sibling of write and grant it without thinking. On
the metadata tree it is the most destructive capability the
engine offers.
Two further mechanisms let a rule say more than which
operations are allowed. A templated path interpolates
identity attributes at evaluation time, so
{{identity.entity.id}} in a path gives every identity its own
private subtree from a single rule rather than one rule per
application. Entity name, entity metadata keys and group
membership are available the same way, which is how a policy
survives onboarding a tenth service without being edited.
Parameter constraints narrow a rule further by restricting
the body of the request rather than its path.
required_parameters insists a named field is present,
allowed_parameters limits which fields may be sent at all,
and denied_parameters refuses named fields and takes
precedence over the allow list. They are how you grant a write
that may set a value but may not, for example, alter a
retention setting on the same endpoint. When two rules merge
and both carry wrapping time-to-live bounds, the lowest minimum
and the lowest maximum apply, which is the same
smallest-number-wins principle that governs token lifetimes.
Production discipline
- Start from the operation, not from the path. Ask what the application must do, once, at startup or per request, then write the smallest rule that permits exactly that. Do not begin with a wildcard and trim.
- Grant
listonly where enumeration is genuinely required. Most applications know the path they want. Enumeration is a discovery capability and it is what an attacker with a stolen token uses first. - Treat
deleteon a metadata path as a destructive capability. It belongs to an operator role with a change process, never to a workload. - Keep a break-glass deny policy written and ready. During an incident the fastest containment for a specific path is attaching a deny, because it cannot be overridden and it does not require unpicking the grants that legitimate work depends on.
- Exercise every policy with a scoped token before it ships. Confirm the permitted operation succeeds and at least one adjacent operation is refused. A policy proven only by reading it is not proven.
Cross-course references
- Kubernetes for Production Sysadmins - Part LVIII (RBAC) covers verbs on resources, the same additive deny-by-default model expressed against API objects instead of paths.
- Linux for Production Sysadmins - Part V (sudo and Privileged Access) covers the rule-file model whose habits, particularly the meaning of delete, transfer badly to metadata paths.
- Git, CI/CD & GitOps for Infrastructure Engineers - Part LXXXIII (GitOps RBAC) covers scoping a controller’s access to the environments it reconciles rather than the whole cluster.
Quiz
Knowledge check · 4 questions
Q1. A token holds a policy granting read on kv/data/app/*. The holder runs a list against kv/app and receives 403. Why?
Q2. An explicit deny in one attached policy cannot be overridden by a grant in another attached policy, even one carrying sudo.
Q3. Explain the difference between a path that is unset in every attached policy and a path that carries an explicit deny.
Q4. Identify the defect in the policy and state the change that fixes it without widening access.
A reporting job on web-01 enumerates the children of kv/app once at startup and then reads each child. Its policy contains a single rule: path kv/* with capabilities read and list. The job has worked for a year against a KV version 1 mount. The mount was migrated to KV version 2 at 14:00 UTC on 2026-08-24 and since then every startup fails with 403 on both the enumeration and the reads, while an operator using a privileged token can read the same paths successfully.
Passing score: 75%. Answers are checked in this browser.