Bind OpenBao builds to approved inputs and secure credential delivery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e75a-fc5c-7913-9dba-9846210c766d
This commit is contained in:
tegwick 2026-09-28 11:55:39 +02:00
parent ee1dc2b651
commit 2b83324c01
8 changed files with 622 additions and 84 deletions

View file

@ -28,7 +28,7 @@ decision gate, in plain terms; (4) build, only after approval.
- Deciding *whether* access should exist — that's architecture/founder,
ops-mason builds what's already decided
- Runtime authorization decisions — flex-auth
- Runtime authorization decisions — access-engine
- Identity/MFA — key-cape/Keycloak
- Routing consumers to lanes once built — ops-warden
- SSH certificate issuance — ops-warden
@ -38,9 +38,28 @@ decision gate, in plain terms; (4) build, only after approval.
## Current State
Charter only (`INTENT.md`). `MASON-WP-0001` scopes the four-phase
pipeline and its first real exercise: the `rein-openweights` OpenBao
AppRole that `glas-harness/GLAS-WP-0002-T02` is blocked on. No code yet.
The four-phase construction process, OpenBao AppRole/Kubernetes-auth builders,
metadata inventory, and guarded Kubernetes-plane CLI are implemented and tested.
Construction plans and build evidence live in `plans/` and `docs/evidence/`.
OpenBao builds require an approved plan whose machine-readable specification
and approved digest match the supplied execution inputs. Existing policies are
content-pinned before reuse. AppRole credentials are delivered through private,
exclusive files; the builders do not read downstream KV values. See
`docs/construction-plan-format.md` for review and execution requirements.
Kubernetes apply enforces pinned readiness tiers, the explicit whitehat
placement, the dated policy-nexus transition, and recorded emergency activation.
See `docs/kubernetes-plane.md`.
The layer declaration is `Staff`, with PEP-shaped behavior, in `INTENT.md`.
The founder-approved plan-approval exception and direct-contact gaps remain
explicitly declared; this is not a claim of full layer-model conformance.
Remaining work is held in existing blocked workplans: credential descriptions
(MASON-WP-0004), the attended Telegram credential lane (MASON-WP-0005), and the
2026-12-21 readiness/approval review (MASON-WP-0006-T06). `WORK-RECORDS.md` is
the generated index; workplan files remain the source of task status.
## Getting Oriented

View file

@ -95,3 +95,93 @@ section does not exist until phase 4 has run.
See `plans/rein-openweights-openrouter-approle.md` once T01 is
implemented against this format — the first real plan, not a synthetic
one, per `MASON-WP-0001`'s own scoping.
## Binding OpenBao approval to execution inputs
The AppRole and Kubernetes-auth builders additionally require two frontmatter
fields: `build_spec` and `approved_spec_sha256`. `build_spec` is the complete
value-free output of `ops_mason.executor.build_spec_document(spec)`. It includes
the engine, exact policy/role/path and identity bindings, token bounds, policy
reuse and content pin, delivery destination, audit destination, and executable.
`credential_type` must match its `engine`.
During drafting, render the specification and its digest for review:
```python
from pathlib import Path
from ops_mason.executor import AppRoleKVSpec, build_spec_document, build_spec_digest
spec = AppRoleKVSpec(
policy_name="workload-kv-read-example",
kv_path="platform/workloads/example/runtime",
approle_name="example",
token_num_uses=8,
delivery_dir=Path("/home/consumer/.local/example/approle"),
audit_log_path=Path("/home/builder/ops-mason/audit/build-log.jsonl"),
)
document = build_spec_document(spec) # put this mapping under build_spec
candidate_digest = build_spec_digest(document) # present alongside the review
```
The digest is SHA-256 of UTF-8 JSON with sorted keys, compact separators and
no NaN values. Paths render as absolute strings; tuples render as lists. All
specification fields, including explicit defaults and null values, participate.
After the founder approves that exact specification, record the digest as
`approved_spec_sha256` alongside `approved_by`, `approved_at` and
`status: approved`. Quote approval dates in YAML. A candidate digest alone
is not approval, and the digest is an integrity marker, not a signature.
Before any OpenBao command, the builder reloads the plan from disk, checks
approval and engine, validates the specification, and compares both the
frontmatter mapping and approved digest to the actual inputs. Duplicate YAML
keys, missing build specifications, changed scope or changed destinations are
refused. Editing the mapping without renewing its approval digest cannot reuse
the previous approval. The plan file remains the trust boundary: this does not
protect against someone authorized to rewrite the approval record itself.
Historical plans stay historical. They are not automatically given new
specifications or approvals. Before re-executing one, draft and review the exact
new inputs through the existing four-phase process. No live lane was changed
by this format migration.
### Input and policy constraints
The existing AppRole engine creates only exact KV-v2 read lanes: one literal
entry path, `read` on data and metadata, no wildcard, empty/parent path segment,
HCL injection, or write capability. OIDC matrix writers remain a separate,
blocked contract under MASON-WP-0005. Role and policy names must be single
literal names; Kubernetes service-account names/namespaces must be explicit,
nonempty, unique DNS labels. Token use counts must be explicit nonnegative
integers (zero is deliberately unlimited when approved); TTLs are positive
integer durations in `s`, `m` or `h`, with maximum TTL at least initial TTL.
`secret_id_ttl: "0"` remains an explicit reviewable choice.
For `AppRoleKVSpec(reuse_policy=True)`, also provide
`reuse_policy_sha256`. For `KubernetesKVSpec`, provide `policy_sha256`, since
that builder always binds an existing policy. During the scoped structure
survey, hash the exact UTF-8 output of `bao policy read <name>`, including its
trailing newline, and review that policy's full scope. The builder reads only
policy text, checks its hash before auth-role writes, and refuses drift. A
reused policy may deliberately cover more than the AppRole's nominal KV path;
its reviewed contents, not that path field alone, define the grant. Rechecking
a pin is not an atomic lock against later administrative policy changes.
### Credential delivery and failed builds
AppRole delivery requires an absolute directory. Every directory component is
opened without following symlinks; missing directories are created privately.
An existing final directory must belong to the executing account with mode
0700. Both credential files are reserved exclusively at mode 0600 before any
policy/auth write or credential issuance. Existing files, hardlinks and symlink
paths are refused rather than overwritten or permission-repaired. Delivery
uses the held file descriptors and flushes credentials to disk.
OpenBao error and timeout output is suppressed so a failed credential command
cannot echo sensitive material. Audit output contains object names, the
approved specification digest and approval attribution, never credentials.
A failed build can leave private empty or partially delivered files, and may
have changed policy/auth structure or issued a credential before failing.
Do not blindly retry or delete them: inspect structure and audit metadata,
resolve/revoke any partial issuance through the scoped custody procedure, and
review a fresh delivery destination before retrying. The executor does not
claim transactional rollback or automatically widen its revocation authority.

View file

@ -36,7 +36,8 @@ id: MASON-IN-0002
kind: intake
title: 'Declaration requested: state this repository''s layer in INTENT.md (security
layer model §11)'
status: open
status: closed
outcome: absorbed
origin: cross-repo
origin_ref: net-kingdom security-layer-model_v0.4 §11
priority: low
@ -61,5 +62,12 @@ description: 'A conformance sweep on 2026-08-28 found this repository has no lay
If the proposed layer is wrong for what this repository actually does, that is more
useful to us than a label added to close a checkbox. Standard: net-kingdom/canon/standards/security-layer-model_v0.4.md.'
created: '2026-08-28T21:03:29.920563Z'
updated: '2026-08-28T21:03:29.920563Z'
updated: '2026-09-28T09:54:34.065401Z'
notes: >-
Closed against the repository-authored Staff / pep-shaped declaration in
INTENT.md, dated 2026-09-21. That declaration names access-engine as the
authorization decision point and explicitly records direct Tooling contacts
and the founder-accepted plan-approval exception. The declaration request is
fulfilled; the accepted conformance gaps and their 2026-12-21 review remain
declared and are not marked conformant by this intake closure.
```

View file

@ -18,13 +18,18 @@ printable form beyond confirmation that delivery happened.
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import subprocess
from dataclasses import dataclass, field
from contextlib import contextmanager
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from ops_mason.audit import record_build
from ops_mason.plan import ConstructionPlan
from ops_mason.plan import ConstructionPlan, PlanError
class BuildRefused(RuntimeError):
@ -56,6 +61,7 @@ class AppRoleKVSpec:
# When True the named policy already exists (and may grant more than one
# KV path). Do not rewrite it — AppRole bind only.
reuse_policy: bool = False
reuse_policy_sha256: str | None = None
@dataclass
@ -64,21 +70,21 @@ class KubernetesKVSpec:
role_name: str
service_account_names: tuple[str, ...]
service_account_namespaces: tuple[str, ...]
policy_sha256: str = ""
token_ttl: str = "15m"
audit_log_path: Path | None = None
bao_bin: str = "bao"
def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str:
proc = subprocess.run(
[bao_bin, *args],
input=input_text,
capture_output=True,
text=True,
timeout=30,
)
try:
proc = subprocess.run(
[bao_bin, *args], input=input_text, capture_output=True, text=True, timeout=30,
)
except (OSError, UnicodeError, subprocess.TimeoutExpired):
raise BuildError("OpenBao command unavailable or timed out; output suppressed") from None
if proc.returncode != 0:
raise BuildError(f"`{bao_bin} {' '.join(args)}` failed: {proc.stderr.strip()[:300]}")
raise BuildError(f"OpenBao command failed (exit {proc.returncode}); output suppressed")
return proc.stdout
@ -93,6 +99,12 @@ def _policy_hcl(kv_path: str, capabilities: tuple[str, ...]) -> str:
OpenBao evaluates policy against the real `data/`-prefixed path, not
the one a caller might naively check capabilities against.
"""
if not isinstance(kv_path, str) or len(kv_path.split("/")) < 2:
raise BuildRefused("KV path must contain a mount and a literal entry")
for part in kv_path.split("/"):
_name(part, "KV path segment")
if not isinstance(capabilities, tuple) or capabilities != ("read",):
raise BuildRefused("this KV read-lane engine permits only the read capability")
mount, _, rest = kv_path.partition("/")
caps = ", ".join(f'"{c}"' for c in capabilities)
return (
@ -101,57 +113,214 @@ def _policy_hcl(kv_path: str, capabilities: tuple[str, ...]) -> str:
)
def _name(value: str, label: str) -> None:
if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*", value):
raise BuildRefused(f"{label} must be one literal name, without wildcards or separators")
def _duration(value: str, label: str, *, allow_zero: bool = False) -> int:
if allow_zero and value == "0":
return 0
if not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]*[smh]", value):
raise BuildRefused(f"{label} must be a positive duration in s, m or h")
return int(value[:-1]) * {"s": 1, "m": 60, "h": 3600}[value[-1]]
def _hash(value: str) -> None:
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value):
raise BuildRefused("reused policy requires an exact SHA-256 content pin")
def _validate_spec(spec: AppRoleKVSpec | KubernetesKVSpec) -> None:
_name(spec.policy_name, "policy name")
_duration(spec.token_ttl, "token_ttl")
if not isinstance(spec.bao_bin, str) or not spec.bao_bin.strip():
raise BuildRefused("bao_bin must name an executable")
if spec.audit_log_path is not None and not isinstance(spec.audit_log_path, Path):
raise BuildRefused("audit_log_path must be a Path")
if isinstance(spec, AppRoleKVSpec):
_name(spec.approle_name, "AppRole name")
_policy_hcl(spec.kv_path, spec.kv_capabilities)
if type(spec.token_num_uses) is not int or spec.token_num_uses < 0:
raise BuildRefused("token_num_uses must be an explicit nonnegative integer")
if _duration(spec.token_max_ttl, "token_max_ttl") < _duration(spec.token_ttl, "token_ttl"):
raise BuildRefused("token_max_ttl must not be shorter than token_ttl")
_duration(spec.secret_id_ttl, "secret_id_ttl", allow_zero=True)
if type(spec.reuse_policy) is not bool:
raise BuildRefused("reuse_policy must be boolean")
if spec.reuse_policy:
_hash(spec.reuse_policy_sha256)
elif spec.reuse_policy_sha256 is not None:
raise BuildRefused("policy pin is only used with reuse_policy")
if not isinstance(spec.delivery_dir, Path) or not spec.delivery_dir.is_absolute():
raise BuildRefused("credential delivery requires an explicit absolute directory")
if ".." in spec.delivery_dir.parts:
raise BuildRefused("credential delivery path must not contain parent traversal")
if spec.audit_log_path is not None and spec.audit_log_path.absolute() in {
spec.delivery_dir, spec.delivery_dir / "role_id", spec.delivery_dir / "secret_id"
}:
raise BuildRefused("audit path must not overlap credential delivery")
else:
_name(spec.role_name, "Kubernetes role name")
_hash(spec.policy_sha256)
for values in (spec.service_account_names, spec.service_account_namespaces):
if not isinstance(values, tuple) or not values:
raise BuildRefused("Kubernetes role requires explicit service account bindings")
for value in values:
if not isinstance(value, str) or not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", value) or len(value) > 63:
raise BuildRefused("service account bindings must be literal DNS labels")
if len(set(values)) != len(values):
raise BuildRefused("duplicate service account binding")
def build_spec_document(spec: AppRoleKVSpec | KubernetesKVSpec) -> dict:
"""Render the complete, value-free execution input for phase-3 review."""
_validate_spec(spec)
fields = asdict(spec)
for key, value in fields.items():
if isinstance(value, Path):
fields[key] = str(value.absolute())
elif isinstance(value, tuple):
fields[key] = list(value)
return {
"engine": "openbao-approle-kv" if isinstance(spec, AppRoleKVSpec) else "openbao-kubernetes-kv",
**fields,
}
def build_spec_digest(document: dict) -> str:
"""Hash canonical JSON; this is an integrity marker, not a signature."""
try:
encoded = json.dumps(document, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
except (TypeError, ValueError) as exc:
raise BuildRefused("build specification is not canonical JSON") from exc
return hashlib.sha256(encoded).hexdigest()
def _approved_build(plan: ConstructionPlan, spec) -> ConstructionPlan:
# The file is authoritative, so revoking approval after load takes effect.
try:
current = ConstructionPlan.load(plan.path)
except (OSError, PlanError) as exc:
raise BuildRefused("cannot reload construction-plan approval") from exc
if current.id != plan.id or not current.is_approved():
raise BuildRefused("construction plan is not approved; refusing to build")
document = build_spec_document(spec)
if current.credential_type != document["engine"]:
raise BuildRefused("credential_type does not match the build engine")
if not isinstance(current.build_spec, dict) or not current.approved_spec_sha256:
raise BuildRefused("plan requires build_spec and approved_spec_sha256")
expected = build_spec_digest(document)
if build_spec_digest(current.build_spec) != expected or current.approved_spec_sha256 != expected:
raise BuildRefused("build specification does not match the approved specification digest")
return current
def _verify_policy(bao_bin: str, name: str, expected: str) -> None:
content = _run(bao_bin, ["policy", "read", name])
if hashlib.sha256(content.encode()).hexdigest() != expected:
raise BuildRefused("live reused policy does not match the approved content pin")
@contextmanager
def _delivery_files(path: Path):
"""Reserve private files before Bao writes, without following any symlink.
Hold directory/file descriptors throughout delivery. Existing files are
never opened or overwritten. Failed issuance leaves private partial files
for explicit recovery; retries must use a newly reviewed destination.
"""
directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
files = {}
try:
for component in path.parts[1:]:
try:
os.mkdir(component, 0o700, dir_fd=directory)
except FileExistsError:
pass
child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
os.close(directory)
directory = child
info = os.fstat(directory)
if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise BuildRefused("delivery directory must be owned by the caller with mode 0700")
# Check both first so a pre-existing destination never causes a write.
for name in ("role_id", "secret_id"):
try:
os.stat(name, dir_fd=directory, follow_symlinks=False)
except FileNotFoundError:
continue
raise BuildRefused("credential destination already exists; refusing overwrite")
for name in ("role_id", "secret_id"):
files[name] = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=directory)
os.fchmod(files[name], 0o600)
except (OSError, BuildRefused) as exc:
for fd in files.values():
os.close(fd)
os.close(directory)
if isinstance(exc, BuildRefused):
raise
raise BuildRefused("cannot reserve safe credential delivery files") from None
try:
yield files
finally:
for fd in files.values():
os.close(fd)
os.close(directory)
def _write_credential(fd: int, value: str) -> None:
if not value or "\n" in value or "\r" in value or "\x00" in value:
raise BuildError("credential command returned an invalid response")
with os.fdopen(os.dup(fd), "w") as stream:
stream.write(value + "\n")
stream.flush()
os.fsync(stream.fileno())
def build_approle_kv_lane(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[str, str]:
"""Create the policy + AppRole for an `openbao-approle-kv` plan, deliver role_id/secret_id.
Refuses unless plan.is_approved(). Returns object names only (no
secret material) and appends a metadata-only audit record.
"""
if not plan.is_approved():
raise BuildRefused(
f"plan {plan.id!r} is not approved "
f"(status={plan.status!r}, approved_by={plan.approved_by!r}, "
f"approved_at={plan.approved_at!r}) — refusing to build"
spec = replace(spec)
plan = _approved_build(plan, spec)
if spec.reuse_policy:
_verify_policy(spec.bao_bin, spec.policy_name, spec.reuse_policy_sha256)
with _delivery_files(spec.delivery_dir) as files:
if not spec.reuse_policy:
policy_hcl = _policy_hcl(spec.kv_path, spec.kv_capabilities)
_run(spec.bao_bin, ["policy", "write", spec.policy_name, "-"], input_text=policy_hcl)
_run(
spec.bao_bin,
[
"write",
f"auth/approle/role/{spec.approle_name}",
f"token_policies={spec.policy_name}",
f"token_ttl={spec.token_ttl}",
f"token_max_ttl={spec.token_max_ttl}",
f"token_num_uses={spec.token_num_uses}",
f"secret_id_ttl={spec.secret_id_ttl}",
],
)
if not spec.reuse_policy:
policy_hcl = _policy_hcl(spec.kv_path, spec.kv_capabilities)
_run(spec.bao_bin, ["policy", "write", spec.policy_name, "-"], input_text=policy_hcl)
role_id = _run(
spec.bao_bin, ["read", "-field=role_id", f"auth/approle/role/{spec.approle_name}/role-id"]
).strip()
secret_id = _run(
spec.bao_bin,
["write", "-field=secret_id", "-f", f"auth/approle/role/{spec.approle_name}/secret-id"],
).strip()
_run(
spec.bao_bin,
[
"write",
f"auth/approle/role/{spec.approle_name}",
f"token_policies={spec.policy_name}",
f"token_ttl={spec.token_ttl}",
f"token_max_ttl={spec.token_max_ttl}",
f"token_num_uses={spec.token_num_uses}",
f"secret_id_ttl={spec.secret_id_ttl}",
],
)
role_id = _run(
spec.bao_bin, ["read", "-field=role_id", f"auth/approle/role/{spec.approle_name}/role-id"]
).strip()
secret_id = _run(
spec.bao_bin,
["write", "-field=secret_id", "-f", f"auth/approle/role/{spec.approle_name}/secret-id"],
).strip()
delivered_to = ""
if spec.delivery_dir is not None:
spec.delivery_dir.mkdir(parents=True, exist_ok=True)
os.chmod(spec.delivery_dir, 0o700)
role_id_path = spec.delivery_dir / "role_id"
secret_id_path = spec.delivery_dir / "secret_id"
role_id_path.write_text(role_id + "\n")
secret_id_path.write_text(secret_id + "\n")
os.chmod(role_id_path, 0o600)
os.chmod(secret_id_path, 0o600)
delivered_to = str(spec.delivery_dir)
_write_credential(files["role_id"], role_id)
_write_credential(files["secret_id"], secret_id)
delivered_to = str(spec.delivery_dir)
objects = {
"approved_spec_sha256": plan.approved_spec_sha256,
"policy_name": spec.policy_name,
"approle_name": spec.approle_name,
"kv_path": spec.kv_path,
@ -172,14 +341,9 @@ def build_kubernetes_kv_lane(
plan: ConstructionPlan, spec: KubernetesKVSpec
) -> dict[str, str]:
"""Create a policy-bound Kubernetes auth role without handling secret values."""
if not plan.is_approved():
raise BuildRefused(
f"plan {plan.id!r} is not approved "
f"(status={plan.status!r}, approved_by={plan.approved_by!r}, "
f"approved_at={plan.approved_at!r}) — refusing to build"
)
if not spec.service_account_names or not spec.service_account_namespaces:
raise BuildRefused("Kubernetes role requires explicit service account bindings")
spec = replace(spec)
plan = _approved_build(plan, spec)
_verify_policy(spec.bao_bin, spec.policy_name, spec.policy_sha256)
_run(
spec.bao_bin,
@ -193,6 +357,7 @@ def build_kubernetes_kv_lane(
],
)
objects = {
"approved_spec_sha256": plan.approved_spec_sha256,
"policy_name": spec.policy_name,
"kubernetes_role_name": spec.role_name,
"service_account_names": ",".join(spec.service_account_names),

View file

@ -19,6 +19,23 @@ class PlanError(RuntimeError):
pass
class _PlanLoader(yaml.SafeLoader):
"""Reject ambiguous approval documents rather than keeping the last key."""
def _unique_mapping(loader, node, deep=False):
result = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str) or key in result:
raise PlanError("plan mappings require unique string keys")
result[key] = loader.construct_object(value_node, deep=deep)
return result
_PlanLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping)
@dataclass
class ConstructionPlan:
id: str
@ -29,15 +46,26 @@ class ConstructionPlan:
consumer_repo: str
credential_type: str
path: Path
build_spec: dict[str, Any] | None = None
approved_spec_sha256: str | None = None
@classmethod
def load(cls, path: str | Path) -> ConstructionPlan:
path = Path(path)
text = path.read_text()
parts = text.split("---", 2)
if len(parts) < 3 or not text.startswith("---"):
lines = text.splitlines()
if not lines or lines[0] != "---":
raise PlanError(f"{path}: missing YAML frontmatter")
data: dict[str, Any] = yaml.safe_load(parts[1]) or {}
try:
end = lines.index("---", 1)
except ValueError:
raise PlanError(f"{path}: missing YAML frontmatter delimiter") from None
try:
data = yaml.load("\n".join(lines[1:end]), Loader=_PlanLoader)
except yaml.YAMLError as exc:
raise PlanError(f"{path}: invalid YAML frontmatter") from exc
if not isinstance(data, dict):
raise PlanError(f"{path}: frontmatter must be a mapping")
missing = {"id", "status"} - set(data)
if missing:
raise PlanError(f"{path}: frontmatter missing field(s): {', '.join(sorted(missing))}")
@ -50,7 +78,12 @@ class ConstructionPlan:
consumer_repo=data.get("consumer_repo", ""),
credential_type=data.get("credential_type", ""),
path=path,
build_spec=data.get("build_spec"),
approved_spec_sha256=data.get("approved_spec_sha256"),
)
def is_approved(self) -> bool:
return self.status == "approved" and bool(self.approved_by) and bool(self.approved_at)
return self.status == "approved" and all(
isinstance(value, str) and bool(value.strip())
for value in (self.approved_by, self.approved_at)
)

View file

@ -1,4 +1,10 @@
import hashlib
import json
import os
import stat
import subprocess
import yaml
from unittest.mock import MagicMock, patch
import pytest
@ -9,6 +15,8 @@ from ops_mason.executor import (
BuildRefused,
KubernetesKVSpec,
_policy_hcl,
build_spec_document,
build_spec_digest,
build_approle_kv_lane,
build_kubernetes_kv_lane,
)
@ -23,18 +31,18 @@ def test_policy_hcl_uses_kv_v2_data_and_metadata_paths() -> None:
from ops_mason.plan import ConstructionPlan
def _plan(tmp_path, *, status="approved", approved_by="bernd", approved_at="2026-07-27"):
text = (
"---\n"
"id: test-lane\n"
f"status: {status}\n"
+ (f'approved_by: "{approved_by}"\n' if approved_by else "approved_by: null\n")
+ (f'approved_at: "{approved_at}"\n' if approved_at else "approved_at: null\n")
+ "---\n# Plan\n"
)
POLICY = 'path "reins/data/test/openrouter" { capabilities = ["read"] }\n'
POLICY_HASH = hashlib.sha256(POLICY.encode()).hexdigest()
def _plan(tmp_path, *, status="approved", approved_by="bernd", approved_at="2026-07-27", spec=None):
document = build_spec_document(spec or _spec(tmp_path))
fields = dict(id="test-lane", status=status, approved_by=approved_by,
approved_at=approved_at, credential_type=document["engine"],
build_spec=document, approved_spec_sha256=build_spec_digest(document))
p = tmp_path / "plan.md"
p.write_text(text)
return ConstructionPlan.load(str(p))
p.write_text("---\n" + yaml.safe_dump(fields) + "---\n# Plan\n")
return ConstructionPlan.load(p)
def _spec(tmp_path) -> AppRoleKVSpec:
@ -81,7 +89,9 @@ def test_build_writes_policy_approle_and_delivers_credentials(tmp_path) -> None:
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
result = MagicMock(returncode=0, stderr="")
if cmd[1:3] == ["read", "-field=role_id"]:
if cmd[1:3] == ["policy", "read"]:
result.stdout = POLICY
elif cmd[1:3] == ["read", "-field=role_id"]:
result.stdout = "role-id-value\n"
elif "-field=secret_id" in cmd:
result.stdout = "secret-id-value\n"
@ -121,10 +131,14 @@ def test_reuse_policy_does_not_rewrite_existing_policy(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _spec(tmp_path)
spec.reuse_policy = True
spec.reuse_policy_sha256 = POLICY_HASH
plan = _plan(tmp_path, spec=spec)
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
result = MagicMock(returncode=0, stderr="")
if cmd[1:3] == ["read", "-field=role_id"]:
if cmd[1:3] == ["policy", "read"]:
result.stdout = POLICY
elif cmd[1:3] == ["read", "-field=role_id"]:
result.stdout = "role-id-value\n"
elif "-field=secret_id" in cmd:
result.stdout = "secret-id-value\n"
@ -136,7 +150,7 @@ def test_reuse_policy_does_not_rewrite_existing_policy(tmp_path) -> None:
build_approle_kv_lane(plan, spec)
bao_cmds = [c.args[0] for c in run.call_args_list]
assert not any(cmd[:2] == ["bao", "policy"] for cmd in bao_cmds)
assert not any(cmd[:3] == ["bao", "policy", "write"] for cmd in bao_cmds)
assert any(cmd[1:3] == ["write", "auth/approle/role/test-lane"] for cmd in bao_cmds)
@ -169,7 +183,7 @@ def test_bao_failure_raises_build_error(tmp_path) -> None:
fake_result = MagicMock(returncode=1, stderr="permission denied", stdout="")
with patch("ops_mason.executor.subprocess.run", return_value=fake_result):
with pytest.raises(BuildError, match="permission denied"):
with pytest.raises(BuildError, match="output suppressed"):
build_approle_kv_lane(plan, spec)
@ -177,6 +191,7 @@ def _kubernetes_spec(tmp_path) -> KubernetesKVSpec:
return KubernetesKVSpec(
policy_name="workload-kv-read-binky-qonto-api",
role_name="external-secrets-rapp-qonto",
policy_sha256=POLICY_HASH,
service_account_names=("external-secrets",),
service_account_namespaces=("external-secrets",),
audit_log_path=tmp_path / "audit.jsonl",
@ -194,7 +209,8 @@ def test_kubernetes_lane_refusal_never_calls_bao(tmp_path) -> None:
def test_kubernetes_lane_builds_exact_service_account_binding(tmp_path) -> None:
plan = _plan(tmp_path)
spec = _kubernetes_spec(tmp_path)
result = MagicMock(returncode=0, stderr="", stdout="")
plan = _plan(tmp_path, spec=spec)
result = MagicMock(returncode=0, stderr="", stdout=POLICY)
with (
patch("ops_mason.executor.subprocess.run", return_value=result) as run,
patch("ops_mason.executor.record_build") as audit,
@ -220,6 +236,7 @@ def test_kubernetes_lane_requires_nonempty_bindings(tmp_path) -> None:
spec = KubernetesKVSpec(
policy_name="policy",
role_name="role",
policy_sha256=POLICY_HASH,
service_account_names=(),
service_account_namespaces=("external-secrets",),
)
@ -227,3 +244,176 @@ def test_kubernetes_lane_requires_nonempty_bindings(tmp_path) -> None:
with pytest.raises(BuildRefused, match="explicit service account"):
build_kubernetes_kv_lane(plan, spec)
run.assert_not_called()
def _edit_plan(plan, edit):
text = plan.path.read_text().split("---", 2)
data = yaml.safe_load(text[1])
edit(data)
plan.path.write_text("---\n" + yaml.safe_dump(data) + "---" + text[2])
@pytest.mark.parametrize("field,value", [
("kv_path", "reins/another/openrouter"), ("policy_name", "other-policy"),
("approle_name", "other-role"), ("token_num_uses", 0),
("token_ttl", "10m"), ("token_max_ttl", "1h"), ("secret_id_ttl", "15m"),
("bao_bin", "another-bao"),
])
def test_changed_build_scope_refused_before_bao(tmp_path, field, value):
spec = _spec(tmp_path)
plan = _plan(tmp_path, spec=spec)
setattr(spec, field, value)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused, match="approved specification"):
build_approle_kv_lane(plan, spec)
run.assert_not_called()
assert not spec.delivery_dir.exists()
def test_destination_is_part_of_approval(tmp_path):
plan = _plan(tmp_path)
spec = _spec(tmp_path)
spec.delivery_dir = tmp_path / "elsewhere"
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused, match="approved specification"):
build_approle_kv_lane(plan, spec)
run.assert_not_called()
@pytest.mark.parametrize("edit", [
lambda d: d.update(status="reviewed"),
lambda d: d.update(approved_spec_sha256="0" * 64),
lambda d: d.pop("build_spec"),
lambda d: d.update(credential_type="openbao-kubernetes-kv"),
lambda d: d["build_spec"].update(token_num_uses=0),
])
def test_changed_or_legacy_plan_refused_even_with_previously_loaded_approval(tmp_path, edit):
plan = _plan(tmp_path)
_edit_plan(plan, edit)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused):
build_approle_kv_lane(plan, _spec(tmp_path))
run.assert_not_called()
@pytest.mark.parametrize("field,value", [
("kv_path", "reins/test/*"), ("kv_path", "reins/test/+"),
("kv_path", "reins/test/"), ("kv_path", "reins/../test"),
("kv_path", 'reins/test/" { capabilities = ["sudo"] }'),
("kv_path", "reins//test"), ("kv_path", "reins"),
("policy_name", "a,b"), ("approle_name", "../other"),
("kv_capabilities", ("read", "sudo")), ("kv_capabilities", ("create", "read")),
("token_num_uses", True), ("token_num_uses", -1),
("token_ttl", "0"), ("token_max_ttl", "1s"),
("delivery_dir", None), ("reuse_policy", "false"),
])
def test_unsafe_spec_is_rejected_before_any_command(tmp_path, field, value):
plan = _plan(tmp_path)
spec = _spec(tmp_path)
setattr(spec, field, value)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused):
build_approle_kv_lane(plan, spec)
run.assert_not_called()
@pytest.mark.parametrize("bindings", [("*",), ("a,b",), ("../a",), ("valid", "valid")])
def test_kubernetes_wildcard_or_ambiguous_bindings_refused(tmp_path, bindings):
spec = _kubernetes_spec(tmp_path)
plan = _plan(tmp_path, spec=spec)
spec.service_account_names = bindings
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused):
build_kubernetes_kv_lane(plan, spec)
run.assert_not_called()
def test_kubernetes_valid_but_unapproved_binding_refused(tmp_path):
spec = _kubernetes_spec(tmp_path)
plan = _plan(tmp_path, spec=spec)
spec.service_account_namespaces = ("other",)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused, match="approved specification"):
build_kubernetes_kv_lane(plan, spec)
run.assert_not_called()
@pytest.mark.parametrize("engine", ["approle", "kubernetes"])
def test_changed_reused_policy_refuses_before_mutation(tmp_path, engine):
if engine == "approle":
spec = _spec(tmp_path)
spec.reuse_policy, spec.reuse_policy_sha256 = True, POLICY_HASH
build = build_approle_kv_lane
else:
spec = _kubernetes_spec(tmp_path)
build = build_kubernetes_kv_lane
plan = _plan(tmp_path, spec=spec)
with patch("ops_mason.executor.subprocess.run", return_value=MagicMock(returncode=0, stdout=POLICY + "# changed")) as run:
with pytest.raises(BuildRefused, match="content pin"):
build(plan, spec)
assert len(run.call_args_list) == 1
assert run.call_args.args[0][1:3] == ["policy", "read"]
def test_credentials_are_private_before_first_bao_call_even_with_open_umask(tmp_path):
plan, spec = _plan(tmp_path), _spec(tmp_path)
def fake_run(*args, **kwargs):
assert stat.S_IMODE(spec.delivery_dir.stat().st_mode) == 0o700
for name in ("role_id", "secret_id"):
assert stat.S_IMODE((spec.delivery_dir / name).stat().st_mode) == 0o600
return MagicMock(returncode=0, stdout="synthetic-credential\n", stderr="")
previous = os.umask(0)
try:
with patch("ops_mason.executor.subprocess.run", side_effect=fake_run):
build_approle_kv_lane(plan, spec)
finally:
os.umask(previous)
assert "synthetic-credential" not in spec.audit_log_path.read_text()
@pytest.mark.parametrize("case", ["existing", "symlink-file", "symlink-directory", "symlink-parent", "public-directory", "hardlink"])
def test_unsafe_delivery_refuses_without_mutation_or_overwrite(tmp_path, case):
spec = _spec(tmp_path)
victim = tmp_path / "victim"
victim.write_text("leave untouched")
if case == "symlink-directory":
target = tmp_path / "target"
target.mkdir(mode=0o700)
spec.delivery_dir.symlink_to(target, target_is_directory=True)
elif case == "symlink-parent":
parent = tmp_path / "parent"
parent.symlink_to(tmp_path, target_is_directory=True)
spec.delivery_dir = parent / "delivery"
else:
spec.delivery_dir.mkdir(mode=0o700)
if case == "public-directory":
spec.delivery_dir.chmod(0o755)
elif case == "existing":
(spec.delivery_dir / "secret_id").write_text("existing-credential")
elif case == "hardlink":
os.link(victim, spec.delivery_dir / "secret_id")
else:
(spec.delivery_dir / "secret_id").symlink_to(victim)
plan = _plan(tmp_path, spec=spec)
with patch("ops_mason.executor.subprocess.run") as run:
with pytest.raises(BuildRefused):
build_approle_kv_lane(plan, spec)
run.assert_not_called()
assert victim.read_text() == "leave untouched"
if case == "existing":
assert (spec.delivery_dir / "secret_id").read_text() == "existing-credential"
@pytest.mark.parametrize("failure", ["exit", "timeout"])
def test_command_errors_never_echo_credentials(tmp_path, failure):
plan, spec = _plan(tmp_path), _spec(tmp_path)
marker = "synthetic-sensitive-output"
kwargs = ({"return_value": MagicMock(returncode=1, stdout=marker, stderr=marker)}
if failure == "exit" else
{"side_effect": subprocess.TimeoutExpired(["bao"], 30, output=marker, stderr=marker)})
with patch("ops_mason.executor.subprocess.run", **kwargs):
with pytest.raises(BuildError) as error:
build_approle_kv_lane(plan, spec)
assert marker not in str(error.value)
assert not spec.audit_log_path.exists()
assert stat.S_IMODE((spec.delivery_dir / "secret_id").stat().st_mode) == 0o600

View file

@ -49,3 +49,24 @@ def test_missing_required_field_raises(tmp_path) -> None:
path = _write(tmp_path, "id: p\n# status omitted")
with pytest.raises(PlanError, match="missing field"):
ConstructionPlan.load(path)
@pytest.mark.parametrize("frontmatter", [
"id: p\nstatus: reviewed\nstatus: approved",
"id: p\nstatus: approved\nbuild_spec:\n token_num_uses: 8\n token_num_uses: 0",
"- not-a-mapping",
])
def test_ambiguous_or_malformed_approval_document_refused(tmp_path, frontmatter):
with pytest.raises(PlanError):
ConstructionPlan.load(_write(tmp_path, frontmatter))
@pytest.mark.parametrize("approver", ['" "', "true", "[bernd]"])
def test_non_string_or_blank_approval_marker_is_not_approval(tmp_path, approver):
path = _write(tmp_path, f'id: p\nstatus: approved\napproved_by: {approver}\napproved_at: "2026-09-28"')
assert not ConstructionPlan.load(path).is_approved()
def test_triple_dash_inside_specification_is_not_frontmatter_delimiter(tmp_path):
path = _write(tmp_path, 'id: p\nstatus: draft\nbuild_spec:\n policy_name: "lane---example"')
assert ConstructionPlan.load(path).build_spec["policy_name"] == "lane---example"

View file

@ -130,6 +130,18 @@ approved (`test_refusal_never_calls_bao`); role_id/secret_id land as
`0600` files and never appear in a log line or exception message; the
policy HCL goes over stdin, never argv.
**Hardening follow-up (2026-09-28).** The existing build executor now binds
execution to a reviewed `build_spec` plus `approved_spec_sha256`, reloads the
approval before use, validates literal scope/token/binding inputs, and pins
existing policy contents. Credential files are exclusively created as 0600
inside caller-owned 0700 directories before issuance, with symlinks and
existing destinations refused. Command errors suppress sensitive output.
Historical build approvals were not rewritten and no live lane was changed.
The suite passes 108 tests, including refusal-before-mutation, policy drift,
permissive-umask, symlink/hardlink/overwrite and error-output regressions.
The source contract and recovery limitations are documented in
`docs/construction-plan-format.md`. This is maintenance of T04, not a new task.
```task
id: MASON-WP-0001-T04
status: done