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
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue