feat: admit existing OpenBao catalog lanes
This commit is contained in:
parent
9d383442c8
commit
784be978bf
29 changed files with 1490 additions and 79 deletions
|
|
@ -37,38 +37,56 @@ def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str
|
|||
|
||||
# 1. KV mount. Auth-capability lanes grant operational access on an existing
|
||||
# mount (for example ssh/sign/<role>) and never create or store KV values.
|
||||
if entry.stores_kv_value():
|
||||
if entry.manages_mount:
|
||||
if client.kv_mount_exists(entry.mount):
|
||||
skipped.append(f"kv-mount {entry.mount} (already present)")
|
||||
else:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
applied.append(f"kv-mount {entry.mount}")
|
||||
elif entry.stores_kv_value():
|
||||
skipped.append(f"kv-mount {entry.mount} (externally managed; no mutation)")
|
||||
else:
|
||||
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
|
||||
|
||||
# 2. Consumer ACL policy (write only if changed).
|
||||
current = client.read_policy(plan.policy_name)
|
||||
if current and _normalize(current) == _normalize(plan.policy_hcl):
|
||||
skipped.append(f"policy {plan.policy_name} (unchanged)")
|
||||
if entry.manages_delivery_auth:
|
||||
current = client.read_policy(plan.policy_name)
|
||||
if current and _normalize(current) == _normalize(plan.policy_hcl):
|
||||
skipped.append(f"policy {plan.policy_name} (unchanged)")
|
||||
else:
|
||||
client.write_policy(plan.policy_name, plan.policy_hcl)
|
||||
applied.append(f"policy {plan.policy_name}")
|
||||
else:
|
||||
client.write_policy(plan.policy_name, plan.policy_hcl)
|
||||
applied.append(f"policy {plan.policy_name}")
|
||||
skipped.append(f"policy {plan.policy_name} (externally managed; no mutation)")
|
||||
|
||||
# 3. Consumer approle bound to that policy.
|
||||
client.ensure_approle_enabled()
|
||||
if entry.kind == "auth-capability":
|
||||
client.write_approle(
|
||||
plan.role_name,
|
||||
[plan.policy_name],
|
||||
ttl=entry.token_ttl,
|
||||
max_ttl=entry.token_max_ttl,
|
||||
secret_id_ttl=entry.secret_id_ttl,
|
||||
secret_id_num_uses=entry.secret_id_num_uses,
|
||||
token_num_uses=entry.token_num_uses,
|
||||
)
|
||||
if entry.manages_delivery_auth:
|
||||
client.ensure_approle_enabled()
|
||||
if entry.kind == "auth-capability":
|
||||
client.write_approle(
|
||||
plan.role_name,
|
||||
[plan.policy_name],
|
||||
ttl=entry.token_ttl,
|
||||
max_ttl=entry.token_max_ttl,
|
||||
secret_id_ttl=entry.secret_id_ttl,
|
||||
secret_id_num_uses=entry.secret_id_num_uses,
|
||||
token_num_uses=entry.token_num_uses,
|
||||
)
|
||||
else:
|
||||
client.write_approle(
|
||||
plan.role_name,
|
||||
[plan.policy_name],
|
||||
ttl=entry.delivery_token_ttl or ttl,
|
||||
max_ttl=entry.delivery_token_max_ttl,
|
||||
secret_id_ttl=entry.delivery_secret_id_ttl,
|
||||
secret_id_num_uses=entry.delivery_secret_id_num_uses,
|
||||
token_num_uses=entry.delivery_token_num_uses,
|
||||
)
|
||||
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
|
||||
elif entry.has_delivery_auth:
|
||||
skipped.append(f"approle {plan.role_name} (externally managed; no mutation)")
|
||||
else:
|
||||
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
|
||||
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
|
||||
skipped.append("approle (no native delivery auth declared)")
|
||||
|
||||
return ApplyResult(applied=applied, skipped=skipped)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ VALID_DELIVERY_MODES = (
|
|||
"approle-login",
|
||||
)
|
||||
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
|
||||
VALID_MOUNT_MANAGEMENT = ("engine", "existing")
|
||||
VALID_DELIVERY_AUTH_MANAGEMENT = ("engine", "existing", "none")
|
||||
VALID_DELIVERY_AUTH_METHODS = ("approle", "none")
|
||||
VALID_RISK_CLASSIFICATIONS = ("standard", "high")
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"id",
|
||||
|
|
@ -66,6 +70,10 @@ class CatalogEntry:
|
|||
rotation: dict[str, Any]
|
||||
deactivation: dict[str, Any]
|
||||
audit: dict[str, Any]
|
||||
mount_management: str = "engine"
|
||||
delivery_auth: dict[str, Any] = field(default_factory=dict)
|
||||
workload_delivery: list[dict[str, Any]] = field(default_factory=list)
|
||||
risk: dict[str, Any] = field(default_factory=dict)
|
||||
delivery_config: dict[str, Any] = field(default_factory=dict)
|
||||
auth_capability: dict[str, Any] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
|
|
@ -95,14 +103,62 @@ class CatalogEntry:
|
|||
def policy_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("policy_name") or self.id
|
||||
if self.delivery_auth.get("policy_name"):
|
||||
return str(self.delivery_auth["policy_name"])
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def role_name(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return self.auth_capability.get("role_name") or self.id
|
||||
if self.delivery_auth.get("role_name"):
|
||||
return str(self.delivery_auth["role_name"])
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def manages_mount(self) -> bool:
|
||||
return self.kind == "kv" and self.mount_management == "engine"
|
||||
|
||||
@property
|
||||
def delivery_auth_method(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return "approle"
|
||||
return str(self.delivery_auth.get("method", "approle"))
|
||||
|
||||
@property
|
||||
def delivery_auth_management(self) -> str:
|
||||
if self.kind == "auth-capability":
|
||||
return "engine"
|
||||
return str(self.delivery_auth.get("management", "engine"))
|
||||
|
||||
@property
|
||||
def manages_delivery_auth(self) -> bool:
|
||||
return self.delivery_auth_management == "engine"
|
||||
|
||||
@property
|
||||
def has_delivery_auth(self) -> bool:
|
||||
return self.delivery_auth_management != "none"
|
||||
|
||||
@property
|
||||
def delivery_token_ttl(self) -> str:
|
||||
return str(self.delivery_auth.get("token_ttl", "30m"))
|
||||
|
||||
@property
|
||||
def delivery_token_max_ttl(self) -> str:
|
||||
return str(self.delivery_auth.get("token_max_ttl", self.delivery_token_ttl))
|
||||
|
||||
@property
|
||||
def delivery_secret_id_ttl(self) -> str:
|
||||
return str(self.delivery_auth.get("secret_id_ttl", self.delivery_token_ttl))
|
||||
|
||||
@property
|
||||
def delivery_secret_id_num_uses(self) -> int:
|
||||
return int(self.delivery_auth.get("secret_id_num_uses", 0))
|
||||
|
||||
@property
|
||||
def delivery_token_num_uses(self) -> int:
|
||||
return int(self.delivery_auth.get("token_num_uses", 0))
|
||||
|
||||
@property
|
||||
def auth_allowed_paths(self) -> dict[str, list[str]]:
|
||||
"""Allowed OpenBao paths/capabilities for an auth-capability lane."""
|
||||
|
|
@ -156,8 +212,16 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
)
|
||||
if data["kind"] == "auth-capability":
|
||||
data.setdefault("fields", [])
|
||||
data.setdefault("mount_management", "existing")
|
||||
data.setdefault("delivery_auth", {})
|
||||
data.setdefault("workload_delivery", [])
|
||||
else:
|
||||
data.setdefault("auth_capability", {})
|
||||
data.setdefault("mount_management", "engine")
|
||||
data.setdefault(
|
||||
"delivery_auth", {"method": "approle", "management": "engine"}
|
||||
)
|
||||
data.setdefault("workload_delivery", [])
|
||||
|
||||
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
|
||||
if data["kind"] == "kv" and not data.get("fields"):
|
||||
|
|
@ -195,6 +259,75 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"{source}: each consumer needs at least 'name' and 'auth'"
|
||||
)
|
||||
|
||||
if data["mount_management"] not in VALID_MOUNT_MANAGEMENT:
|
||||
raise CatalogError(
|
||||
f"{source}: mount_management '{data['mount_management']}' invalid; "
|
||||
f"must be one of {VALID_MOUNT_MANAGEMENT}"
|
||||
)
|
||||
|
||||
workload_delivery = data["workload_delivery"]
|
||||
if not isinstance(workload_delivery, list):
|
||||
raise CatalogError(f"{source}: workload_delivery must be a list")
|
||||
for item in workload_delivery:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("mode"), str)
|
||||
or not item["mode"].strip()
|
||||
or not isinstance(item.get("owner"), str)
|
||||
or not item["owner"].strip()
|
||||
):
|
||||
raise CatalogError(
|
||||
f"{source}: each workload_delivery item needs non-empty mode and owner"
|
||||
)
|
||||
|
||||
if data["kind"] == "kv":
|
||||
delivery_auth = data["delivery_auth"]
|
||||
if not isinstance(delivery_auth, dict):
|
||||
raise CatalogError(f"{source}: delivery_auth must be a mapping")
|
||||
auth_method = delivery_auth.get("method", "approle")
|
||||
auth_management = delivery_auth.get("management", "engine")
|
||||
if auth_method not in VALID_DELIVERY_AUTH_METHODS:
|
||||
raise CatalogError(
|
||||
f"{source}: delivery_auth.method '{auth_method}' invalid; "
|
||||
f"must be one of {VALID_DELIVERY_AUTH_METHODS}"
|
||||
)
|
||||
if auth_management not in VALID_DELIVERY_AUTH_MANAGEMENT:
|
||||
raise CatalogError(
|
||||
f"{source}: delivery_auth.management '{auth_management}' invalid; "
|
||||
f"must be one of {VALID_DELIVERY_AUTH_MANAGEMENT}"
|
||||
)
|
||||
if auth_management == "none" and auth_method != "none":
|
||||
raise CatalogError(
|
||||
f"{source}: delivery_auth.management none requires method none"
|
||||
)
|
||||
if auth_management != "none" and auth_method != "approle":
|
||||
raise CatalogError(
|
||||
f"{source}: current native delivery auth must use approle"
|
||||
)
|
||||
if auth_management == "existing" and not delivery_auth.get("role_name"):
|
||||
raise CatalogError(
|
||||
f"{source}: existing delivery auth requires delivery_auth.role_name"
|
||||
)
|
||||
for ttl_field in ("token_ttl", "token_max_ttl", "secret_id_ttl"):
|
||||
if ttl_field in delivery_auth and (
|
||||
not isinstance(delivery_auth[ttl_field], str)
|
||||
or not delivery_auth[ttl_field].strip()
|
||||
):
|
||||
raise CatalogError(
|
||||
f"{source}: delivery_auth.{ttl_field} must be a non-empty string"
|
||||
)
|
||||
for uses_field in ("secret_id_num_uses", "token_num_uses"):
|
||||
uses = delivery_auth.get(uses_field, 0)
|
||||
if isinstance(uses, bool) or not isinstance(uses, int) or uses < 0:
|
||||
raise CatalogError(
|
||||
f"{source}: delivery_auth.{uses_field} must be a non-negative integer"
|
||||
)
|
||||
native_modes = {"exec-env", "exec-file", "npm-config", "read-check", "wrapped"}
|
||||
if native_modes.intersection(modes) and auth_management == "none":
|
||||
raise CatalogError(
|
||||
f"{source}: native delivery/verification modes require delivery_auth"
|
||||
)
|
||||
|
||||
approval = data["approval"]
|
||||
if not isinstance(approval, dict) or "model" not in approval:
|
||||
raise CatalogError(f"{source}: approval must include a 'model'")
|
||||
|
|
@ -204,6 +337,27 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"allowed {VALID_APPROVAL_MODELS}"
|
||||
)
|
||||
|
||||
risk = data.get("risk", {})
|
||||
if not isinstance(risk, dict):
|
||||
raise CatalogError(f"{source}: risk must be a mapping")
|
||||
classification = risk.get("classification", "standard")
|
||||
if classification not in VALID_RISK_CLASSIFICATIONS:
|
||||
raise CatalogError(
|
||||
f"{source}: risk.classification '{classification}' invalid; "
|
||||
f"must be one of {VALID_RISK_CLASSIFICATIONS}"
|
||||
)
|
||||
if classification == "high":
|
||||
if approval["model"] == "bootstrap-only":
|
||||
raise CatalogError(
|
||||
f"{source}: high-risk lanes cannot use bootstrap-only approval"
|
||||
)
|
||||
for lifecycle_name in ("rotation", "deactivation"):
|
||||
lifecycle = data[lifecycle_name]
|
||||
if not isinstance(lifecycle, dict) or not lifecycle.get("owner"):
|
||||
raise CatalogError(
|
||||
f"{source}: high-risk lanes require {lifecycle_name}.owner"
|
||||
)
|
||||
|
||||
# npm-config delivery must declare WHERE it publishes (registry + scope), so
|
||||
# the registry is catalog data, never hardcoded in the engine.
|
||||
if "npm-config" in modes:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ def _writer(cfg: Config) -> EvidenceWriter:
|
|||
return EvidenceWriter(evidence_dir=cfg.evidence_dir, hub_url=cfg.hub_url, topic_id=cfg.topic_id)
|
||||
|
||||
|
||||
def _require_lane_approval(cfg: Config, entry):
|
||||
"""Resolve and enforce the lane approval for a privileged live action."""
|
||||
if not entry.approval_required():
|
||||
return None
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url,
|
||||
repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
return decision
|
||||
|
||||
|
||||
# -- command handlers ------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -89,13 +102,23 @@ def cmd_catalog_show(cfg: Config, args) -> int:
|
|||
print(f"openbao: mount={e.mount} allowed={sorted(e.auth_allowed_paths)}")
|
||||
print(f"approle: {e.role_name} policy={e.policy_name}")
|
||||
else:
|
||||
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
|
||||
print(
|
||||
f"openbao: {e.mount}/{e.path} fields={e.fields} "
|
||||
f"mount_management={e.mount_management}"
|
||||
)
|
||||
print(
|
||||
f"delivery auth: {e.delivery_auth_method}/"
|
||||
f"{e.delivery_auth_management} role={e.role_name if e.has_delivery_auth else '-'}"
|
||||
)
|
||||
print(f"workload: {e.workload_delivery}")
|
||||
print(f"consumers: {[c['name'] for c in e.consumers]}")
|
||||
print(f"delivery: {e.delivery_modes}")
|
||||
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
|
||||
print(f"verification: {e.verification}")
|
||||
print(f"rotation: {e.rotation}")
|
||||
print(f"deactivation: {e.deactivation}")
|
||||
if e.risk:
|
||||
print(f"risk: {e.risk}")
|
||||
print(f"description: {e.description.strip()}")
|
||||
return 0
|
||||
|
||||
|
|
@ -172,6 +195,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
if args.stage != entry.stage:
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
|
||||
decision = _require_lane_approval(cfg, entry)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
if args.generate:
|
||||
|
|
@ -182,12 +206,13 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
mode = "from-file"
|
||||
print(f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed")
|
||||
_writer(cfg).record("provision", result=mode, catalog_id=entry.id, stage=entry.stage,
|
||||
detail={"field": f})
|
||||
decision_id=decision.id if decision else "", detail={"field": f})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(cfg: Config, args) -> int:
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
decision = _require_lane_approval(cfg, entry)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
if entry.stores_kv_value() and not field:
|
||||
|
|
@ -202,7 +227,8 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
if not r.passed:
|
||||
rc = 7
|
||||
_writer(cfg).record("verify", result=f"{r.check}:{'pass' if r.passed else 'fail'}",
|
||||
catalog_id=entry.id, stage=entry.stage, detail=r.detail)
|
||||
catalog_id=entry.id, stage=entry.stage,
|
||||
decision_id=decision.id if decision else "", detail=r.detail)
|
||||
return rc
|
||||
|
||||
|
||||
|
|
@ -215,13 +241,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
|
||||
if entry.kind != "auth-capability":
|
||||
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
|
||||
decision = None
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
decision = _require_lane_approval(cfg, entry)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
result = write_approle_handoff(
|
||||
client,
|
||||
|
|
@ -257,12 +277,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||
# require approval + readiness before running.
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
decision = _require_lane_approval(cfg, entry)
|
||||
if not args.command:
|
||||
from secrets_engine.errors import DeliveryError
|
||||
raise DeliveryError("no command after '--'")
|
||||
|
|
@ -270,9 +285,11 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
w = _writer(cfg)
|
||||
w.record("exec", result="attempt", catalog_id=entry.id, stage=entry.stage,
|
||||
decision_id=decision.id if decision else "",
|
||||
detail={"command": args.command[0], "mode": args.mode})
|
||||
rc = exec_with_secret(client, entry, field, args.command, mode=args.mode)
|
||||
w.record("exec", result=f"exit-{rc}", catalog_id=entry.id, stage=entry.stage,
|
||||
decision_id=decision.id if decision else "",
|
||||
detail={"command": args.command[0]})
|
||||
return rc
|
||||
|
||||
|
|
@ -322,6 +339,7 @@ def cmd_route(cfg: Config, args) -> int:
|
|||
|
||||
def cmd_revoke(cfg: Config, args) -> int:
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
decision = None if args.dry_run else _require_lane_approval(cfg, entry)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
if entry.kind == "auth-capability":
|
||||
if args.dry_run:
|
||||
|
|
@ -336,7 +354,10 @@ def cmd_revoke(cfg: Config, args) -> int:
|
|||
f"revoked lane '{entry.id}': deleted approle {entry.role_name} "
|
||||
f"and policy {entry.policy_name}"
|
||||
)
|
||||
_writer(cfg).record("revoke", result="auth-capability-deactivated", catalog_id=entry.id, stage=entry.stage)
|
||||
_writer(cfg).record(
|
||||
"revoke", result="auth-capability-deactivated", catalog_id=entry.id,
|
||||
stage=entry.stage, decision_id=decision.id if decision else ""
|
||||
)
|
||||
return 0
|
||||
if args.dry_run:
|
||||
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
|
||||
|
|
@ -344,7 +365,10 @@ def cmd_revoke(cfg: Config, args) -> int:
|
|||
return 0
|
||||
client.kv_delete_metadata(entry.mount, entry.path)
|
||||
print(f"revoked lane '{entry.id}': KV metadata deleted at {entry.mount}/{entry.path}")
|
||||
_writer(cfg).record("revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage)
|
||||
_writer(cfg).record(
|
||||
"revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage,
|
||||
decision_id=decision.id if decision else ""
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ def resolve_npm_token_env(entry: CatalogEntry, *, policy_dir=None) -> str:
|
|||
|
||||
def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
|
||||
"""Read the field value via an approle-scoped token. Held in memory only."""
|
||||
if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth:
|
||||
raise DeliveryError(
|
||||
f"lane '{entry.id}' has no AppRole delivery auth for native exec"
|
||||
)
|
||||
try:
|
||||
token = client.approle_login_token(entry.role_name)
|
||||
except Exception as e:
|
||||
|
|
@ -126,6 +130,10 @@ def exec_with_secret(
|
|||
"""
|
||||
if not command:
|
||||
raise DeliveryError("no command given to exec")
|
||||
if field not in entry.fields:
|
||||
raise DeliveryError(
|
||||
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
|
||||
)
|
||||
|
||||
declared = set(entry.delivery_modes)
|
||||
if mode == "auto":
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
|||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.safe_paths import containing_git_worktree
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -27,12 +28,12 @@ class HandoffResult:
|
|||
|
||||
def _assert_outside_git_worktree(path: Path) -> Path:
|
||||
resolved = path.expanduser().resolve()
|
||||
for parent in (resolved.parent, *resolved.parent.parents):
|
||||
if (parent / ".git").exists():
|
||||
raise ProvisioningError(
|
||||
f"handoff file {resolved} is inside a Git worktree ({parent}); "
|
||||
"keep AppRole material outside repos"
|
||||
)
|
||||
worktree = containing_git_worktree(resolved)
|
||||
if worktree is not None:
|
||||
raise ProvisioningError(
|
||||
f"handoff file {resolved} is inside a Git worktree ({worktree}); "
|
||||
"keep AppRole material outside repos"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
|
||||
from secrets_engine.errors import BackendError, ProvisioningError
|
||||
from secrets_engine.safe_paths import containing_git_worktree
|
||||
|
||||
|
||||
def _check_token_file(path: Path) -> str:
|
||||
|
|
@ -36,12 +37,12 @@ def _check_token_file(path: Path) -> str:
|
|||
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
|
||||
)
|
||||
# Refuse a token file living inside a Git worktree.
|
||||
for parent in path.resolve().parents:
|
||||
if (parent / ".git").exists():
|
||||
raise ProvisioningError(
|
||||
f"bootstrap token file {path} is inside a Git worktree ({parent}); "
|
||||
"store it outside any repo"
|
||||
)
|
||||
worktree = containing_git_worktree(path)
|
||||
if worktree is not None:
|
||||
raise ProvisioningError(
|
||||
f"bootstrap token file {path} is inside a Git worktree ({worktree}); "
|
||||
"store it outside any repo"
|
||||
)
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
if not token:
|
||||
raise ProvisioningError(f"bootstrap token file {path} is empty")
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from secrets_engine.roles import (
|
|||
|
||||
@dataclass
|
||||
class PlanAction:
|
||||
kind: str # "kv-mount" | "policy" | "approle"
|
||||
kind: str # mutation or non-mutating check/preview action
|
||||
target: str # human-readable target
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
|
@ -87,19 +87,60 @@ def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Pla
|
|||
assert_path_in_stage(entry) # path must be in-stage, no wildcards
|
||||
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
|
||||
|
||||
actions = [
|
||||
PlanAction("kv-mount", entry.mount, {"type": "kv-v2"}),
|
||||
PlanAction(
|
||||
"policy",
|
||||
policy_name,
|
||||
{"paths": f"{entry.mount}/data/{entry.path}"},
|
||||
),
|
||||
PlanAction(
|
||||
"approle",
|
||||
entry.role_name,
|
||||
{"token_policies": policy_name, "auth": "approle"},
|
||||
),
|
||||
]
|
||||
mount_action = (
|
||||
PlanAction("kv-mount", entry.mount, {"type": "kv-v2", "management": "engine"})
|
||||
if entry.manages_mount
|
||||
else PlanAction(
|
||||
"kv-mount-check",
|
||||
entry.mount,
|
||||
{"type": "kv-v2", "management": "existing", "mutation": "none"},
|
||||
)
|
||||
)
|
||||
actions = [mount_action]
|
||||
if entry.manages_delivery_auth:
|
||||
actions.extend(
|
||||
[
|
||||
PlanAction(
|
||||
"policy",
|
||||
policy_name,
|
||||
{"paths": f"{entry.mount}/data/{entry.path}"},
|
||||
),
|
||||
PlanAction(
|
||||
"approle",
|
||||
entry.role_name,
|
||||
{
|
||||
"token_policies": policy_name,
|
||||
"auth": "approle",
|
||||
"token_ttl": entry.delivery_token_ttl,
|
||||
"token_max_ttl": entry.delivery_token_max_ttl,
|
||||
"token_num_uses": entry.delivery_token_num_uses,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
elif entry.has_delivery_auth:
|
||||
actions.extend(
|
||||
[
|
||||
PlanAction(
|
||||
"policy-check",
|
||||
policy_name,
|
||||
{"paths": f"{entry.mount}/data/{entry.path}", "mutation": "none"},
|
||||
),
|
||||
PlanAction(
|
||||
"approle-check",
|
||||
entry.role_name,
|
||||
{"auth": "approle", "management": "existing", "mutation": "none"},
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
actions.append(
|
||||
PlanAction(
|
||||
"policy-preview",
|
||||
policy_name,
|
||||
{"paths": f"{entry.mount}/data/{entry.path}", "mutation": "none"},
|
||||
)
|
||||
)
|
||||
return Plan(
|
||||
catalog_id=entry.id,
|
||||
stage=stage,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from pathlib import Path
|
|||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.errors import ProvisioningError
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.safe_paths import containing_git_worktree
|
||||
|
||||
|
||||
def _read_value_file(path: Path) -> str:
|
||||
|
|
@ -29,12 +30,12 @@ def _read_value_file(path: Path) -> str:
|
|||
f"value file {path} is group/other-accessible "
|
||||
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
|
||||
)
|
||||
for parent in path.resolve().parents:
|
||||
if (parent / ".git").exists():
|
||||
raise ProvisioningError(
|
||||
f"value file {path} is inside a Git worktree ({parent}); "
|
||||
"keep secret material outside repos"
|
||||
)
|
||||
worktree = containing_git_worktree(path)
|
||||
if worktree is not None:
|
||||
raise ProvisioningError(
|
||||
f"value file {path} is inside a Git worktree ({worktree}); "
|
||||
"keep secret material outside repos"
|
||||
)
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if not value:
|
||||
raise ProvisioningError(f"value file {path} is empty")
|
||||
|
|
@ -54,7 +55,8 @@ def provision_from_file(
|
|||
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
|
||||
)
|
||||
value = _read_value_file(Path(file_path))
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
if entry.manages_mount:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
client.kv_put(entry.mount, entry.path, field, value)
|
||||
del value
|
||||
return field
|
||||
|
|
@ -77,7 +79,8 @@ def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str)
|
|||
)
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
value = "test-" + "".join(_secrets.choice(alphabet) for _ in range(32))
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
if entry.manages_mount:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
client.kv_put(entry.mount, entry.path, field, value)
|
||||
del value
|
||||
return field
|
||||
|
|
|
|||
|
|
@ -60,9 +60,10 @@ def route_lane(
|
|||
metadata_applied = False
|
||||
value_present = False
|
||||
if client is not None and client.is_reachable():
|
||||
policy_applied = client.read_policy(entry.policy_name) is not None
|
||||
role_applied = client.approle_exists(entry.role_name)
|
||||
metadata_applied = policy_applied and role_applied
|
||||
if entry.has_delivery_auth:
|
||||
policy_applied = client.read_policy(entry.policy_name) is not None
|
||||
role_applied = client.approle_exists(entry.role_name)
|
||||
metadata_applied = policy_applied and role_applied
|
||||
if entry.stores_kv_value():
|
||||
# Presence check uses the engine's own token; reports boolean only.
|
||||
field = entry.fields[0] if entry.fields else ""
|
||||
|
|
@ -80,8 +81,17 @@ def route_lane(
|
|||
missing = f"approved decision for '{decision_ref}'"
|
||||
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
|
||||
elif not metadata_applied:
|
||||
missing = "OpenBao policy/role apply"
|
||||
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
|
||||
if entry.kind == "kv" and entry.delivery_auth_management == "existing":
|
||||
missing = "externally managed OpenBao policy/AppRole readiness"
|
||||
next_command = (
|
||||
f"secrets-engine verify {entry.id} --positive --negative"
|
||||
)
|
||||
elif entry.kind == "kv" and not entry.has_delivery_auth:
|
||||
missing = "native delivery auth declaration"
|
||||
next_command = f"secrets-engine plan {decision_ref or entry.id} --stage {entry.stage}"
|
||||
else:
|
||||
missing = "OpenBao policy/role apply"
|
||||
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
|
||||
elif entry.kind == "auth-capability":
|
||||
missing = ""
|
||||
next_command = (
|
||||
|
|
@ -96,7 +106,12 @@ def route_lane(
|
|||
)
|
||||
else:
|
||||
missing = ""
|
||||
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
|
||||
if {"exec-env", "npm-config"}.intersection(entry.delivery_modes):
|
||||
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
|
||||
else:
|
||||
next_command = (
|
||||
f"secrets-engine verify {entry.id} --positive --negative"
|
||||
)
|
||||
|
||||
return RouteResult(
|
||||
catalog_id=entry.id,
|
||||
|
|
|
|||
20
src/secrets_engine/safe_paths.py
Normal file
20
src/secrets_engine/safe_paths.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Filesystem checks shared by secret provisioning and handoff paths."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def containing_git_worktree(path: Path) -> Path | None:
|
||||
"""Return the nearest enclosing Git worktree, if one is identifiable.
|
||||
|
||||
A real worktree has either a ``.git`` file (linked worktrees/submodules) or
|
||||
a ``.git`` directory containing ``HEAD``. Merely finding an empty directory
|
||||
named ``.git`` is not enough; sandbox and test environments may use such a
|
||||
marker outside any repository.
|
||||
"""
|
||||
resolved = path.expanduser().resolve()
|
||||
for parent in (resolved.parent, *resolved.parent.parents):
|
||||
marker = parent / ".git"
|
||||
if marker.is_file() or (marker.is_dir() and (marker / "HEAD").is_file()):
|
||||
return parent
|
||||
return None
|
||||
|
|
@ -29,6 +29,12 @@ class VerifyResult:
|
|||
|
||||
def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult:
|
||||
"""Approved consumer token must be able to read the field."""
|
||||
if entry.delivery_auth_method != "approle" or not entry.has_delivery_auth:
|
||||
return VerifyResult(
|
||||
"positive",
|
||||
False,
|
||||
{"reason": "lane has no AppRole delivery auth", "path": entry.path},
|
||||
)
|
||||
try:
|
||||
token = client.approle_login_token(entry.role_name)
|
||||
except Exception as e: # backend errors -> failed verification, not a value leak
|
||||
|
|
@ -145,6 +151,10 @@ def verify_auth_capability_negative(client: OpenBaoClient, entry: CatalogEntry)
|
|||
def run_verification(
|
||||
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
|
||||
) -> list[VerifyResult]:
|
||||
if entry.kind == "kv" and field not in entry.fields:
|
||||
raise VerificationError(
|
||||
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
|
||||
)
|
||||
results: list[VerifyResult] = []
|
||||
if entry.kind == "auth-capability":
|
||||
if positive:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue