ops-warden/src/warden/routing/models.py
tegwick e3b9b1620c fix: route OpenBao recovery ceremonies safely
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0290b-3241-74c3-b868-6049545af836
2026-08-22 20:54:45 +02:00

325 lines
14 KiB
Python

"""Data model for routing catalog entries.
A `RouteEntry` is a pointer: it names the owner and the authoritative doc for a
credential need. Only the SSH lane (`warden_executes: true`) may carry an authored
`steps` block and a `cert_command` pattern — every other entry is identifiers and
pointers only (the no-double-source rule, enforced in `catalog.py`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
# Risk grade vocabulary (ADR-0007). Grades outside LOW_RISK_GRADES — including
# the "ungraded" default and any value from a newer catalog — are treated as
# high by is_high_risk, so the read-boundary fails safe in both directions.
LOW_RISK_GRADES = frozenset({"standard", "low", "accepted"})
GRADED_RISK = frozenset({"standard", "low", "accepted", "high", "critical"})
@dataclass
class RotationGuide:
"""Structured-but-advisory renewal guidance for a lane (WARDEN-WP-0026 T06).
Held in the ops-warden registry, never in OpenBao. ``steps`` are authored
advisory prose (screened for secret material like every catalog string) — they
tell an operator *how* to renew, they are not executed here. ``method`` is
``rotate`` (provider re-mints the same kind of credential) or ``re-establish``
(regenerate from source, e.g. a new age keypair + re-encrypt). ``automatable``
is a hint for a future Strand-B executable driver (WARDEN-WP-0027).
"""
method: str # "rotate" | "re-establish"
steps: List[str]
owner: str
automatable: bool = False
# Risk classes for agent read-boundary (WARDEN-WP-0026 T04).
# high — recovery escrow, upload tokens, admin PATs, high-spend provider keys.
# Agent identities must not hold raw data-read (metadata/capabilities only).
# standard — ordinary workload secrets (ESO-fed, non-escrow); normal least-privilege.
VALID_RISK = ("standard", "high")
# Delegation modes (WARDEN-WP-0030). Absence of a block is implicit interim.
# native — intended owner already fronts the lane (route-primary / pointer)
# interim — ops-warden covers a gap; intended_owner + blocked_on required
# permanent — ops-warden is the designed owner of this front door (SSH only today)
VALID_DELEGATION_MODES = ("native", "interim", "permanent")
VALID_WORKLOAD_APPLICABILITY = ("applicable", "not-applicable")
IMPLICIT_DELEGATION_BLOCKED_ON = (
"unclassified — no delegation block; treat as a question, not a settlement"
)
@dataclass
class Delegation:
"""Who should own this front door, and what is missing (WARDEN-WP-0030).
Pointer-layer only: names the intended owner and the blocker. Does not
restate how that owner will implement their front door.
"""
mode: str # native | interim | permanent
intended_owner: Optional[str] = None
blocked_on: Optional[str] = None
reviewed: Optional[str] = None
verified: Optional[str] = None
implicit: bool = False
#: How `reviewed` was established. The distinction exists because a date
#: bumped by editing the entry looks identical to one bumped by re-checking
#: the blocker, and on 2026-08-21 six lanes read as freshly reviewed when
#: only some had actually been re-verified (WARDEN-WP-0033-T05).
#:
#: `asked-and-waiting` deliberately does NOT count as verification: it is the
#: state the secrets-engine lanes sat in for ten days while looking fresh.
VERIFICATION_METHODS = (
"owner-confirmed", # the intended owner stated the blocker's status
"source-read", # re-derived from the owner's code, canon, or CCR
"asked-and-waiting", # a question is outstanding — NOT verification
"unverified", # carried forward without a check
)
#: Methods that mean the claim was actually re-established.
VERIFYING_METHODS = ("owner-confirmed", "source-read")
@property
def is_verified(self) -> bool:
"""True only when the blocker was re-established, not merely re-edited."""
return self.verified in self.VERIFYING_METHODS
def to_dict(self) -> dict:
return {
"mode": self.mode,
"intended_owner": self.intended_owner,
"blocked_on": self.blocked_on,
"reviewed": self.reviewed,
"verified": self.verified,
"is_verified": self.is_verified,
"implicit": self.implicit,
}
@dataclass(frozen=True)
class WorkloadReference:
"""Authoritative workload join for a catalog lane (WARDEN-WP-0032).
Managed deployables use the Repo Manager v1 ``rapp_id``/``name`` tuple.
Independently governed operational workloads use ``name`` plus an owner
declaration reference. An applicable lane whose owner has not published an
identity remains explicitly ``unknown``; it is never inferred from the
credential path or repository name.
"""
applicability: str # applicable | not-applicable
rapp_id: Optional[str] = None
name: Optional[str] = None
deployable: Optional[str] = None
declaration_ref: Optional[str] = None
reason: Optional[str] = None
unknown_reason: Optional[str] = None
@property
def resolution(self) -> str:
if self.applicability == "not-applicable":
return "not-applicable"
if self.unknown_reason:
return "unknown"
return "resolved"
def to_dict(self) -> dict:
return {
"applicability": self.applicability,
"rapp_id": self.rapp_id,
"name": self.name,
"deployable": self.deployable,
"declaration_ref": self.declaration_ref,
"reason": self.reason,
"unknown_reason": self.unknown_reason,
"resolution": self.resolution,
}
@dataclass
class RouteEntry:
id: str
title: str
need_keywords: List[str]
owner_repo: str
subsystem: str
warden_executes: bool
wiki_ref: str
canon_ref: str
reviewed: str
status: str # "active" | "draft"
# Explicit workload applicability and authoritative join. Never inferred.
workload_ref: Optional[WorkloadReference] = None
# SSH lane only — None/empty for routed (non-executed) needs.
steps: List[str] = field(default_factory=list)
cert_command: Optional[str] = None
# Structured handoff (WP-0014) — optional, allowed on any lane. These are
# *templates and pointers* the `warden access` assist layer renders (and, for
# exec_capable lanes, proxies). They are NOT authored procedure prose and they
# never carry a secret value — only placeholders (`<...>`) and field names.
# Validation in catalog.py enforces the no-secret-material rule on every one.
auth_method: Optional[str] = None # how the caller authenticates to the owner
path_template: Optional[str] = None # owner-side path with `<...>` placeholders
fetch_command: Optional[str] = None # command skeleton run *as the caller*
exec_capable: bool = False # may `warden access --fetch/--exec` proxy it
policy_ref: Optional[str] = None # flex-auth check the fetch path runs first
# Proxy lane semantics (WP-0014 T4):
# "secret" — read a value (gated by flex-auth secret-read; caller must already
# be authenticated; value transits via inherit-stdout or child env).
# "login" — interactive auth bootstrap (OIDC/MFA). No secret-read gate (you have
# no identity yet), no caller-auth precheck (the point is to get one),
# run interactively as the caller; warden never captures the token.
# "ceremony" — attended owner operation such as Shamir seal/unseal. It is a
# pointer plus approval boundary, never an executable access lane.
lane: str = "secret"
# Owner-native exec front door (WP-0019). When `exec_owner` is set, that subsystem
# (e.g. secrets-engine) provides the PRIMARY way to run a secret-backed command; the
# catalog routes to it and keeps ops-warden's own --fetch/--exec proxy as a transparent
# fallback (route-primary, proxy-fallback). Pointers/templates only — never a value.
exec_owner: Optional[str] = None # subsystem owning the native exec (e.g. secrets-engine)
exec_command: Optional[str] = None # e.g. "secrets-engine exec --catalog <id> -- <cmd>"
pointer_command: Optional[str] = None # e.g. "secrets-engine route <id> --json"
# Rotation / re-establishment guidance (WP-0026 T06) — advisory, no secret values.
rotation: Optional[RotationGuide] = None
# Agent read-boundary risk class (WP-0026 T04). high → agents use wrap/out/exec only.
# Default is "ungraded", which FAILS SAFE: it is treated as high. Before
# ADR-0007 this defaulted to "standard", so a lane that simply omitted the
# field was silently placed outside the read-boundary (RISK-F-0003) — the
# control was never relaxed by decision, it was never reached.
risk: str = "ungraded" # "standard" | "high" | "ungraded"
# Delegation register (WP-0030). None → implicit interim with unknown owner.
delegation: Optional[Delegation] = None
@property
def is_active(self) -> bool:
return self.status == "active"
@property
def is_graded(self) -> bool:
"""False when this lane carries no explicit risk grade (ADR-0007)."""
return self.risk in GRADED_RISK
@property
def is_high_risk(self) -> bool:
"""True when this lane is on the agent raw-read deny list (WP-0026 T04).
Anything not explicitly graded low is high. An ungraded lane, or one
carrying a grade this version does not recognise, is treated as high
rather than waved through — ADR-0007: absence is not a grade.
"""
return self.risk not in LOW_RISK_GRADES
def risk_for_zone(
self,
*,
effective_zone: str = "unknown",
admission: str = "unknown",
synthetic_only: bool = False,
) -> str:
"""Resolve an absent grade using security-zones_v0.1 section 5.1.
Explicit grades always win. The sole lower default is a satisfied
``z0-experimental`` workload proven synthetic-only. Every other zone,
failed/unknown admission, and missing context fails safe to at least
``high``; z3 reports ``critical`` (which the read boundary treats as
high). Catalog CI still requires explicit grades, so this is the safe
runtime behavior for malformed or newer external catalogs.
"""
if self.is_graded:
return self.risk
if (
effective_zone == "z0-experimental"
and admission == "satisfied"
and synthetic_only
):
return "standard"
if effective_zone == "z3-critical" and admission == "satisfied":
return "critical"
return "high"
@property
def has_rotation(self) -> bool:
"""True when this lane carries renewal guidance (WP-0026 T06)."""
return self.rotation is not None
@property
def vends_secret(self) -> bool:
"""True when this lane hands back a rotatable static secret value.
Rotation guidance (WP-0026 T06) applies to these. It excludes the SSH lane
(short-lived certs — renewal is re-issuance), ``login`` lanes (re-auth, no
stored value), and pure routing pointers with no secret path (tunnel,
principals, emission sinks, policy checks).
"""
if self.warden_executes or self.lane != "secret":
return False
return bool(self.path_template or self.fetch_command or self.exec_owner)
@property
def has_native_exec(self) -> bool:
"""True when an owner-native exec front door is the primary path for this lane."""
return bool(self.exec_owner and self.exec_command)
@property
def effective_delegation(self) -> Delegation:
"""Declared delegation, or implicit interim with an unknown owner.
Absence of a ``delegation:`` block is a question (WP-0030), not a
settlement that ops-warden owns the front door.
"""
if self.delegation is not None:
return self.delegation
return Delegation(
mode="interim",
intended_owner=None,
blocked_on=IMPLICIT_DELEGATION_BLOCKED_ON,
reviewed=None,
implicit=True,
)
@property
def is_interim(self) -> bool:
"""True when this lane is a tracked gap (explicit or implicit)."""
return self.effective_delegation.mode == "interim"
@property
def has_handoff(self) -> bool:
"""True when structured assist fields are present (advisory richness)."""
return any((self.auth_method, self.path_template, self.fetch_command))
@property
def resolvable(self) -> bool:
"""True when `warden access --fetch` can run this lane with no further input.
A resolvable lane is active, exec_capable, and its fetch command (with the path
inlined) carries no unresolved ``<...>`` placeholder. Template lanes — like the
generic ``openbao-api-key`` or the ``<domain>``-parameterized login — are *not*
resolvable until an owner ships concrete names. Lets an automated caller know
whether ``--fetch`` will work *before* attempting it (whynot-design request).
"""
if not (self.is_active and self.exec_capable and self.fetch_command):
return False
blob = f"{self.fetch_command} {self.path_template or ''}"
return "<" not in blob and ">" not in blob
def match_score(self, tokens: List[str]) -> int:
"""Keyword-overlap score against need_keywords, title, and id.
Pure ranking helper — no I/O, no external calls.
"""
haystack = set(k.lower() for k in self.need_keywords)
haystack.update(self.id.lower().replace("-", " ").split())
haystack.update(self.title.lower().replace("-", " ").split())
score = 0
for tok in tokens:
t = tok.lower()
if t in haystack:
score += 2
elif any(t in h or h in t for h in haystack):
score += 1
return score