MASON-IN-0005: restore ops-mason as the OpenBao builder (ops-mason-construct)

Founder decision 2026-10-18, "build, but never log in": a plan-scoped grant
that writes workload-kv-read-* policies, external-secrets-* and AppRole role
definitions, and create-only placeholder KV under platform/{workloads,suppliers},
and denies role-id, secret-id, token creation and all value reads, so the
MASON-IN-0004 chain stays broken. Adds bao-session.sh build, policy tests,
INTENT/AGENTS alignment and MASON-WP-0015. Not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 278674@bnt-lap001
Assistant-Session: 64827407-e196-4ce3-b70c-76e7b537493f
This commit is contained in:
tegwick 2026-10-18 11:48:27 +02:00
parent e2ac6448c2
commit e7a368af0f
7 changed files with 406 additions and 21 deletions

View file

@ -285,9 +285,9 @@ it as `BAO_TOKEN=$(cat ~/.claude-bao-token) bao <cmd>`.
### What the grant authorises
Policy `ops-mason-survey` (`policies/ops-mason-survey.hcl`), 45 minutes, max
one hour, named `claude-<task>`. Read-only, by founder decision 2026-10-07
(MASON-IN-0004, option (c)+(a)); it replaces `ops-mason-build`, now in
`policies/retired/` and never to be installed.
one hour, named `claude-<task>`. Read-only (MASON-IN-0004, 2026-10-07); used
for surveys. Building uses the separate `build` grant below (MASON-IN-0005).
`ops-mason-build` stays in `policies/retired/`, never to be installed.
| Allowed | Denied / not granted |
|---|---|
@ -333,15 +333,40 @@ 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.
### The build grant (MASON-IN-0005)
ops-mason is the builder. After a plan is approved, the founder mints the
build grant:
```bash
./scripts/bao-session.sh build <plan-task> # ops-mason-construct, 45m, claude-<task>
```
Policy `ops-mason-construct` (`policies/ops-mason-construct.hcl`). It
includes everything the survey grant can do, plus:
| Allowed | Still denied |
|---|---|
| create/update/delete `sys/policies/acl/workload-kv-read-*` | every other policy; `default`, `root`, `ops-mason-*` (explicit deny) |
| create/update/delete `auth/kubernetes/role/external-secrets-*` and AppRole role definitions, with `token_policies` limited to `workload-kv-read-*` | AppRole `role-id`, `secret-id*`, `custom-secret-id`; `auth/token/create*` |
| `create` (no read or update) on `platform/data/{workloads,suppliers}/*`, so placeholders only | reading any `*/data/*`; overwriting an existing value |
| full metadata on `platform/metadata/{workloads,suppliers}/*` (describe, tear down) | `sys/mounts/*`, `sys/auth/*`, `operators/` and `secret/` writes |
The grant can build a lane but can never log in as one, which keeps the
MASON-IN-0004 chain broken. AppRole secret-id delivery stays a founder act;
Kubernetes-auth lanes need no secret-id. After every build, compare the
written policy text with the plan's policy file (MASON-WP-0015-T04).
Install it the same way as the survey policy, with `ops-mason-construct` and
its file path.
### When a broader grant is genuinely needed
Writing a policy or auth role, minting a secret-id or a token, enabling a
mount, reading a value, or touching another subsystem's paths is out of
`ops-mason-survey` on purpose. Do not widen the policy to get past a refusal.
Builds that need writes are applied by the founder through the
railiance-platform delegated applier (`scripts/credential-change.py
applier-apply` under `warden access openbao-platform-admin-login --exec`), as
for CCR-2026-0033/0034; ops-mason supplies the plan and verifies afterwards.
Anything else belongs to another repo or needs its own named policy and its
own founder decision.
Minting a secret-id or a token, enabling a mount, reading a value, a policy
outside `workload-kv-read-*`, or another subsystem's paths is outside both
grants on purpose. Do not widen a policy to get past a refusal. Those acts go
through the founder and the railiance-platform delegated applier
(`scripts/credential-change.py applier-apply` under `warden access
openbao-platform-admin-login --exec`), or need their own named policy and
founder decision.

View file

@ -79,10 +79,12 @@ tooling_contacts:
intended_owner: secrets-engine
blocked_on: >-
No engine exposes a policy / auth-role construction surface. Since
MASON-IN-0004 (founder, 2026-10-07) ops-mason holds no build grant:
these writes are applied by the founder through the railiance-platform
delegated applier; ops-mason surveys and verifies under the read-only
ops-mason-survey token.
MASON-IN-0005 (founder, 2026-10-18) ops-mason builds these writes itself
under the founder-minted, plan-scoped ops-mason-construct token:
workload-kv-read-* policies, external-secrets-* Kubernetes roles, AppRole
role definitions, and placeholder KV structure. It can never read a
role-id, mint a secret-id or token, or read a value (MASON-IN-0004 stays
closed). Survey-only sessions use ops-mason-survey.
review: "2026-12-21"
gap_state: accepted
accepted_exception: *plan-approval-gate
@ -97,7 +99,10 @@ tooling_contacts:
intended_owner: secrets-engine
blocked_on: >-
No engine exposes credential issuance for AppRole lanes; secrets-engine
owns credential lifecycle but fronts no issuance API today.
owns credential lifecycle but fronts no issuance API today. Since
MASON-IN-0005 this step is a founder act: ops-mason-construct denies
role-id and secret-id, so ops-mason prepares the delivery and the founder
runs it. Kubernetes-auth lanes need no such step.
review: "2026-12-21"
gap_state: accepted
accepted_exception: *plan-approval-gate
@ -167,7 +172,7 @@ tooling_contacts:
tooling: OpenBao
operation: "Operator OIDC login; mint a scoped, bounded token under the operator's own session"
write: true
capability: "Hand an agent session a task-scoped, read-only ops-mason-survey token instead of the operator's session"
capability: "Hand an agent session a task-scoped token instead of the operator's session: read-only ops-mason-survey, or plan-scoped ops-mason-construct after approval (MASON-IN-0005)"
supplied_authority: none
note: >-
Runs under the operator's own authority and narrows it; ops-mason holds

View file

@ -189,3 +189,41 @@ installed: >-
A follow-up read shows ops-mason-build absent and ops-mason-survey present.
state_hub_intake_id: "01a11118-1f35-7ad5-ad51-65dd785c932f"
```
## MASON-IN-0005 — Restore ops-mason as the OpenBao builder without reopening MASON-IN-0004
```yaml
id: MASON-IN-0005
kind: intake
title: Restore ops-mason as the OpenBao builder without reopening MASON-IN-0004
status: open
origin: review
origin_ref: MASON-IN-0004; INTENT.md review of ops-mason and ops-warden, 2026-10-18
priority: high
owner: ops-mason
requested_by: founder
description: >-
Founder, 2026-10-18: ops-mason exists to set up and organize secrets
structure in OpenBao systematically; ops-warden finds and provides it. The
read-only resolution of MASON-IN-0004 returned every build to the founder,
which contradicts ops-mason INTENT.md (phase 4, success criterion 1: no
founder hand-running bao) and leaves ops-warden's INTENT (route and assist,
ops-mason the peer lane owner that builds) with nothing built to route to.
Both INTENT bodies already agree; the grant and the INTENT frontmatter
blocked_on text do not.
resolution: >-
Founder decision (Bernd Worsch, 2026-10-18, this session): "Build, but
never log in". New policy policies/ops-mason-construct.hcl: create, update and
delete workload-kv-read-* policies; external-secrets-* Kubernetes auth roles
and AppRole role definitions limited to workload-kv-read-* token policies;
create-only placeholder KV data and full metadata under
platform/{workloads,suppliers}/; explicit denies on role-id, secret-id,
token creation, every */data/* read, and the default, root and ops-mason-* policies.
ops-mason can build a lane but never log in as one, which breaks the
MASON-IN-0004 chain at step three. AppRole secret-id delivery stays a
founder act; Kubernetes auth (the current default) needs none. A post-build
check comparing policy text with the plan file is the detective control for
over-broad content. ops-mason-survey stays for survey-only sessions.
Implementation and install: MASON-WP-0015.
updated: '2026-10-18T00:00:00Z'
```

View file

@ -0,0 +1,133 @@
# ops-mason-construct — build security structure; never become what it builds.
#
# Founder decision 2026-10-18 (MASON-IN-0005, supersedes the read-only
# resolution of MASON-IN-0004): ops-mason is the builder (INTENT.md, phase 4).
# It creates, changes and tears down policies, auth roles and KV structure
# for an approved construction plan. ops-warden routes to what it built.
#
# How the MASON-IN-0004 escalation stays closed: that chain was
# write policy -> bind role -> read role-id -> mint secret-id -> log in.
# This grant keeps the first two steps (the job) and holds none of the steps
# that turn a role into a login: no role-id, no secret-id, no token creation,
# no KV value reads. ops-mason can build a lane but cannot use one. The
# remaining risk, a built policy broader than its plan, is caught after the
# fact by a post-build check, policy text vs plan file (MASON-WP-0015-T04), and the
# audit log (display_name claude-*).
#
# Granted only by the founder's own OIDC session through
# GRANT_POLICY=ops-mason-construct ./scripts/bao-session.sh grant <plan-task>
# as a 45-minute named token, after the plan is approved. Survey-only sessions
# keep using ops-mason-survey.
#
# Path priority (same note as ops-mason-survey.hcl): within one policy the most
# specific path wins, and a `+` segment loses to a literal one. The denies below
# are literal or more specific than the grants they bound.
#
# allowed_parameters: OpenBao glob support on list-valued parameters must be
# confirmed by the negative tests in MASON-WP-0015-T03 before this is relied on.
# --- everything the survey grant can read -----------------------------------
path "sys/mounts" { capabilities = ["read", "list"] }
path "sys/auth" { capabilities = ["read", "list"] }
path "sys/policies/acl" { capabilities = ["list"] }
path "auth/approle/role" { capabilities = ["list"] }
path "auth/netkingdom/role/*" { capabilities = ["read", "list"] }
path "auth/jwt/role/*" { capabilities = ["read", "list"] }
path "auth/keycape-services/role/*" { capabilities = ["read", "list"] }
path "operators/metadata/*" { capabilities = ["read", "list"] }
path "sys/capabilities" { capabilities = ["update"] }
path "sys/capabilities-self" { capabilities = ["update"] }
path "auth/token/lookup-self" { capabilities = ["read"] }
path "auth/token/revoke-self" { capabilities = ["update"] }
# Every policy stays readable for the survey and for verification.
path "sys/policies/acl/*" {
capabilities = ["read"]
}
# --- build: workload read policies ------------------------------------------
# The lane convention (railiance-platform/openbao/policies). Content is not
# constrainable in OpenBao; the post-build check compares it with the plan.
path "sys/policies/acl/workload-kv-read-*" {
capabilities = ["create", "read", "update", "delete"]
}
# --- build: Kubernetes auth roles for External Secrets ----------------------
# Role may carry only workload-kv-read-* policies; legacy `policies` refused.
path "auth/kubernetes/role/external-secrets-*" {
capabilities = ["create", "read", "update", "delete", "list"]
allowed_parameters = {
"token_policies" = ["workload-kv-read-*"]
"*" = []
}
denied_parameters = {
"policies" = []
}
}
path "auth/kubernetes/role/*" {
capabilities = ["read", "list"]
}
# --- build: AppRole role definitions (never credentials) --------------------
# Definition only. role-id and secret-id are denied below, so an AppRole
# lane's credential delivery stays a founder act.
path "auth/approle/role/+" {
capabilities = ["create", "read", "update", "delete"]
allowed_parameters = {
"token_policies" = ["workload-kv-read-*"]
"*" = []
}
denied_parameters = {
"policies" = []
}
}
# --- build: KV structure on the platform mount ------------------------------
# create without read/update: a new path can get placeholder values (`xxx`),
# an existing value can be neither read nor overwritten.
path "platform/data/workloads/*" {
capabilities = ["create"]
}
path "platform/data/suppliers/*" {
capabilities = ["create"]
}
# Describe, inventory and tear down paths (custom_metadata, metadata delete).
path "platform/metadata/workloads/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "platform/metadata/suppliers/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "platform/metadata/*" {
capabilities = ["read", "list"]
}
# --- the grant cannot widen itself or every token ---------------------------
path "sys/policies/acl/ops-mason-construct" { capabilities = ["deny"] }
path "sys/policies/acl/ops-mason-survey" { capabilities = ["deny"] }
path "sys/policies/acl/default" { capabilities = ["deny"] }
path "sys/policies/acl/root" { capabilities = ["deny"] }
# --- never become what it builds ---------------------------------------------
path "auth/approle/role/+/role-id" { capabilities = ["deny"] }
path "auth/approle/role/+/secret-id" { capabilities = ["deny"] }
path "auth/approle/role/+/secret-id/*" { capabilities = ["deny"] }
path "auth/approle/role/+/secret-id-accessor/*" { capabilities = ["deny"] }
path "auth/approle/role/+/custom-secret-id" { capabilities = ["deny"] }
path "auth/token/create" { capabilities = ["deny"] }
path "auth/token/create/*" { capabilities = ["deny"] }
path "auth/token/create-orphan" { capabilities = ["deny"] }
# --- never read a value --------------------------------------------------------
path "platform/data/*" { capabilities = ["deny"] }
path "operators/data/*" { capabilities = ["deny"] }
path "secret/data/*" { capabilities = ["deny"] }
# Not granted, on purpose: sys/mounts/* and sys/auth/* (no new mounts or auth
# methods), auth/token/revoke (other tokens), operators/ and secret/ writes,
# any policy outside workload-kv-read-*, any role outside the two shapes above.

View file

@ -4,6 +4,7 @@
# ./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 <task> mint a scoped, time-boxed token for Claude
# ./scripts/bao-session.sh build <task> same, with ops-mason-construct (approved plan only)
# ./scripts/bao-session.sh revoke end the granted token immediately
#
# Why `grant` exists
@ -16,8 +17,12 @@
# and read mounts, auth methods, policies, auth role definitions
# and KV *metadata*, and query capabilities. It cannot write a
# policy or role, mint a secret-id or a token, or read a secret
# value (MASON-IN-0004, founder decision 2026-10-07). Builds go
# through the founder-attended railiance-platform applier.
# value (MASON-IN-0004, founder decision 2026-10-07).
# `build` carries ops-mason-construct instead (MASON-IN-0005,
# 2026-10-18): it writes workload-kv-read-* policies, ESO/AppRole
# role definitions and placeholder KV structure, and still cannot
# read a role-id, mint a secret-id or token, or read a value.
# Mint it only after the plan is approved.
# * bounded — 45 minutes, no renewal past one hour.
# * named — display_name claude-<task>, so the audit log says which task an
# action belonged to, not just that platform-root did it.
@ -130,6 +135,7 @@ case "${1:-status}" in
status) cmd_status ;;
login) cmd_login ;;
grant) shift; cmd_grant "${1:-}" ;;
build) shift; GRANT_POLICY=ops-mason-construct; cmd_grant "${1:-}" ;;
revoke) cmd_revoke ;;
*) echo "usage: $0 status|login|grant <task>|revoke" >&2; exit 2 ;;
*) echo "usage: $0 status|login|grant <task>|build <task>|revoke" >&2; exit 2 ;;
esac

View file

@ -0,0 +1,68 @@
"""ops-mason-construct can build a lane but never log in as one (MASON-IN-0005)."""
import re
from pathlib import Path
POLICY = Path(__file__).resolve().parents[1] / "policies" / "ops-mason-construct.hcl"
def _stanzas() -> dict[str, str]:
text = "\n".join(line.split("#", 1)[0] for line in POLICY.read_text().splitlines())
return {
m.group(1): m.group(2)
for m in re.finditer(r'path\s+"([^"]+)"\s*\{(.*?)\n?\s*\}\s*(?=path|\Z)', text, re.S)
}
def _caps(body: str) -> set[str]:
m = re.search(r"capabilities\s*=\s*\[([^\]]*)\]", body)
return set(re.findall(r'"(\w+)"', m.group(1))) if m else set()
def test_login_and_value_paths_are_denied():
stanzas = _stanzas()
for path in [
"auth/approle/role/+/role-id",
"auth/approle/role/+/secret-id",
"auth/approle/role/+/secret-id/*",
"auth/approle/role/+/custom-secret-id",
"auth/token/create",
"auth/token/create/*",
"auth/token/create-orphan",
"platform/data/*",
"operators/data/*",
"secret/data/*",
"sys/policies/acl/ops-mason-construct",
"sys/policies/acl/ops-mason-survey",
"sys/policies/acl/default",
"sys/policies/acl/root",
]:
assert _caps(stanzas[path]) == {"deny"}, path
def test_kv_data_is_create_only():
stanzas = _stanzas()
for path in ["platform/data/workloads/*", "platform/data/suppliers/*"]:
assert _caps(stanzas[path]) == {"create"}, path
for path, body in stanzas.items():
if "/data/" in path:
assert not _caps(body) & {"read", "update", "list"}, path
def test_policy_writes_limited_to_workload_read_prefix():
for path, body in _stanzas().items():
if path.startswith("sys/policies/acl/") and _caps(body) & {"create", "update", "delete"}:
assert path == "sys/policies/acl/workload-kv-read-*", path
def test_role_writes_restrict_token_policies():
for path, body in _stanzas().items():
if path.startswith("auth/") and "/role/" in path and _caps(body) & {"create", "update"}:
assert '"token_policies" = ["workload-kv-read-*"]' in body, path
assert re.search(r'denied_parameters\s*=\s*\{\s*"policies"\s*=\s*\[\]', body), path
def test_no_mount_or_auth_method_writes():
for path, body in _stanzas().items():
if path.startswith(("sys/mounts/", "sys/auth/")):
assert _caps(body) <= {"read", "list", "deny"}, path

View file

@ -0,0 +1,110 @@
---
id: MASON-WP-0015
type: workplan
title: "Restore ops-mason as the OpenBao builder: the ops-mason-construct grant"
domain: infotech
repo: ops-mason
status: active
owner: claude
topic_slug: netkingdom-security-architecture
created: "2026-10-18"
updated: "2026-10-18"
flavor: implementation
depends_on: []
---
# ops-mason-construct grant
Decision: `intakes/intakes.md` MASON-IN-0005 (founder, 2026-10-18, "Build, but
never log in"). ops-mason builds structure in OpenBao; ops-warden finds and
provides it. The grant breaks the MASON-IN-0004 chain at the login step.
## Review both INTENTs and draft the grant
```task
id: MASON-WP-0015-T01
status: done
priority: high
```
Reviewed ops-mason and ops-warden INTENT.md. The bodies agree (ops-mason
builds, ops-warden routes and assists); the read-only grant did not. Wrote
`policies/ops-mason-construct.hcl` and `tests/test_construct_policy.py`, added
`bao-session.sh build <task>`, and updated INTENT.md frontmatter and the
AGENTS.md OpenBao section.
## Install the policy (founder act)
```task
id: MASON-WP-0015-T02
status: todo
priority: high
```
Same silent wrapper and hash receipt as the survey policy (AGENTS.md), with
`ops-mason-construct` and
`/home/worsch/ops-mason/policies/ops-mason-construct.hcl`. Confirm your OIDC
session can mint a token carrying it (`bao-session.sh build MASON-WP-0015-T03`).
## Live negative and positive tests
```task
id: MASON-WP-0015-T03
status: wait
priority: high
```
Under the build grant, against throwaway names (`workload-kv-read-mason-probe`,
`external-secrets-mason-probe`, `platform/suppliers/mason/probe`): policy and
role create/delete succeed; a role with `token_policies=default`, or with
legacy `policies`, is refused (this confirms glob support in
`allowed_parameters`); role-id read, secret-id write, token create, data read,
and a write over the existing probe path are all refused; a policy write
outside `workload-kv-read-*` is refused. Record the results as a
names-only receipt. If the glob check fails, stop and redesign the role
stanzas before any build.
## Executor and post-build check
```task
id: MASON-WP-0015-T04
status: todo
priority: high
```
`src/ops_mason/executor.py` `build_approle_kv_lane` mints the role-id and
secret-id itself (lines ~420-426). Split it so that it stops after the role
definition and emits the founder delivery step. Add a post-build check that
reads each written policy and compares it with the plan's policy file
(normalising the trailing newline). Run the check in every build; a mismatch
fails the build record.
## Align ops-warden and railiance-platform references
```task
id: MASON-WP-0015-T05
status: todo
priority: medium
```
ops-warden `registry/routing/catalog.yaml` and
`wiki/playbooks/openbao-structure-survey.md` describe ops-mason as read-only.
The railiance-platform references are in
`docs/credential-lane-designs/application-backup-delivery-2026-10-08.md` and
RPF-WP-0053 and RPF-WP-0057. Message each owner with MASON-IN-0005, pointers
only. They edit their own files.
## Close the Kubernetes variant
```task
id: MASON-WP-0015-T06
status: todo
priority: medium
```
A Kubernetes role ops-mason builds can be logged in to by anyone holding a
token for the bound ServiceAccount. Confirm that the kube identity ops-mason
applies with cannot `create serviceaccounts/token` or Pods in namespaces it
binds roles to (`kubectl auth can-i`). `kubernetes_plane` already refuses
the Pod and Secret kinds. Record the result in INTENT.md's
`kubernetes-plane-apply` entry.