2026-06-28 12:28:45 +02:00
|
|
|
"""Apply a guarded plan to OpenBao. Idempotent and decision-gated.
|
|
|
|
|
|
|
|
|
|
Apply only ever writes *metadata* (KV mount, ACL policy, approle role). It does
|
|
|
|
|
NOT write secret values — that is the separate, more constrained `provision` step.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
from secrets_engine.catalog import CatalogEntry
|
|
|
|
|
from secrets_engine.openbao import OpenBaoClient
|
|
|
|
|
from secrets_engine.plan import Plan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class ApplyResult:
|
|
|
|
|
applied: list[str]
|
|
|
|
|
skipped: list[str]
|
|
|
|
|
|
|
|
|
|
def render(self) -> str:
|
|
|
|
|
out = []
|
|
|
|
|
for a in self.applied:
|
|
|
|
|
out.append(f" applied: {a}")
|
|
|
|
|
for s in self.skipped:
|
|
|
|
|
out.append(f" unchanged: {s}")
|
|
|
|
|
return "\n".join(out) or " (nothing to do)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str = "30m") -> ApplyResult:
|
|
|
|
|
"""Execute the plan's metadata actions idempotently.
|
|
|
|
|
|
|
|
|
|
Idempotency: KV mount and approle are enable-if-absent; the policy is written
|
|
|
|
|
only when its current body differs from the desired HCL.
|
|
|
|
|
"""
|
|
|
|
|
applied: list[str] = []
|
|
|
|
|
skipped: list[str] = []
|
|
|
|
|
|
2026-06-29 16:58:16 +02:00
|
|
|
# 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.
|
2026-08-21 08:20:33 +02:00
|
|
|
if entry.manages_mount:
|
2026-06-29 16:58:16 +02:00
|
|
|
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}")
|
2026-08-21 08:20:33 +02:00
|
|
|
elif entry.stores_kv_value():
|
|
|
|
|
skipped.append(f"kv-mount {entry.mount} (externally managed; no mutation)")
|
2026-06-28 12:28:45 +02:00
|
|
|
else:
|
2026-06-29 16:58:16 +02:00
|
|
|
skipped.append(f"kv-mount {entry.mount} (not applicable for {entry.kind})")
|
2026-06-28 12:28:45 +02:00
|
|
|
|
|
|
|
|
# 2. Consumer ACL policy (write only if changed).
|
2026-08-21 08:20:33 +02:00
|
|
|
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}")
|
2026-06-28 12:28:45 +02:00
|
|
|
else:
|
2026-08-21 08:20:33 +02:00
|
|
|
skipped.append(f"policy {plan.policy_name} (externally managed; no mutation)")
|
2026-06-28 12:28:45 +02:00
|
|
|
|
|
|
|
|
# 3. Consumer approle bound to that policy.
|
2026-08-21 08:20:33 +02:00
|
|
|
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)")
|
2026-06-29 16:58:16 +02:00
|
|
|
else:
|
2026-08-21 08:20:33 +02:00
|
|
|
skipped.append("approle (no native delivery auth declared)")
|
2026-06-28 12:28:45 +02:00
|
|
|
|
|
|
|
|
return ApplyResult(applied=applied, skipped=skipped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize(hcl: str) -> str:
|
|
|
|
|
"""Compare policy bodies ignoring comments and whitespace noise."""
|
|
|
|
|
lines = []
|
|
|
|
|
for raw in hcl.splitlines():
|
|
|
|
|
line = raw.strip()
|
|
|
|
|
if not line or line.startswith("#"):
|
|
|
|
|
continue
|
|
|
|
|
lines.append(" ".join(line.split()))
|
|
|
|
|
return "\n".join(lines)
|