OnCallReady

Lesson 32.11 · Vault & Secrets Management · 25 min read

Policies: paths, capabilities, and the rule that wins

In plain words

Think of a hotel key card system. The card itself is just plastic; what matters is the list at reception that says "this card opens room 214, the gym and the pool, nothing else". If the list says nothing about a door, the card does not open it. If the list says "never the staff room", that wins even if another line says "all rooms on floor 2".

Vault policies are those lists. Each one is a set of path rules with capabilities (read, create, update, delete, list, sudo, deny). A token carries policies; Vault takes the most specific rule that matches the request path, and anything not granted is denied. The catch in this lesson: the "door" for a KV v2 read is secret/data/..., not the path you typed.

Why this lesson exists

"I gave the app the right policy and it still gets permission denied" is the ticket every Vault team answers every week. Nine times out of ten the policy names a path the request never uses - most often the KV v2 data/ path from the last lesson - and the tenth time a more specific rule elsewhere wins. Policies are short and readable, but Vault applies them literally. This lesson is how to write them, read them, and prove what a token can do before anyone deploys.

What you need to know already: tokens carry policies (the tokens lesson); KV v2 API paths: data/, metadata/, delete/, undelete/, destroy/ (the KV lesson); globbing with * (4.15); RBAC's idea of verbs on resources (17.30).

The words you need first

Writing a policy

# orders-app: what the orders service needs, nothing more
path "secret/data/shop/orders/*" {
  capabilities = ["read"]
}

path "secret/metadata/shop/orders/*" {
  capabilities = ["list"]
}

A policy file is HCL (the same syntax family as Terraform and vault.hcl): one path block per pattern, a list of capabilities in each.

$ vault policy write orders-read /tmp/orders-read.hcl
Success! Uploaded policy: orders-read
$ vault policy list
default
lab-admin
orders-read
root

Mistakes are rejected at upload, with the real parser errors:

$ vault policy write bad /tmp/bad.hcl
Error uploading policy: Error making API request.

URL: PUT https://127.0.0.1:8200/v1/sys/policies/acl/bad
Code: 400. Errors:

* 1 error occurred:
	* failed to parse policy: path "secret/data/x": invalid capability "write"

There is no write capability: writing is create (the path does not exist yet) and update (it does). A misspelt key gives invalid key "capability" on line 2; a missing bracket gives the HCL parser's At 3:1: expected: ... got: RBRACE. vault policy fmt FILE rewrites a file in the canonical layout and fails on the same errors - use it in CI before upload.

Capabilities and HTTP methods

capabilityrequests it allows
readGET
listLIST (GET ?list=true)
createPOST/PUT to a path that does not exist yet
updatePOST/PUT to a path that exists (and most "action" endpoints: login, renew, rotate)
patchPATCH (vault kv patch, vault patch)
deleteDELETE
sudoon top of the others: the root-protected paths (sys/audit, enabling auth methods, sys/seal, revoke-prefix, raft snapshots...)
denynothing - and it overrides every other capability in the same rule

create vs update matters for KV: a policy with only update can change an existing secret but not make a new one; a CI job that "seeds secrets" needs create.

Which rule applies

Vault never adds up rules from different patterns. For a request it finds every rule that matches the path, picks the most specific one, and uses only that rule's capabilities. Rules with the same pattern (from several policies of the token) are merged.

"Most specific" follows five rules, in order - P1 loses to P2 when:

  1. P1's first * or + comes earlier in the pattern;
  2. P1 ends in * and P2 does not;
  3. P1 has more + segments;
  4. P1 is shorter;
  5. P1 is smaller in alphabetical order.

The consequence people trip on: a broad deny does not beat a more specific allow.

path "secret/*" {
  capabilities = ["deny"]
}
path "secret/data/shop/orders/db" {
  capabilities = ["read"]
}
$ vault token capabilities $(vault token create -policy=prio -field=token) secret/data/shop/orders/db
read
$ vault token capabilities $(vault token create -policy=prio -field=token) secret/data/shop/payments/x
deny

The exact path wins (rule 1: no wildcard at all), so the read is allowed. To forbid something whatever else a token has, put the deny on a pattern at least as specific as any allow - or keep the allow out of the policy.

Segment wildcards

+ matches exactly one segment:

$ vault token capabilities $T secret/data/shop/orders/db
read
$ vault token capabilities $T secret/data/a/b/orders/db
deny

with path "secret/data/+/orders/*". Useful for "the same sub-path in every team's folder": secret/data/+/ci/*.

Templated paths

A path can contain the identity of the token's owner:

path "secret/data/users/{{identity.entity.name}}/*" {
  capabilities = ["create", "read", "update", "patch", "delete"]
}

One policy gives every person a private folder named after their entity. Since Vault 2.0 a template that renders to something containing * or + is refused with permission denied - an entity named * cannot open everyone's folder.

The KV v2 paths, once more

The single most common policy bug, side by side:

# WRONG for KV v2: no request ever uses this path
path "secret/shop/orders/*" {
  capabilities = ["read", "list"]
}
$ vault token capabilities $(vault token create -policy=orders-read -field=token) secret/shop/orders/db
list, read
$ vault token capabilities $(vault token create -policy=orders-read -field=token) secret/data/shop/orders/db
deny

The CLI's vault kv get secret/shop/orders/db reads secret/data/shop/orders/db, so it fails - and it fails late: the preflight to sys/internal/ui/mounts succeeds (any rule under the mount is enough to see the mount exists), then the real read gets:

Error reading secret/data/shop/orders/db: Error making API request.

URL: GET https://127.0.0.1:8200/v1/secret/data/shop/orders/db
Code: 403. Errors:

* 1 error occurred:
	* permission denied

The URL in the error is the clue: it says secret/data/..., the policy says secret/.... The KV v2 policy, complete:

path "secret/data/shop/orders/*" {          # kv get / put / patch / delete (latest)
  capabilities = ["create", "read", "update", "patch", "delete"]
}
path "secret/metadata/shop/orders/*" {      # kv list, kv metadata get/put, kv metadata delete
  capabilities = ["list", "read"]
}
path "secret/delete/shop/orders/*" {        # kv delete -versions=...
  capabilities = ["update"]
}
path "secret/undelete/shop/orders/*" {      # kv undelete
  capabilities = ["update"]
}
path "secret/destroy/shop/orders/*" {       # kv destroy: think twice before giving it
  capabilities = ["update"]
}

And one detail of listing: vault kv list secret/shop/orders lists secret/metadata/shop/orders/ - which secret/metadata/shop/orders/* matches (the glob includes the empty string after the slash). Listing secret/shop/ itself needs a rule that matches secret/metadata/shop/.

Proving what a token can do

Never guess. Three tools:

$ vault kv get -output-policy secret/shop/orders/db
path "secret/data/shop/orders/db" {
  capabilities = ["read"]
}

To create a test token with policies you do not hold, your token needs sudo on auth/token/create (as lab-admin has); otherwise Vault answers * child policies must be subset of parent's policies.

Parameter constraints

A rule can also limit what may be written:

path "auth/userpass/users/*" {
  capabilities = ["create", "update"]
  allowed_parameters = {
    "password" = []
  }
}

This helpdesk policy can reset passwords but cannot change a user's token_policies (and so cannot hand out admin). denied_parameters forbids keys, required_parameters makes keys mandatory. They apply to create/update requests only - and they are the kind of detail auditors ask about.

The sudo paths

Some paths need sudo in addition to the normal capability - mostly the ones that could lock everyone out or hide activity: enabling and disabling auth methods (sys/auth/*), audit devices (sys/audit*), sys/seal, sys/rotate, sys/leases/revoke-prefix/*, listing token accessors, raft snapshots. An admin policy has sudo on those; an application never does.

Designing policies that age well

In an interview: "A token has a policy that allows secret/app/* but vault kv get secret/app/db is denied - why?" - with KV v2 the read goes to secret/data/app/db; the policy must say path "secret/data/app/*" (and secret/metadata/app/* to list). Prove it with vault token capabilities or -output-policy.

What you can now do

Why it helps

"Permission denied" is the most common Vault error, and the fix is almost always in the policy: the wrong path (the KV v2 data/ prefix, a missing metadata/ rule for list), a glob that does not match, a deny that wins, or a sudo-protected path. Being able to prove what a token can do (vault token capabilities, -output-policy) instead of guessing turns those into quick fixes.

Good policies are also what security reviews look at: one per job, least privilege, templated paths per identity, parameter constraints for sensitive writes, and no humans with root.

Commands in this lesson

vault

FAQ

If two rules match the same path, which one wins?

The most specific one, by Vault's priority rules: a path without wildcards beats one with, an earlier * or + loses to a later one, fewer + segments win, and a longer path wins. Capabilities are not merged across matching rules in one policy. Across several policies, capabilities add up, except that deny anywhere always wins.

What is the difference between * and +?

* is a prefix glob and is only allowed at the end: secret/data/app/* matches everything under it. + matches exactly one path segment anywhere: secret/data/+/config matches secret/data/orders/config but not secret/data/orders/eu/config. Use + when the varying part is in the middle of the path, such as a team name.

Why do I need a separate rule to list secrets?

Listing is a different operation on a different path: vault kv list secret/app sends LIST to secret/metadata/app/. A rule for secret/data/app/* does not cover it, so add path "secret/metadata/app/*" { capabilities = ["list"] }. Without it the list returns 403 even though reads work.

What does the sudo capability do?

It is needed in addition to the normal capability on root-protected paths: enabling audit devices, sys/raw, rotating keys, creating orphan tokens or tokens with policies the caller does not have. It grants nothing by itself, so give it only on the exact paths an admin policy needs.

How do I test a policy before giving it to an app?

Create a token with only that policy (vault token create -policy=orders-app -ttl=10m), then ask vault token capabilities <token> <path> for each path the app uses, or run the app's commands with VAULT_TOKEN set to it. vault kv get -output-policy prints the rule a command needs without running it.

In an interview Mid

A token has a policy that allows secret/app/*, but vault kv get secret/app/db is denied. Why?

Because the mount is KV v2, and the CLI hides the real path: vault kv get secret/app/db sends GET /v1/secret/data/app/db. The policy rule path "secret/app/*" never matches a real KV v2 request, so Vault denies by default. The policy must say path "secret/data/app/*" { capabilities = ["read"] }, plus secret/metadata/app/* with list to list.

I would prove it rather than guess: vault token capabilities <token> secret/data/app/db returns deny before and read after the fix, and vault kv get -output-policy secret/app/db prints exactly the rule the command needs.

Also asked: How do policies combine when a token has several of them? · How would you give each team access only to its own path without one policy per team? · What are parameter constraints in a policy used for?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.