Spaces:
Sleeping
Sleeping
File size: 8,937 Bytes
fbe9dad ebd50f7 fbe9dad ebd50f7 fbe9dad ebd50f7 fbe9dad ebd50f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """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
|