underwrit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- underwrit/__init__.py +19 -0
- underwrit/__main__.py +3 -0
- underwrit/api.py +1763 -0
- underwrit/auth.py +348 -0
- underwrit/backup.py +125 -0
- underwrit/bundle.py +110 -0
- underwrit/cedar.py +150 -0
- underwrit/chain.py +519 -0
- underwrit/checkpoint.py +428 -0
- underwrit/cli.py +155 -0
- underwrit/db.py +377 -0
- underwrit/demo.py +310 -0
- underwrit/ecdsa_p256.py +108 -0
- underwrit/ed25519.py +171 -0
- underwrit/evidence.py +590 -0
- underwrit/mcp.py +147 -0
- underwrit/merkle.py +144 -0
- underwrit/migrate.py +186 -0
- underwrit/obs.py +108 -0
- underwrit/oidc.py +362 -0
- underwrit/opa.py +103 -0
- underwrit/policy.py +933 -0
- underwrit/ratelimit.py +46 -0
- underwrit/recorder.py +139 -0
- underwrit/replay.py +136 -0
- underwrit/retention.py +220 -0
- underwrit/store.py +607 -0
- underwrit/subject.py +57 -0
- underwrit/taskdraft.py +40 -0
- underwrit/tsa.py +180 -0
- underwrit-0.3.0.dist-info/METADATA +174 -0
- underwrit-0.3.0.dist-info/RECORD +37 -0
- underwrit-0.3.0.dist-info/WHEEL +5 -0
- underwrit-0.3.0.dist-info/entry_points.txt +2 -0
- underwrit-0.3.0.dist-info/licenses/LICENSE +202 -0
- underwrit-0.3.0.dist-info/licenses/NOTICE +4 -0
- underwrit-0.3.0.dist-info/top_level.txt +1 -0
underwrit/api.py
ADDED
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
"""The data plane: decide, record, resolve, and produce evidence. Runs on the customer's side.
|
|
2
|
+
|
|
3
|
+
It decides locally and it keeps deciding when the control plane is unreachable, because it sits in
|
|
4
|
+
the critical path of production actions: if this service cannot answer, either their agents stop or
|
|
5
|
+
it fails open, and failing open defeats the product. Policy is fetched and cached; a stale policy is
|
|
6
|
+
a far better outcome than no decision.
|
|
7
|
+
|
|
8
|
+
**Nothing sensitive leaves.** The chain records an argument *digest*; the arguments themselves stay
|
|
9
|
+
in the local store under their own retention, and what is reported upstream is counts and digests.
|
|
10
|
+
That is what makes the deployment model answerable to a regulated buyer, and it is enforced here
|
|
11
|
+
rather than promised in a document — `report()` is built from a fixed set of scalar fields, and
|
|
12
|
+
reporting happens on a background queue so the control plane is never in the decision path even
|
|
13
|
+
when it is slow.
|
|
14
|
+
|
|
15
|
+
Every decision is written to the chain as it is made, including the ones that allowed. A log
|
|
16
|
+
containing only refusals cannot show that a control was working the rest of the time, and a log
|
|
17
|
+
containing only successes cannot show somebody probing for what they are not allowed to do.
|
|
18
|
+
|
|
19
|
+
**An approval is a contract, not a flag.** It is bound to the exact arguments (by digest), to the
|
|
20
|
+
policy it was given under, to whatever state the caller said must still be true, and to a clock.
|
|
21
|
+
`/v1/decisions/:id/claim` is where a runtime, about to execute, presents the same arguments and is
|
|
22
|
+
told whether the approval still holds — and a claim is exactly-once, so a repeated request cannot
|
|
23
|
+
become a repeated action.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import queue
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.parse
|
|
37
|
+
import urllib.request
|
|
38
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from . import (
|
|
42
|
+
auth, bundle, cedar, chain, opa, checkpoint as cp_mod, db, evidence, migrate, obs, oidc,
|
|
43
|
+
policy as policy_mod, ratelimit, replay as replay_mod, retention, store)
|
|
44
|
+
from .subject import Subject
|
|
45
|
+
|
|
46
|
+
DB_PATH = os.environ.get("UNDERWRIT_DB", "underwrit.db")
|
|
47
|
+
CONTROL_URL = os.environ.get("UNDERWRIT_CONTROL_URL", "").rstrip("/")
|
|
48
|
+
# Further witnesses beyond the control plane: "https://witness-a|<node token>,https://witness-b|<token>".
|
|
49
|
+
# Each speaks the same C2SP witness protocol (POST /v1/witness) — another Underwrit control plane, run by
|
|
50
|
+
# a different party, is the intended one. A checkpoint cosigned by N independent witnesses is N
|
|
51
|
+
# statements that this history is the one they saw.
|
|
52
|
+
EXTRA_WITNESSES: list[tuple[str, str]] = [
|
|
53
|
+
(part.split("|", 1)[0].strip().rstrip("/"), (part.split("|", 1) + [""])[1].strip())
|
|
54
|
+
for part in os.environ.get("UNDERWRIT_WITNESSES", "").split(",") if part.strip()
|
|
55
|
+
]
|
|
56
|
+
NODE_ID = os.environ.get("UNDERWRIT_NODE", "node-1")
|
|
57
|
+
# This data plane's credential for the control plane. Minted there with role=node, which may fetch
|
|
58
|
+
# policy and report — never rewrite the policy the fleet receives. Either the value or a file
|
|
59
|
+
# holding it (a mounted secret), read at use so a file that appears after start is picked up.
|
|
60
|
+
NODE_TOKEN = os.environ.get("UNDERWRIT_NODE_TOKEN", "")
|
|
61
|
+
NODE_TOKEN_FILE = os.environ.get("UNDERWRIT_NODE_TOKEN_FILE", "")
|
|
62
|
+
POLL_SECONDS = float(os.environ.get("UNDERWRIT_POLICY_POLL", "30") or 30)
|
|
63
|
+
SWEEP_SECONDS = float(os.environ.get("UNDERWRIT_RETENTION_SWEEP_SECONDS", "3600") or 0)
|
|
64
|
+
RATE_PER_MINUTE = int(os.environ.get("UNDERWRIT_RATE_LIMIT_PER_MINUTE", "600") or 0)
|
|
65
|
+
HOST = os.environ.get("UNDERWRIT_HOST", "127.0.0.1")
|
|
66
|
+
CHECKPOINT_SECONDS = float(os.environ.get("UNDERWRIT_CHECKPOINT_SECONDS", "60") or 0)
|
|
67
|
+
|
|
68
|
+
_local = threading.local()
|
|
69
|
+
_key: cp_mod.Key | None = None
|
|
70
|
+
_key_lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def log_key() -> cp_mod.Key:
|
|
74
|
+
"""The Ed25519 log key, loaded once per process from a file (never from the database)."""
|
|
75
|
+
global _key
|
|
76
|
+
with _key_lock:
|
|
77
|
+
if _key is None:
|
|
78
|
+
_key, _ = cp_mod.load_or_create(cp_mod.default_key_path(DB_PATH), f"underwrit/{NODE_ID}")
|
|
79
|
+
return _key
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def witness(cp: dict | None = None, c=None) -> dict:
|
|
83
|
+
"""Ask the control plane to cosign the latest checkpoint. Failure is reported, never fatal.
|
|
84
|
+
|
|
85
|
+
Sends the checkpoint, the log's public key, the size the witness last cosigned and the
|
|
86
|
+
consistency proof from it. A 409 carrying the witness's size means our record of what it saw
|
|
87
|
+
is stale; we adopt its size and retry once. A 409 saying the proof does not connect is the one
|
|
88
|
+
that matters: the witness holds a root this log can no longer reach, which is what a rewritten
|
|
89
|
+
history looks like from outside, and it is logged as such.
|
|
90
|
+
"""
|
|
91
|
+
targets = ([("control", CONTROL_URL, node_token())] if CONTROL_URL else []) + \
|
|
92
|
+
[(url, url, tok) for url, tok in EXTRA_WITNESSES]
|
|
93
|
+
if not targets:
|
|
94
|
+
return {"result": "no witness configured"}
|
|
95
|
+
c = c if c is not None else conn()
|
|
96
|
+
cp = cp or cp_mod.latest(c) or cp_mod.issue(c, log_key())
|
|
97
|
+
results = {label: _witness_one(c, cp, label, url, tok) for label, url, tok in targets}
|
|
98
|
+
primary = results.get("control") or next(iter(results.values()))
|
|
99
|
+
return {**primary, **({"witnesses": results} if len(results) > 1 else {})}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _witness_one(c, cp: dict, label: str, url: str, tok: str) -> dict:
|
|
103
|
+
state = cp_mod.witness_state_for(c, label)
|
|
104
|
+
old_size = state["size"] if state else 0
|
|
105
|
+
for _ in range(2):
|
|
106
|
+
try:
|
|
107
|
+
proof = cp_mod.consistency(c, old_size)["proof"] if 0 < old_size < cp["size"] else []
|
|
108
|
+
except ValueError:
|
|
109
|
+
proof = []
|
|
110
|
+
body = {"node": NODE_ID, "checkpoint": cp["note"], "logKey": log_key().public_json()["publicKey"],
|
|
111
|
+
"oldSize": old_size, "consistency": proof}
|
|
112
|
+
try:
|
|
113
|
+
req = urllib.request.Request(
|
|
114
|
+
f"{url}/v1/witness", data=json.dumps(body).encode("utf-8"),
|
|
115
|
+
headers={"Content-Type": "application/json",
|
|
116
|
+
**({"Authorization": f"Bearer {tok}"} if tok else {})}, method="POST")
|
|
117
|
+
with urllib.request.urlopen(req, timeout=8) as r:
|
|
118
|
+
out = json.loads(r.read().decode("utf-8"))
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
try:
|
|
121
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
122
|
+
except ValueError:
|
|
123
|
+
payload = {}
|
|
124
|
+
if exc.code == 409 and "size" in payload and payload["size"] != old_size and "connect" not in payload.get("error", ""):
|
|
125
|
+
old_size = int(payload["size"])
|
|
126
|
+
continue
|
|
127
|
+
obs.log("witness.refused", witness=label, status=exc.code, error=payload.get("error", ""))
|
|
128
|
+
obs.metrics.inc("underwrit_witness_refusals_total", {"witness": label})
|
|
129
|
+
return {"result": "refused", "status": exc.code, **payload}
|
|
130
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
131
|
+
return {"result": f"unreachable: {type(exc).__name__}"}
|
|
132
|
+
origin = (out.get("witness") or {}).get("origin", "") or (state or {}).get("witness", "")
|
|
133
|
+
# The witness is another party. Its answer is checked before it is believed: same body we
|
|
134
|
+
# sent, our own signature line intact, and — when it told us its key — a cosignature that
|
|
135
|
+
# verifies under it. A witness that returns anything else is refused, like a bad proof.
|
|
136
|
+
try:
|
|
137
|
+
ours, theirs = cp_mod.parse_note(cp["note"]), cp_mod.parse_note(str(out.get("cosignedNote") or ""))
|
|
138
|
+
same_body = (ours["origin"], ours["size"], ours["root"]) == (theirs["origin"], theirs["size"], theirs["root"])
|
|
139
|
+
log_line = cp_mod.verify_note(out["cosignedNote"], log_key().public, log_key().origin)
|
|
140
|
+
wpub = base64.b64decode((out.get("witness") or {}).get("publicKey") or "") if out.get("witness") else b""
|
|
141
|
+
cosig_ok = True if not wpub else cp_mod.verify_cosignature(out["cosignedNote"], wpub, origin) is not None
|
|
142
|
+
sizes = int(out.get("size")) == cp["size"] and str(out.get("root")) == cp["root"]
|
|
143
|
+
except (ValueError, KeyError, TypeError, IndexError):
|
|
144
|
+
same_body = log_line = cosig_ok = sizes = False
|
|
145
|
+
if not (same_body and log_line and cosig_ok and sizes):
|
|
146
|
+
obs.log("witness.refused", witness=label, status=0, error="witness answer did not verify")
|
|
147
|
+
obs.metrics.inc("underwrit_witness_refusals_total", {"witness": label})
|
|
148
|
+
return {"result": "refused", "error": "the witness's answer did not verify against the checkpoint sent"}
|
|
149
|
+
cp_mod.save_witness_state(c, url=label, size=out["size"], root=out["root"],
|
|
150
|
+
cosigned_note=out["cosignedNote"], witness=origin)
|
|
151
|
+
st = cp_mod.witness_state(c)
|
|
152
|
+
obs.metrics.set("underwrit_witness_fork", 1.0 if st and st.get("fork") else 0.0)
|
|
153
|
+
if st and st.get("fork"):
|
|
154
|
+
obs.log("witness.fork", size=st["size"], roots=len(st["roots"]))
|
|
155
|
+
obs.log("witness.cosigned", witness=label, size=out["size"])
|
|
156
|
+
return {"result": "ok", "size": out["size"], "unchanged": out.get("unchanged", False), "witness": origin}
|
|
157
|
+
return {"result": "refused", "error": "witness size did not converge"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def anchor(c, cp: dict) -> list[dict]:
|
|
161
|
+
"""External clocks (RFC 3161 TSA, OpenTimestamps) over a checkpoint; logged, never fatal."""
|
|
162
|
+
try:
|
|
163
|
+
got = cp_mod.anchor(c, cp)
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
obs.log("timestamp.error", error=type(exc).__name__)
|
|
166
|
+
return []
|
|
167
|
+
for t in got:
|
|
168
|
+
obs.log("timestamp", kind=t["kind"], size=cp["size"])
|
|
169
|
+
obs.metrics.inc("underwrit_timestamps_total", {"kind": t["kind"]})
|
|
170
|
+
return got
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_chain_verdict: dict = {"at": 0.0, "verdict": None}
|
|
174
|
+
_chain_lock = threading.Lock()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def chain_verdict(c, max_age: float = 60.0) -> dict:
|
|
178
|
+
"""The whole-chain verdict, recomputed at most every `max_age` seconds.
|
|
179
|
+
|
|
180
|
+
`/v1/health` is a liveness probe; walking a large log on every probe is how a healthy pod gets
|
|
181
|
+
killed for being slow. The checkpointer refreshes this on its own clock as well.
|
|
182
|
+
"""
|
|
183
|
+
with _chain_lock:
|
|
184
|
+
if _chain_verdict["verdict"] is not None and time.time() - _chain_verdict["at"] < max_age:
|
|
185
|
+
return _chain_verdict["verdict"]
|
|
186
|
+
verdict = chain.verify_chain(c)
|
|
187
|
+
verdict = {**verdict, "checkedAt": time.time()}
|
|
188
|
+
with _chain_lock:
|
|
189
|
+
_chain_verdict.update(at=time.time(), verdict=verdict)
|
|
190
|
+
obs.metrics.set("underwrit_chain_ok", 1 if verdict.get("ok") else 0)
|
|
191
|
+
return verdict
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _checkpointer() -> None:
|
|
195
|
+
while True:
|
|
196
|
+
time.sleep(CHECKPOINT_SECONDS)
|
|
197
|
+
try:
|
|
198
|
+
with _scoped() as c:
|
|
199
|
+
chain_verdict(c, max_age=CHECKPOINT_SECONDS)
|
|
200
|
+
latest = cp_mod.latest(c)
|
|
201
|
+
if not latest or latest["size"] != len(cp_mod.leaves(c)):
|
|
202
|
+
cp = cp_mod.issue(c, log_key())
|
|
203
|
+
obs.log("checkpoint", size=cp["size"], keyId=cp["keyId"])
|
|
204
|
+
witness(cp, c)
|
|
205
|
+
anchor(c, cp)
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
obs.log("checkpoint.error", error=type(exc).__name__)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_migrated = False
|
|
211
|
+
_migrate_lock = threading.Lock()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def conn():
|
|
215
|
+
"""This thread's connection. Migrations run once per process, not once per thread — DDL on
|
|
216
|
+
every request thread's first connection is a lock storm on SQLite and noise on Postgres."""
|
|
217
|
+
global _migrated
|
|
218
|
+
if getattr(_local, "conn", None) is None:
|
|
219
|
+
c = db.connect(DB_PATH)
|
|
220
|
+
if not _migrated:
|
|
221
|
+
with _migrate_lock:
|
|
222
|
+
if not _migrated:
|
|
223
|
+
migrate.data_plane(c)
|
|
224
|
+
_migrated = True
|
|
225
|
+
_local.conn = c
|
|
226
|
+
return _local.conn
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _scoped:
|
|
230
|
+
"""A connection for one iteration of a background loop, closed (and on Postgres rolled back and
|
|
231
|
+
returned to the pool) when the iteration ends. A loop that held a pooled connection forever
|
|
232
|
+
starved request threads; one that hit an error mid-transaction stayed aborted for good."""
|
|
233
|
+
|
|
234
|
+
def __enter__(self):
|
|
235
|
+
self.c = db.connect(DB_PATH)
|
|
236
|
+
return self.c
|
|
237
|
+
|
|
238
|
+
def __exit__(self, *exc):
|
|
239
|
+
try:
|
|
240
|
+
if exc[0] is not None:
|
|
241
|
+
try:
|
|
242
|
+
self.c.rollback()
|
|
243
|
+
except Exception: # noqa: BLE001
|
|
244
|
+
pass
|
|
245
|
+
finally:
|
|
246
|
+
try:
|
|
247
|
+
self.c.close()
|
|
248
|
+
except Exception: # noqa: BLE001
|
|
249
|
+
pass
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def node_token() -> str:
|
|
254
|
+
if NODE_TOKEN:
|
|
255
|
+
return NODE_TOKEN
|
|
256
|
+
if NODE_TOKEN_FILE:
|
|
257
|
+
try:
|
|
258
|
+
return Path(NODE_TOKEN_FILE).read_text().strip()
|
|
259
|
+
except OSError:
|
|
260
|
+
return ""
|
|
261
|
+
return ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------------------------
|
|
265
|
+
# Policy: fetched, cached, and never a reason to stop deciding
|
|
266
|
+
|
|
267
|
+
_policy_lock = threading.Lock()
|
|
268
|
+
_policy: policy_mod.Policy | None = None
|
|
269
|
+
_policy_at: float = 0.0
|
|
270
|
+
_policy_source = "default"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def current_policy() -> tuple[policy_mod.Policy, str]:
|
|
274
|
+
global _policy, _policy_source
|
|
275
|
+
with _policy_lock:
|
|
276
|
+
if _policy is None:
|
|
277
|
+
doc, _ = store.cached_policy(conn())
|
|
278
|
+
if doc:
|
|
279
|
+
_policy, _policy_source = policy_mod.Policy.from_json(doc), "cache"
|
|
280
|
+
else:
|
|
281
|
+
_policy, _policy_source = policy_mod.Policy(), "default"
|
|
282
|
+
return _policy, _policy_source
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def set_local_policy(doc: dict) -> policy_mod.Policy:
|
|
286
|
+
"""A policy written here, for a data plane with no control plane. Cached like a fetched one."""
|
|
287
|
+
global _policy, _policy_at, _policy_source
|
|
288
|
+
current, _ = current_policy()
|
|
289
|
+
merged = {**current.to_json(), **doc, "version": int(current.version) + 1}
|
|
290
|
+
pol = policy_mod.Policy.from_json(merged)
|
|
291
|
+
with _policy_lock:
|
|
292
|
+
_policy, _policy_at, _policy_source = pol, time.time(), "local"
|
|
293
|
+
store.save_policy(conn(), pol.to_json())
|
|
294
|
+
return pol
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def refresh_policy() -> str:
|
|
298
|
+
"""Pull policy from the control plane. Failure is reported, never fatal."""
|
|
299
|
+
global _policy, _policy_at, _policy_source
|
|
300
|
+
if not CONTROL_URL:
|
|
301
|
+
return "no control plane configured"
|
|
302
|
+
tok = node_token()
|
|
303
|
+
try:
|
|
304
|
+
req = urllib.request.Request(
|
|
305
|
+
f"{CONTROL_URL}/v1/policy",
|
|
306
|
+
headers={"Authorization": f"Bearer {tok}"} if tok else {},
|
|
307
|
+
)
|
|
308
|
+
with urllib.request.urlopen(req, timeout=8) as r:
|
|
309
|
+
doc = json.loads(r.read().decode("utf-8"))
|
|
310
|
+
except urllib.error.HTTPError as exc:
|
|
311
|
+
# Refused, not unreachable. They need different responses — a 401 here means this node's
|
|
312
|
+
# token is wrong or revoked, which no amount of retrying fixes, and reporting it as a
|
|
313
|
+
# network problem sends somebody after one that does not exist.
|
|
314
|
+
obs.log("policy.refresh", result="refused", status=exc.code)
|
|
315
|
+
return f"refused: HTTP {exc.code}"
|
|
316
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
317
|
+
obs.log("policy.refresh", result="unreachable", error=type(exc).__name__)
|
|
318
|
+
return f"unreachable: {type(exc).__name__}"
|
|
319
|
+
with _policy_lock:
|
|
320
|
+
_policy = policy_mod.Policy.from_json(doc)
|
|
321
|
+
_policy_at = time.time()
|
|
322
|
+
_policy_source = "control-plane"
|
|
323
|
+
with _scoped() as c:
|
|
324
|
+
store.save_policy(c, doc)
|
|
325
|
+
obs.log("policy.refresh", result="ok", version=doc.get("version"))
|
|
326
|
+
return "ok"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------------------------
|
|
330
|
+
# Reporting: counts and digests upstream, off the decision path
|
|
331
|
+
|
|
332
|
+
_reports: queue.Queue = queue.Queue(maxsize=10000)
|
|
333
|
+
_workers_lock = threading.Lock()
|
|
334
|
+
_workers_started = False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _post_report(payload: dict) -> None:
|
|
338
|
+
tok = node_token()
|
|
339
|
+
try:
|
|
340
|
+
req = urllib.request.Request(
|
|
341
|
+
f"{CONTROL_URL}/v1/report",
|
|
342
|
+
data=json.dumps({"node": NODE_ID, **payload}).encode("utf-8"),
|
|
343
|
+
headers={"Content-Type": "application/json",
|
|
344
|
+
**({"Authorization": f"Bearer {tok}"} if tok else {})},
|
|
345
|
+
method="POST",
|
|
346
|
+
)
|
|
347
|
+
urllib.request.urlopen(req, timeout=5).close()
|
|
348
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
|
349
|
+
pass # Reporting is telemetry. It must never affect whether an action was decided.
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _report_worker() -> None:
|
|
353
|
+
while True:
|
|
354
|
+
payload = _reports.get()
|
|
355
|
+
try:
|
|
356
|
+
if CONTROL_URL:
|
|
357
|
+
_post_report(payload)
|
|
358
|
+
finally:
|
|
359
|
+
_reports.task_done()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _ensure_workers() -> None:
|
|
363
|
+
global _workers_started
|
|
364
|
+
with _workers_lock:
|
|
365
|
+
if not _workers_started:
|
|
366
|
+
threading.Thread(target=_report_worker, daemon=True, name="underwrit-report").start()
|
|
367
|
+
_workers_started = True
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def report(payload: dict) -> None:
|
|
371
|
+
"""Queue counts and digests for the control plane. Never payloads — see the module docstring."""
|
|
372
|
+
if not CONTROL_URL:
|
|
373
|
+
return
|
|
374
|
+
_ensure_workers()
|
|
375
|
+
try:
|
|
376
|
+
_reports.put_nowait(payload)
|
|
377
|
+
except queue.Full:
|
|
378
|
+
obs.metrics.inc("underwrit_reports_dropped_total")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def flush_reports() -> None:
|
|
382
|
+
"""Wait for queued reports to be delivered. For tests and orderly shutdown."""
|
|
383
|
+
_reports.join()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
APPROVAL_WEBHOOK = os.environ.get("UNDERWRIT_APPROVAL_WEBHOOK", "").strip()
|
|
387
|
+
CONSOLE_URL = os.environ.get("UNDERWRIT_CONSOLE_URL", "").rstrip("/")
|
|
388
|
+
_notify_q: queue.Queue = queue.Queue(maxsize=1000)
|
|
389
|
+
_escalate_q: queue.Queue = queue.Queue(maxsize=1000)
|
|
390
|
+
_notifier_started = False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def notify_hold(rec: dict, sess: dict, decision) -> None:
|
|
394
|
+
"""Tell an approval channel that a decision is waiting. Slack-compatible incoming webhook.
|
|
395
|
+
|
|
396
|
+
Carries the tool, session, reasons, risk and a link — never an argument value. The approver
|
|
397
|
+
reads the arguments in the console, where they stay.
|
|
398
|
+
"""
|
|
399
|
+
global _notifier_started
|
|
400
|
+
if not APPROVAL_WEBHOOK:
|
|
401
|
+
return
|
|
402
|
+
text = (f"Underwrit: {rec['tool']} held for a person in {sess.get('environment') or 'unknown'} "
|
|
403
|
+
f"(session {sess['id']}, risk {decision.risk}{'' if decision.enforced else ', shadow'}).\n"
|
|
404
|
+
+ "\n".join(f"• {r}" for r in decision.reasons[:3])
|
|
405
|
+
+ (f"\n{CONSOLE_URL}/#inbox" if CONSOLE_URL else ""))
|
|
406
|
+
payload = {"text": text, "decision": rec["id"], "session": sess["id"], "tool": rec["tool"],
|
|
407
|
+
"environment": sess.get("environment", ""), "risk": decision.risk,
|
|
408
|
+
"enforced": decision.enforced, "reasons": decision.reasons[:3]}
|
|
409
|
+
with _workers_lock:
|
|
410
|
+
if not _notifier_started:
|
|
411
|
+
threading.Thread(target=_notifier, daemon=True, name="underwrit-notify").start()
|
|
412
|
+
threading.Thread(target=_notifier, args=(_escalate_q,), daemon=True, name="underwrit-escalate-notify").start()
|
|
413
|
+
_notifier_started = True
|
|
414
|
+
try:
|
|
415
|
+
_notify_q.put_nowait(payload)
|
|
416
|
+
except queue.Full:
|
|
417
|
+
obs.metrics.inc("underwrit_notifications_dropped_total")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
ESCALATION_WEBHOOK = os.environ.get("UNDERWRIT_ESCALATION_WEBHOOK", "").strip() or APPROVAL_WEBHOOK
|
|
421
|
+
ESCALATION_SWEEP_SECONDS = float(os.environ.get("UNDERWRIT_ESCALATION_SWEEP_SECONDS", "30") or 0)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def approver_stats(c, window: float = 86400.0) -> list[dict]:
|
|
425
|
+
"""Per approver: answers, allow rate, latency, and a fatigue flag.
|
|
426
|
+
|
|
427
|
+
The flag is a heuristic, named as one: more than twenty answers in the window, over 95% allows,
|
|
428
|
+
and a median latency under ten seconds — the pattern of somebody clicking through rather than
|
|
429
|
+
reading. It is a prompt to look, not a verdict on the person.
|
|
430
|
+
"""
|
|
431
|
+
since = time.time() - window
|
|
432
|
+
rows = c.execute(
|
|
433
|
+
"SELECT a.subject, a.verdict, a.at, d.decided_at FROM approvals a JOIN decisions d ON d.id = a.decision_id "
|
|
434
|
+
"ORDER BY a.at ASC").fetchall()
|
|
435
|
+
by: dict[str, dict] = {}
|
|
436
|
+
for r in rows:
|
|
437
|
+
st = by.setdefault(r["subject"], {"approver": r["subject"], "total": 0, "allow": 0, "deny": 0,
|
|
438
|
+
"windowTotal": 0, "windowAllow": 0, "latencies": [], "lastAt": 0.0})
|
|
439
|
+
st["total"] += 1
|
|
440
|
+
st["allow" if r["verdict"] == "allow" else "deny"] += 1
|
|
441
|
+
st["lastAt"] = max(st["lastAt"], float(r["at"]))
|
|
442
|
+
if float(r["at"]) >= since:
|
|
443
|
+
st["windowTotal"] += 1
|
|
444
|
+
st["windowAllow"] += r["verdict"] == "allow"
|
|
445
|
+
st["latencies"].append(max(0.0, float(r["at"]) - float(r["decided_at"] or r["at"])))
|
|
446
|
+
out = []
|
|
447
|
+
for st in by.values():
|
|
448
|
+
lat = sorted(st.pop("latencies"))
|
|
449
|
+
median = lat[len(lat) // 2] if lat else None
|
|
450
|
+
st["medianLatencySeconds"] = round(median, 1) if median is not None else None
|
|
451
|
+
st["meanLatencySeconds"] = round(sum(lat) / len(lat), 1) if lat else None
|
|
452
|
+
st["windowAllowRate"] = round(st["windowAllow"] / st["windowTotal"], 3) if st["windowTotal"] else None
|
|
453
|
+
st["fatigue"] = bool(st["windowTotal"] > 20 and (st["windowAllowRate"] or 0) > 0.95
|
|
454
|
+
and median is not None and median < 10)
|
|
455
|
+
out.append(st)
|
|
456
|
+
return sorted(out, key=lambda x: -x["total"])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def escalate_overdue(c) -> list[str]:
|
|
460
|
+
"""Holds past the approval SLA that nobody has been told about a second time: one escalation
|
|
461
|
+
each, chained, to UNDERWRIT_ESCALATION_WEBHOOK (or the approval webhook). Returns the decision ids."""
|
|
462
|
+
pol, _ = current_policy()
|
|
463
|
+
if not pol.approval_sla:
|
|
464
|
+
return []
|
|
465
|
+
now = time.time()
|
|
466
|
+
done: list[str] = []
|
|
467
|
+
for r in store.overdue_unescalated(c, pol.approval_sla):
|
|
468
|
+
waited = int(now - float(r["decidedAt"]))
|
|
469
|
+
payload = {"kind": "escalation", "text": (f"Underwrit: {r['tool']} has waited {waited}s for a person in "
|
|
470
|
+
f"{r.get('environment') or 'unknown'} (SLA {pol.approval_sla}s, "
|
|
471
|
+
f"session {r['sessionId']})" + (f"\n{CONSOLE_URL}/#inbox/{r['id']}" if CONSOLE_URL else "")),
|
|
472
|
+
"decision": r["id"], "session": r["sessionId"], "tool": r["tool"],
|
|
473
|
+
"environment": r.get("environment", ""), "waitedSeconds": waited, "slaSeconds": pol.approval_sla,
|
|
474
|
+
"answeredBy": [a["subject"] for a in r.get("approvals", [])], "needed": r.get("needed")}
|
|
475
|
+
store.mark_escalated(c, r["id"])
|
|
476
|
+
chain.record(c, actor="system", actor_id=r["sessionId"], action="decision.escalate", outcome="noted",
|
|
477
|
+
environment=r.get("environment", ""), target=r["tool"],
|
|
478
|
+
reason=f"held {waited}s, SLA {pol.approval_sla}s",
|
|
479
|
+
detail={"sessionId": r["sessionId"], "decisionId": r["id"], "waitedSeconds": waited,
|
|
480
|
+
"slaSeconds": pol.approval_sla}, source_ip="")
|
|
481
|
+
obs.metrics.inc("underwrit_holds_escalated_total", {"environment": r.get("environment", "")})
|
|
482
|
+
obs.log("hold.escalated", decision=r["id"], waited=waited)
|
|
483
|
+
if ESCALATION_WEBHOOK:
|
|
484
|
+
global _notifier_started
|
|
485
|
+
with _workers_lock:
|
|
486
|
+
if not _notifier_started:
|
|
487
|
+
threading.Thread(target=_notifier, daemon=True, name="underwrit-notify").start()
|
|
488
|
+
threading.Thread(target=_notifier, args=(_escalate_q,), daemon=True, name="underwrit-escalate-notify").start()
|
|
489
|
+
_notifier_started = True
|
|
490
|
+
try:
|
|
491
|
+
# Its own queue and worker: an escalation must not wait behind a backlog of holds.
|
|
492
|
+
_escalate_q.put_nowait({**payload, "_url": ESCALATION_WEBHOOK})
|
|
493
|
+
except queue.Full:
|
|
494
|
+
obs.metrics.inc("underwrit_notifications_dropped_total", {"queue": "escalation"})
|
|
495
|
+
done.append(r["id"])
|
|
496
|
+
return done
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
SHADOW_HOLD_TTL = float(os.environ.get("UNDERWRIT_SHADOW_HOLD_TTL_SECONDS", str(7 * 86400)) or 0)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def expire_shadow_holds(c) -> list[str]:
|
|
503
|
+
"""A hold in an environment that is not enforcing was never going to stop anything; after
|
|
504
|
+
UNDERWRIT_SHADOW_HOLD_TTL_SECONDS it is closed as `expired` so the inbox shows what still matters.
|
|
505
|
+
Chained, like every other way a decision is settled."""
|
|
506
|
+
if SHADOW_HOLD_TTL <= 0:
|
|
507
|
+
return []
|
|
508
|
+
pol, _ = current_policy()
|
|
509
|
+
done: list[str] = []
|
|
510
|
+
for r in store.stale_holds(c, SHADOW_HOLD_TTL):
|
|
511
|
+
env = r.get("environment", "")
|
|
512
|
+
if pol.enforcing(env):
|
|
513
|
+
continue
|
|
514
|
+
settled = store.resolve_decision(c, r["id"], approver="system", verdict="expired",
|
|
515
|
+
reason=f"shadow hold older than {int(SHADOW_HOLD_TTL)}s")
|
|
516
|
+
if not settled or not settled.get("settledNow"):
|
|
517
|
+
continue
|
|
518
|
+
chain.record(c, actor="system", actor_id=r["sessionId"], action="decision.expire", outcome="noted",
|
|
519
|
+
environment=env, target=r["tool"], reason="shadow hold expired unanswered",
|
|
520
|
+
detail={"sessionId": r["sessionId"], "decisionId": r["id"], "ttlSeconds": SHADOW_HOLD_TTL},
|
|
521
|
+
source_ip="")
|
|
522
|
+
obs.metrics.inc("underwrit_holds_expired_total", {"environment": env})
|
|
523
|
+
done.append(r["id"])
|
|
524
|
+
return done
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _escalator() -> None:
|
|
528
|
+
while True:
|
|
529
|
+
time.sleep(ESCALATION_SWEEP_SECONDS)
|
|
530
|
+
try:
|
|
531
|
+
with _scoped() as c:
|
|
532
|
+
escalate_overdue(c)
|
|
533
|
+
expire_shadow_holds(c)
|
|
534
|
+
except Exception as exc: # noqa: BLE001
|
|
535
|
+
obs.log("escalation.error", error=type(exc).__name__)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _notifier(q: "queue.Queue | None" = None) -> None:
|
|
539
|
+
q = q if q is not None else _notify_q
|
|
540
|
+
while True:
|
|
541
|
+
payload = q.get()
|
|
542
|
+
url = payload.pop("_url", None) or APPROVAL_WEBHOOK
|
|
543
|
+
try:
|
|
544
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"),
|
|
545
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
546
|
+
urllib.request.urlopen(req, timeout=5).close()
|
|
547
|
+
obs.metrics.inc("underwrit_notifications_total", {"result": "sent"})
|
|
548
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
|
549
|
+
obs.metrics.inc("underwrit_notifications_total", {"result": "failed"})
|
|
550
|
+
finally:
|
|
551
|
+
_notify_q.task_done()
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _poller() -> None:
|
|
555
|
+
while True:
|
|
556
|
+
refresh_policy()
|
|
557
|
+
time.sleep(POLL_SECONDS)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _sweeper() -> None:
|
|
561
|
+
while True:
|
|
562
|
+
time.sleep(SWEEP_SECONDS)
|
|
563
|
+
try:
|
|
564
|
+
with _scoped() as c:
|
|
565
|
+
r = retention.sweep(c, actor="system:retention", record_empty=False)
|
|
566
|
+
if r["purged"]:
|
|
567
|
+
obs.metrics.inc("underwrit_retention_purged_total", value=r["purged"])
|
|
568
|
+
obs.log("retention.sweep", purged=r["purged"], sessions=r["sessions"])
|
|
569
|
+
except Exception as exc: # noqa: BLE001 — a sweep must never take the service down
|
|
570
|
+
obs.log("retention.error", error=type(exc).__name__)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# --------------------------------------------------------------------------------------------
|
|
574
|
+
# Rate limiting
|
|
575
|
+
|
|
576
|
+
_limiter: ratelimit.Limiter | None = None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def limiter() -> ratelimit.Limiter:
|
|
580
|
+
global _limiter
|
|
581
|
+
if _limiter is None:
|
|
582
|
+
_limiter = ratelimit.Limiter(RATE_PER_MINUTE)
|
|
583
|
+
return _limiter
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --------------------------------------------------------------------------------------------
|
|
587
|
+
# Handlers
|
|
588
|
+
|
|
589
|
+
FAILED_OUTCOMES = ("failed", "error", "denied")
|
|
590
|
+
OUTCOMES = ("succeeded", "failed", "denied", "uncertain", "skipped", "error")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _act(verdict: str, enforced: bool) -> str:
|
|
594
|
+
# `act` is what the caller should actually do. In shadow mode a held verdict still says
|
|
595
|
+
# "proceed" — the operator has not turned enforcement on, and a client that blocked anyway
|
|
596
|
+
# would be enforcing a policy nobody enabled.
|
|
597
|
+
return "hold" if verdict in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY) and enforced else "proceed"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def h_decide(body: dict) -> tuple[int, dict]:
|
|
601
|
+
session_id = str(body.get("session") or "")
|
|
602
|
+
tool = str(body.get("tool") or (body.get("action") or {}).get("kind") or "")
|
|
603
|
+
if not tool:
|
|
604
|
+
return 400, {"error": "tool is required"}
|
|
605
|
+
|
|
606
|
+
c = conn()
|
|
607
|
+
sess = store.get_session(c, session_id) if session_id else None
|
|
608
|
+
if sess is None:
|
|
609
|
+
sess = store.open_session(
|
|
610
|
+
c, session_id=session_id, actor=str(body.get("actor") or ""),
|
|
611
|
+
agent=str(body.get("agent") or ""), environment=str(body.get("environment") or ""),
|
|
612
|
+
intent=str(body.get("intent") or ""),
|
|
613
|
+
task_policy=body.get("taskPolicy") if isinstance(body.get("taskPolicy"), dict) else None,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
for field_name in ("arguments", "action", "taskPolicy"):
|
|
617
|
+
if field_name in body and body[field_name] is not None and not isinstance(body[field_name], dict):
|
|
618
|
+
return 400, {"error": f"{field_name} must be a JSON object"}
|
|
619
|
+
if "preconditions" in body and isinstance(body["preconditions"], (str, int, float, bool)):
|
|
620
|
+
return 400, {"error": "preconditions must be a JSON object or array"}
|
|
621
|
+
idem = str(body.get("idempotencyKey") or body.get("idempotency_key") or "")
|
|
622
|
+
if idem:
|
|
623
|
+
existing = store.find_by_idempotency(c, sess["id"], idem)
|
|
624
|
+
if existing:
|
|
625
|
+
# The same request again is the same decision. Recording it twice would make a retry
|
|
626
|
+
# look like two proposals, and a claim on either would let one approval run twice.
|
|
627
|
+
return 200, {**_decision_view(existing), "session": sess["id"], "duplicate": True,
|
|
628
|
+
"act": _act(existing["verdict"], existing["enforced"])}
|
|
629
|
+
|
|
630
|
+
pol, _ = current_policy()
|
|
631
|
+
args = body.get("arguments") or {}
|
|
632
|
+
preconditions = body.get("preconditions")
|
|
633
|
+
# An external classifier's verdict, if the runtime ran one: it can raise taint and risk, never
|
|
634
|
+
# lower them. Underwrit stays a provenance engine; the classifier stays outside it.
|
|
635
|
+
signals = body.get("signals") if isinstance(body.get("signals"), dict) else None
|
|
636
|
+
if signals and signals.get("taint"):
|
|
637
|
+
mark = f"signal:{str(signals.get('source') or 'classifier')[:40]}"
|
|
638
|
+
if mark not in (sess.get("taint") or []):
|
|
639
|
+
store.set_taint(c, sess["id"], [*(sess.get("taint") or []), mark])
|
|
640
|
+
sess = store.get_session(c, sess["id"]) or sess
|
|
641
|
+
# v2: the authority check reads the user's request and what earlier calls returned. Nothing
|
|
642
|
+
# propagates through the model; a value is looked up at the sink in text the session holds.
|
|
643
|
+
prov = policy_mod.Provenance.from_history(
|
|
644
|
+
intent=sess["intent"], history=store.history_for(c, sess["id"]), policy=pol,
|
|
645
|
+
task=policy_mod.TaskPolicy.from_json(sess.get("taskPolicy")))
|
|
646
|
+
decision = policy_mod.decide(
|
|
647
|
+
tool=tool, environment=sess["environment"], taint=sess["taint"], policy=pol,
|
|
648
|
+
arguments=args if isinstance(args, dict) else {}, provenance=prov,
|
|
649
|
+
held_so_far=store.held_count(c, sess["id"]),
|
|
650
|
+
session_totals=store.session_totals(c, sess["id"], pol),
|
|
651
|
+
)
|
|
652
|
+
# An external policy decision point, if one is configured. It may tighten Underwrit's verdict; the
|
|
653
|
+
# exchange is chained so an auditor sees which rule spoke, and a hold that came from OPA says so.
|
|
654
|
+
decision, external = opa.consult(decision, tool=tool, environment=sess["environment"], session_id=sess["id"],
|
|
655
|
+
arguments=args if isinstance(args, dict) else {}, intent=sess["intent"])
|
|
656
|
+
if external:
|
|
657
|
+
obs.metrics.inc("underwrit_opa_total", {"result": external.get("result", ""),
|
|
658
|
+
"applied": str(bool(external.get("applied"))).lower()})
|
|
659
|
+
if signals and isinstance(signals.get("risk"), (int, float)) and int(signals["risk"]) > decision.risk:
|
|
660
|
+
decision = policy_mod.Decision(decision.verdict, list(decision.reasons)
|
|
661
|
+
+ [f"risk raised to {int(signals['risk'])} by {str(signals.get('source') or 'classifier')[:40]}"],
|
|
662
|
+
decision.enforced, decision.taint, decision.kinds, decision.authority,
|
|
663
|
+
max(0, min(100, int(signals["risk"]))), decision.budgeted)
|
|
664
|
+
frozen = store.active_freeze(c, session_id=sess["id"], agent=sess["agent"])
|
|
665
|
+
if frozen:
|
|
666
|
+
# The stop button. Every proposal is denied until somebody releases it, and the denial names
|
|
667
|
+
# who pressed it — the record of a stop is as much evidence as the record of an action.
|
|
668
|
+
decision = policy_mod.Decision(
|
|
669
|
+
policy_mod.DENY, [f"{frozen['kind']} frozen by {frozen['frozenBy']}: {frozen['reason'] or 'no reason given'}"],
|
|
670
|
+
decision.enforced, decision.taint, decision.kinds, decision.authority, 100)
|
|
671
|
+
rec = None
|
|
672
|
+
for _attempt in range(3):
|
|
673
|
+
seq = store.next_seq(c, sess["id"])
|
|
674
|
+
try:
|
|
675
|
+
rec = store.record_decision(c, session_id=sess["id"], tool=tool, args=args,
|
|
676
|
+
decision=decision, seq=seq, policy_version=pol.version,
|
|
677
|
+
preconditions=preconditions, idempotency_key=idem)
|
|
678
|
+
break
|
|
679
|
+
except Exception as exc: # noqa: BLE001 — only a unique-index collision is retried
|
|
680
|
+
msg = str(exc).lower()
|
|
681
|
+
if "unique" not in msg and "duplicate" not in msg:
|
|
682
|
+
raise
|
|
683
|
+
try:
|
|
684
|
+
c.rollback()
|
|
685
|
+
except Exception: # noqa: BLE001
|
|
686
|
+
pass
|
|
687
|
+
if idem:
|
|
688
|
+
existing = store.find_by_idempotency(c, sess["id"], idem)
|
|
689
|
+
if existing:
|
|
690
|
+
return 200, {**_decision_view(existing), "session": sess["id"], "duplicate": True,
|
|
691
|
+
"act": _act(existing["verdict"], existing["enforced"])}
|
|
692
|
+
if rec is None:
|
|
693
|
+
return 503, {"error": "could not record the decision after concurrent retries"}
|
|
694
|
+
|
|
695
|
+
entry = chain.record(
|
|
696
|
+
c, actor=f"agent:{sess['agent'] or 'unknown'}", actor_id=sess["id"], action="decision",
|
|
697
|
+
outcome=decision.verdict, environment=sess["environment"], target=tool,
|
|
698
|
+
reason="; ".join(decision.reasons),
|
|
699
|
+
detail={"sessionId": sess["id"], "decisionId": rec["id"], "argsDigest": rec["argsDigest"],
|
|
700
|
+
"preconditionsDigest": rec["preconditionsDigest"], "policyVersion": pol.version,
|
|
701
|
+
"enforced": decision.enforced, "taint": decision.taint,
|
|
702
|
+
# Field names and provenance flags only — never a value. This is the chain.
|
|
703
|
+
"authority": {"status": decision.authority["status"],
|
|
704
|
+
"fields": decision.authority["fields"],
|
|
705
|
+
"detail": decision.authority["detail"]},
|
|
706
|
+
"risk": decision.risk, "budgeted": decision.budgeted,
|
|
707
|
+
**({"external": {"opa": external}} if external else {}),
|
|
708
|
+
**({"signals": {"source": str(signals.get("source") or "classifier")[:40], "taint": bool(signals.get("taint")),
|
|
709
|
+
"risk": signals.get("risk") if isinstance(signals.get("risk"), (int, float)) else None}}
|
|
710
|
+
if signals else {})},
|
|
711
|
+
source_ip="",
|
|
712
|
+
)
|
|
713
|
+
obs.metrics.inc("underwrit_decisions_total", {"verdict": decision.verdict,
|
|
714
|
+
"environment": sess["environment"],
|
|
715
|
+
"enforced": str(decision.enforced).lower()})
|
|
716
|
+
# The endorsement-request rate is the number to watch (arXiv 2606.26479): holds over sink calls.
|
|
717
|
+
if policy_mod.SINK in decision.kinds:
|
|
718
|
+
obs.metrics.inc("underwrit_sink_calls_total", {"environment": sess["environment"]})
|
|
719
|
+
if decision.verdict == policy_mod.REQUIRE_HUMAN:
|
|
720
|
+
obs.metrics.inc("underwrit_endorsement_requests_total", {"environment": sess["environment"]})
|
|
721
|
+
obs.log("decision", session=sess["id"], decision=rec["id"], tool=tool,
|
|
722
|
+
environment=sess["environment"], verdict=decision.verdict,
|
|
723
|
+
enforced=decision.enforced, argsDigest=rec["argsDigest"])
|
|
724
|
+
report({"kind": "decision", "verdict": decision.verdict, "environment": sess["environment"],
|
|
725
|
+
"enforced": decision.enforced, "argsDigest": rec["argsDigest"]})
|
|
726
|
+
if decision.verdict == policy_mod.REQUIRE_HUMAN:
|
|
727
|
+
notify_hold(rec, sess, decision)
|
|
728
|
+
|
|
729
|
+
out = {
|
|
730
|
+
"id": rec["id"], "session": sess["id"], **decision.to_json(),
|
|
731
|
+
"argsDigest": rec["argsDigest"], "preconditionsDigest": rec["preconditionsDigest"],
|
|
732
|
+
"policyVersion": pol.version, "act": _act(decision.verdict, decision.enforced),
|
|
733
|
+
# The receipt: where this decision sits in the chain, now. The Merkle proof against a
|
|
734
|
+
# signed checkpoint follows at GET /v1/receipts/<seq> once a checkpoint covers it.
|
|
735
|
+
"receipt": {"seq": entry.get("seq"), "hash": entry.get("hash"), "prevHash": entry.get("prevHash") or entry.get("prev_hash"),
|
|
736
|
+
"proof": f"/v1/receipts/{entry.get('seq')}"},
|
|
737
|
+
}
|
|
738
|
+
if pol.terse_feedback and decision.verdict in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY):
|
|
739
|
+
# The agent learns that it is held, not why. The why is in the chain and the console; an
|
|
740
|
+
# agent that is told which value tripped the check can try another (arXiv 2604.04035).
|
|
741
|
+
out["reasons"] = ["held for a person"]
|
|
742
|
+
out["authority"] = {"status": "withheld", "fields": []}
|
|
743
|
+
return 200, out
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _decision_view(rec: dict) -> dict:
|
|
747
|
+
return {k: rec[k] for k in ("id", "sessionId", "seq", "tool", "argsDigest", "verdict",
|
|
748
|
+
"enforced", "reasons", "taint", "decidedAt", "resolvedBy",
|
|
749
|
+
"resolvedVerdict", "resolvedAt", "outcome", "policyVersion",
|
|
750
|
+
"preconditionsDigest", "claimedAt", "verification")}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def h_claim(decision_id: str, body: dict) -> tuple[int, dict]:
|
|
754
|
+
"""Revalidate an approval immediately before execution, and use it exactly once.
|
|
755
|
+
|
|
756
|
+
Refusals are recorded with the reason, and in shadow mode the caller is still told to proceed —
|
|
757
|
+
what changes is that the chain shows an approval that would not have held.
|
|
758
|
+
"""
|
|
759
|
+
c = conn()
|
|
760
|
+
rec = store.get_decision(c, decision_id)
|
|
761
|
+
if not rec:
|
|
762
|
+
return 404, {"error": "no such decision"}
|
|
763
|
+
sess = store.get_session(c, rec["sessionId"]) or {}
|
|
764
|
+
env = sess.get("environment", "")
|
|
765
|
+
pol, _ = current_policy()
|
|
766
|
+
enforced = pol.enforcing(env)
|
|
767
|
+
now = time.time()
|
|
768
|
+
|
|
769
|
+
refusal = ""
|
|
770
|
+
if rec["outcome"] == "uncertain":
|
|
771
|
+
refusal = ("the previous execution of this decision is uncertain — reconcile it with the "
|
|
772
|
+
"target and report an outcome before retrying")
|
|
773
|
+
elif rec["claimedAt"]:
|
|
774
|
+
refusal = "already claimed — an approval is used exactly once"
|
|
775
|
+
elif rec["verdict"] == policy_mod.DENY:
|
|
776
|
+
refusal = "denied by policy"
|
|
777
|
+
elif rec["verdict"] == policy_mod.REQUIRE_HUMAN:
|
|
778
|
+
if rec["resolvedVerdict"] == "deny":
|
|
779
|
+
refusal = "refused by " + (rec["resolvedBy"] or "an approver")
|
|
780
|
+
elif rec["resolvedVerdict"] == "expired":
|
|
781
|
+
refusal = "the hold expired unanswered — propose the action again"
|
|
782
|
+
elif rec["resolvedVerdict"] != "allow":
|
|
783
|
+
refusal = "awaiting approval"
|
|
784
|
+
elif rec["resolvedAt"] and now - rec["resolvedAt"] > pol.approval_ttl:
|
|
785
|
+
refusal = (f"approval expired — granted {int(now - rec['resolvedAt'])}s ago, "
|
|
786
|
+
f"valid for {pol.approval_ttl}s")
|
|
787
|
+
else:
|
|
788
|
+
# The quorum is re-evaluated under the policy in force now, not the one at approval
|
|
789
|
+
# time: a one-person shadow approval must not stay claimable after production is
|
|
790
|
+
# switched to enforcing, where two distinct people are the rule.
|
|
791
|
+
q = auth.quorum(c, decision_id=decision_id, environment=env, enforcing=enforced)
|
|
792
|
+
if not q["met"]:
|
|
793
|
+
refusal = (f"approval no longer satisfies the quorum in force ({q['have']} of "
|
|
794
|
+
f"{q['needed']} distinct people) — propose the action again")
|
|
795
|
+
key = store.args_key_of(c, decision_id)
|
|
796
|
+
if not refusal and ("arguments" in body or "preconditions" in body) and not key:
|
|
797
|
+
refusal = ("the decision's arguments have been purged by retention, so a claim can no "
|
|
798
|
+
"longer be revalidated against them")
|
|
799
|
+
if not refusal and "arguments" in body:
|
|
800
|
+
if store.digest(body.get("arguments") or {}, key) != rec["argsDigest"]:
|
|
801
|
+
refusal = "arguments changed since the decision — reconsideration required"
|
|
802
|
+
if not refusal and "preconditions" in body:
|
|
803
|
+
if store.digest(body.get("preconditions"), key) != rec["preconditionsDigest"]:
|
|
804
|
+
refusal = "relevant state changed since the decision — reconsideration required"
|
|
805
|
+
if not refusal:
|
|
806
|
+
# Recheck under the policy in force now. An approval survives a policy change unless the
|
|
807
|
+
# new policy would have refused the action outright, or would hold one that was allowed
|
|
808
|
+
# without a person. The session's taint is the one recorded at decision time — the
|
|
809
|
+
# question is whether *this* approval still holds, not whether a new one would be needed.
|
|
810
|
+
fresh = policy_mod.decide(tool=rec["tool"], environment=env, taint=rec["taint"], policy=pol)
|
|
811
|
+
if fresh.verdict == policy_mod.DENY:
|
|
812
|
+
refusal = f"policy v{pol.version} now denies {rec['tool']}"
|
|
813
|
+
elif (fresh.verdict == policy_mod.REQUIRE_HUMAN
|
|
814
|
+
and rec["verdict"] in (policy_mod.ALLOW, policy_mod.ALLOW_RECORDED)):
|
|
815
|
+
refusal = f"policy v{pol.version} now requires a person for {rec['tool']}"
|
|
816
|
+
|
|
817
|
+
actor = f"agent:{sess.get('agent') or 'unknown'}"
|
|
818
|
+
if refusal:
|
|
819
|
+
chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
|
|
820
|
+
outcome="denied", environment=env, target=rec["tool"], reason=refusal,
|
|
821
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
|
|
822
|
+
"enforced": enforced}, source_ip="")
|
|
823
|
+
obs.metrics.inc("underwrit_claims_total", {"result": "refused", "enforced": str(enforced).lower()})
|
|
824
|
+
return 200, {"ok": False, "act": "hold" if enforced else "proceed", "enforced": enforced,
|
|
825
|
+
"reason": refusal, "decision": _decision_view(rec)}
|
|
826
|
+
|
|
827
|
+
if not store.claim_decision(c, decision_id, at=now):
|
|
828
|
+
# Two claims raced past the read above; the conditional UPDATE is the gate, not the read.
|
|
829
|
+
refusal = "already claimed — an approval is used exactly once"
|
|
830
|
+
chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
|
|
831
|
+
outcome="denied", environment=env, target=rec["tool"], reason=refusal,
|
|
832
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
|
|
833
|
+
"enforced": enforced}, source_ip="")
|
|
834
|
+
obs.metrics.inc("underwrit_claims_total", {"result": "refused", "enforced": str(enforced).lower()})
|
|
835
|
+
return 200, {"ok": False, "act": "hold" if enforced else "proceed", "enforced": enforced,
|
|
836
|
+
"reason": refusal, "decision": _decision_view(store.get_decision(c, decision_id))}
|
|
837
|
+
chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
|
|
838
|
+
outcome="allowed", environment=env, target=rec["tool"],
|
|
839
|
+
reason="approval revalidated against arguments, state, policy and time",
|
|
840
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
|
|
841
|
+
"argsDigest": rec["argsDigest"], "policyVersion": pol.version,
|
|
842
|
+
"approvedBy": rec["resolvedBy"]}, source_ip="")
|
|
843
|
+
obs.metrics.inc("underwrit_claims_total", {"result": "allowed", "enforced": str(enforced).lower()})
|
|
844
|
+
return 200, {"ok": True, "act": "proceed", "enforced": enforced, "reason": "",
|
|
845
|
+
"decision": _decision_view(store.get_decision(c, decision_id))}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def h_outcome(body: dict) -> tuple[int, dict]:
|
|
849
|
+
did = str(body.get("decision") or body.get("decision_id") or "")
|
|
850
|
+
c = conn()
|
|
851
|
+
rec = store.get_decision(c, did)
|
|
852
|
+
if not rec:
|
|
853
|
+
return 404, {"error": "no such decision"}
|
|
854
|
+
status = str(body.get("status") or "")
|
|
855
|
+
detail = str(body.get("detail") or "")
|
|
856
|
+
if rec["outcome"] == "uncertain" and status and not body.get("reconcile"):
|
|
857
|
+
# An uncertain write is replaced only by a deliberate reconciliation, so a retry cannot
|
|
858
|
+
# quietly overwrite the fact that nobody knew whether the first attempt happened.
|
|
859
|
+
return 409, {"error": "outcome is uncertain; pass reconcile: true with what the target shows"}
|
|
860
|
+
store.set_outcome(c, did, outcome=status, detail=detail)
|
|
861
|
+
|
|
862
|
+
sess = store.get_session(c, rec["sessionId"]) or {}
|
|
863
|
+
new_taint = policy_mod.taint_after(
|
|
864
|
+
tool=rec["tool"], ok=status not in FAILED_OUTCOMES, returned=detail,
|
|
865
|
+
taint=sess.get("taint") or [], policy=current_policy()[0],
|
|
866
|
+
)
|
|
867
|
+
if new_taint != (sess.get("taint") or []):
|
|
868
|
+
store.set_taint(c, rec["sessionId"], new_taint)
|
|
869
|
+
|
|
870
|
+
actor = f"agent:{sess.get('agent') or 'unknown'}"
|
|
871
|
+
# Tool output is customer data with a retention clock; the chain is forever. It gets a digest
|
|
872
|
+
# and a length, never the text — the text stays in `outcome_detail` until the sweep takes it.
|
|
873
|
+
returned = (f"returned {len(detail)} chars, sha256 {hashlib.sha256(detail.encode('utf-8')).hexdigest()[:16]}"
|
|
874
|
+
if detail else "returned nothing")
|
|
875
|
+
entry = chain.record(
|
|
876
|
+
c, actor=actor, actor_id=rec["sessionId"],
|
|
877
|
+
action="outcome", outcome=status or "unknown", environment=sess.get("environment", ""),
|
|
878
|
+
target=rec["tool"], reason=f"{status or 'unknown'}; {returned}",
|
|
879
|
+
detail={"sessionId": rec["sessionId"], "decisionId": did, "returnedChars": len(detail),
|
|
880
|
+
"returnedDigest": hashlib.sha256(detail.encode("utf-8")).hexdigest() if detail else "",
|
|
881
|
+
"reconciled": bool(body.get("reconcile"))}, source_ip="",
|
|
882
|
+
)
|
|
883
|
+
out = {"seq": entry.get("seq"), "hash": entry["hash"], "taint": new_taint,
|
|
884
|
+
"receipt": {"seq": entry.get("seq"), "hash": entry["hash"], "proof": f"/v1/receipts/{entry.get('seq')}"}}
|
|
885
|
+
|
|
886
|
+
# Verification is recorded separately from execution. "The API returned 200" and "the service
|
|
887
|
+
# is healthy again" are different facts, and a record that conflated them would let a change
|
|
888
|
+
# that broke something read as a success because the call that made it succeeded.
|
|
889
|
+
ver = body.get("verification")
|
|
890
|
+
if isinstance(ver, dict) and ver.get("status"):
|
|
891
|
+
vstatus = str(ver.get("status"))
|
|
892
|
+
checks = ver.get("checks") if isinstance(ver.get("checks"), list) else []
|
|
893
|
+
record = {"status": vstatus, "checks": checks[:50], "at": time.time()}
|
|
894
|
+
store.set_verification(c, did, record)
|
|
895
|
+
v = chain.record(
|
|
896
|
+
c, actor=actor, actor_id=rec["sessionId"], action="decision.verify",
|
|
897
|
+
outcome={"passed": "verified", "failed": "verification_failed"}.get(vstatus, "unverified"),
|
|
898
|
+
environment=sess.get("environment", ""), target=rec["tool"],
|
|
899
|
+
reason=str(ver.get("summary") or "")[:400],
|
|
900
|
+
detail={"sessionId": rec["sessionId"], "decisionId": did, "status": vstatus,
|
|
901
|
+
"checks": [{"name": str(ch.get("name", "")), "ok": bool(ch.get("ok"))}
|
|
902
|
+
for ch in checks[:50] if isinstance(ch, dict)]},
|
|
903
|
+
source_ip="",
|
|
904
|
+
)
|
|
905
|
+
out["verification"] = {"seq": v.get("seq"), "hash": v["hash"], "status": vstatus}
|
|
906
|
+
return 200, out
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def h_resolve(decision_id: str, body: dict, identity: dict) -> tuple[int, dict]:
|
|
910
|
+
"""Resolve a held decision.
|
|
911
|
+
|
|
912
|
+
The approver is `identity["subject"]` — taken from the token that authenticated the request and
|
|
913
|
+
never from the body. A record of who approved something is worthless if the subject of the
|
|
914
|
+
record supplies it, and this endpoint previously believed `{"approver": "priya"}`.
|
|
915
|
+
"""
|
|
916
|
+
c = conn()
|
|
917
|
+
approver = identity["subject"]
|
|
918
|
+
verdict = str(body.get("verdict") or "")
|
|
919
|
+
reason = str(body.get("reason") or "")
|
|
920
|
+
if verdict not in ("allow", "deny"):
|
|
921
|
+
return 400, {"error": "verdict must be allow or deny"}
|
|
922
|
+
|
|
923
|
+
rec = store.get_decision(c, decision_id)
|
|
924
|
+
if not rec:
|
|
925
|
+
return 404, {"error": "no such decision"}
|
|
926
|
+
if rec["verdict"] != policy_mod.REQUIRE_HUMAN:
|
|
927
|
+
return 409, {"error": f"decision is {rec['verdict']}, not held for a person"}
|
|
928
|
+
if rec["resolvedAt"]:
|
|
929
|
+
# Settled. A later answer is not recorded as a resolution it did not produce; the record
|
|
930
|
+
# and the behaviour must agree, and the behaviour is the first settlement.
|
|
931
|
+
return 409, {"error": f"already resolved ({rec['resolvedVerdict']}) by {rec['resolvedBy']}",
|
|
932
|
+
"resolvedVerdict": rec["resolvedVerdict"], "resolvedBy": rec["resolvedBy"],
|
|
933
|
+
"resolvedAt": rec["resolvedAt"]}
|
|
934
|
+
|
|
935
|
+
sess = store.get_session(c, rec["sessionId"]) or {}
|
|
936
|
+
env = sess.get("environment", "")
|
|
937
|
+
pol, _ = current_policy()
|
|
938
|
+
enforcing = pol.enforcing(env)
|
|
939
|
+
|
|
940
|
+
# What the approver was shown versus what was proposed (OWASP ASI09). A client that sends back
|
|
941
|
+
# the arguments it displayed gets them checked against the decision's digest; a mismatch means
|
|
942
|
+
# the person is approving something other than what the agent proposed, and that is refused
|
|
943
|
+
# and chained rather than quietly accepted.
|
|
944
|
+
displayed_ok = None
|
|
945
|
+
if "displayed" in body:
|
|
946
|
+
key = store.args_key_of(c, decision_id)
|
|
947
|
+
displayed_ok = bool(key) and store.digest(body.get("displayed") or {}, key) == rec["argsDigest"]
|
|
948
|
+
if not displayed_ok:
|
|
949
|
+
chain.record(c, actor=approver, actor_id=rec["sessionId"], action="decision.approval",
|
|
950
|
+
outcome="denied", environment=env, target=rec["tool"],
|
|
951
|
+
reason="what the approver was shown does not match what was proposed",
|
|
952
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
|
|
953
|
+
"approver": approver, "displayedMatches": False}, source_ip="")
|
|
954
|
+
return 409, {"error": "what you were shown does not match what the agent proposed; "
|
|
955
|
+
"reload the decision before answering", "displayedMatches": False}
|
|
956
|
+
|
|
957
|
+
auth.record_approval(c, decision_id=decision_id, subject=approver,
|
|
958
|
+
verdict=verdict, reason=reason)
|
|
959
|
+
# Approver metrics: who answers, how, and how fast. A person who allows everything in seconds
|
|
960
|
+
# is the fatigue signal the SLA sweeper and GET /v1/approvers surface (arXiv 2606.26479).
|
|
961
|
+
latency = max(0.0, time.time() - float(rec["decidedAt"] or time.time()))
|
|
962
|
+
obs.metrics.inc("underwrit_approvals_total", {"approver": approver, "verdict": verdict})
|
|
963
|
+
obs.metrics.inc("underwrit_approval_latency_seconds_sum", {"approver": approver}, value=latency)
|
|
964
|
+
obs.metrics.inc("underwrit_approval_latency_seconds_count", {"approver": approver})
|
|
965
|
+
q = auth.quorum(c, decision_id=decision_id, environment=env, enforcing=enforcing)
|
|
966
|
+
|
|
967
|
+
# Every answer is chained, including the first of two. A quorum that only recorded the vote that
|
|
968
|
+
# completed it would lose the fact that somebody else had already agreed.
|
|
969
|
+
chain.record(
|
|
970
|
+
c, actor=approver, actor_id=rec["sessionId"], action="decision.approval",
|
|
971
|
+
outcome="allowed" if verdict == "allow" else "denied", environment=env,
|
|
972
|
+
target=rec["tool"], reason=reason,
|
|
973
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id, "approver": approver,
|
|
974
|
+
"argsDigest": rec["argsDigest"], "policyVersion": rec["policyVersion"],
|
|
975
|
+
"displayedMatches": displayed_ok,
|
|
976
|
+
"quorumNeeded": q["needed"], "quorumHave": q["have"]},
|
|
977
|
+
source_ip="",
|
|
978
|
+
)
|
|
979
|
+
report({"kind": "resolve", "verdict": verdict, "environment": env})
|
|
980
|
+
|
|
981
|
+
if not q["met"]:
|
|
982
|
+
return 200, {**rec, "quorum": q,
|
|
983
|
+
"note": f"{q['have']} of {q['needed']} approvals — waiting on another person"}
|
|
984
|
+
|
|
985
|
+
settled = store.resolve_decision(c, decision_id, approver=", ".join(q["approvers"]),
|
|
986
|
+
verdict=q["outcome"], reason=reason) or {**rec, "settledNow": False}
|
|
987
|
+
if not settled.pop("settledNow", False):
|
|
988
|
+
# Another answer settled it between our read and our write. The store kept the first;
|
|
989
|
+
# so does the chain, which never records a resolution that did not take effect.
|
|
990
|
+
return 409, {"error": f"already resolved ({settled['resolvedVerdict']}) by {settled['resolvedBy']}",
|
|
991
|
+
"resolvedVerdict": settled["resolvedVerdict"], "resolvedBy": settled["resolvedBy"],
|
|
992
|
+
"resolvedAt": settled["resolvedAt"]}
|
|
993
|
+
chain.record(
|
|
994
|
+
c, actor=settled["resolvedBy"], actor_id=rec["sessionId"], action="decision.resolve",
|
|
995
|
+
outcome="allowed" if settled["resolvedVerdict"] == "allow" else "denied", environment=env,
|
|
996
|
+
target=rec["tool"], reason=reason,
|
|
997
|
+
detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
|
|
998
|
+
"approvers": q["approvers"], "argsDigest": rec["argsDigest"],
|
|
999
|
+
"policyVersion": rec["policyVersion"], "validForSeconds": pol.approval_ttl},
|
|
1000
|
+
source_ip="",
|
|
1001
|
+
)
|
|
1002
|
+
obs.log("resolve", decision=decision_id, verdict=settled["resolvedVerdict"], approvers=len(q["approvers"]))
|
|
1003
|
+
return 200, {**settled, "quorum": q, "validForSeconds": pol.approval_ttl}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _subject_for(c, session_id: str) -> Subject | None:
|
|
1007
|
+
sess = store.get_session(c, session_id)
|
|
1008
|
+
if not sess:
|
|
1009
|
+
return None
|
|
1010
|
+
counts = store.decision_counts(c, session_id)
|
|
1011
|
+
holds = retention.active_holds(c, session_id)
|
|
1012
|
+
return Subject(
|
|
1013
|
+
id=session_id, kind="session",
|
|
1014
|
+
descriptor={"agent": sess["agent"], "environment": sess["environment"],
|
|
1015
|
+
"intent": sess["intent"], "openedAt": sess["openedAt"],
|
|
1016
|
+
"closedAt": sess["closedAt"]},
|
|
1017
|
+
sections={
|
|
1018
|
+
"decisions": counts, "taint": sess["taint"],
|
|
1019
|
+
# Visible in the pack: a reader must be able to see that this subject's arguments
|
|
1020
|
+
# survived a sweep because somebody said they must, and who.
|
|
1021
|
+
"legalHold": {"active": bool(holds), "holds": holds},
|
|
1022
|
+
"retention": {"days": retention.days_for(sess["environment"]),
|
|
1023
|
+
"note": "Arguments are purged after this many days unless held; "
|
|
1024
|
+
"the chain holds digests and is unaffected."},
|
|
1025
|
+
},
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def witnessed_now(c) -> None:
|
|
1030
|
+
"""Issue a checkpoint at the current size and have the witness cosign it, before an export.
|
|
1031
|
+
|
|
1032
|
+
A bundle should carry a cosigned checkpoint whenever the witness is reachable; issuing one at
|
|
1033
|
+
export time and shipping it unwitnessed would make every fresh bundle look unwitnessed for the
|
|
1034
|
+
few seconds until the timer caught up. If the control plane is down the bundle still ships,
|
|
1035
|
+
with the log's own signature only, and says so.
|
|
1036
|
+
"""
|
|
1037
|
+
latest = cp_mod.latest(c)
|
|
1038
|
+
if not latest or latest["size"] != len(cp_mod.leaves(c)):
|
|
1039
|
+
latest = cp_mod.issue(c, log_key())
|
|
1040
|
+
state = cp_mod.witness_state(c)
|
|
1041
|
+
if not state or state["size"] != latest["size"] or state["root"] != latest["root"]:
|
|
1042
|
+
witness(latest)
|
|
1043
|
+
anchor(c, latest)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def h_evidence(session_id: str) -> tuple[int, dict]:
|
|
1047
|
+
c = conn()
|
|
1048
|
+
subject = _subject_for(c, session_id)
|
|
1049
|
+
if subject is None:
|
|
1050
|
+
return 404, {"error": "no such session"}
|
|
1051
|
+
witnessed_now(c)
|
|
1052
|
+
pack, _ = bundle.files_for(c, subject, log_key())
|
|
1053
|
+
return 200, pack
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def h_replay(body: dict) -> tuple[int, dict]:
|
|
1057
|
+
pol, _ = current_policy()
|
|
1058
|
+
candidate = policy_mod.Policy.from_json({**pol.to_json(), **(body.get("policy") or body)})
|
|
1059
|
+
since = body.get("since")
|
|
1060
|
+
try:
|
|
1061
|
+
limit = max(1, min(int(body.get("limit") or 500), 5000))
|
|
1062
|
+
except (TypeError, ValueError):
|
|
1063
|
+
return 400, {"error": "limit must be an integer"}
|
|
1064
|
+
return 200, replay_mod.replay(conn(), candidate, since=float(since) if since else None,
|
|
1065
|
+
limit=limit, attribute_changes=True, current=pol)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
# --------------------------------------------------------------------------------------------
|
|
1069
|
+
# HTTP
|
|
1070
|
+
|
|
1071
|
+
_ID_PARENTS = {"sessions", "decisions", "evidence", "tokens", "holds", "freezes", "tenants"}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
_KNOWN_HEADS = {"health", "ready", "auth", "keys", "sessions", "decide", "outcome", "decisions", "evidence",
|
|
1075
|
+
"pending", "approvers", "policy", "retention", "holds", "tokens", "checkpoint", "freezes", "whoami",
|
|
1076
|
+
"migrations", "witness", "tenants", "usage", "fleet", "report", "chain"}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def route_label(path: str) -> str:
|
|
1080
|
+
"""`/v1/decisions/d-123/resolve` → `/v1/decisions/:id/resolve`; unknown paths → `/other`."""
|
|
1081
|
+
return obs.route_label(path, _KNOWN_HEADS, _ID_PARENTS)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
MAX_BODY = int(os.environ.get("UNDERWRIT_MAX_BODY_BYTES", str(1 << 20)) or (1 << 20))
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
class _Answered(Exception):
|
|
1088
|
+
"""Raised after a response has already been sent, to unwind a handler cleanly."""
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _num(q: dict, name: str, default, *, lo, hi, integer: bool = False):
|
|
1092
|
+
"""A bounded number from a query string, the default when absent, None when malformed."""
|
|
1093
|
+
raw = (q.get(name) or [None])[0]
|
|
1094
|
+
if raw is None:
|
|
1095
|
+
return default
|
|
1096
|
+
try:
|
|
1097
|
+
v = int(raw) if integer else float(raw)
|
|
1098
|
+
except (TypeError, ValueError):
|
|
1099
|
+
return None
|
|
1100
|
+
return v if lo <= v <= hi else None
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1104
|
+
server_version = "underwrit/0.3"
|
|
1105
|
+
timeout = float(os.environ.get("UNDERWRIT_SOCKET_TIMEOUT", "30") or 30) # an idle client cannot pin a thread
|
|
1106
|
+
|
|
1107
|
+
def log_message(self, fmt, *args): # structured logging replaces the default
|
|
1108
|
+
pass
|
|
1109
|
+
|
|
1110
|
+
def _send(self, code: int, payload, ctype="application/json", headers: dict | None = None):
|
|
1111
|
+
raw = payload if isinstance(payload, bytes) else json.dumps(payload, indent=2).encode()
|
|
1112
|
+
self._status = code
|
|
1113
|
+
self.send_response(code)
|
|
1114
|
+
self.send_header("Content-Type", ctype)
|
|
1115
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
1116
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1117
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
1118
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
1119
|
+
for k, v in (headers or {}).items():
|
|
1120
|
+
self.send_header(k, v)
|
|
1121
|
+
self.end_headers()
|
|
1122
|
+
if self.command != "HEAD":
|
|
1123
|
+
self.wfile.write(raw)
|
|
1124
|
+
|
|
1125
|
+
def _identity(self):
|
|
1126
|
+
if getattr(self, "_ident", None) is None:
|
|
1127
|
+
self._ident = auth.identify(conn(), self.headers.get("Authorization"), plane="data") or {}
|
|
1128
|
+
return self._ident or None
|
|
1129
|
+
|
|
1130
|
+
def _gate(self, action: str):
|
|
1131
|
+
"""Authorise, or send the refusal and return None.
|
|
1132
|
+
|
|
1133
|
+
Refusals are audited. A log of things that worked cannot show somebody probing for what they
|
|
1134
|
+
are not allowed to do, and that is most of what an audit log is read for.
|
|
1135
|
+
"""
|
|
1136
|
+
ident = self._identity()
|
|
1137
|
+
try:
|
|
1138
|
+
if ident and ident.get("breakGlass") and action in ("token.mint", "token.revoke"):
|
|
1139
|
+
raise auth.Denied(403, "an emergency credential cannot mint, rotate or revoke tokens; "
|
|
1140
|
+
"issue what you need from the host")
|
|
1141
|
+
return auth.require(ident, action)
|
|
1142
|
+
except auth.Denied as d:
|
|
1143
|
+
chain.record(
|
|
1144
|
+
conn(), actor=(ident or {}).get("subject", "anonymous"),
|
|
1145
|
+
actor_id=(ident or {}).get("tokenId", ""), action=f"refused.{action}",
|
|
1146
|
+
outcome="denied", environment="", target=self.path.split("?")[0],
|
|
1147
|
+
reason=d.reason, detail={"status": d.status}, source_ip=self.client_address[0],
|
|
1148
|
+
)
|
|
1149
|
+
self._send(d.status, {"error": d.reason})
|
|
1150
|
+
return None
|
|
1151
|
+
|
|
1152
|
+
def _limited(self) -> bool:
|
|
1153
|
+
lim = limiter()
|
|
1154
|
+
if not lim.enabled:
|
|
1155
|
+
return False
|
|
1156
|
+
# Underwrit tokens are looked up locally; anything else (an OIDC JWT, garbage) is limited by
|
|
1157
|
+
# address *before* it can make the plane fetch a JWKS or hash a token.
|
|
1158
|
+
header = self.headers.get("Authorization") or ""
|
|
1159
|
+
local = header.lower().startswith("bearer underwrit_")
|
|
1160
|
+
ident = self._identity() if local else None
|
|
1161
|
+
key = ident["tokenId"] if ident else f"ip:{self.client_address[0]}"
|
|
1162
|
+
ok, wait = lim.allow(key)
|
|
1163
|
+
if ok:
|
|
1164
|
+
return False
|
|
1165
|
+
obs.metrics.inc("underwrit_rate_limited_total")
|
|
1166
|
+
self._send(429, {"error": "rate limited", "retryAfterSeconds": round(wait, 2)},
|
|
1167
|
+
headers={"Retry-After": str(max(1, int(wait + 0.999)))})
|
|
1168
|
+
return True
|
|
1169
|
+
|
|
1170
|
+
def _body(self) -> dict:
|
|
1171
|
+
"""The JSON object body, or a 400/413 already sent. Nothing is read before the caller has
|
|
1172
|
+
an identity, so an anonymous client cannot make the plane consume a large body."""
|
|
1173
|
+
raw_len = self.headers.get("Content-Length")
|
|
1174
|
+
try:
|
|
1175
|
+
n = int(raw_len or 0)
|
|
1176
|
+
except (TypeError, ValueError):
|
|
1177
|
+
self._send(400, {"error": "Content-Length must be an integer"})
|
|
1178
|
+
raise _Answered()
|
|
1179
|
+
if n < 0:
|
|
1180
|
+
self._send(400, {"error": "Content-Length must not be negative"})
|
|
1181
|
+
raise _Answered()
|
|
1182
|
+
if n > MAX_BODY:
|
|
1183
|
+
self._send(413, {"error": f"body larger than {MAX_BODY} bytes"})
|
|
1184
|
+
raise _Answered()
|
|
1185
|
+
if not n:
|
|
1186
|
+
return {}
|
|
1187
|
+
try:
|
|
1188
|
+
v = json.loads(self.rfile.read(n).decode("utf-8"))
|
|
1189
|
+
except (ValueError, UnicodeDecodeError):
|
|
1190
|
+
self._send(400, {"error": "body must be valid JSON"})
|
|
1191
|
+
raise _Answered()
|
|
1192
|
+
if not isinstance(v, dict):
|
|
1193
|
+
self._send(400, {"error": "body must be a JSON object"})
|
|
1194
|
+
raise _Answered()
|
|
1195
|
+
return v
|
|
1196
|
+
|
|
1197
|
+
def _dispatch(self, fn) -> None:
|
|
1198
|
+
t0 = time.monotonic()
|
|
1199
|
+
self._status = 0
|
|
1200
|
+
self._ident = None
|
|
1201
|
+
path = self.path.split("?")[0]
|
|
1202
|
+
try:
|
|
1203
|
+
fn(path.rstrip("/") or "/")
|
|
1204
|
+
except _Answered:
|
|
1205
|
+
pass
|
|
1206
|
+
except Exception as exc: # noqa: BLE001 — a handler bug must produce a response, not a hang
|
|
1207
|
+
obs.log("http.error", method=self.command, path=path, error=type(exc).__name__)
|
|
1208
|
+
if not self._status:
|
|
1209
|
+
try:
|
|
1210
|
+
self._send(500, {"error": "internal error"})
|
|
1211
|
+
except OSError:
|
|
1212
|
+
pass
|
|
1213
|
+
finally:
|
|
1214
|
+
ident = self._ident or {}
|
|
1215
|
+
if ident.get("breakGlass") and self._status and path not in ("/v1/health", "/v1/ready"):
|
|
1216
|
+
# Emergency access leaves the fullest trail: every request, by name, chained.
|
|
1217
|
+
try:
|
|
1218
|
+
chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"],
|
|
1219
|
+
action=f"break-glass.{self.command.lower()}", outcome=str(self._status),
|
|
1220
|
+
environment="", target=path, reason="emergency credential in use",
|
|
1221
|
+
detail={"status": self._status}, source_ip=self.client_address[0])
|
|
1222
|
+
obs.metrics.inc("underwrit_break_glass_requests_total")
|
|
1223
|
+
report({"kind": "break-glass", "verdict": self.command.lower(), "environment": path})
|
|
1224
|
+
except Exception: # noqa: BLE001
|
|
1225
|
+
pass
|
|
1226
|
+
if db.is_postgres() and getattr(_local, "conn", None) is not None:
|
|
1227
|
+
# Request threads are short-lived: hand the pooled connection back, or the pool
|
|
1228
|
+
# drains after ten requests and every later one waits on a connection that is never
|
|
1229
|
+
# coming back.
|
|
1230
|
+
try:
|
|
1231
|
+
_local.conn.close()
|
|
1232
|
+
finally:
|
|
1233
|
+
_local.conn = None
|
|
1234
|
+
obs.metrics.inc("underwrit_http_requests_total", {"method": self.command,
|
|
1235
|
+
"route": route_label(path),
|
|
1236
|
+
"status": str(self._status)})
|
|
1237
|
+
obs.log("http", method=self.command, path=path, status=self._status,
|
|
1238
|
+
ms=round((time.monotonic() - t0) * 1000, 1),
|
|
1239
|
+
subject=ident.get("subject", ""), role=ident.get("role", ""),
|
|
1240
|
+
ip=self.client_address[0])
|
|
1241
|
+
|
|
1242
|
+
def do_OPTIONS(self):
|
|
1243
|
+
self._send(204, b"")
|
|
1244
|
+
|
|
1245
|
+
def do_GET(self):
|
|
1246
|
+
self._dispatch(self._get)
|
|
1247
|
+
|
|
1248
|
+
def _get(self, path: str):
|
|
1249
|
+
c = conn()
|
|
1250
|
+
# Health is deliberately open: a load balancer has no token, and it reveals no session,
|
|
1251
|
+
# decision or argument — only whether this process is answering and what it is enforcing.
|
|
1252
|
+
if path == "/v1/health":
|
|
1253
|
+
pol, src = current_policy()
|
|
1254
|
+
verdict = chain_verdict(c)
|
|
1255
|
+
return self._send(200, {
|
|
1256
|
+
"status": "ok", "node": NODE_ID, "policyVersion": pol.version,
|
|
1257
|
+
"policySource": src, "controlPlane": CONTROL_URL or None,
|
|
1258
|
+
"enforcing": pol.enforce, "chain": verdict, "logKeyId": log_key().key_id,
|
|
1259
|
+
})
|
|
1260
|
+
if path == "/v1/ready":
|
|
1261
|
+
# Readiness is "the database answers and this node has the policy it was told to
|
|
1262
|
+
# follow", not "the process is alive". A node configured for a control plane that has
|
|
1263
|
+
# never fetched a policy would be enforcing defaults while looking healthy.
|
|
1264
|
+
try:
|
|
1265
|
+
c.execute("SELECT 1").fetchone()
|
|
1266
|
+
except Exception as exc: # noqa: BLE001
|
|
1267
|
+
return self._send(503, {"ready": False, "error": type(exc).__name__})
|
|
1268
|
+
pol, src = current_policy()
|
|
1269
|
+
if CONTROL_URL and src not in ("control-plane", "cache"):
|
|
1270
|
+
return self._send(503, {"ready": False, "error": "policy has never been fetched from the control plane",
|
|
1271
|
+
"controlPlane": CONTROL_URL, "policySource": src})
|
|
1272
|
+
return self._send(200, {"ready": True, "policySource": src})
|
|
1273
|
+
if path == "/v1/auth/config":
|
|
1274
|
+
# Open: what a browser needs to start a sign-in. Public-client details only.
|
|
1275
|
+
return self._send(200, {**oidc.public_config(), "tokens": True,
|
|
1276
|
+
"breakGlassActive": bool(conn().execute(
|
|
1277
|
+
"SELECT COUNT(*) AS n FROM tokens WHERE break_glass = 1 AND revoked_at IS NULL "
|
|
1278
|
+
"AND (expires_at IS NULL OR expires_at > ?)", (time.time(),)).fetchone()["n"])})
|
|
1279
|
+
if path == "/v1/keys":
|
|
1280
|
+
# Open: a public key is public, and an auditor with a bundle needs it to check origin.
|
|
1281
|
+
# Every key that ever signed a checkpoint here is listed, with when, so a bundle
|
|
1282
|
+
# from before a rotation can still be pinned.
|
|
1283
|
+
k = log_key()
|
|
1284
|
+
history = cp_mod.key_history(c)
|
|
1285
|
+
return self._send(200, {"keys": [{**k.public_json(), "current": True}]
|
|
1286
|
+
+ [{**h, "current": False} for h in history if h["keyId"] != k.key_id],
|
|
1287
|
+
"note": "Pin the publicKey when running verify.py to check origin; "
|
|
1288
|
+
"UNDERWRIT_PUBLIC_KEY accepts a comma-separated list."})
|
|
1289
|
+
if self._limited():
|
|
1290
|
+
return
|
|
1291
|
+
if path == "/metrics":
|
|
1292
|
+
if not self._gate("metrics"):
|
|
1293
|
+
return
|
|
1294
|
+
return self._send(200, obs.metrics.render().encode("utf-8"),
|
|
1295
|
+
ctype="text/plain; version=0.0.4; charset=utf-8")
|
|
1296
|
+
if path == "/v1/checkpoint":
|
|
1297
|
+
if not self._gate("read"):
|
|
1298
|
+
return
|
|
1299
|
+
# A read never signs: the console polling this must not mint checkpoints. The first
|
|
1300
|
+
# one is issued so a fresh plane has something to show; after that the checkpointer,
|
|
1301
|
+
# an export, or POST /v1/checkpoint moves it.
|
|
1302
|
+
latest = cp_mod.latest(c) or cp_mod.issue(c, log_key())
|
|
1303
|
+
latest = {**latest, "stale": latest["size"] != len(cp_mod.leaves(c)), "logSize": len(cp_mod.leaves(c))}
|
|
1304
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
1305
|
+
out = {**latest, "history": cp_mod.history(c, 20), "witnessed": cp_mod.witness_state(c),
|
|
1306
|
+
"witnesses": [{k: v for k, v in st.items() if k != "cosignedNote"} for st in cp_mod.witness_states(c)],
|
|
1307
|
+
"timestamps": [{k: v for k, v in t.items() if k != "token"}
|
|
1308
|
+
for t in cp_mod.timestamps_for(c, latest["size"], latest["root"])]}
|
|
1309
|
+
if q.get("from"):
|
|
1310
|
+
try:
|
|
1311
|
+
frm = _num(q, "from", 0, lo=1, hi=1 << 62, integer=True)
|
|
1312
|
+
if frm is None:
|
|
1313
|
+
return self._send(400, {"error": "from must be a positive integer"})
|
|
1314
|
+
out["consistency"] = cp_mod.consistency(c, frm)
|
|
1315
|
+
except ValueError as exc:
|
|
1316
|
+
out["consistency"] = {"error": str(exc)}
|
|
1317
|
+
return self._send(200, out)
|
|
1318
|
+
if path == "/v1/whoami":
|
|
1319
|
+
ident = self._gate("whoami")
|
|
1320
|
+
if not ident:
|
|
1321
|
+
return
|
|
1322
|
+
return self._send(200, {**ident, "grants": sorted(auth.GRANTS.get(ident["role"], ())),
|
|
1323
|
+
"breakGlass": bool(ident.get("breakGlass"))})
|
|
1324
|
+
if path == "/v1/sessions":
|
|
1325
|
+
if not self._gate("read"):
|
|
1326
|
+
return
|
|
1327
|
+
return self._send(200, {"sessions": store.list_sessions(c)})
|
|
1328
|
+
if path.startswith("/v1/receipts/"):
|
|
1329
|
+
# A per-entry receipt (the SCITT shape): leaf index, tree size, root, inclusion proof and
|
|
1330
|
+
# the signed (and, when witnessed, cosigned) checkpoint. 202 until a checkpoint covers it.
|
|
1331
|
+
if not self._gate("decision.read"):
|
|
1332
|
+
return
|
|
1333
|
+
try:
|
|
1334
|
+
seq = int(path[len("/v1/receipts/"):])
|
|
1335
|
+
except ValueError:
|
|
1336
|
+
return self._send(400, {"error": "seq must be an integer"})
|
|
1337
|
+
latest = cp_mod.latest(c)
|
|
1338
|
+
snapshot = cp_mod.tree(c)
|
|
1339
|
+
covered = latest and seq in snapshot[0][:latest["size"]]
|
|
1340
|
+
if not covered:
|
|
1341
|
+
return self._send(202, {"seq": seq, "pending": True,
|
|
1342
|
+
"note": "no signed checkpoint covers this entry yet; ask again after the next checkpoint"})
|
|
1343
|
+
view, proofs = cp_mod.inclusion_for(c, [seq], snapshot=(snapshot[0][:latest["size"]], snapshot[1][:latest["size"]]))
|
|
1344
|
+
pr = proofs.get(seq)
|
|
1345
|
+
state = cp_mod.witness_state(c)
|
|
1346
|
+
note = latest["note"]
|
|
1347
|
+
if state and not state.get("fork") and state["size"] == latest["size"] and state["root"] == latest["root"]:
|
|
1348
|
+
note = state["cosignedNote"]
|
|
1349
|
+
return self._send(200, {"seq": seq, "leafIndex": pr["leafIndex"], "treeSize": latest["size"], "root": latest["root"],
|
|
1350
|
+
"proof": pr["proof"], "checkpoint": note, "keyId": latest["keyId"],
|
|
1351
|
+
"witnessed": bool(state and not state.get("fork") and state["size"] == latest["size"]),
|
|
1352
|
+
"verify": "leaf = SHA-256(0x00 ‖ hash); RFC 9162 inclusion against root; note signed by keyId"})
|
|
1353
|
+
if path == "/v1/approvers":
|
|
1354
|
+
if not self._gate("read"):
|
|
1355
|
+
return
|
|
1356
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
1357
|
+
window = _num(q, "windowSeconds", 86400.0, lo=60.0, hi=366 * 86400.0)
|
|
1358
|
+
if window is None:
|
|
1359
|
+
return self._send(400, {"error": "windowSeconds must be a number between 60 and 31622400"})
|
|
1360
|
+
stats = approver_stats(c, window)
|
|
1361
|
+
for st in stats:
|
|
1362
|
+
obs.metrics.set("underwrit_approver_fatigue", 1.0 if st["fatigue"] else 0.0, {"approver": st["approver"]})
|
|
1363
|
+
return self._send(200, {"approvers": stats, "windowSeconds": window,
|
|
1364
|
+
"fatigueRule": ">20 answers in the window, >95% allows, median latency <10s"})
|
|
1365
|
+
if path == "/v1/pending":
|
|
1366
|
+
if not self._gate("read"):
|
|
1367
|
+
return
|
|
1368
|
+
pol, _ = current_policy()
|
|
1369
|
+
now = time.time()
|
|
1370
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
1371
|
+
limit = _num(q, "limit", 100, lo=1, hi=500, integer=True)
|
|
1372
|
+
offset = _num(q, "offset", 0, lo=0, hi=10_000_000, integer=True)
|
|
1373
|
+
if limit is None or offset is None:
|
|
1374
|
+
return self._send(400, {"error": "limit must be 1..500 and offset a non-negative integer"})
|
|
1375
|
+
total = store.pending_count(c)
|
|
1376
|
+
rows = store.pending(c, limit=limit, offset=offset)
|
|
1377
|
+
for r in rows:
|
|
1378
|
+
r["overdue"] = bool(pol.approval_sla and now - r["decidedAt"] > pol.approval_sla)
|
|
1379
|
+
r["needed"] = 2 if auth.needs_two(r.get("environment", ""), pol.enforcing(r.get("environment", ""))) else 1
|
|
1380
|
+
obs.metrics.set("underwrit_holds_pending", total)
|
|
1381
|
+
obs.metrics.set("underwrit_holds_overdue", store.overdue_count(c, pol.approval_sla) if pol.approval_sla else 0)
|
|
1382
|
+
return self._send(200, {"pending": rows, "slaSeconds": pol.approval_sla, "total": total,
|
|
1383
|
+
"limit": limit, "offset": offset})
|
|
1384
|
+
if path == "/v1/policy":
|
|
1385
|
+
if not self._gate("read"):
|
|
1386
|
+
return
|
|
1387
|
+
pol, src = current_policy()
|
|
1388
|
+
return self._send(200, {"source": src, **pol.to_json()})
|
|
1389
|
+
if path == "/v1/retention":
|
|
1390
|
+
if not self._gate("retention.read"):
|
|
1391
|
+
return
|
|
1392
|
+
return self._send(200, retention.describe(c))
|
|
1393
|
+
if path == "/v1/holds":
|
|
1394
|
+
if not self._gate("retention.read"):
|
|
1395
|
+
return
|
|
1396
|
+
return self._send(200, {"holds": retention.all_holds(c)})
|
|
1397
|
+
if path == "/v1/freezes":
|
|
1398
|
+
if not self._gate("read"):
|
|
1399
|
+
return
|
|
1400
|
+
return self._send(200, {"freezes": store.list_freezes(c, active_only=not self.path.endswith("?all=1"))})
|
|
1401
|
+
if path == "/v1/tokens":
|
|
1402
|
+
if not self._gate("token.read"):
|
|
1403
|
+
return
|
|
1404
|
+
return self._send(200, {"tokens": auth.list_tokens(
|
|
1405
|
+
c, include_revoked=bool(self.path.endswith("?all=1")))})
|
|
1406
|
+
if path == "/v1/migrations":
|
|
1407
|
+
if not self._gate("read"):
|
|
1408
|
+
return
|
|
1409
|
+
return self._send(200, {"component": "data-plane",
|
|
1410
|
+
"version": migrate.current(c, "data-plane"),
|
|
1411
|
+
"history": migrate.history(c, "data-plane")})
|
|
1412
|
+
if path.startswith("/v1/decisions/"):
|
|
1413
|
+
if not self._gate("decision.read"):
|
|
1414
|
+
return
|
|
1415
|
+
did = path[len("/v1/decisions/"):]
|
|
1416
|
+
rec = store.get_decision(c, did)
|
|
1417
|
+
if not rec:
|
|
1418
|
+
return self._send(404, {"error": "no such decision"})
|
|
1419
|
+
if (self._identity() or {}).get("role") == "agent":
|
|
1420
|
+
# An agent sees the contract view: no argument values (it sent them), and, under
|
|
1421
|
+
# terse feedback, no reasons — the same shape /v1/decide gave it.
|
|
1422
|
+
view = _decision_view(rec)
|
|
1423
|
+
pol, _ = current_policy()
|
|
1424
|
+
if pol.terse_feedback and rec["verdict"] in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY):
|
|
1425
|
+
view["reasons"] = ["held for a person"]
|
|
1426
|
+
return self._send(200, {**view, "approvals": [{"verdict": a["verdict"], "at": a["at"]}
|
|
1427
|
+
for a in auth.approvals_for(c, did)]})
|
|
1428
|
+
return self._send(200, {**rec, "approvals": auth.approvals_for(c, did)})
|
|
1429
|
+
if path.startswith("/v1/sessions/"):
|
|
1430
|
+
if not self._gate("read"):
|
|
1431
|
+
return
|
|
1432
|
+
rest = path[len("/v1/sessions/"):]
|
|
1433
|
+
sid, _, tail = rest.partition("/")
|
|
1434
|
+
if tail == "decisions":
|
|
1435
|
+
return self._send(200, {"decisions": store.decisions_for(c, sid)})
|
|
1436
|
+
sess = store.get_session(c, sid)
|
|
1437
|
+
return self._send(200, sess) if sess else self._send(404, {"error": "no such session"})
|
|
1438
|
+
if path.startswith("/v1/evidence/"):
|
|
1439
|
+
if not self._gate("evidence.read"):
|
|
1440
|
+
return
|
|
1441
|
+
rest = path[len("/v1/evidence/"):]
|
|
1442
|
+
sid, _, tail = rest.partition("/")
|
|
1443
|
+
if tail == "bundle":
|
|
1444
|
+
subject = _subject_for(c, sid)
|
|
1445
|
+
if subject is None:
|
|
1446
|
+
return self._send(404, {"error": "no such session"})
|
|
1447
|
+
witnessed_now(c)
|
|
1448
|
+
blob, name = bundle.zip_for(c, subject, log_key())
|
|
1449
|
+
self._status = 200
|
|
1450
|
+
self.send_response(200)
|
|
1451
|
+
self.send_header("Content-Type", "application/zip")
|
|
1452
|
+
self.send_header("Content-Disposition", f'attachment; filename="{name}"')
|
|
1453
|
+
self.send_header("Content-Length", str(len(blob)))
|
|
1454
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
1455
|
+
self.end_headers()
|
|
1456
|
+
return self.wfile.write(blob)
|
|
1457
|
+
code, payload = h_evidence(sid)
|
|
1458
|
+
return self._send(code, payload)
|
|
1459
|
+
self._send(404, {"error": "not found"})
|
|
1460
|
+
|
|
1461
|
+
def do_POST(self):
|
|
1462
|
+
self._dispatch(self._post)
|
|
1463
|
+
|
|
1464
|
+
def _post(self, path: str):
|
|
1465
|
+
if self._limited():
|
|
1466
|
+
return
|
|
1467
|
+
if self._identity() is None:
|
|
1468
|
+
# Every POST on this plane needs a token. Refuse before reading a byte of the body.
|
|
1469
|
+
self._gate("decide")
|
|
1470
|
+
return
|
|
1471
|
+
body = self._body()
|
|
1472
|
+
if path == "/v1/decide":
|
|
1473
|
+
if not self._gate("decide"):
|
|
1474
|
+
return
|
|
1475
|
+
return self._send(*h_decide(body))
|
|
1476
|
+
if path == "/v1/outcome":
|
|
1477
|
+
if not self._gate("outcome"):
|
|
1478
|
+
return
|
|
1479
|
+
return self._send(*h_outcome(body))
|
|
1480
|
+
if path == "/v1/sessions":
|
|
1481
|
+
if not self._gate("session.open"):
|
|
1482
|
+
return
|
|
1483
|
+
c = conn()
|
|
1484
|
+
tp = body.get("taskPolicy") if isinstance(body.get("taskPolicy"), dict) else None
|
|
1485
|
+
s = store.open_session(
|
|
1486
|
+
c, session_id=str(body.get("session") or ""), actor=str(body.get("actor") or ""),
|
|
1487
|
+
agent=str(body.get("agent") or ""),
|
|
1488
|
+
environment=str(body.get("environment") or ""),
|
|
1489
|
+
intent=str(body.get("intent") or ""), task_policy=tp)
|
|
1490
|
+
# The task policy is chained with the session: an auditor sees the scope the task was
|
|
1491
|
+
# given before it read anything, and can judge each later hold against it.
|
|
1492
|
+
ident = self._identity() or {}
|
|
1493
|
+
chain.record(c, actor=f"agent:{s['agent'] or 'unknown'}", actor_id=s["id"],
|
|
1494
|
+
action="session.open", outcome="allowed", environment=s["environment"],
|
|
1495
|
+
target=s["id"], reason="",
|
|
1496
|
+
detail={"sessionId": s["id"], **({"taskPolicy": tp} if tp else {}),
|
|
1497
|
+
# Who opened it, and — from an `act` chain — on whose behalf.
|
|
1498
|
+
"openedBy": ident.get("subject", ""),
|
|
1499
|
+
**({"onBehalfOf": ident["actor"]} if ident.get("actor") else {})},
|
|
1500
|
+
source_ip="")
|
|
1501
|
+
return self._send(200, s)
|
|
1502
|
+
if path.startswith("/v1/sessions/") and path.endswith("/close"):
|
|
1503
|
+
if not self._gate("session.close"):
|
|
1504
|
+
return
|
|
1505
|
+
sid = path[len("/v1/sessions/"):-len("/close")]
|
|
1506
|
+
c = conn()
|
|
1507
|
+
s = store.get_session(c, sid)
|
|
1508
|
+
if not s:
|
|
1509
|
+
return self._send(404, {"error": "no such session"})
|
|
1510
|
+
store.close_session(c, sid)
|
|
1511
|
+
chain.record(c, actor=f"agent:{s['agent'] or 'unknown'}", actor_id=sid,
|
|
1512
|
+
action="session.close", outcome=str(body.get("status") or "succeeded"),
|
|
1513
|
+
environment=s["environment"], target=sid, reason="",
|
|
1514
|
+
detail={"sessionId": sid}, source_ip="")
|
|
1515
|
+
return self._send(200, store.get_session(c, sid))
|
|
1516
|
+
if path.startswith("/v1/decisions/") and path.endswith("/resolve"):
|
|
1517
|
+
ident = self._gate("decision.resolve")
|
|
1518
|
+
if not ident:
|
|
1519
|
+
return
|
|
1520
|
+
did = path[len("/v1/decisions/"):-len("/resolve")]
|
|
1521
|
+
return self._send(*h_resolve(did, body, ident))
|
|
1522
|
+
if path.startswith("/v1/decisions/") and path.endswith("/claim"):
|
|
1523
|
+
if not self._gate("decide"):
|
|
1524
|
+
return
|
|
1525
|
+
did = path[len("/v1/decisions/"):-len("/claim")]
|
|
1526
|
+
return self._send(*h_claim(did, body))
|
|
1527
|
+
if path == "/v1/policy/refresh":
|
|
1528
|
+
if not self._gate("policy.write"):
|
|
1529
|
+
return
|
|
1530
|
+
return self._send(200, {"result": refresh_policy()})
|
|
1531
|
+
if path == "/v1/policy/replay":
|
|
1532
|
+
if not self._gate("policy.read"):
|
|
1533
|
+
return
|
|
1534
|
+
return self._send(*h_replay(body))
|
|
1535
|
+
if path == "/v1/policy":
|
|
1536
|
+
ident = self._gate("policy.write")
|
|
1537
|
+
if not ident:
|
|
1538
|
+
return
|
|
1539
|
+
if CONTROL_URL:
|
|
1540
|
+
# Policy is operator data distributed from one place. A local write that the next
|
|
1541
|
+
# poll silently replaced would be the "looks configured, does nothing" failure.
|
|
1542
|
+
return self._send(409, {"error": "policy is distributed by the control plane; "
|
|
1543
|
+
f"write it there ({CONTROL_URL}/v1/policy)"})
|
|
1544
|
+
pol = set_local_policy(body)
|
|
1545
|
+
chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"],
|
|
1546
|
+
action="policy.write", outcome="allowed", environment="",
|
|
1547
|
+
target=f"v{pol.version}", reason="local policy",
|
|
1548
|
+
detail={"version": pol.version, "enforce": pol.enforce}, source_ip="")
|
|
1549
|
+
return self._send(200, {"source": "local", **pol.to_json()})
|
|
1550
|
+
if path == "/v1/policy/import":
|
|
1551
|
+
ident = self._gate("policy.write")
|
|
1552
|
+
if not ident:
|
|
1553
|
+
return
|
|
1554
|
+
fmt = str(body.get("format") or "cedar")
|
|
1555
|
+
if fmt != "cedar":
|
|
1556
|
+
return self._send(400, {"error": f"unsupported format {fmt!r}; cedar is the one importer"})
|
|
1557
|
+
try:
|
|
1558
|
+
fragment = cedar.to_policy(str(body.get("text") or ""))
|
|
1559
|
+
except cedar.CedarError as exc:
|
|
1560
|
+
return self._send(400, {"error": str(exc), "format": "cedar"})
|
|
1561
|
+
pol, _ = current_policy()
|
|
1562
|
+
merged = cedar.merge(pol.to_json(), fragment)
|
|
1563
|
+
if not body.get("apply"):
|
|
1564
|
+
return self._send(200, {"applied": False, "fragment": fragment, "wouldBecome": merged})
|
|
1565
|
+
new = set_local_policy({k: merged[k] for k in ("deniedTools", "allowlist", "ceilings")})
|
|
1566
|
+
chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="policy.write",
|
|
1567
|
+
outcome="allowed", environment="", target=f"v{new.version}", reason="cedar import",
|
|
1568
|
+
detail={"version": new.version, "format": "cedar",
|
|
1569
|
+
"deniedTools": fragment["deniedTools"], "allowlistFields": sorted(fragment["allowlist"]),
|
|
1570
|
+
"ceilings": fragment["ceilings"]}, source_ip="")
|
|
1571
|
+
return self._send(200, {"applied": True, "fragment": fragment, **new.to_json()})
|
|
1572
|
+
if path == "/v1/holds/escalate":
|
|
1573
|
+
if not self._gate("policy.write"):
|
|
1574
|
+
return
|
|
1575
|
+
return self._send(200, {"escalated": escalate_overdue(conn()), "expired": expire_shadow_holds(conn())})
|
|
1576
|
+
if path == "/v1/checkpoint":
|
|
1577
|
+
if not self._gate("policy.write"):
|
|
1578
|
+
return
|
|
1579
|
+
cp = cp_mod.issue(conn(), log_key())
|
|
1580
|
+
w = witness(cp)
|
|
1581
|
+
return self._send(200, {**cp, "witness": w, "timestamps": anchor(conn(), cp)})
|
|
1582
|
+
if path == "/v1/retention/sweep":
|
|
1583
|
+
ident = self._gate("retention.write")
|
|
1584
|
+
if not ident:
|
|
1585
|
+
return
|
|
1586
|
+
r = retention.sweep(conn(), actor=ident["subject"], dry_run=bool(body.get("dryRun")))
|
|
1587
|
+
if r["purged"]:
|
|
1588
|
+
obs.metrics.inc("underwrit_retention_purged_total", value=r["purged"])
|
|
1589
|
+
return self._send(200, r)
|
|
1590
|
+
if path == "/v1/freezes":
|
|
1591
|
+
ident = self._gate("decision.resolve")
|
|
1592
|
+
if not ident:
|
|
1593
|
+
return
|
|
1594
|
+
try:
|
|
1595
|
+
f = store.freeze(conn(), kind=str(body.get("kind") or "session"), key=str(body.get("key") or ""),
|
|
1596
|
+
by=ident["subject"], reason=str(body.get("reason") or ""))
|
|
1597
|
+
except ValueError as exc:
|
|
1598
|
+
return self._send(400, {"error": str(exc)})
|
|
1599
|
+
chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="freeze",
|
|
1600
|
+
outcome="succeeded", environment="", target=f["key"], reason=f["reason"],
|
|
1601
|
+
detail={"kind": f["kind"], "freezeId": f["id"],
|
|
1602
|
+
**({"sessionId": f["key"]} if f["kind"] == "session" else {})}, source_ip="")
|
|
1603
|
+
return self._send(200, f)
|
|
1604
|
+
if path.startswith("/v1/freezes/") and path.endswith("/release"):
|
|
1605
|
+
ident = self._gate("decision.resolve")
|
|
1606
|
+
if not ident:
|
|
1607
|
+
return
|
|
1608
|
+
fid = path[len("/v1/freezes/"):-len("/release")]
|
|
1609
|
+
f = store.release_freeze(conn(), fid, by=ident["subject"], reason=str(body.get("reason") or ""))
|
|
1610
|
+
if not f:
|
|
1611
|
+
return self._send(404, {"error": "no such freeze"})
|
|
1612
|
+
chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="freeze.release",
|
|
1613
|
+
outcome="succeeded", environment="", target=f["key"], reason=f["releaseReason"],
|
|
1614
|
+
detail={"kind": f["kind"], "freezeId": f["id"],
|
|
1615
|
+
**({"sessionId": f["key"]} if f["kind"] == "session" else {})}, source_ip="")
|
|
1616
|
+
return self._send(200, f)
|
|
1617
|
+
if path == "/v1/holds":
|
|
1618
|
+
ident = self._gate("hold.write")
|
|
1619
|
+
if not ident:
|
|
1620
|
+
return
|
|
1621
|
+
try:
|
|
1622
|
+
h = retention.place_hold(conn(), subject=str(body.get("subject") or ""),
|
|
1623
|
+
by=ident["subject"], reason=str(body.get("reason") or ""))
|
|
1624
|
+
except ValueError as exc:
|
|
1625
|
+
return self._send(400, {"error": str(exc)})
|
|
1626
|
+
return self._send(200, h)
|
|
1627
|
+
if path.startswith("/v1/holds/") and path.endswith("/release"):
|
|
1628
|
+
ident = self._gate("hold.write")
|
|
1629
|
+
if not ident:
|
|
1630
|
+
return
|
|
1631
|
+
hid = path[len("/v1/holds/"):-len("/release")]
|
|
1632
|
+
try:
|
|
1633
|
+
h = retention.release_hold(conn(), hid, by=ident["subject"],
|
|
1634
|
+
reason=str(body.get("reason") or ""))
|
|
1635
|
+
except ValueError as exc:
|
|
1636
|
+
return self._send(400, {"error": str(exc)})
|
|
1637
|
+
return self._send(200, h) if h else self._send(404, {"error": "no such hold"})
|
|
1638
|
+
if path == "/v1/tokens":
|
|
1639
|
+
ident = self._gate("token.mint")
|
|
1640
|
+
if not ident:
|
|
1641
|
+
return
|
|
1642
|
+
ttl = body.get("ttlSeconds")
|
|
1643
|
+
try:
|
|
1644
|
+
token, meta = auth.mint(conn(), subject=str(body.get("subject") or ""),
|
|
1645
|
+
role=str(body.get("role") or ""),
|
|
1646
|
+
ttl=float(ttl) if ttl is not None else None)
|
|
1647
|
+
except (ValueError, TypeError) as exc:
|
|
1648
|
+
return self._send(400, {"error": str(exc)})
|
|
1649
|
+
chain.record(conn(), actor=ident["subject"], actor_id=meta["id"], action="token.mint",
|
|
1650
|
+
outcome="allowed", environment="", target=meta["subject"],
|
|
1651
|
+
reason=f"role {meta['role']}", detail=meta, source_ip="")
|
|
1652
|
+
# Returned once. There is no endpoint that reads it back — only the digest is stored.
|
|
1653
|
+
return self._send(200, {**meta, "token": token,
|
|
1654
|
+
"note": "Shown once. It is stored only as a SHA-256 digest."})
|
|
1655
|
+
if path.startswith("/v1/tokens/") and path.endswith("/revoke"):
|
|
1656
|
+
ident = self._gate("token.revoke")
|
|
1657
|
+
if not ident:
|
|
1658
|
+
return
|
|
1659
|
+
tid = path[len("/v1/tokens/"):-len("/revoke")]
|
|
1660
|
+
ok = auth.revoke(conn(), tid)
|
|
1661
|
+
chain.record(conn(), actor=ident["subject"], actor_id=tid, action="token.revoke",
|
|
1662
|
+
outcome="succeeded" if ok else "failed", environment="", target=tid,
|
|
1663
|
+
reason="" if ok else "no such active token", detail={}, source_ip="")
|
|
1664
|
+
return self._send(200 if ok else 404, {"revoked": ok, "id": tid})
|
|
1665
|
+
if path.startswith("/v1/tokens/") and path.endswith("/rotate"):
|
|
1666
|
+
ident = self._gate("token.mint")
|
|
1667
|
+
if not ident:
|
|
1668
|
+
return
|
|
1669
|
+
tid = path[len("/v1/tokens/"):-len("/rotate")]
|
|
1670
|
+
ttl = body.get("ttlSeconds")
|
|
1671
|
+
try:
|
|
1672
|
+
r = auth.rotate(conn(), tid, ttl=float(ttl) if ttl is not None else None)
|
|
1673
|
+
except (ValueError, TypeError) as exc:
|
|
1674
|
+
return self._send(400, {"error": str(exc)})
|
|
1675
|
+
if not r:
|
|
1676
|
+
return self._send(404, {"error": "no such active token"})
|
|
1677
|
+
token, meta = r
|
|
1678
|
+
chain.record(conn(), actor=ident["subject"], actor_id=meta["id"], action="token.rotate",
|
|
1679
|
+
outcome="succeeded", environment="", target=meta["subject"],
|
|
1680
|
+
reason=f"replaces {tid}", detail=meta, source_ip="")
|
|
1681
|
+
return self._send(200, {**meta, "token": token,
|
|
1682
|
+
"note": "Shown once. The previous token is revoked."})
|
|
1683
|
+
self._send(404, {"error": "not found"})
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def bootstrap() -> dict:
|
|
1687
|
+
"""Mint a first admin token when the database has none, and print it once.
|
|
1688
|
+
|
|
1689
|
+
Printed rather than written to a file or defaulted to a fixed value. A product whose first
|
|
1690
|
+
credential is a known string is a product with no first credential, and one that writes it to
|
|
1691
|
+
disk leaves it there for whoever copies the directory next. If the operator loses this, the
|
|
1692
|
+
remedy is to stop the service and delete the tokens row — not a recovery path that would itself
|
|
1693
|
+
be a way in. (`UNDERWRIT_ADMIN_TOKEN_FILE` is the one exception, for a compose or Kubernetes
|
|
1694
|
+
bootstrap where the file is a mounted secret rather than a directory somebody copies.)
|
|
1695
|
+
"""
|
|
1696
|
+
c = conn()
|
|
1697
|
+
n = c.execute("SELECT COUNT(*) AS n FROM tokens WHERE revoked_at IS NULL").fetchone()["n"]
|
|
1698
|
+
if n:
|
|
1699
|
+
return {"bootstrapped": False, "tokens": n}
|
|
1700
|
+
token, meta = auth.mint(c, subject=os.environ.get("UNDERWRIT_ADMIN", "admin"), role="admin", ttl=0)
|
|
1701
|
+
chain.record(c, actor="system", actor_id=meta["id"], action="token.bootstrap",
|
|
1702
|
+
outcome="allowed", environment="", target=meta["subject"],
|
|
1703
|
+
reason="first admin token", detail=meta, source_ip="")
|
|
1704
|
+
return {"bootstrapped": True, "token": token, **meta}
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def write_secret_file(path: str, value: str) -> None:
|
|
1708
|
+
p = Path(path)
|
|
1709
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1710
|
+
p.write_text(value + "\n")
|
|
1711
|
+
try:
|
|
1712
|
+
os.chmod(p, 0o600)
|
|
1713
|
+
except OSError:
|
|
1714
|
+
pass
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def serve(host: str | None = None, port: int = 8787) -> None:
|
|
1718
|
+
host = host or HOST
|
|
1719
|
+
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
1720
|
+
conn()
|
|
1721
|
+
boot = bootstrap()
|
|
1722
|
+
if CONTROL_URL:
|
|
1723
|
+
refresh_policy()
|
|
1724
|
+
threading.Thread(target=_poller, daemon=True, name="underwrit-policy").start()
|
|
1725
|
+
_ensure_workers()
|
|
1726
|
+
if SWEEP_SECONDS > 0:
|
|
1727
|
+
threading.Thread(target=_sweeper, daemon=True, name="underwrit-retention").start()
|
|
1728
|
+
key = log_key()
|
|
1729
|
+
if CHECKPOINT_SECONDS > 0:
|
|
1730
|
+
threading.Thread(target=_checkpointer, daemon=True, name="underwrit-checkpoint").start()
|
|
1731
|
+
if ESCALATION_SWEEP_SECONDS > 0:
|
|
1732
|
+
threading.Thread(target=_escalator, daemon=True, name="underwrit-escalation").start()
|
|
1733
|
+
pol, src = current_policy()
|
|
1734
|
+
enforcing = [k for k, v in pol.enforce.items() if v] or ["nothing — shadow mode"]
|
|
1735
|
+
say = lambda m: print(m, flush=True)
|
|
1736
|
+
say(f"underwrit data plane http://{host}:{port}")
|
|
1737
|
+
say(f" database {DB_PATH} (schema v{migrate.current(conn(), 'data-plane')})")
|
|
1738
|
+
say(f" control {CONTROL_URL or 'none (local policy)'} [{src}]")
|
|
1739
|
+
say(f" enforcing {', '.join(enforcing)}")
|
|
1740
|
+
say(f" retention {retention.config()['defaultDays']} days"
|
|
1741
|
+
+ (f", per environment {retention.config()['environments']}" if retention.config()['environments'] else "")
|
|
1742
|
+
+ (f", sweep every {int(SWEEP_SECONDS)}s" if SWEEP_SECONDS > 0 else ", no scheduled sweep"))
|
|
1743
|
+
say(f" rate limit {RATE_PER_MINUTE or 'off'}" + (" requests/min per token" if RATE_PER_MINUTE else ""))
|
|
1744
|
+
say(f" log key {key.key_id} ({cp_mod.default_key_path(DB_PATH)})"
|
|
1745
|
+
+ ("" if os.environ.get("UNDERWRIT_SIGNING_KEY_FILE") else
|
|
1746
|
+
" — DEFAULT PATH beside the database: set UNDERWRIT_SIGNING_KEY_FILE outside development, or "
|
|
1747
|
+
"moving the database mints a new key and the witness will refuse it")
|
|
1748
|
+
+ (f", checkpoint every {int(CHECKPOINT_SECONDS)}s" if CHECKPOINT_SECONDS > 0 else ""))
|
|
1749
|
+
if boot.get("bootstrapped"):
|
|
1750
|
+
admin_file = os.environ.get("UNDERWRIT_ADMIN_TOKEN_FILE", "")
|
|
1751
|
+
if admin_file:
|
|
1752
|
+
write_secret_file(admin_file, boot["token"])
|
|
1753
|
+
say(f" ADMIN TOKEN written once to {admin_file} (mode 0600); stored only as a digest")
|
|
1754
|
+
else:
|
|
1755
|
+
print()
|
|
1756
|
+
say(" ADMIN TOKEN — shown once, stored only as a digest:")
|
|
1757
|
+
say(f" {boot['token']}")
|
|
1758
|
+
say(f" subject {boot['subject']} · mint others with POST /v1/tokens")
|
|
1759
|
+
else:
|
|
1760
|
+
say(f" tokens {boot['tokens']} active")
|
|
1761
|
+
obs.log("start", plane="data", host=host, port=port, node=NODE_ID,
|
|
1762
|
+
control=bool(CONTROL_URL), policySource=src)
|
|
1763
|
+
ThreadingHTTPServer((host, port), Handler).serve_forever()
|