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

@ -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)
)