diff --git a/AGENTS.md b/AGENTS.md index 81f12a3..c999190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,3 +187,53 @@ To create a new workplan: 1. Write the file following the format above 2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or State Hub API is unavailable. + +## OpenBao Access — how to grant a session, and what it authorises + +ops-mason builds in OpenBao, so it needs a session. Do not hand it your own +operator session: that gives everything you have, for as long as you have it, +and every action lands in the audit log as you. + +```bash +./scripts/bao-session.sh status # what session exists, if any +./scripts/bao-session.sh login # refresh YOUR operator session (OIDC) +./scripts/bao-session.sh grant # mint a scoped token for Claude +./scripts/bao-session.sh revoke # end it immediately +``` + +`grant` mints a **separate** token into `~/.claude-bao-token` — your own +`~/.vault-token` is untouched, and the two revoke independently. The agent uses +it as `BAO_TOKEN=$(cat ~/.claude-bao-token) bao `. + +### What the grant authorises + +Policy `ops-mason-build` (`policies/ops-mason-build.hcl`), 45 minutes, max one +hour, named `claude-`: + +| Allowed | Denied | +|---|---| +| read `sys/mounts`, `sys/auth`, policy list — the phase-2 survey | **every read of `*/data/*`** on `platform`, `operators`, `secret` | +| create/update policies under `sys/policies/acl/*` | enabling or tuning mounts (`sys/mounts/*`) — a railiance-platform act | +| create/update `auth/kubernetes/role/*`, `auth/approle/role/*` | anything not listed | +| read KV **metadata** — versions and timestamps, enough to confirm a delivery landed | KV **values** | +| mint and revoke short-lived test tokens, check capabilities | | + +The denial is the point. `SCOPE.md` says ops-mason never touches secret values; +this makes OpenBao enforce it rather than leaving it to whoever is driving. An +explicit `deny` outranks any grant, including one added to this policy later by +mistake. + +### Choosing the task name + +`grant ` sets `display_name=claude-`, so the audit log attributes an +action to a piece of work rather than to a person. Use the workplan task where +there is one — `grant MASON-WP-0003-T02` — otherwise something a reader would +recognise later. + +### When a broader grant is genuinely needed + +Enabling a mount, reading a value, or touching another subsystem's paths is out +of `ops-mason-build` on purpose. Do not widen the policy to get past a refusal. +Either the act belongs to another repo, or it needs its own named policy and its +own decision — the way `operators/` did. + diff --git a/policies/ops-mason-build.hcl b/policies/ops-mason-build.hcl new file mode 100644 index 0000000..3abe5a3 --- /dev/null +++ b/policies/ops-mason-build.hcl @@ -0,0 +1,97 @@ +# ops-mason-build — the authority ops-mason actually needs, and no more. +# +# SCOPE.md says ops-mason "never touches secret values, even transiently". Until +# now that was a promise in a document, kept by whoever was driving. This policy +# makes OpenBao enforce it: the builder can create policies, auth roles and KV +# path structure, and is denied every read of secret data on any mount. +# +# Granted through scripts/bao-session.sh as a 45-minute, named token, so an +# action in the audit log is attributable to a task rather than to whoever's +# operator session happened to be open. + +# --- survey ---------------------------------------------------------------- +# Phase 2 of the construction process is an existing-structure survey, and the +# plan for the state-hub lane was written without one because no session was +# available. That omission is why an AppRole was proposed on a cluster that +# already had kubernetes auth enabled. +path "sys/mounts" { + capabilities = ["read", "list"] +} + +path "sys/auth" { + capabilities = ["read", "list"] +} + +path "sys/policies/acl" { + capabilities = ["list"] +} + +path "sys/policies/acl/*" { + capabilities = ["create", "read", "update", "list"] +} + +# --- auth roles ------------------------------------------------------------ +path "auth/kubernetes/role/*" { + capabilities = ["create", "read", "update", "list"] +} + +path "auth/approle/role/*" { + capabilities = ["create", "read", "update", "list"] +} + +# --- KV structure, never KV values ----------------------------------------- +# Metadata carries versions, timestamps and custom_metadata — enough to confirm +# a path exists and that a paste-once delivery landed. It does not carry the +# value. +path "platform/metadata/*" { + capabilities = ["read", "list"] +} + +path "operators/metadata/*" { + capabilities = ["read", "list"] +} + +# --- verification ---------------------------------------------------------- +# Minting a short-lived test token and asking what it can reach is how a lane is +# proven correctly scoped, positively and negatively. +path "auth/token/create" { + capabilities = ["create", "update"] +} + +path "auth/token/revoke" { + capabilities = ["update"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "sys/capabilities" { + capabilities = ["create", "update"] +} + +path "sys/capabilities-self" { + capabilities = ["create", "update"] +} + +# --- the line, stated as a denial ------------------------------------------ +# Explicit deny outranks any grant, including one added here later by mistake. +# If ops-mason needs to prove a credential works, the consumer proves it, or a +# human does — see plans/state-hub-forge-derivation-read.md §8 for the one time +# this line was crossed and why it was recorded rather than glossed. +path "platform/data/*" { + capabilities = ["deny"] +} + +path "operators/data/*" { + capabilities = ["deny"] +} + +path "secret/data/*" { + capabilities = ["deny"] +} + +# Mount management is deliberately absent. Enabling or tuning a secrets engine +# is a railiance-platform act; ops-mason builds inside mounts that already +# exist. A grant that needed sys/mounts/* would be a different, broader thing +# and should be recognised as such rather than folded in here. diff --git a/scripts/bao-session.sh b/scripts/bao-session.sh new file mode 100755 index 0000000..acf1f44 --- /dev/null +++ b/scripts/bao-session.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# bao-session.sh — check, or grant, an OpenBao session an agent session can use. +# +# ./scripts/bao-session.sh status what session exists, if any +# ./scripts/bao-session.sh login refresh YOUR operator session (OIDC) +# ./scripts/bao-session.sh grant mint a scoped, time-boxed token for Claude +# ./scripts/bao-session.sh revoke end the granted token immediately +# +# Why `grant` exists +# ------------------ +# Handing an agent your own platform-admin session gives it everything you have, +# for as long as you have it, indistinguishable from you in the audit log. The +# grant path instead mints a separate token that is: +# +# * scoped — carries the ops-mason-build policy, which can create policies, +# auth roles and KV *structure* but cannot read secret values. +# SCOPE.md's "never touch secret values" stops being a promise in +# a document and becomes something OpenBao enforces. +# * bounded — 45 minutes, no renewal past one hour. +# * named — display_name claude-, so the audit log says which task an +# action belonged to, not just that platform-root did it. +# +# It is written to ~/.claude-bao-token, NOT ~/.vault-token, so your own operator +# session is untouched and the two can be revoked independently. +# +# Under WSL2 the OIDC browser launch fails (gio cannot open a browser). This +# script prints the URL plainly and waits, rather than appearing to hang. + +set -euo pipefail + +export BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" +GRANT_FILE="${GRANT_FILE:-$HOME/.claude-bao-token}" +GRANT_POLICY="${GRANT_POLICY:-ops-mason-build}" +GRANT_TTL="${GRANT_TTL:-45m}" +GRANT_MAX_TTL="${GRANT_MAX_TTL:-1h}" +OIDC_PATH="${OIDC_PATH:-netkingdom}" + +green() { printf '\033[32m%s\033[0m\n' "$1"; } +red() { printf '\033[31m%s\033[0m\n' "$1"; } + +describe() { + # Prints session metadata only. Never the token. + local token="${1:-}" label="$2" + local out + if ! out="$(BAO_TOKEN="$token" bao token lookup -format=json 2>/dev/null)"; then + red " $label: none or expired" + return 1 + fi + printf '%s' "$out" | python3 -c " +import sys, json +d = json.load(sys.stdin)['data'] +print(' $label: ttl=%s policies=%s name=%s' % ( + d.get('ttl'), ','.join(d.get('policies', [])), d.get('display_name') or '-')) +" +} + +cmd_status() { + echo "BAO_ADDR=$BAO_ADDR" + describe "$(cat "$HOME/.vault-token" 2>/dev/null || true)" "your operator session" || true + if [[ -f "$GRANT_FILE" ]]; then + describe "$(cat "$GRANT_FILE")" "granted to Claude " || true + else + echo " granted to Claude : none ($GRANT_FILE absent)" + fi +} + +cmd_login() { + if [[ ! -t 0 ]]; then + red "login needs an interactive terminal."; exit 2 + fi + echo "Opening OIDC login. Under WSL2 no browser will launch —" + echo "copy the URL below into your Windows browser, then come back here." + echo + bao login -method=oidc -path="$OIDC_PATH" + echo + cmd_status +} + +cmd_grant() { + local task="${1:-}" + if [[ -z "$task" ]]; then + red "usage: $0 grant e.g. 'operators-mount'"; exit 2 + fi + if ! bao policy read "$GRANT_POLICY" >/dev/null 2>&1; then + red "policy $GRANT_POLICY does not exist yet." + echo "Create it first — see docs, or ask Claude to draft it. Refusing to" + echo "fall back to a broader policy: an unscoped grant is the thing this" + echo "script exists to avoid." + exit 1 + fi + umask 077 + bao token create \ + -policy="$GRANT_POLICY" \ + -ttl="$GRANT_TTL" \ + -explicit-max-ttl="$GRANT_MAX_TTL" \ + -display-name="claude-${task}" \ + -field=token > "$GRANT_FILE" + green "granted: $GRANT_POLICY for '$task', ttl $GRANT_TTL (max $GRANT_MAX_TTL)" + echo " file: $GRANT_FILE" + describe "$(cat "$GRANT_FILE")" "granted to Claude " || true + echo + echo "Claude uses it as: BAO_TOKEN=\$(cat $GRANT_FILE) bao " + echo "Revoke any time: $0 revoke" +} + +cmd_revoke() { + if [[ ! -f "$GRANT_FILE" ]]; then + echo "nothing to revoke ($GRANT_FILE absent)"; return 0 + fi + bao token revoke "$(cat "$GRANT_FILE")" >/dev/null 2>&1 || true + shred -u "$GRANT_FILE" 2>/dev/null || rm -f "$GRANT_FILE" + green "granted token revoked and removed" +} + +case "${1:-status}" in + status) cmd_status ;; + login) cmd_login ;; + grant) shift; cmd_grant "${1:-}" ;; + revoke) cmd_revoke ;; + *) echo "usage: $0 status|login|grant |revoke" >&2; exit 2 ;; +esac