Add governance blueprint: policy-as-code, operator/auditor, identity setup
Reviewable governance scaffolding — NOTHING applied to live OpenBAO yet: - policies/: materialize all existing policies as code (faithfully fetched from live) + new `operator` (use engines, no admin) and `auditor` (read-only governance visibility, no secret material) - scripts/apply-policies.sh: idempotent policy-as-code apply, with a read-only --dry-run that diffs files vs live (ignores comments) - scripts/setup-identity.sh: identity-as-code — policy-bound groups (g-admins/operators/auditors/personal) + a human entity/alias; DEFAULT DRY-RUN, --apply to execute - GOVERNANCE.md: the layered model, policy catalog, naming, apply order, and cross-cutting controls (audit device, root offline, AppRole migration) Dry-runs verified read-only: apply-policies shows operator/auditor as NEW, all others unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
90
GOVERNANCE.md
Normal file
90
GOVERNANCE.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# OpenBAO Governance Blueprint
|
||||
|
||||
A layered access model for this instance. Everything here is **policy-as-code**:
|
||||
edit files, review in git, then apply with the scripts. Nothing is live until
|
||||
you run the apply scripts.
|
||||
|
||||
## Model
|
||||
|
||||
```
|
||||
auth method → identity (entity / group) → policy → paths
|
||||
(how you log in) (who you are) (what's allowed) (where)
|
||||
```
|
||||
|
||||
Rule of thumb: **bind policies to GROUPS (humans) and ROLES (machines)** — not to
|
||||
individuals or scattered standalone tokens. Access then = "which groups/roles am I in".
|
||||
|
||||
## Policy catalog (`policies/*.hcl`)
|
||||
|
||||
| Policy | Kind | Grants |
|
||||
|--------|------|--------|
|
||||
| `admin` | human | platform administration (auth, mounts, policies, engines) — **not** root-only ops |
|
||||
| `operator` | human | day-to-day *use* of engines (KV, issue certs, sign SSH, transit) — no administration |
|
||||
| `auditor` | human | read-only governance visibility — config + policies, **no secret material** |
|
||||
| `personal-rw` | human | the isolated `personal/` KV vault |
|
||||
| `demo-app` | machine | read `secret/demo-app/*` (AppRole example) |
|
||||
| `gitea-push-ro` | machine | read `secret/gitea/push` (git credential helper) |
|
||||
| `cert-renew` | machine | `pki_int/issue/famfi-home` (cert renewal timer) |
|
||||
| `backup` | machine | `sys/storage/raft/snapshot` (snapshot timer) |
|
||||
| `ssh-sign-user` | machine | `ssh/sign/user` (ssh-login helper) |
|
||||
| `personal-transit` | machine | encrypt/decrypt with `transit-personal/personal-backup` |
|
||||
|
||||
`default` and `root` are built-in and not managed here.
|
||||
|
||||
## Humans — via identity groups
|
||||
|
||||
| Group | Policy | Who |
|
||||
|-------|--------|-----|
|
||||
| `g-admins` | `admin` | you (platform changes; not daily use) |
|
||||
| `g-operators` | `operator` | routine work |
|
||||
| `g-auditors` | `auditor` | the governance lens (read-only) |
|
||||
| `g-personal` | `personal-rw` | personal vault access |
|
||||
|
||||
A person = an **entity**; each login (e.g. `admin@userpass`) is an **alias** to that
|
||||
entity; the entity's **group membership** confers policies. Compose access by adding
|
||||
an entity to more groups — no token edits.
|
||||
|
||||
## Machines — AppRole, one role + least-privilege policy per service
|
||||
|
||||
Target: migrate the static-token helpers to AppRole roles `svc-backup`,
|
||||
`svc-cert-renew`, `svc-ssh-sign`, `app-gitea` (each bound to the matching policy
|
||||
above). `demo-app` already demonstrates the pattern.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
- Policies: `<domain>-<resource>-<access>` (`kv-personal-rw`, `app-gitea-ro`)
|
||||
- AppRoles: `app-<name>` (apps) / `svc-<name>` (infra jobs)
|
||||
- Groups: `g-<persona>`
|
||||
|
||||
## How to apply
|
||||
|
||||
```bash
|
||||
# 1. Review what would change (read-only):
|
||||
scripts/apply-policies.sh --dry-run
|
||||
|
||||
# 2. Apply the policies (NEW/CHANGED only):
|
||||
scripts/apply-policies.sh
|
||||
|
||||
# 3. Create groups + a human entity (dry-run first, then --apply):
|
||||
scripts/setup-identity.sh
|
||||
scripts/setup-identity.sh --apply # aliases to the 'admin' userpass user
|
||||
```
|
||||
|
||||
## Cross-cutting controls
|
||||
|
||||
1. **Audit device** (currently OFF — top gap). Enable a file audit log:
|
||||
```bash
|
||||
docker compose exec openbao bao audit enable file file_path=/openbao/logs/audit.log
|
||||
```
|
||||
(logs to the container's `/openbao/logs`; mount/ship it for retention).
|
||||
2. **Root sealed** — move `init-output.json` (recovery keys + root token) offline; root is break-glass only.
|
||||
3. **Least privilege + short TTLs** — services use AppRole short tokens; humans get group-derived policies.
|
||||
4. **Trust-domain isolation by mount** — `personal/` and `transit-personal/` are excluded from `admin`/`operator`; only `g-personal` reaches them.
|
||||
5. **Policy-as-code** — all rules live in `policies/`, applied by `apply-policies.sh`, reviewed in git.
|
||||
|
||||
## Apply order (when you're ready)
|
||||
|
||||
1. `apply-policies.sh` (adds `operator`, `auditor`)
|
||||
2. `setup-identity.sh --apply` (groups + entity)
|
||||
3. Enable the audit device
|
||||
4. (Optional) give `admin` `identity/*`; migrate services to AppRole; move root offline
|
||||
24
policies/auditor.hcl
Normal file
24
policies/auditor.hcl
Normal file
@@ -0,0 +1,24 @@
|
||||
# auditor — read-only governance visibility. Can see HOW the system is
|
||||
# configured and WHO can do what, but CANNOT read any secret material and
|
||||
# CANNOT change anything. The independent "governance lens".
|
||||
|
||||
# Configuration visibility
|
||||
path "sys/health" { capabilities = ["read"] }
|
||||
path "sys/seal-status" { capabilities = ["read"] }
|
||||
path "sys/mounts" { capabilities = ["read"] }
|
||||
path "sys/auth" { capabilities = ["read"] }
|
||||
path "sys/metrics" { capabilities = ["read"] }
|
||||
|
||||
# Policy visibility (read the rules, not change them)
|
||||
path "sys/policies/acl" { capabilities = ["list"] }
|
||||
path "sys/policies/acl/*" { capabilities = ["read"] }
|
||||
|
||||
# Identity visibility (who exists) — names only, not secrets
|
||||
path "identity/entity/name" { capabilities = ["list"] }
|
||||
path "identity/group/name" { capabilities = ["list"] }
|
||||
|
||||
# Audit device configuration visibility (sudo required for this path)
|
||||
path "sys/audit" { capabilities = ["read", "sudo"] }
|
||||
|
||||
# NOTE: intentionally NO `secret/`, `personal/`, `pki*`, `ssh/`, `transit*`
|
||||
# data paths — an auditor reviews governance, not secrets.
|
||||
2
policies/backup.hcl
Normal file
2
policies/backup.hcl
Normal file
@@ -0,0 +1,2 @@
|
||||
# backup — materialized from live OpenBAO (policy-as-code).
|
||||
path "sys/storage/raft/snapshot" { capabilities = ["read"] }
|
||||
2
policies/cert-renew.hcl
Normal file
2
policies/cert-renew.hcl
Normal file
@@ -0,0 +1,2 @@
|
||||
# cert-renew — materialized from live OpenBAO (policy-as-code).
|
||||
path "pki_int/issue/famfi-home" { capabilities = ["update"] }
|
||||
3
policies/demo-app.hcl
Normal file
3
policies/demo-app.hcl
Normal file
@@ -0,0 +1,3 @@
|
||||
# demo-app — materialized from live OpenBAO (policy-as-code).
|
||||
path "secret/data/demo-app/*" { capabilities=["read"] }
|
||||
path "secret/metadata/demo-app/*" { capabilities=["read","list"] }
|
||||
2
policies/gitea-push-ro.hcl
Normal file
2
policies/gitea-push-ro.hcl
Normal file
@@ -0,0 +1,2 @@
|
||||
# gitea-push-ro — materialized from live OpenBAO (policy-as-code).
|
||||
path "secret/data/gitea/push" { capabilities = ["read"] }
|
||||
33
policies/operator.hcl
Normal file
33
policies/operator.hcl
Normal file
@@ -0,0 +1,33 @@
|
||||
# operator — day-to-day USE of secrets engines, but NO administration.
|
||||
# Deliberately cannot manage mounts, auth methods, policies, or identity, and
|
||||
# has no access to the personal/ or transit-personal/ trust domains. This is
|
||||
# the policy a human should hold for routine work instead of full `admin`.
|
||||
|
||||
# KV v2 data (shared secrets)
|
||||
path "secret/data/*" { capabilities = ["create", "read", "update", "patch", "delete", "list"] }
|
||||
path "secret/metadata/*" { capabilities = ["read", "list", "delete"] }
|
||||
|
||||
# PKI: issue/sign leaf certs (NOT manage the CA)
|
||||
path "pki_int/issue/*" { capabilities = ["update"] }
|
||||
path "pki_int/sign/*" { capabilities = ["update"] }
|
||||
path "pki/issue/*" { capabilities = ["update"] }
|
||||
|
||||
# SSH: sign user/host certs (NOT manage the CA or roles)
|
||||
path "ssh/sign/*" { capabilities = ["update"] }
|
||||
|
||||
# Transit: use existing keys (NOT create/rotate/delete)
|
||||
path "transit/encrypt/*" { capabilities = ["update"] }
|
||||
path "transit/decrypt/*" { capabilities = ["update"] }
|
||||
|
||||
# TOTP codes
|
||||
path "totp/code/*" { capabilities = ["create", "read", "update"] }
|
||||
|
||||
# Read-only operational visibility
|
||||
path "sys/mounts" { capabilities = ["read"] }
|
||||
path "sys/health" { capabilities = ["read"] }
|
||||
path "sys/seal-status" { capabilities = ["read"] }
|
||||
|
||||
# Token self-management
|
||||
path "auth/token/lookup-self" { capabilities = ["read"] }
|
||||
path "auth/token/renew-self" { capabilities = ["update"] }
|
||||
path "auth/token/revoke-self" { capabilities = ["update"] }
|
||||
7
policies/personal-rw.hcl
Normal file
7
policies/personal-rw.hcl
Normal file
@@ -0,0 +1,7 @@
|
||||
# personal-rw — materialized from live OpenBAO (policy-as-code).
|
||||
path "personal/data/*" { capabilities=["create","read","update","patch","delete","list"] }
|
||||
path "personal/metadata/*" { capabilities=["read","list","delete"] }
|
||||
path "personal/metadata" { capabilities=["list"] }
|
||||
path "personal/delete/*" { capabilities=["update"] }
|
||||
path "personal/undelete/*" { capabilities=["update"] }
|
||||
path "personal/destroy/*" { capabilities=["update"] }
|
||||
4
policies/personal-transit.hcl
Normal file
4
policies/personal-transit.hcl
Normal file
@@ -0,0 +1,4 @@
|
||||
# personal-transit — materialized from live OpenBAO (policy-as-code).
|
||||
path "transit-personal/encrypt/personal-backup" { capabilities=["update"] }
|
||||
path "transit-personal/decrypt/personal-backup" { capabilities=["update"] }
|
||||
path "transit-personal/keys/personal-backup" { capabilities=["read"] }
|
||||
2
policies/ssh-sign-user.hcl
Normal file
2
policies/ssh-sign-user.hcl
Normal file
@@ -0,0 +1,2 @@
|
||||
# ssh-sign-user — materialized from live OpenBAO (policy-as-code).
|
||||
path "ssh/sign/user" { capabilities = ["update"] }
|
||||
39
scripts/apply-policies.sh
Executable file
39
scripts/apply-policies.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Policy-as-code: apply every policies/*.hcl to OpenBAO. Idempotent.
|
||||
# apply-policies.sh --dry-run # read-only: show NEW/CHANGED/unchanged, change nothing
|
||||
# apply-policies.sh # apply (writes policies that are NEW or CHANGED)
|
||||
#
|
||||
# Auth: needs a token allowed to write sys/policies/acl/* (root or admin).
|
||||
# Uses $BAO_TOKEN if set, else falls back to the root token in init-output.json.
|
||||
# Built-in `default`/`root` policies have no file here and are never touched.
|
||||
set -euo pipefail
|
||||
|
||||
SELF="$(cd "$(dirname "$0")" && pwd)"; ROOT_DIR="$(cd "$SELF/.." && pwd)"
|
||||
DIR="$ROOT_DIR/policies"; INIT="$ROOT_DIR/init-output.json"
|
||||
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||
DRY=0; [ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
TOKEN="${BAO_TOKEN:-}"
|
||||
[ -z "$TOKEN" ] && [ -r "$INIT" ] && TOKEN="$(python3 -c "import json;print(json.load(open('$INIT'))['root_token'])")"
|
||||
[ -n "$TOKEN" ] || { echo "no token (set BAO_TOKEN or provide init-output.json)" >&2; exit 1; }
|
||||
|
||||
# functional comparison: ignore comments and blank lines
|
||||
norm(){ grep -vE '^[[:space:]]*#' | grep -vE '^[[:space:]]*$' | sed 's/[[:space:]]*$//'; }
|
||||
|
||||
echo "${DRY:+[dry-run] }policies under $DIR -> $ADDR"
|
||||
for f in "$DIR"/*.hcl; do
|
||||
name="$(basename "$f" .hcl)"; filetxt="$(cat "$f")"
|
||||
live="$(curl -sS -H "X-Vault-Token: $TOKEN" "$ADDR/v1/sys/policies/acl/$name" \
|
||||
| python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('policy','') if 'data' in d else '')" 2>/dev/null || true)"
|
||||
if [ -z "$live" ]; then status="NEW"
|
||||
elif [ "$(printf '%s' "$filetxt" | norm)" = "$(printf '%s' "$live" | norm)" ]; then status="unchanged"
|
||||
else status="CHANGED"; fi
|
||||
|
||||
if [ "$DRY" = 1 ] || [ "$status" = unchanged ]; then
|
||||
printf ' %-10s %s\n' "$status" "$name"; continue
|
||||
fi
|
||||
code="$(printf '%s' "$filetxt" | python3 -c 'import json,sys;print(json.dumps({"policy":sys.stdin.read()}))' \
|
||||
| curl -sS -o /dev/null -w '%{http_code}' -H "X-Vault-Token: $TOKEN" --data @- "$ADDR/v1/sys/policies/acl/$name")"
|
||||
printf ' %-10s %s (HTTP %s)\n' "applied[$status]" "$name" "$code"
|
||||
done
|
||||
[ "$DRY" = 1 ] && echo "(dry-run — nothing changed)" || true
|
||||
55
scripts/setup-identity.sh
Executable file
55
scripts/setup-identity.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Identity-as-code: create governance GROUPS (each bound to a policy) and wire a
|
||||
# human ENTITY to a userpass login alias. Demonstrates the full chain:
|
||||
# userpass username -> alias -> entity -> group -> policy
|
||||
#
|
||||
# DEFAULT IS DRY-RUN (prints planned API calls, changes nothing).
|
||||
# setup-identity.sh # dry-run, username 'admin'
|
||||
# setup-identity.sh --apply # actually create, alias to 'admin'
|
||||
# setup-identity.sh --apply lutz # alias to userpass user 'lutz'
|
||||
#
|
||||
# Auth: $BAO_TOKEN or root from init-output.json. Idempotent (safe to re-run).
|
||||
set -euo pipefail
|
||||
SELF="$(cd "$(dirname "$0")" && pwd)"; ROOT_DIR="$(cd "$SELF/.." && pwd)"
|
||||
INIT="$ROOT_DIR/init-output.json"; ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||
APPLY=0; USERNAME="admin"
|
||||
for a in "$@"; do case "$a" in --apply) APPLY=1;; *) USERNAME="$a";; esac; done
|
||||
TOKEN="${BAO_TOKEN:-}"; [ -z "$TOKEN" ] && [ -r "$INIT" ] && TOKEN="$(python3 -c "import json;print(json.load(open('$INIT'))['root_token'])")"
|
||||
[ -n "$TOKEN" ] || { echo "no token" >&2; exit 1; }
|
||||
api(){ curl -sS -H "X-Vault-Token: $TOKEN" "$@"; }
|
||||
|
||||
# Groups -> policy. The HUMAN entity goes in g-admins (matches today). Adding it
|
||||
# to g-personal/g-operators would compose those policies onto the same login.
|
||||
GROUP_MAP="g-admins:admin g-operators:operator g-auditors:auditor g-personal:personal-rw"
|
||||
ENTITY="lutz"; MEMBER_OF="g-admins"
|
||||
|
||||
echo "${APPLY:+}$([ $APPLY = 0 ] && echo '[DRY-RUN] ')target $ADDR username=$USERNAME entity=$ENTITY"
|
||||
|
||||
run(){ # METHOD PATH [JSON]
|
||||
if [ "$APPLY" = 0 ]; then printf ' DRY %s %s %s\n' "$1" "$2" "${3:-}"; return 0; fi
|
||||
api -X "$1" ${3:+--data "$3"} "$ADDR/v1/$2"
|
||||
}
|
||||
|
||||
echo "== groups =="
|
||||
for gp in $GROUP_MAP; do g="${gp%%:*}"; pol="${gp##*:}"
|
||||
echo " $g -> policy:$pol"
|
||||
run POST "identity/group" "{\"name\":\"$g\",\"type\":\"internal\",\"policies\":[\"$pol\"]}" >/dev/null
|
||||
done
|
||||
|
||||
echo "== entity =="
|
||||
echo " entity:$ENTITY (policies via groups, none direct)"
|
||||
run POST "identity/entity" "{\"name\":\"$ENTITY\"}" >/dev/null
|
||||
|
||||
echo "== alias: $USERNAME@userpass -> entity:$ENTITY =="
|
||||
if [ "$APPLY" = 1 ]; then
|
||||
ACC="$(api "$ADDR/v1/sys/auth" | python3 -c "import sys,json;print(json.load(sys.stdin)['userpass/']['accessor'])")"
|
||||
EID="$(api "$ADDR/v1/identity/entity/name/$ENTITY" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])")"
|
||||
run POST "identity/entity-alias" "{\"name\":\"$USERNAME\",\"canonical_id\":\"$EID\",\"mount_accessor\":\"$ACC\"}" >/dev/null
|
||||
GID="$(api "$ADDR/v1/identity/group/name/$MEMBER_OF" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])")"
|
||||
run POST "identity/group/name/$MEMBER_OF" "{\"member_entity_ids\":[\"$EID\"]}" >/dev/null
|
||||
echo " added entity:$ENTITY to $MEMBER_OF"
|
||||
else
|
||||
echo " DRY POST identity/entity-alias {name:$USERNAME, mount_accessor:<userpass>, canonical_id:<entity:$ENTITY>}"
|
||||
echo " DRY POST identity/group/name/$MEMBER_OF {member_entity_ids:[<entity:$ENTITY>]}"
|
||||
fi
|
||||
[ "$APPLY" = 0 ] && echo "(dry-run — nothing changed; re-run with --apply to create)"
|
||||
Reference in New Issue
Block a user