"""Direct API backend — the first real execution backend. WHAT THIS IS ------------ This is a **mock of a SOC tool's REST API** (think: a ticketing system / CMDB). It is one of the four execution backends the dispatcher can route an authorized action to. It does the *actual work* of a Direct-API action: * READS — get_asset, get_ticket, get_alert (return mock records) * WRITES — create_low_risk_ticket, add_comment, (low-risk, reversible update_status changes to a store) It reads and writes the static fixtures from :mod:`data`. There are **no real network calls** — everything happens against an in-memory copy of the mock data, so the demo is safe and repeatable. WHERE IT SITS IN THE TRUST MODEL -------------------------------- This backend never decides *whether* an action is allowed — that already happened upstream. By the time ``run`` is called, the gate has said ``ALLOW`` and the dispatcher has verified the gate's signed grant. So this file can focus purely on *doing*. gate decides ─► dispatcher verifies grant ─► THIS backend executes TWO SAFETY PROPERTIES, EVEN THOUGH IT ONLY RUNS AFTER ALLOW ----------------------------------------------------------- 1. **Allow-list of handlers (defense in depth).** This backend only knows how to perform a fixed set of safe actions. A high-risk action (e.g. ``block_ip``) has *no handler here at all*, so even if one somehow reached this backend it would be refused, not executed. High-risk actions are approval-gated by the policy and only ever reach the Safe-CLI/MCP backends after approval — never this one without it. 2. **Writes only touch a private copy.** The ticket store is seeded with its own deep copy of the fixtures, so creating or editing a ticket here can never corrupt the shared seed data other components read. """ from __future__ import annotations from typing import Any, Callable import data from control_plane.schema import ProposedAction class DirectApiError(Exception): """Base error for anything this backend refuses or cannot do.""" class DirectApiUnsupportedActionError(DirectApiError): """The requested action is not in this backend's allow-list of handlers. This is the defense-in-depth guard: anything that isn't one of the safe reads/writes below (notably high-risk actions like ``block_ip``) lands here and is refused rather than executed. """ class RecordNotFoundError(DirectApiError): """A read/write referenced an ID that does not exist in the mock data.""" class MissingArgumentError(DirectApiError): """A required argument was absent from the action's ``arguments`` map.""" class DirectApiBackend: """Mock Direct-API adapter satisfying the dispatcher's ``BackendAdapter`` contract. Construct it once and let the dispatcher call :meth:`run` per authorized action. Each instance owns its own mutable ticket store, so tests (and separate runs) stay isolated from one another. """ def __init__(self) -> None: # Read-only reference data: looked up by ID, never modified here. self._assets = data.index_by_id("assets") self._alerts = data.index_by_id("alerts") # The one *writable* store. We take a private deep copy (``load_records``) # so create/comment/update operations mutate only this instance, never the # shared on-disk seed. Keyed by ticket_id for O(1) reads and updates. self._tickets: dict[str, dict[str, Any]] = { t["ticket_id"]: t for t in data.load_records("tickets") } # The allow-list: action_name -> handler. Membership in this table is what # makes an action executable by this backend. Anything not here is refused # by :meth:`run` (see DirectApiUnsupportedActionError). self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = { # reads "get_asset": self._get_asset, "get_ticket": self._get_ticket, "get_alert": self._get_alert, # bounded, reversible writes "create_low_risk_ticket": self._create_low_risk_ticket, "add_comment": self._add_comment, "update_status": self._update_status, } # -- dispatcher entry point ------------------------------------------------ def run(self, action: ProposedAction) -> Any: """Execute *action* and return its result (the affected mock record). Routes on ``action.action_name`` to a handler in the allow-list. An action this backend doesn't handle raises :class:`DirectApiUnsupportedActionError` — it is never silently ignored or guessed at. """ handler = self._handlers.get(action.action_name) if handler is None: raise DirectApiUnsupportedActionError( f"direct_api backend cannot perform {action.action_name!r} " f"(not in its allow-list of safe actions)" ) # Handlers read everything they need from the action's arguments map. return handler(action.arguments) # -- read handlers --------------------------------------------------------- def _get_asset(self, args: dict[str, Any]) -> dict[str, Any]: asset_id = _require(args, "asset_id") return _found(self._assets.get(asset_id), "asset", asset_id) def _get_alert(self, args: dict[str, Any]) -> dict[str, Any]: alert_id = _require(args, "alert_id") return _found(self._alerts.get(alert_id), "alert", alert_id) def _get_ticket(self, args: dict[str, Any]) -> dict[str, Any]: # Tickets are read from the writable store, so a ticket created earlier in # the same run can be read back. ticket_id = _require(args, "ticket_id") return _found(self._tickets.get(ticket_id), "ticket", ticket_id) # -- write handlers (low-risk, reversible) --------------------------------- def _create_low_risk_ticket(self, args: dict[str, Any]) -> dict[str, Any]: """Create a new ticket in the mock store and return it. This is the "L2 low-risk ticket" action. It only adds a record; it changes nothing on a real host or user, which is what makes it a *bounded, reversible* action the policy allows at L2. """ ticket = { "ticket_id": self._next_ticket_id(), "incident_id": args.get("incident_id"), "title": _require(args, "title"), "status": "open", "priority": args.get("priority", "low"), "risk": "low", # this handler only ever creates low-risk tickets "asset_id": args.get("asset_id"), "assignee_user_id": args.get("assignee_user_id"), "comments": [], } self._tickets[ticket["ticket_id"]] = ticket return ticket def _add_comment(self, args: dict[str, Any]) -> dict[str, Any]: """Append a comment to an existing ticket (a reversible annotation).""" ticket = self._get_ticket_for_write(args) ticket["comments"].append( { "author_user_id": args.get("author_user_id"), "body": _require(args, "body"), } ) return ticket def _update_status(self, args: dict[str, Any]) -> dict[str, Any]: """Update a ticket's status field (e.g. open -> in_progress).""" ticket = self._get_ticket_for_write(args) ticket["status"] = _require(args, "status") return ticket # -- helpers --------------------------------------------------------------- def _get_ticket_for_write(self, args: dict[str, Any]) -> dict[str, Any]: """Resolve the ticket a write targets, or refuse if the ID is unknown.""" ticket_id = _require(args, "ticket_id") return _found(self._tickets.get(ticket_id), "ticket", ticket_id) def _next_ticket_id(self) -> str: """Mint the next ``TKT-####`` id, one past the highest currently stored.""" numbers = [ int(tid.split("-")[1]) for tid in self._tickets if tid.startswith("TKT-") and tid.split("-")[1].isdigit() ] return f"TKT-{(max(numbers, default=5000) + 1):04d}" # Module-level helpers keep the handlers above short and uniform. def _require(args: dict[str, Any], key: str) -> Any: """Return ``args[key]`` or raise a clear error naming the missing argument.""" if key not in args or args[key] in (None, ""): raise MissingArgumentError(f"missing required argument {key!r}") return args[key] def _found(record: dict[str, Any] | None, kind: str, record_id: str) -> dict[str, Any]: """Return *record* if present, else raise a clear not-found error.""" if record is None: raise RecordNotFoundError(f"no {kind} with id {record_id!r}") return record