edangx100's picture
Update app and control-plane modules to latest
fbe9dad
Raw
History Blame Contribute Delete
8.94 kB
"""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