Home / Stories / Vault as code
Story · Secrets & access In development

Your Vault lives in git. Someone should read it like a reviewer would.

Most teams set up Vault in the UI or with one-off CLI calls. A few go further. They declare policies, auth roles and secret engines in a repository that CI applies. That is already beyond what most teams automate. The assistant reads that repository every day. It flags the grants that a careful reviewer would stop. A planned next step is to keep the repository in step with the Kubernetes workloads that use it.

Starts fromA Vault config repository. It holds HCL/JSON policies, auth roles, and engine and auth-method declarations. Your CI applies them.
Looks atEvery declared policy path and capability. Every role's policy list. The assistant reads them from git through the GitLab API.
You getSecurity issues that name the exact path, the capability and the fix. Draft merge requests (MRs) where the change is provably narrow.
01 · Vault, declared

One repo. CI applies it. Nobody clicks.

Policies live under policies/ and auth roles under roles/. Auth methods and secret engines sit next to them. A pipeline plans on every MR. It applies on the default branch, with a short-lived token from your own CI.

The assistant never applies anything. It reads the repository like any other reviewer. It proposes changes through the same MRs your team uses.

02 · Read it exactly

A small parser for a small grammar.

Vault policies are flat. Each has a path and a list of capabilities. The assistant parses them with a focused, dependency-free reader. The reader copes with either brace style. It also loads the role JSON, so every later check works on structure, not on text matching.

On a real Vault setup with 14 policies and 4 roles, this reader and the rules below produced zero findings. That clean baseline is the point. The assistant speaks only when something is actually wrong.

03 · Capability hygiene

Write or sudo on an escalation surface.

The assistant flags a grant that gives sudo, or create / update / delete, on a path that controls Vault itself:

  • near-root wildcards: *, sys/*, auth/*
  • the control-plane engines: sys/, auth/, identity/
  • audit devices
  • CA and key management on pki/, transit/, ssh/

A companion check catches broad read/list access on those same wildcard surfaces.

04 · Don't cry wolf

Least-privilege consumer paths are exempt, by name.

A service that issues certificates needs update on pki/issue/<role>. That grant has the right scope. It is not a finding. So the check fires on everything under the consumer engines except a curated list of consumer leaves. These leaves are issue, sign, encrypt, decrypt, creds, and the public read endpoints.

Anything not on that list keeps firing. This includes management verbs that nobody thought to list. Key export and verbatim signing stay off the list on purpose.

05 · Cross-reference integrity

A role that grants a policy that does not exist.

An auth role lists the policies that its tokens receive. If one of them is missing from policies/, the role mints tokens bound to nothing. Today that does nothing and gives no warning. The day someone creates a policy with that name, it becomes an unreviewed grant.

06 · Findings go out as issues

Named path, named capability, "N of M paths".

Each finding says which file, which path and which capability. It also says how much of the policy it concerns. It passes the same fail-closed safety layer as the audit story. The assistant never flags or edits the protected root policy. It never proposes anything that touches an audit device.

Lint findings are issue-only. The audit-driven policy widen from the previous story can open draft MRs against this repository. That path is in development. It is switched on, but as of October 2026 it has not fired on a real production denial.

07 · Consistent with its consumers Roadmap

Planned: check Vault against the workloads that use it.

Planned: join the Vault repository to the cluster side. Take each ExternalSecret or agent-injected path. The policy that its ServiceAccount gets through a Kubernetes auth role should allow that read. Every role should also point at a ServiceAccount and namespace that still exist.

Today the assistant sees Vault on the cluster side only as an External Secrets backend. So this join is not built yet.

08 · Declared vs live Roadmap

Planned: compare git with what Vault actually runs.

The assistant could use a read-only Vault token (list and read on policies, auth methods and mounts). It could then flag policies, roles or engines that exist in Vault but not in git. This is the classic "someone fixed it in the UI" drift. It needs a token that an operator provides, and it is not built yet.

acme/vault-config · main
policies/        14 files · HCL
  shop-orders-api.hcl  payments-worker.hcl  pki-issuer.hcl  …
roles/           auth roles · JSON
  kubernetes-shop.json  approle-ci.json  …
auth/            kubernetes · approle · oidc
engines/         kv-v2 · pki · transit
.gitlab-ci.yml   plan on MR · apply on main
Merge requestplan
mainapply job
Vaultpolicies · roles · mounts
The assistant reads the repository. Your CI owns the apply.
policies/payments-worker.hcl → parsed
path "secret/data/shop/payments/*" { capabilities = ["read"] }
path "transit/encrypt/payments"
{
  capabilities = ["update"]
}
  • secret/data/shop/payments/* read
  • transit/encrypt/payments update · consumer leaf
14 policies · 4 roles parsed · 0 findings on a clean setup
policies/pki-issuer.hcl
path "pki/issue/shop-internal" {
  capabilities = ["update"]
}
path "pki/root/*" {
  capabilities = ["create", "update", "sudo"]
}
path "sys/mounts" {
  capabilities = ["read"]
}
  • pki/root/* sudo_grant · CA management
  • pki/issue/shop-internal consumer leaf · exempt
  • sys/mounts read, exact path · no finding
consumer-leaf exempt list · write + sudo matrix
  • pki/issue · pki/sign · pki/cert · pki/ca · pki/crl exempt
  • transit/encrypt · decrypt · sign · verify · hash · random exempt
  • ssh/sign · ssh/creds · ssh/public_key exempt
  • pki/keys · pki/issuers · pki/intermediate · pki/roles fires
  • transit/keys · transit/export · transit/backup · restore fires
  • pki/sign-verbatim · pki/sign-intermediate fires
escalation by default · exempt only the known-safe leaves
roles/kubernetes-shop.json
{
  "bound_service_account_names": ["orders-api"],
  "bound_service_account_namespaces": ["shop"],
  "token_policies": ["shop-orders-api", "shop-orders-api-v2"]
}
  • shop-orders-api policies/shop-orders-api.hcl
  • shop-orders-api-v2 role_missing_policy · not in policies/
Detectorsoverbroad_capability · sudo_grant · wildcard_path · role_missing_policy
Safety layerprotected root policy dropped · audit devices untouchable · unsure = suppress
Issuecategory:security · source:vault · "1 of 3 paths"
cluster · namespace shop
ExternalSecret orders-api-psp
ES
ServiceAccount orders-api
SA
vault-config repo
role kubernetes-shop
R
policy shop-orders-api
P
  • ExternalSecret path readable by the SA's role policy? planned
  • role still points at a live SA + namespace? planned
Roadmap. Illustrative of the planned join.
declared (git) vs live (Vault) · planned
  • policy shop-orders-api in git · in Vault · identical
  • policy tmp-debug in Vault only · created outside git?
  • auth/approle role legacy-batch in Vault only
  • mount kv-old/ in Vault only

The issue you get.

This example is illustrative. Static-lint findings are issue-only today. A Vault capability change needs a person to decide the intent. This is the shape of the issue the assistant writes.

Open issue

Vault policy pki-issuer grants sudo on pki/root/*

in acme/vault-config · policies/pki-issuer.hcl · 1 of 3 paths
category:securitysource:vaultrisk:high
OverviewEvidenceSuggested changeWhy not an MR
Summary

Policy pki-issuer grants create, update and sudo on pki/root/*. That subtree manages the root CA. It can generate, import and replace it. Any token with this policy can mint a new root for the whole PKI engine.

Scope

The assistant flags 1 of 3 paths in this policy. pki/issue/shop-internal (issuing leaf certificates) is a least-privilege consumer path. The assistant does not flag it.

PathCapabilitiesVerdict
pki/issue/shop-internalupdateconsumer leaf, exempt
pki/root/*create, update, sudosudo_grant on CA management
sys/mountsreadread on an exact path, no finding

Roles binding this policy: approle-ci.

If the CI role only issues certificates, drop the root block:

 path "pki/issue/shop-internal" {
   capabilities = ["update"]
 }
-path "pki/root/*" {
-  capabilities = ["create", "update", "sudo"]
-}

If you really need root rotation, move it to a separate policy. Bind that policy only to the operator identity that performs rotations.

Removing a capability can break a job that depends on it. Only you know if the CI role rotates the root CA. The assistant files the evidence. The suggested change in this example is the kind of fix to consider. The decision stays with you.

On the roadmap: turn lint findings into draft MRs where the narrowing is provably unused. That means no successful request on that path in the audit window.

What keeps it honest.

Precision matters more than coverage here. A wrong Vault finding teaches people to ignore the next one.

Deterministic, no modelThe checks are rule-based pure functions over parsed policy and role files. No AI model is involved. The same repository always gives the same findings.
Tested in both directionsTests pin every consumer path to produce zero findings. Tests also pin every privileged path to fire, with write and sudo.
Escalation by defaultUnknown management verbs keep firing. Only named consumer leaves are exempt. So a forgotten path fails loud, not silent.
Protected policies stay protectedThe assistant recognises the operator's root policy and never flags or edits it. A proposal never concerns audit devices.
Read-only by constructionThe assistant reads the repository through the forge API. It never contacts Vault. Your CI remains the only thing that applies changes.
Opt-in per Vault repositoryOff by default. You enable it for each Vault repository. It runs in the quiet early-morning band, so a first scan never lands in working hours.

Where this stands.

Live
  • Daily lint of the Vault config repository (policies and auth roles)
  • Capability hygiene on escalation surfaces, with the consumer-leaf exempt list
  • Role → policy cross-reference integrity
  • Issue-only security findings through a fail-closed safety layer
In development
  • Draft MRs to the Vault repository from audit-log widen findings (see Vault audit logs)
Roadmap
  • Consistency with Kubernetes consumers: ExternalSecret paths, ServiceAccounts, auth roles
  • Declared-vs-live drift for policies, auth methods and engines
  • Lint checks for auth-method and secret-engine declarations
  • Draft MRs for lint findings proven unused by the audit log
← Previous storyVault audit logs to policy fixes