2026-06-28 12:28:45 +02:00
|
|
|
"""ops-warden routing contract.
|
|
|
|
|
|
|
|
|
|
ops-warden routes non-SSH credential needs here. It must NOT vend secret values.
|
|
|
|
|
A route result is a pointer: catalog id, readiness, decision status, and the safe
|
|
|
|
|
next command. This module computes that pointer for a lane. No value is read.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, asdict
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from secrets_engine.catalog import CatalogEntry
|
|
|
|
|
from secrets_engine.decisions import Decision, resolve_decision
|
|
|
|
|
from secrets_engine.errors import DecisionError
|
|
|
|
|
from secrets_engine.openbao import OpenBaoClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class RouteResult:
|
|
|
|
|
catalog_id: str
|
2026-06-29 16:58:16 +02:00
|
|
|
kind: str
|
2026-06-28 12:28:45 +02:00
|
|
|
owner: str
|
|
|
|
|
stage: str
|
|
|
|
|
decision_status: str
|
|
|
|
|
decision_ref: str
|
|
|
|
|
review_url: str
|
|
|
|
|
metadata_applied: bool
|
|
|
|
|
value_present: bool
|
2026-08-23 12:05:58 +02:00
|
|
|
missing_fields: list[str]
|
2026-06-28 12:28:45 +02:00
|
|
|
ready: bool
|
|
|
|
|
next_command: str
|
|
|
|
|
missing: str
|
|
|
|
|
|
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
|
|
|
return asdict(self)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def route_lane(
|
|
|
|
|
entry: CatalogEntry,
|
|
|
|
|
*,
|
|
|
|
|
hub_url: str,
|
|
|
|
|
repo_root: Path,
|
|
|
|
|
client: OpenBaoClient | None = None,
|
|
|
|
|
) -> RouteResult:
|
|
|
|
|
"""Build the front-door routing pointer for a lane. Never reads the value."""
|
|
|
|
|
decision_status = "n/a (bootstrap-only)"
|
|
|
|
|
decision_ref = entry.approval.get("decision_ref", "")
|
|
|
|
|
review_url = ""
|
|
|
|
|
decision: Decision | None = None
|
|
|
|
|
if entry.approval_required():
|
|
|
|
|
try:
|
|
|
|
|
decision = resolve_decision(
|
|
|
|
|
hub_url=hub_url, repo_root=repo_root, decision_ref=decision_ref
|
|
|
|
|
)
|
|
|
|
|
decision_status = decision.status
|
|
|
|
|
review_url = decision.review_url
|
|
|
|
|
except DecisionError:
|
|
|
|
|
decision_status = "missing"
|
|
|
|
|
|
|
|
|
|
metadata_applied = False
|
|
|
|
|
value_present = False
|
2026-08-23 12:05:58 +02:00
|
|
|
missing_fields: list[str] = list(entry.fields) if entry.stores_kv_value() else []
|
2026-06-28 12:28:45 +02:00
|
|
|
if client is not None and client.is_reachable():
|
2026-08-21 08:20:33 +02:00
|
|
|
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
|
2026-06-29 16:58:16 +02:00
|
|
|
if entry.stores_kv_value():
|
2026-08-23 12:05:58 +02:00
|
|
|
# One read produces booleans only; readiness requires every field.
|
|
|
|
|
presence = client.kv_fields_present(entry.mount, entry.path, entry.fields)
|
|
|
|
|
missing_fields = [field for field in entry.fields if not presence.get(field)]
|
|
|
|
|
value_present = bool(entry.fields) and not missing_fields
|
2026-06-29 16:58:16 +02:00
|
|
|
else:
|
|
|
|
|
# Auth-capability lanes have no stored value; a fresh secret_id is minted
|
|
|
|
|
# on demand through the handoff command once metadata exists.
|
|
|
|
|
value_present = metadata_applied
|
2026-06-28 12:28:45 +02:00
|
|
|
|
2026-06-29 16:58:16 +02:00
|
|
|
approved = not entry.approval_required() or (decision is not None and decision.is_approved())
|
2026-06-28 12:28:45 +02:00
|
|
|
ready = approved and metadata_applied and value_present
|
|
|
|
|
|
|
|
|
|
if not approved:
|
|
|
|
|
missing = f"approved decision for '{decision_ref}'"
|
|
|
|
|
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
|
|
|
|
|
elif not metadata_applied:
|
2026-08-21 08:20:33 +02:00
|
|
|
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}"
|
2026-06-29 16:58:16 +02:00
|
|
|
elif entry.kind == "auth-capability":
|
|
|
|
|
missing = ""
|
|
|
|
|
next_command = (
|
|
|
|
|
f"secrets-engine handoff {entry.id} --stage {entry.stage} "
|
|
|
|
|
"--role-id-file <path> --secret-id-file <path>"
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
elif not value_present:
|
2026-08-23 12:05:58 +02:00
|
|
|
names = ", ".join(missing_fields)
|
|
|
|
|
missing = f"provisioned secret fields: {names}"
|
|
|
|
|
next_field = missing_fields[0] if missing_fields else entry.fields[0]
|
2026-06-28 12:28:45 +02:00
|
|
|
next_command = (
|
|
|
|
|
f"secrets-engine provision {entry.id} --stage {entry.stage} "
|
2026-08-23 12:05:58 +02:00
|
|
|
f"--field {next_field} --from-file <path>"
|
2026-06-28 12:28:45 +02:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
missing = ""
|
2026-08-21 08:20:33 +02:00
|
|
|
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"
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
|
|
|
|
|
return RouteResult(
|
|
|
|
|
catalog_id=entry.id,
|
2026-06-29 16:58:16 +02:00
|
|
|
kind=entry.kind,
|
2026-06-28 12:28:45 +02:00
|
|
|
owner=entry.owner,
|
|
|
|
|
stage=entry.stage,
|
|
|
|
|
decision_status=decision_status,
|
|
|
|
|
decision_ref=decision_ref,
|
|
|
|
|
review_url=review_url,
|
|
|
|
|
metadata_applied=metadata_applied,
|
|
|
|
|
value_present=value_present,
|
2026-08-23 12:05:58 +02:00
|
|
|
missing_fields=missing_fields,
|
2026-06-28 12:28:45 +02:00
|
|
|
ready=ready,
|
|
|
|
|
next_command=next_command,
|
|
|
|
|
missing=missing,
|
|
|
|
|
)
|