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