Skip to main content
RunBook Academy

Secrets, PKI & CertificatesXII · Secret Management PlatformsSecretManagers

Policies and least privilege: path rules, capabilities and the KV v2 split

Advanced⏱ ~26 min🧪 Lab requiredbao

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

Not yet marked complete on this device.

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.

CapabilityHTTP verbGrants
createPOST or PUTWriting where nothing exists
readGETRetrieving the value
updatePOST or PUTOverwriting an existing value
patchPATCHPartial modification
deleteDELETERemoval at that path
listLISTEnumerating child keys
scanSCANRecursive 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 attemptedPath the request usedResult
bao kv get kv/app/configkv/data/app/configPermitted
bao kv get kv/app/otherkv/data/app/other403, path not granted
bao kv put kv/app/config x=ykv/data/app/config403, no update
bao kv list kv/appkv/metadata/app403, 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

  1. 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.
  2. Grant list only 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.
  3. Treat delete on a metadata path as a destructive capability. It belongs to an operator role with a change process, never to a workload.
  4. 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.
  5. 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

  1. Q1. A token holds a policy granting read on kv/data/app/*. The holder runs a list against kv/app and receives 403. Why?

  2. Q2. An explicit deny in one attached policy cannot be overridden by a grant in another attached policy, even one carrying sudo.

  3. Q3. Explain the difference between a path that is unset in every attached policy and a path that carries an explicit deny.

  4. 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.