Spaces:
Sleeping
Sleeping
| """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 | |