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:
parent
ee1dc2b651
commit
2b83324c01
8 changed files with 622 additions and 84 deletions
27
SCOPE.md
27
SCOPE.md
|
|
@ -28,7 +28,7 @@ decision gate, in plain terms; (4) build, only after approval.
|
||||||
|
|
||||||
- Deciding *whether* access should exist — that's architecture/founder,
|
- Deciding *whether* access should exist — that's architecture/founder,
|
||||||
ops-mason builds what's already decided
|
ops-mason builds what's already decided
|
||||||
- Runtime authorization decisions — flex-auth
|
- Runtime authorization decisions — access-engine
|
||||||
- Identity/MFA — key-cape/Keycloak
|
- Identity/MFA — key-cape/Keycloak
|
||||||
- Routing consumers to lanes once built — ops-warden
|
- Routing consumers to lanes once built — ops-warden
|
||||||
- SSH certificate issuance — ops-warden
|
- SSH certificate issuance — ops-warden
|
||||||
|
|
@ -38,9 +38,28 @@ decision gate, in plain terms; (4) build, only after approval.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
Charter only (`INTENT.md`). `MASON-WP-0001` scopes the four-phase
|
The four-phase construction process, OpenBao AppRole/Kubernetes-auth builders,
|
||||||
pipeline and its first real exercise: the `rein-openweights` OpenBao
|
metadata inventory, and guarded Kubernetes-plane CLI are implemented and tested.
|
||||||
AppRole that `glas-harness/GLAS-WP-0002-T02` is blocked on. No code yet.
|
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
|
## Getting Oriented
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,93 @@ section does not exist until phase 4 has run.
|
||||||
See `plans/rein-openweights-openrouter-approle.md` once T01 is
|
See `plans/rein-openweights-openrouter-approle.md` once T01 is
|
||||||
implemented against this format — the first real plan, not a synthetic
|
implemented against this format — the first real plan, not a synthetic
|
||||||
one, per `MASON-WP-0001`'s own scoping.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ id: MASON-IN-0002
|
||||||
kind: intake
|
kind: intake
|
||||||
title: 'Declaration requested: state this repository''s layer in INTENT.md (security
|
title: 'Declaration requested: state this repository''s layer in INTENT.md (security
|
||||||
layer model §11)'
|
layer model §11)'
|
||||||
status: open
|
status: closed
|
||||||
|
outcome: absorbed
|
||||||
origin: cross-repo
|
origin: cross-repo
|
||||||
origin_ref: net-kingdom security-layer-model_v0.4 §11
|
origin_ref: net-kingdom security-layer-model_v0.4 §11
|
||||||
priority: low
|
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
|
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.'
|
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'
|
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.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,18 @@ printable form beyond confirmation that delivery happened.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import asdict, dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ops_mason.audit import record_build
|
from ops_mason.audit import record_build
|
||||||
from ops_mason.plan import ConstructionPlan
|
from ops_mason.plan import ConstructionPlan, PlanError
|
||||||
|
|
||||||
|
|
||||||
class BuildRefused(RuntimeError):
|
class BuildRefused(RuntimeError):
|
||||||
|
|
@ -56,6 +61,7 @@ class AppRoleKVSpec:
|
||||||
# When True the named policy already exists (and may grant more than one
|
# When True the named policy already exists (and may grant more than one
|
||||||
# KV path). Do not rewrite it — AppRole bind only.
|
# KV path). Do not rewrite it — AppRole bind only.
|
||||||
reuse_policy: bool = False
|
reuse_policy: bool = False
|
||||||
|
reuse_policy_sha256: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -64,21 +70,21 @@ class KubernetesKVSpec:
|
||||||
role_name: str
|
role_name: str
|
||||||
service_account_names: tuple[str, ...]
|
service_account_names: tuple[str, ...]
|
||||||
service_account_namespaces: tuple[str, ...]
|
service_account_namespaces: tuple[str, ...]
|
||||||
|
policy_sha256: str = ""
|
||||||
token_ttl: str = "15m"
|
token_ttl: str = "15m"
|
||||||
audit_log_path: Path | None = None
|
audit_log_path: Path | None = None
|
||||||
bao_bin: str = "bao"
|
bao_bin: str = "bao"
|
||||||
|
|
||||||
|
|
||||||
def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str:
|
def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str:
|
||||||
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[bao_bin, *args],
|
[bao_bin, *args], input=input_text, capture_output=True, text=True, timeout=30,
|
||||||
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:
|
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
|
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
|
OpenBao evaluates policy against the real `data/`-prefixed path, not
|
||||||
the one a caller might naively check capabilities against.
|
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("/")
|
mount, _, rest = kv_path.partition("/")
|
||||||
caps = ", ".join(f'"{c}"' for c in capabilities)
|
caps = ", ".join(f'"{c}"' for c in capabilities)
|
||||||
return (
|
return (
|
||||||
|
|
@ -101,19 +113,183 @@ 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]:
|
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.
|
"""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
|
Refuses unless plan.is_approved(). Returns object names only (no
|
||||||
secret material) and appends a metadata-only audit record.
|
secret material) and appends a metadata-only audit record.
|
||||||
"""
|
"""
|
||||||
if not plan.is_approved():
|
spec = replace(spec)
|
||||||
raise BuildRefused(
|
plan = _approved_build(plan, spec)
|
||||||
f"plan {plan.id!r} is not approved "
|
if spec.reuse_policy:
|
||||||
f"(status={plan.status!r}, approved_by={plan.approved_by!r}, "
|
_verify_policy(spec.bao_bin, spec.policy_name, spec.reuse_policy_sha256)
|
||||||
f"approved_at={plan.approved_at!r}) — refusing to build"
|
with _delivery_files(spec.delivery_dir) as files:
|
||||||
)
|
|
||||||
|
|
||||||
if not spec.reuse_policy:
|
if not spec.reuse_policy:
|
||||||
policy_hcl = _policy_hcl(spec.kv_path, spec.kv_capabilities)
|
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, ["policy", "write", spec.policy_name, "-"], input_text=policy_hcl)
|
||||||
|
|
@ -139,19 +315,12 @@ def build_approle_kv_lane(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[s
|
||||||
["write", "-field=secret_id", "-f", f"auth/approle/role/{spec.approle_name}/secret-id"],
|
["write", "-field=secret_id", "-f", f"auth/approle/role/{spec.approle_name}/secret-id"],
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
delivered_to = ""
|
_write_credential(files["role_id"], role_id)
|
||||||
if spec.delivery_dir is not None:
|
_write_credential(files["secret_id"], secret_id)
|
||||||
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)
|
delivered_to = str(spec.delivery_dir)
|
||||||
|
|
||||||
objects = {
|
objects = {
|
||||||
|
"approved_spec_sha256": plan.approved_spec_sha256,
|
||||||
"policy_name": spec.policy_name,
|
"policy_name": spec.policy_name,
|
||||||
"approle_name": spec.approle_name,
|
"approle_name": spec.approle_name,
|
||||||
"kv_path": spec.kv_path,
|
"kv_path": spec.kv_path,
|
||||||
|
|
@ -172,14 +341,9 @@ def build_kubernetes_kv_lane(
|
||||||
plan: ConstructionPlan, spec: KubernetesKVSpec
|
plan: ConstructionPlan, spec: KubernetesKVSpec
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Create a policy-bound Kubernetes auth role without handling secret values."""
|
"""Create a policy-bound Kubernetes auth role without handling secret values."""
|
||||||
if not plan.is_approved():
|
spec = replace(spec)
|
||||||
raise BuildRefused(
|
plan = _approved_build(plan, spec)
|
||||||
f"plan {plan.id!r} is not approved "
|
_verify_policy(spec.bao_bin, spec.policy_name, spec.policy_sha256)
|
||||||
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")
|
|
||||||
|
|
||||||
_run(
|
_run(
|
||||||
spec.bao_bin,
|
spec.bao_bin,
|
||||||
|
|
@ -193,6 +357,7 @@ def build_kubernetes_kv_lane(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
objects = {
|
objects = {
|
||||||
|
"approved_spec_sha256": plan.approved_spec_sha256,
|
||||||
"policy_name": spec.policy_name,
|
"policy_name": spec.policy_name,
|
||||||
"kubernetes_role_name": spec.role_name,
|
"kubernetes_role_name": spec.role_name,
|
||||||
"service_account_names": ",".join(spec.service_account_names),
|
"service_account_names": ",".join(spec.service_account_names),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,23 @@ class PlanError(RuntimeError):
|
||||||
pass
|
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
|
@dataclass
|
||||||
class ConstructionPlan:
|
class ConstructionPlan:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -29,15 +46,26 @@ class ConstructionPlan:
|
||||||
consumer_repo: str
|
consumer_repo: str
|
||||||
credential_type: str
|
credential_type: str
|
||||||
path: Path
|
path: Path
|
||||||
|
build_spec: dict[str, Any] | None = None
|
||||||
|
approved_spec_sha256: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: str | Path) -> ConstructionPlan:
|
def load(cls, path: str | Path) -> ConstructionPlan:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
parts = text.split("---", 2)
|
lines = text.splitlines()
|
||||||
if len(parts) < 3 or not text.startswith("---"):
|
if not lines or lines[0] != "---":
|
||||||
raise PlanError(f"{path}: missing YAML frontmatter")
|
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)
|
missing = {"id", "status"} - set(data)
|
||||||
if missing:
|
if missing:
|
||||||
raise PlanError(f"{path}: frontmatter missing field(s): {', '.join(sorted(missing))}")
|
raise PlanError(f"{path}: frontmatter missing field(s): {', '.join(sorted(missing))}")
|
||||||
|
|
@ -50,7 +78,12 @@ class ConstructionPlan:
|
||||||
consumer_repo=data.get("consumer_repo", ""),
|
consumer_repo=data.get("consumer_repo", ""),
|
||||||
credential_type=data.get("credential_type", ""),
|
credential_type=data.get("credential_type", ""),
|
||||||
path=path,
|
path=path,
|
||||||
|
build_spec=data.get("build_spec"),
|
||||||
|
approved_spec_sha256=data.get("approved_spec_sha256"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_approved(self) -> bool:
|
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)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import yaml
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -9,6 +15,8 @@ from ops_mason.executor import (
|
||||||
BuildRefused,
|
BuildRefused,
|
||||||
KubernetesKVSpec,
|
KubernetesKVSpec,
|
||||||
_policy_hcl,
|
_policy_hcl,
|
||||||
|
build_spec_document,
|
||||||
|
build_spec_digest,
|
||||||
build_approle_kv_lane,
|
build_approle_kv_lane,
|
||||||
build_kubernetes_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
|
from ops_mason.plan import ConstructionPlan
|
||||||
|
|
||||||
|
|
||||||
def _plan(tmp_path, *, status="approved", approved_by="bernd", approved_at="2026-07-27"):
|
POLICY = 'path "reins/data/test/openrouter" { capabilities = ["read"] }\n'
|
||||||
text = (
|
POLICY_HASH = hashlib.sha256(POLICY.encode()).hexdigest()
|
||||||
"---\n"
|
|
||||||
"id: test-lane\n"
|
|
||||||
f"status: {status}\n"
|
def _plan(tmp_path, *, status="approved", approved_by="bernd", approved_at="2026-07-27", spec=None):
|
||||||
+ (f'approved_by: "{approved_by}"\n' if approved_by else "approved_by: null\n")
|
document = build_spec_document(spec or _spec(tmp_path))
|
||||||
+ (f'approved_at: "{approved_at}"\n' if approved_at else "approved_at: null\n")
|
fields = dict(id="test-lane", status=status, approved_by=approved_by,
|
||||||
+ "---\n# Plan\n"
|
approved_at=approved_at, credential_type=document["engine"],
|
||||||
)
|
build_spec=document, approved_spec_sha256=build_spec_digest(document))
|
||||||
p = tmp_path / "plan.md"
|
p = tmp_path / "plan.md"
|
||||||
p.write_text(text)
|
p.write_text("---\n" + yaml.safe_dump(fields) + "---\n# Plan\n")
|
||||||
return ConstructionPlan.load(str(p))
|
return ConstructionPlan.load(p)
|
||||||
|
|
||||||
|
|
||||||
def _spec(tmp_path) -> AppRoleKVSpec:
|
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):
|
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
|
||||||
result = MagicMock(returncode=0, stderr="")
|
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"
|
result.stdout = "role-id-value\n"
|
||||||
elif "-field=secret_id" in cmd:
|
elif "-field=secret_id" in cmd:
|
||||||
result.stdout = "secret-id-value\n"
|
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)
|
plan = _plan(tmp_path)
|
||||||
spec = _spec(tmp_path)
|
spec = _spec(tmp_path)
|
||||||
spec.reuse_policy = True
|
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):
|
def fake_run(cmd, input=None, capture_output=True, text=True, timeout=30):
|
||||||
result = MagicMock(returncode=0, stderr="")
|
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"
|
result.stdout = "role-id-value\n"
|
||||||
elif "-field=secret_id" in cmd:
|
elif "-field=secret_id" in cmd:
|
||||||
result.stdout = "secret-id-value\n"
|
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)
|
build_approle_kv_lane(plan, spec)
|
||||||
|
|
||||||
bao_cmds = [c.args[0] for c in run.call_args_list]
|
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)
|
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="")
|
fake_result = MagicMock(returncode=1, stderr="permission denied", stdout="")
|
||||||
with patch("ops_mason.executor.subprocess.run", return_value=fake_result):
|
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)
|
build_approle_kv_lane(plan, spec)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -177,6 +191,7 @@ def _kubernetes_spec(tmp_path) -> KubernetesKVSpec:
|
||||||
return KubernetesKVSpec(
|
return KubernetesKVSpec(
|
||||||
policy_name="workload-kv-read-binky-qonto-api",
|
policy_name="workload-kv-read-binky-qonto-api",
|
||||||
role_name="external-secrets-rapp-qonto",
|
role_name="external-secrets-rapp-qonto",
|
||||||
|
policy_sha256=POLICY_HASH,
|
||||||
service_account_names=("external-secrets",),
|
service_account_names=("external-secrets",),
|
||||||
service_account_namespaces=("external-secrets",),
|
service_account_namespaces=("external-secrets",),
|
||||||
audit_log_path=tmp_path / "audit.jsonl",
|
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:
|
def test_kubernetes_lane_builds_exact_service_account_binding(tmp_path) -> None:
|
||||||
plan = _plan(tmp_path)
|
plan = _plan(tmp_path)
|
||||||
spec = _kubernetes_spec(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 (
|
with (
|
||||||
patch("ops_mason.executor.subprocess.run", return_value=result) as run,
|
patch("ops_mason.executor.subprocess.run", return_value=result) as run,
|
||||||
patch("ops_mason.executor.record_build") as audit,
|
patch("ops_mason.executor.record_build") as audit,
|
||||||
|
|
@ -220,6 +236,7 @@ def test_kubernetes_lane_requires_nonempty_bindings(tmp_path) -> None:
|
||||||
spec = KubernetesKVSpec(
|
spec = KubernetesKVSpec(
|
||||||
policy_name="policy",
|
policy_name="policy",
|
||||||
role_name="role",
|
role_name="role",
|
||||||
|
policy_sha256=POLICY_HASH,
|
||||||
service_account_names=(),
|
service_account_names=(),
|
||||||
service_account_namespaces=("external-secrets",),
|
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"):
|
with pytest.raises(BuildRefused, match="explicit service account"):
|
||||||
build_kubernetes_kv_lane(plan, spec)
|
build_kubernetes_kv_lane(plan, spec)
|
||||||
run.assert_not_called()
|
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
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,24 @@ def test_missing_required_field_raises(tmp_path) -> None:
|
||||||
path = _write(tmp_path, "id: p\n# status omitted")
|
path = _write(tmp_path, "id: p\n# status omitted")
|
||||||
with pytest.raises(PlanError, match="missing field"):
|
with pytest.raises(PlanError, match="missing field"):
|
||||||
ConstructionPlan.load(path)
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
`0600` files and never appear in a log line or exception message; the
|
||||||
policy HCL goes over stdin, never argv.
|
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
|
```task
|
||||||
id: MASON-WP-0001-T04
|
id: MASON-WP-0001-T04
|
||||||
status: done
|
status: done
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue