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.
@@ -0,0 +1,428 @@
1
+ """Signed checkpoints over the chain, and the key that signs them.
2
+
3
+ A checkpoint is a commitment to the whole log at one moment: its size and its Merkle root, signed
4
+ by the data plane's log key. It is the thing a bundle's inclusion proofs are checked against, the
5
+ thing a witness cosigns, and the thing an auditor keeps from one visit to the next so that the
6
+ consistency proof at the next visit shows the log only grew.
7
+
8
+ Format: the C2SP signed-note checkpoint (https://c2sp.org/tlog-checkpoint), which is what Sigstore
9
+ and Go's transparency logs use and what any witness in that ecosystem expects:
10
+
11
+ underwrit/<node> origin line
12
+ <tree size> ASCII decimal
13
+ <root, base64>
14
+
15
+ — underwrit/<node> <base64(key hash[4] ‖ Ed25519 signature)>
16
+
17
+ The leaf for entry i is `SHA-256(0x00 ‖ hash_i)` where `hash_i` is the entry's own chain hash as
18
+ hex bytes — so the tree commits to exactly what the chain commits to, and the two verify each other.
19
+
20
+ **The key lives in a file, not the database.** A database is backed up and copied; a key in it
21
+ is a key in every copy. The file is created 0600 on first start unless `UNDERWRIT_SIGNING_KEY_FILE`
22
+ points at one an operator provisioned (a mounted secret, a KMS-fetched file). The public key and
23
+ its id are served openly at `/v1/keys` and shipped in every bundle — and a verifier that has only
24
+ the bundle's copy proves self-consistency, not origin, which is why `verify.py` says so and accepts
25
+ a pinned key.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import time
35
+ from pathlib import Path
36
+
37
+ from . import db, ed25519, merkle
38
+
39
+ SCHEMA = [
40
+ """CREATE TABLE IF NOT EXISTS checkpoints (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ size INTEGER NOT NULL,
43
+ root TEXT NOT NULL,
44
+ note TEXT NOT NULL,
45
+ key_id TEXT NOT NULL,
46
+ at REAL NOT NULL
47
+ )""",
48
+ "CREATE INDEX IF NOT EXISTS checkpoints_size ON checkpoints (size)",
49
+ # The last checkpoint each witness cosigned, keyed by where we send checkpoints. The primary
50
+ # witness is the control plane ("control"); UNDERWRIT_WITNESSES adds more. Each row holds that
51
+ # witness's cosigned note; `witness_state()` merges the lines of every witness at the latest
52
+ # size into one note, which is what the bundle ships.
53
+ """CREATE TABLE IF NOT EXISTS witness_states (
54
+ url TEXT PRIMARY KEY,
55
+ witness TEXT NOT NULL DEFAULT '',
56
+ size INTEGER NOT NULL,
57
+ root TEXT NOT NULL,
58
+ cosigned_note TEXT NOT NULL,
59
+ at REAL NOT NULL
60
+ )""",
61
+ # External clocks: RFC 3161 time-stamp tokens and OpenTimestamps proofs over a checkpoint's
62
+ # body. The token is stored whole (base64) so the bundle ships the original bytes.
63
+ """CREATE TABLE IF NOT EXISTS timestamps (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ size INTEGER NOT NULL,
66
+ root TEXT NOT NULL,
67
+ kind TEXT NOT NULL,
68
+ token TEXT NOT NULL,
69
+ meta_json TEXT NOT NULL DEFAULT '{}',
70
+ at REAL NOT NULL
71
+ )""",
72
+ "CREATE INDEX IF NOT EXISTS timestamps_size ON timestamps (size, root)",
73
+ # Every key that has signed a checkpoint on this plane, so a bundle from before a rotation can
74
+ # still be pinned and an auditor can see when each key was in use.
75
+ """CREATE TABLE IF NOT EXISTS log_keys (
76
+ key_id TEXT PRIMARY KEY,
77
+ public_key TEXT NOT NULL,
78
+ origin TEXT NOT NULL DEFAULT '',
79
+ first_seen REAL NOT NULL,
80
+ last_seen REAL NOT NULL
81
+ )""",
82
+ ]
83
+
84
+
85
+ def init(conn) -> None:
86
+ for ddl in SCHEMA:
87
+ conn.execute(db.ddl(ddl))
88
+ conn.commit()
89
+
90
+
91
+ # --------------------------------------------------------------------------------------------
92
+ # Keys
93
+
94
+
95
+ class Key:
96
+ def __init__(self, seed: bytes, origin: str):
97
+ self.seed = seed
98
+ self.public = ed25519.public_key(seed)
99
+ self.origin = origin
100
+
101
+ @property
102
+ def key_hash(self) -> bytes:
103
+ """C2SP signed-note key hash: first four bytes of SHA-256(name ‖ 0x0A ‖ 0x01 ‖ public key)."""
104
+ return hashlib.sha256(self.origin.encode() + b"\n\x01" + self.public).digest()[:4]
105
+
106
+ @property
107
+ def cosig_key_hash(self) -> bytes:
108
+ """The key hash a witness line carries: SHA-256(name ‖ 0x0A ‖ 0x04 ‖ public key)[:4]. C2SP
109
+ gives cosignature/v1 keys their own algorithm byte (0x04) so external tooling can tell a
110
+ witness line from a log line by its hash."""
111
+ return hashlib.sha256(self.origin.encode() + b"\n\x04" + self.public).digest()[:4]
112
+
113
+ @property
114
+ def key_id(self) -> str:
115
+ return self.key_hash.hex()
116
+
117
+ @property
118
+ def cosig_key_id(self) -> str:
119
+ return self.cosig_key_hash.hex()
120
+
121
+ def public_json(self) -> dict:
122
+ return {"alg": "ed25519", "keyId": self.key_id, "cosigKeyId": self.cosig_key_id, "origin": self.origin,
123
+ "publicKey": base64.b64encode(self.public).decode(),
124
+ "fingerprint": hashlib.sha256(self.public).hexdigest()}
125
+
126
+
127
+ def load_or_create(path: str | Path, origin: str) -> tuple[Key, bool]:
128
+ """The log key from `path`, created if absent. Returns (key, created)."""
129
+ p = Path(path)
130
+ if p.exists() and p.read_text().strip():
131
+ seed = bytes.fromhex(p.read_text().strip())
132
+ return Key(seed, origin), False
133
+ seed = ed25519.generate_seed()
134
+ p.parent.mkdir(parents=True, exist_ok=True)
135
+ p.write_text(seed.hex() + "\n")
136
+ try:
137
+ os.chmod(p, 0o600)
138
+ except OSError:
139
+ pass
140
+ return Key(seed, origin), True
141
+
142
+
143
+ def default_key_path(db_path: str) -> str:
144
+ return os.environ.get("UNDERWRIT_SIGNING_KEY_FILE") or str(Path(db_path).with_suffix(".signing.key"))
145
+
146
+
147
+ # --------------------------------------------------------------------------------------------
148
+ # The tree over the chain
149
+
150
+
151
+ def leaves(conn) -> list[bytes]:
152
+ """One leaf per live chain entry, in sequence order."""
153
+ rows = conn.execute("SELECT hash FROM audit_log ORDER BY seq ASC").fetchall()
154
+ return [merkle.leaf_hash((r["hash"] or "").encode("ascii")) for r in rows]
155
+
156
+
157
+ def seqs(conn) -> list[int]:
158
+ return [int(r["seq"]) for r in conn.execute("SELECT seq FROM audit_log ORDER BY seq ASC")]
159
+
160
+
161
+ def tree(conn) -> tuple[list[int], list[bytes]]:
162
+ """(sequence numbers, leaves) so a caller can map a seq to its leaf index."""
163
+ rows = conn.execute("SELECT seq, hash FROM audit_log ORDER BY seq ASC").fetchall()
164
+ return ([int(r["seq"]) for r in rows],
165
+ [merkle.leaf_hash((r["hash"] or "").encode("ascii")) for r in rows])
166
+
167
+
168
+ # --------------------------------------------------------------------------------------------
169
+ # Notes
170
+
171
+
172
+ def note_body(origin: str, size: int, root: bytes) -> str:
173
+ return f"{origin}\n{size}\n{base64.b64encode(root).decode()}\n"
174
+
175
+
176
+ def sign_note(key: Key, size: int, root: bytes) -> str:
177
+ body = note_body(key.origin, size, root)
178
+ sig = ed25519.fast_sign(key.seed, body.encode())
179
+ line = f"— {key.origin} {base64.b64encode(key.key_hash + sig).decode()}"
180
+ return body + "\n" + line + "\n"
181
+
182
+
183
+ def parse_note(note: str) -> dict:
184
+ """{origin, size, root (bytes), signatures: [(name, keyHash hex, sig bytes)]}."""
185
+ body, _, sigs = note.partition("\n\n")
186
+ lines = body.split("\n")
187
+ if len(lines) < 3:
188
+ raise ValueError("checkpoint note is too short")
189
+ out = {"origin": lines[0], "size": int(lines[1]), "root": base64.b64decode(lines[2]),
190
+ "signatures": []}
191
+ for line in sigs.splitlines():
192
+ if not line.startswith("— "):
193
+ continue
194
+ _, name, blob = line.split(" ", 2)
195
+ raw = base64.b64decode(blob)
196
+ out["signatures"].append((name, raw[:4].hex(), raw[4:]))
197
+ return out
198
+
199
+
200
+ def verify_note(note: str, public: bytes, origin: str) -> bool:
201
+ """True when a signature by `public` for `origin` covers the note body."""
202
+ parsed = parse_note(note)
203
+ body = note_body(parsed["origin"], parsed["size"], parsed["root"]).encode()
204
+ want = hashlib.sha256(origin.encode() + b"\n\x01" + public).digest()[:4].hex()
205
+ return any(name == origin and kh == want and ed25519.fast_verify(public, body, sig)
206
+ for name, kh, sig in parsed["signatures"])
207
+
208
+
209
+ # --------------------------------------------------------------------------------------------
210
+ # Issuing
211
+
212
+
213
+ def issue(conn, key: Key, lv: list[bytes] | None = None) -> dict:
214
+ """Sign the log as it stands now (or as the given leaf snapshot) and record the checkpoint."""
215
+ lv = leaves(conn) if lv is None else lv
216
+ root = merkle.root(lv)
217
+ note = sign_note(key, len(lv), root)
218
+ now = time.time()
219
+ conn.execute("INSERT INTO checkpoints (size, root, note, key_id, at) VALUES (?, ?, ?, ?, ?)",
220
+ (len(lv), root.hex(), note, key.key_id, now))
221
+ conn.execute("INSERT INTO log_keys (key_id, public_key, origin, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) "
222
+ "ON CONFLICT (key_id) DO UPDATE SET last_seen = excluded.last_seen",
223
+ (key.key_id, base64.b64encode(key.public).decode(), key.origin, now, now))
224
+ conn.commit()
225
+ return {"size": len(lv), "root": root.hex(), "note": note, "keyId": key.key_id, "at": now}
226
+
227
+
228
+ def key_history(conn) -> list[dict]:
229
+ rows = conn.execute("SELECT * FROM log_keys ORDER BY first_seen ASC").fetchall()
230
+ return [{"alg": "ed25519", "keyId": r["key_id"], "publicKey": r["public_key"], "origin": r["origin"],
231
+ "firstSeen": r["first_seen"], "lastSeen": r["last_seen"]} for r in rows]
232
+
233
+
234
+ def latest(conn) -> dict | None:
235
+ r = conn.execute("SELECT * FROM checkpoints ORDER BY id DESC LIMIT 1").fetchone()
236
+ return ({"size": r["size"], "root": r["root"], "note": r["note"], "keyId": r["key_id"],
237
+ "at": r["at"]} if r else None)
238
+
239
+
240
+ def history(conn, limit: int = 50) -> list[dict]:
241
+ rows = conn.execute("SELECT * FROM checkpoints ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
242
+ return [{"size": r["size"], "root": r["root"], "keyId": r["key_id"], "at": r["at"]} for r in rows]
243
+
244
+
245
+ def consistency(conn, old_size: int) -> dict:
246
+ """A consistency proof from an earlier size to the log as it stands now."""
247
+ lv = leaves(conn)
248
+ if old_size < 1 or old_size > len(lv):
249
+ raise ValueError(f"old size {old_size} outside 1..{len(lv)}")
250
+ proof = merkle.consistency_proof(old_size, lv)
251
+ return {"oldSize": old_size, "newSize": len(lv), "oldRoot": merkle.root(lv[:old_size]).hex(),
252
+ "newRoot": merkle.root(lv).hex(), "proof": [p.hex() for p in proof]}
253
+
254
+
255
+ def inclusion_for(conn, wanted_seqs: list[int], snapshot: tuple[list[int], list[bytes]] | None = None
256
+ ) -> tuple[dict, dict[int, dict]]:
257
+ """A fresh unsigned tree view plus, for each wanted seq, its leaf index and inclusion proof.
258
+
259
+ Unsigned: the caller signs the checkpoint (or uses one already issued at this size) so the
260
+ tree the proofs are against is the tree the signature covers. Pass `snapshot` (from `tree()`)
261
+ to build the proofs and the checkpoint from one read of the log.
262
+ """
263
+ all_seqs, lv = snapshot if snapshot is not None else tree(conn)
264
+ index_of = {s: i for i, s in enumerate(all_seqs)}
265
+ root = merkle.root(lv)
266
+ out: dict[int, dict] = {}
267
+ for s in wanted_seqs:
268
+ i = index_of.get(s)
269
+ if i is None:
270
+ continue # not in the live log (sealed into the archive): no place in this tree
271
+ out[s] = {"leafIndex": i, "proof": [p.hex() for p in merkle.inclusion_proof(i, lv)]}
272
+ return {"size": len(lv), "root": root.hex()}, out
273
+
274
+
275
+ # --------------------------------------------------------------------------------------------
276
+ # Cosignatures (https://c2sp.org/tlog-cosignature)
277
+ #
278
+ # A cosignature is a witness's statement: "as of time T, the largest consistent tree I have seen
279
+ # for this log has this size and root." It is a signature over
280
+ #
281
+ # cosignature/v1
282
+ # time <unix seconds>
283
+ # <the checkpoint body>
284
+ #
285
+ # carried as a signed-note line whose blob is key hash[4] ‖ time (8 bytes, big-endian) ‖ Ed25519
286
+ # signature. Verifiers ignore signature lines from names they do not know, so a log signature and
287
+ # any number of cosignatures share one note.
288
+
289
+ COSIG_PREFIX = "cosignature/v1\n"
290
+
291
+
292
+ def cosign_note(witness: Key, note: str, at: float | None = None) -> str:
293
+ """Append this witness's cosignature line to a checkpoint note."""
294
+ parsed = parse_note(note)
295
+ body = note_body(parsed["origin"], parsed["size"], parsed["root"])
296
+ t = int(time.time() if at is None else at)
297
+ msg = f"{COSIG_PREFIX}time {t}\n{body}".encode()
298
+ sig = ed25519.fast_sign(witness.seed, msg)
299
+ blob = witness.cosig_key_hash + t.to_bytes(8, "big") + sig
300
+ line = f"— {witness.origin} {base64.b64encode(blob).decode()}"
301
+ return note.rstrip("\n") + "\n" + line + "\n"
302
+
303
+
304
+ def verify_cosignature(note: str, witness_public: bytes, witness_origin: str) -> int | None:
305
+ """The cosignature's timestamp when `witness_origin` cosigned this note, else None."""
306
+ parsed = parse_note(note)
307
+ body = note_body(parsed["origin"], parsed["size"], parsed["root"])
308
+ want = hashlib.sha256(witness_origin.encode() + b"\n\x04" + witness_public).digest()[:4].hex()
309
+ for name, kh, rest in parsed["signatures"]:
310
+ if name != witness_origin or kh != want or len(rest) != 72:
311
+ continue
312
+ t = int.from_bytes(rest[:8], "big")
313
+ msg = f"{COSIG_PREFIX}time {t}\n{body}".encode()
314
+ if ed25519.fast_verify(witness_public, msg, rest[8:]):
315
+ return t
316
+ return None
317
+
318
+
319
+ def witness_states(conn) -> list[dict]:
320
+ """What each configured witness last cosigned."""
321
+ rows = conn.execute("SELECT * FROM witness_states ORDER BY url").fetchall()
322
+ return [{"url": r["url"], "witness": r["witness"], "size": r["size"], "root": r["root"],
323
+ "cosignedNote": r["cosigned_note"], "at": r["at"]} for r in rows]
324
+
325
+
326
+ def witness_state_for(conn, url: str) -> dict | None:
327
+ r = conn.execute("SELECT * FROM witness_states WHERE url = ?", (url,)).fetchone()
328
+ return ({"url": r["url"], "witness": r["witness"], "size": r["size"], "root": r["root"],
329
+ "cosignedNote": r["cosigned_note"], "at": r["at"]} if r else None)
330
+
331
+
332
+ def merge_notes(notes: list[str]) -> str:
333
+ """One note carrying every distinct signature line of several notes over the same body."""
334
+ if not notes:
335
+ return ""
336
+ body, _, _ = notes[0].partition("\n\n")
337
+ lines: list[str] = []
338
+ for n in notes:
339
+ b, _, sigs = n.partition("\n\n")
340
+ if b != body:
341
+ continue
342
+ for line in sigs.splitlines():
343
+ if line.startswith("— ") and line not in lines:
344
+ lines.append(line)
345
+ return body + "\n\n" + "\n".join(lines) + "\n"
346
+
347
+
348
+ def witness_state(conn) -> dict | None:
349
+ """The witnessed view at the largest size any witness has cosigned: the merged note of every
350
+ witness at that exact tree, with their origins. None until a witness has cosigned anything."""
351
+ states = witness_states(conn)
352
+ if not states:
353
+ return None
354
+ # A fork: two witnesses cosigned different roots at the same size. That is the split-view
355
+ # event witnesses exist to expose; it is surfaced, never merged or ranked away.
356
+ by_size: dict[int, set[str]] = {}
357
+ for st in states:
358
+ by_size.setdefault(st["size"], set()).add(st["root"])
359
+ forks = {n: roots for n, roots in by_size.items() if len(roots) > 1}
360
+ if forks:
361
+ n = max(forks)
362
+ return {"fork": True, "size": n, "roots": sorted(forks[n]), "cosignedNote": "",
363
+ "witness": "", "witnesses": [], "at": max(st["at"] for st in states),
364
+ "detail": [{"witness": st["witness"], "url": st["url"], "size": st["size"], "root": st["root"]}
365
+ for st in states if st["size"] == n],
366
+ "behind": []}
367
+ top = max(states, key=lambda st: (st["size"], st["at"]))
368
+ at_top = [st for st in states if st["size"] == top["size"] and st["root"] == top["root"]]
369
+ return {"size": top["size"], "root": top["root"], "cosignedNote": merge_notes([st["cosignedNote"] for st in at_top]),
370
+ "witness": ", ".join(st["witness"] for st in at_top), "witnesses": [st["witness"] for st in at_top],
371
+ "at": max(st["at"] for st in at_top),
372
+ "behind": [{"witness": st["witness"], "url": st["url"], "size": st["size"]}
373
+ for st in states if st not in at_top]}
374
+
375
+
376
+ def save_witness_state(conn, *, size: int, root: str, cosigned_note: str, witness: str, url: str = "control") -> None:
377
+ conn.execute("INSERT INTO witness_states (url, witness, size, root, cosigned_note, at) VALUES (?, ?, ?, ?, ?, ?) "
378
+ "ON CONFLICT (url) DO UPDATE SET witness = excluded.witness, size = excluded.size, "
379
+ "root = excluded.root, cosigned_note = excluded.cosigned_note, at = excluded.at",
380
+ (url, witness, size, root, cosigned_note, time.time()))
381
+ conn.commit()
382
+
383
+
384
+ # --------------------------------------------------------------------------------------------
385
+ # External clocks
386
+
387
+
388
+ def imprint(cp: dict) -> bytes:
389
+ """What a time-stamping authority signs over: SHA-256 of the checkpoint body. The TSA sees
390
+ only this digest, never the note — nothing about the log leaves the plane."""
391
+ body = note_body(parse_note(cp["note"])["origin"], cp["size"], bytes.fromhex(cp["root"]))
392
+ return hashlib.sha256(body.encode()).digest()
393
+
394
+
395
+ def timestamps_for(conn, size: int, root: str) -> list[dict]:
396
+ rows = conn.execute("SELECT * FROM timestamps WHERE size = ? AND root = ? ORDER BY id", (size, root)).fetchall()
397
+ return [{"kind": r["kind"], "token": r["token"], "at": r["at"], **json.loads(r["meta_json"] or "{}")}
398
+ for r in rows]
399
+
400
+
401
+ def anchor(conn, cp: dict) -> list[dict]:
402
+ """Ask every configured external clock to stamp this checkpoint; store what comes back.
403
+
404
+ Idempotent per (size, root): a checkpoint re-issued at the same tree is not re-stamped.
405
+ Failures are silent here (the plane logs them); a bundle says which clocks it carries.
406
+ """
407
+ from . import tsa
408
+
409
+ have = {t["kind"] for t in timestamps_for(conn, cp["size"], cp["root"])}
410
+ out = []
411
+ digest = imprint(cp)
412
+ if "rfc3161" not in have:
413
+ tok = tsa.stamp(digest)
414
+ if tok:
415
+ meta = {k: tok.get(k) for k in ("tsa", "genTime", "serial", "imprint", "status", "hashAlg")}
416
+ conn.execute("INSERT INTO timestamps (size, root, kind, token, meta_json, at) VALUES (?, ?, ?, ?, ?, ?)",
417
+ (cp["size"], cp["root"], "rfc3161", tok["token"], json.dumps(meta), time.time()))
418
+ out.append({"kind": "rfc3161", **meta})
419
+ if "ots" not in have:
420
+ proof = tsa.ots_submit(digest)
421
+ if proof:
422
+ meta = {k: proof[k] for k in ("calendar", "status", "submittedAt", "note")}
423
+ conn.execute("INSERT INTO timestamps (size, root, kind, token, meta_json, at) VALUES (?, ?, ?, ?, ?, ?)",
424
+ (cp["size"], cp["root"], "ots", proof["proof"], json.dumps(meta), time.time()))
425
+ out.append({"kind": "ots", **meta})
426
+ if out:
427
+ conn.commit()
428
+ return out
underwrit/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """Entry points.
2
+
3
+ python3 -m underwrit serve [port] # data plane :8787
4
+ python3 -m underwrit control [port] # control plane :8788
5
+ python3 -m underwrit demo # drive sessions through a running data plane
6
+ python3 -m underwrit demo --live # the incident scenario, live, waiting on the console
7
+ python3 -m underwrit demo-stack # both planes + console + seeded data + sign-in links
8
+ python3 -m underwrit migrate # apply schema migrations and print the versions
9
+ python3 -m underwrit verify [db] # whole-chain verdict for a database file
10
+ python3 -m underwrit backup <dest> # online copy of the data plane database, verified
11
+ python3 -m underwrit restore <src> [--force] # put a backup in place; ends in verify_chain()
12
+ python3 -m underwrit sweep [--dry-run] # run a retention sweep now
13
+ python3 -m underwrit mcp # serve the data plane as MCP tools over stdio
14
+ python3 -m underwrit policy-diff cand.json # the shadow report for a candidate policy, from the CLI
15
+ python3 -m underwrit break-glass --plane data|control --reason "..." [--role admin] [--ttl 3600]
16
+
17
+ Environment: UNDERWRIT_DB, UNDERWRIT_CONTROL_DB, UNDERWRIT_CONTROL_URL, UNDERWRIT_NODE_TOKEN[_FILE], UNDERWRIT_HOST,
18
+ UNDERWRIT_RETENTION_DAYS, UNDERWRIT_RETENTION, UNDERWRIT_RATE_LIMIT_PER_MINUTE, UNDERWRIT_TOKEN_TTL_SECONDS.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+
26
+
27
+ def _out(obj) -> None:
28
+ print(json.dumps(obj, indent=2, sort_keys=True, default=str), flush=True)
29
+
30
+
31
+ def _enterprise(cmd: str, rest: list, port) -> int:
32
+ """Hand a command to Underwrit Control Plane when it is installed; say so plainly when it is not."""
33
+ try:
34
+ from underwrit_controlplane import cli as ecli
35
+ except ImportError:
36
+ print(f"`underwrit {cmd}` needs Underwrit Control Plane (the control plane, console and stack), which is "
37
+ "licensed commercially and distributed as container images, not on PyPI: see https://underwrit.io/#pricing. "
38
+ "The data plane runs alone with `underwrit serve`.", file=sys.stderr)
39
+ return 2
40
+ return ecli.main([cmd, *([str(port)] if port else []), *rest])
41
+
42
+
43
+ def main(argv=None) -> int:
44
+ argv = list(sys.argv[1:] if argv is None else argv)
45
+ cmd = argv[0] if argv else "serve"
46
+ rest = argv[1:]
47
+ port = int(rest[0]) if rest and rest[0].isdigit() else None
48
+
49
+ if cmd == "serve":
50
+ from . import api
51
+ api.serve(port=port or 8787)
52
+ elif cmd == "control":
53
+ return _enterprise("control", rest, port)
54
+ elif cmd == "demo":
55
+ from .demo import run
56
+ return run()
57
+ elif cmd == "demo-stack":
58
+ return _enterprise("demo-stack", rest, port)
59
+ elif cmd == "mcp":
60
+ from . import mcp
61
+ return mcp.main()
62
+ elif cmd == "migrate":
63
+ from . import api, db, migrate
64
+ c = db.connect(api.DB_PATH)
65
+ _out({"dataPlane": {"db": api.DB_PATH, "applied": migrate.data_plane(c),
66
+ "version": migrate.current(c, "data-plane")}})
67
+ try:
68
+ from underwrit_controlplane import control # the commercial control plane, when installed
69
+ except ImportError:
70
+ control = None
71
+ if control is not None:
72
+ cc = db.connect(control.DB_PATH)
73
+ _out({"controlPlane": {"db": control.DB_PATH, "applied": control.migrate_db(cc),
74
+ "version": migrate.current(cc, "control-plane")}})
75
+ elif cmd == "verify":
76
+ from . import api, backup
77
+ r = backup.verify(rest[0] if rest else api.DB_PATH)
78
+ _out(r)
79
+ return 0 if r.get("ok") else 1
80
+ elif cmd == "backup":
81
+ if not rest:
82
+ print("usage: python3 -m underwrit backup <dest>", file=sys.stderr)
83
+ return 2
84
+ from . import api, backup
85
+ r = backup.backup(api.DB_PATH, rest[0])
86
+ _out(r)
87
+ return 0 if r["chain"].get("ok") is not False else 1
88
+ elif cmd == "restore":
89
+ if not rest:
90
+ print("usage: python3 -m underwrit restore <src> [--force]", file=sys.stderr)
91
+ return 2
92
+ from . import api, backup
93
+ r = backup.restore(rest[0], api.DB_PATH, force="--force" in rest)
94
+ _out(r)
95
+ return 0 if r["chain"].get("ok") else 1
96
+ elif cmd == "break-glass":
97
+ import getpass
98
+ opts = {rest[i].lstrip("-"): rest[i + 1] for i in range(0, len(rest) - 1, 2) if rest[i].startswith("--")}
99
+ plane = opts.get("plane", "data")
100
+ reason = opts.get("reason", "")
101
+ if not reason:
102
+ print("break glass needs --reason; it goes in the record", file=sys.stderr)
103
+ return 2
104
+ from . import auth, chain, db
105
+ who = opts.get("by") or getpass.getuser()
106
+ ttl = float(opts.get("ttl", "3600"))
107
+ role = opts.get("role", "admin")
108
+ if plane == "control":
109
+ try:
110
+ from underwrit_controlplane import control
111
+ except ImportError:
112
+ print("the control plane is part of Underwrit Control Plane, licensed commercially and shipped as container images, not on PyPI", file=sys.stderr)
113
+ return 2
114
+ token, meta = control.mint_break_glass(reason=reason, by=who, role=role, ttl=ttl, tenant=opts.get("tenant", ""))
115
+ else:
116
+ from . import api
117
+ c = api.conn()
118
+ token, meta = auth.break_glass(c, plane="data", reason=reason, by=who, role=role, ttl=ttl)
119
+ chain.record(c, actor=meta["subject"], actor_id=meta["id"], action="token.break_glass",
120
+ outcome="allowed", environment="", target=meta["subject"], reason=reason,
121
+ detail={"role": role, "ttlSeconds": ttl, "plane": "data"}, source_ip="")
122
+ print(f"BREAK GLASS — {plane} plane, role {role}, {int(ttl // 60)} minutes, taken by {who}")
123
+ print(f" reason: {reason}")
124
+ print(f" every request made with this credential is recorded by name.")
125
+ print(f" {token}")
126
+ print(f" revoke early: POST /v1/tokens/{meta['id']}/revoke")
127
+ elif cmd == "policy-diff":
128
+ if not rest:
129
+ print("usage: python3 -m underwrit policy-diff <candidate.json>", file=sys.stderr)
130
+ return 2
131
+ from . import api, policy as policy_mod, replay
132
+ current, _ = api.current_policy()
133
+ doc = json.loads(open(rest[0]).read())
134
+ candidate = policy_mod.Policy.from_json({**current.to_json(), **doc})
135
+ r = replay.replay(api.conn(), candidate, attribute_changes=True, current=current)
136
+ print(f" {r['decisions']} decisions in {r['sessions']} sessions re-judged")
137
+ print(f" callers stopped: {r['interrupts']['was']} -> {r['interrupts']['would']}")
138
+ for k, v in (r.get("attribution") or {}).items():
139
+ print(f" {k:<22} moved {v['changed']:>4} stopped {v['interruptsWas']} -> {v['interruptsWould']}")
140
+ for c in r["changed"][:50]:
141
+ print(f" {c['sessionId']:<18} {c['tool']:<28} {c['was']:<15} -> {c['would']:<15} {c['reasons'][0][:70] if c['reasons'] else ''}")
142
+ if len(r["changed"]) > 50:
143
+ print(f" … and {len(r['changed']) - 50} more")
144
+ return 0 if not r["changed"] else 3
145
+ elif cmd == "sweep":
146
+ from . import api, retention
147
+ r = retention.sweep(api.conn(), actor="operator:cli", dry_run="--dry-run" in rest)
148
+ _out(r)
149
+ elif cmd in ("version", "--version"):
150
+ from . import __version__
151
+ print(__version__)
152
+ else:
153
+ print(__doc__)
154
+ return 2
155
+ return 0