diff --git a/AGENTS.md b/AGENTS.md index 4e4829e..d078471 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -285,9 +285,9 @@ it as `BAO_TOKEN=$(cat ~/.claude-bao-token) bao `. ### What the grant authorises Policy `ops-mason-survey` (`policies/ops-mason-survey.hcl`), 45 minutes, max -one hour, named `claude-`. 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-`. 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 # ops-mason-construct, 45m, claude- +``` + +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. diff --git a/INTENT.md b/INTENT.md index edbd6e7..503744c 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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 diff --git a/intakes/intakes.md b/intakes/intakes.md index 8e69c4b..f1f7ba0 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -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' +``` diff --git a/policies/ops-mason-construct.hcl b/policies/ops-mason-construct.hcl new file mode 100644 index 0000000..c71aafc --- /dev/null +++ b/policies/ops-mason-construct.hcl @@ -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 +# 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. diff --git a/scripts/bao-session.sh b/scripts/bao-session.sh index 2ac4e98..e0661c6 100755 --- a/scripts/bao-session.sh +++ b/scripts/bao-session.sh @@ -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 mint a scoped, time-boxed token for Claude +# ./scripts/bao-session.sh build 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-, 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 |revoke" >&2; exit 2 ;; + *) echo "usage: $0 status|login|grant |build |revoke" >&2; exit 2 ;; esac diff --git a/tests/test_construct_policy.py b/tests/test_construct_policy.py new file mode 100644 index 0000000..cdc138b --- /dev/null +++ b/tests/test_construct_policy.py @@ -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 diff --git a/workplans/MASON-WP-0015-construct-grant.md b/workplans/MASON-WP-0015-construct-grant.md new file mode 100644 index 0000000..662ea8e --- /dev/null +++ b/workplans/MASON-WP-0015-construct-grant.md @@ -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 `, 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.