keyclerk 0.26.3__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.
Files changed (123) hide show
  1. keyclerk/__init__.py +3 -0
  2. keyclerk/access_paths.py +409 -0
  3. keyclerk/access_state.py +127 -0
  4. keyclerk/anchors.py +177 -0
  5. keyclerk/api.py +652 -0
  6. keyclerk/apply_engine.py +241 -0
  7. keyclerk/approvals.py +310 -0
  8. keyclerk/attest.py +339 -0
  9. keyclerk/clock.py +37 -0
  10. keyclerk/config.py +90 -0
  11. keyclerk/connectors/__init__.py +4 -0
  12. keyclerk/connectors/contract.py +155 -0
  13. keyclerk/connectors/keycloak.py +383 -0
  14. keyclerk/connectors/physical.py +63 -0
  15. keyclerk/connectors/sources.py +112 -0
  16. keyclerk/console.py +365 -0
  17. keyclerk/delegation.py +1279 -0
  18. keyclerk/desk.py +3319 -0
  19. keyclerk/dpop.py +97 -0
  20. keyclerk/edge.py +802 -0
  21. keyclerk/edge_cli.py +311 -0
  22. keyclerk/edge_conformance.py +71 -0
  23. keyclerk/edge_records.py +299 -0
  24. keyclerk/edge_store.py +240 -0
  25. keyclerk/events.py +135 -0
  26. keyclerk/fullcheck.py +244 -0
  27. keyclerk/grants.py +218 -0
  28. keyclerk/graph.py +283 -0
  29. keyclerk/guard.py +209 -0
  30. keyclerk/health.py +206 -0
  31. keyclerk/httpserve.py +39 -0
  32. keyclerk/init.py +310 -0
  33. keyclerk/keys.py +154 -0
  34. keyclerk/leases.py +217 -0
  35. keyclerk/log_conformance.py +64 -0
  36. keyclerk/metrics.py +180 -0
  37. keyclerk/oidc.py +318 -0
  38. keyclerk/piam/__init__.py +20 -0
  39. keyclerk/piam/core.py +118 -0
  40. keyclerk/piam/osdp_controller.py +126 -0
  41. keyclerk/piam/pacs.py +116 -0
  42. keyclerk/places.py +194 -0
  43. keyclerk/policy/__init__.py +1 -0
  44. keyclerk/policy/access.rego +97 -0
  45. keyclerk/policy/access_test.rego +91 -0
  46. keyclerk/policy/ceiling.rego +55 -0
  47. keyclerk/policy/ceiling.wasm +0 -0
  48. keyclerk/policy/ceiling.wasm.build +6 -0
  49. keyclerk/policy/delegation.rego +170 -0
  50. keyclerk/policy/delegation_test.rego +75 -0
  51. keyclerk/policy/edge.rego +93 -0
  52. keyclerk/policy/edge.wasm +0 -0
  53. keyclerk/policy/edge.wasm.build +11 -0
  54. keyclerk/policy/edge_test.rego +54 -0
  55. keyclerk/policy/fallback.rego +93 -0
  56. keyclerk/policy/fallback_test.rego +28 -0
  57. keyclerk/policy/identity.rego +85 -0
  58. keyclerk/policy/identity.wasm +0 -0
  59. keyclerk/policy/identity.wasm.build +10 -0
  60. keyclerk/policy/identity_test.rego +56 -0
  61. keyclerk/policy/second_key.rego +88 -0
  62. keyclerk/policy/second_key.wasm +0 -0
  63. keyclerk/policy/second_key.wasm.build +6 -0
  64. keyclerk/policy/second_key_test.rego +54 -0
  65. keyclerk/policy/tree.rego +40 -0
  66. keyclerk/policy/tree.wasm +0 -0
  67. keyclerk/policy/tree.wasm.build +6 -0
  68. keyclerk/policy/tree_test.rego +42 -0
  69. keyclerk/policy/view.rego +70 -0
  70. keyclerk/policy/view.wasm +0 -0
  71. keyclerk/policy/view.wasm.build +6 -0
  72. keyclerk/policy/view_test.rego +50 -0
  73. keyclerk/policy/visits.rego +321 -0
  74. keyclerk/policy/visits.wasm +0 -0
  75. keyclerk/policy/visits.wasm.build +13 -0
  76. keyclerk/policy/visits_test.rego +192 -0
  77. keyclerk/policy_wasm.py +177 -0
  78. keyclerk/pools.py +92 -0
  79. keyclerk/qr.py +119 -0
  80. keyclerk/reconcile.py +560 -0
  81. keyclerk/records.py +379 -0
  82. keyclerk/relay.py +104 -0
  83. keyclerk/remedies.py +134 -0
  84. keyclerk/risk_port.py +99 -0
  85. keyclerk/schema/__init__.py +1 -0
  86. keyclerk/schema/api.openapi.json +8221 -0
  87. keyclerk/schema/config.schema.json +993 -0
  88. keyclerk/schema/records.schema.json +2295 -0
  89. keyclerk/schema/trust-input.schema.json +153 -0
  90. keyclerk/scope.py +699 -0
  91. keyclerk/signer.py +1148 -0
  92. keyclerk/situation.py +345 -0
  93. keyclerk/store/__init__.py +178 -0
  94. keyclerk/store/desk_store.py +69 -0
  95. keyclerk/store/follow.py +111 -0
  96. keyclerk/store/link.py +283 -0
  97. keyclerk/store/log_desk.py +421 -0
  98. keyclerk/store/log_reconcile.py +224 -0
  99. keyclerk/store/memory.py +215 -0
  100. keyclerk/store/merkle.py +57 -0
  101. keyclerk/store/migrate.py +468 -0
  102. keyclerk/store/replay.py +175 -0
  103. keyclerk/store/sqlite.py +521 -0
  104. keyclerk/store/working.py +288 -0
  105. keyclerk/ui/app.css +444 -0
  106. keyclerk/ui/app.js +1995 -0
  107. keyclerk/ui/apple-touch-icon.png +0 -0
  108. keyclerk/ui/favicon.ico +0 -0
  109. keyclerk/ui/favicon.svg +1 -0
  110. keyclerk/ui/index.html +35 -0
  111. keyclerk/ui/mark-dark.svg +1 -0
  112. keyclerk/ui/mark-light.svg +1 -0
  113. keyclerk/ui/personkey.js +85 -0
  114. keyclerk/ui/vendor/cytoscape.LICENSE +19 -0
  115. keyclerk/ui/vendor/cytoscape.min.js +32 -0
  116. keyclerk/ui/vendor/vendor.json +10 -0
  117. keyclerk/visits.py +407 -0
  118. keyclerk-0.26.3.dist-info/METADATA +310 -0
  119. keyclerk-0.26.3.dist-info/RECORD +123 -0
  120. keyclerk-0.26.3.dist-info/WHEEL +5 -0
  121. keyclerk-0.26.3.dist-info/licenses/LICENSE +190 -0
  122. keyclerk-0.26.3.dist-info/licenses/NOTICE +8 -0
  123. keyclerk-0.26.3.dist-info/top_level.txt +1 -0
keyclerk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+ """KeyClerk: access leases, delegated administration and access paths, decided by policy and driven by configuration."""
3
+ __version__ = "0.26.3"
@@ -0,0 +1,409 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+ """Access paths: one graph of where a subject can be and what it can act on, physical and digital.
3
+
4
+ Nodes are spaces and containers (physical), devices, sessions, segments, workloads and data (digital), plus outside
5
+ entrances. Every edge is a checkpoint with one enforcement point and a requirement (an entitlement of the catalogue,
6
+ or free), for the person, the device or both. Hosting links the two worlds: a workload hosted in a container can be
7
+ reached from that container through its console.
8
+
9
+ Strength is not a count of names: a checkpoint lists what it depends on (its controller, its credential, its admin
10
+ path); checkpoints that share a dependency fail together, so a way in counts one layer per group of checkpoints joined
11
+ by shared dependencies. A portable container's lock counts as delay only. A free exit is also a way in
12
+ through its exit hardware. Every Restricted target needs a two-person checkpoint on every way in.
13
+ Scenarios (fire release, power loss, cut off) open the checkpoints that fail open in them, and every analysis runs per
14
+ scenario. The search is depth-first with an adjacency index and branch and bound; hitting the length bound is
15
+ a finding, never a silent pass.
16
+
17
+ Usage: python -m keyclerk.access_paths GRAPH.yaml [--records RECORDS.yaml]
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from collections import defaultdict, deque
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+ from keyclerk import config
28
+
29
+ R = config.Section("access_paths")
30
+ REQUIRED = ("checkpoint_id", "from", "to", "kind", "layer", "pep", "requires", "depends_on")
31
+
32
+
33
+ def _nodes(g: dict) -> dict[str, dict]:
34
+ return {n["node_id"]: n for n in g.get("nodes", [])}
35
+
36
+
37
+ def checkpoints(g: dict) -> list[dict]:
38
+ """The declared checkpoints plus derived ones: console edges from a hosting container to what it hosts."""
39
+ out = list(g.get("checkpoints", []))
40
+ for n in g.get("nodes", []):
41
+ h = n.get("hosted_in")
42
+ if h:
43
+ out.append({"checkpoint_id": f"console:{h}>{n['node_id']}", "from": h, "to": n["node_id"],
44
+ "kind": "console", "layer": "physical", "pep": n.get("console_pep", "console"),
45
+ "requires": n.get("console_requires", "free"),
46
+ "depends_on": n.get("console_depends_on", [f"console:{h}"]), "derived": True,
47
+ **({"keys": n["console_keys"]} if n.get("console_keys") else {})})
48
+ return out
49
+
50
+
51
+ def _open_in(c: dict, scenario: str) -> bool:
52
+ return scenario != "normal" and c.get("fails", {}).get(scenario) == "open"
53
+
54
+
55
+ def edges(g: dict, scenario: str = "normal") -> list[tuple[str, str, dict, bool]]:
56
+ """(from, to, checkpoint, reverse) for every crossing: forward; back when both ways; back for free when it is a
57
+ free exit (leaving)."""
58
+ out = []
59
+ for c in checkpoints(g):
60
+ out.append((c["from"], c["to"], c, False))
61
+ if c.get("direction") == "both" or c.get("exit") == "free":
62
+ out.append((c["to"], c["from"], c, True))
63
+ return out
64
+
65
+
66
+ def _free(c: dict, reverse: bool, scenario: str = "normal") -> bool:
67
+ return c.get("requires") == "free" or (reverse and c.get("exit") == "free") or _open_in(c, scenario)
68
+
69
+
70
+ def key_spec(c: dict) -> tuple[int, list[str]]:
71
+ """(m, keys): the checkpoint opens with m of these credentials; a list means all of them."""
72
+ k = c.get("keys")
73
+ if not k:
74
+ return 0, []
75
+ if isinstance(k, list):
76
+ return len(k), k
77
+ return k["m"], k["of"]
78
+
79
+
80
+ def keys_ok(c: dict, held: set[str]) -> bool:
81
+ m, ks = key_spec(c)
82
+ return len([x for x in ks if x in held]) >= m
83
+
84
+
85
+ def passes(c: dict, reverse: bool, person: set[str], device: set[str], scenario: str = "normal",
86
+ held: set[str] = frozenset(), pair: bool = False) -> bool:
87
+ """Whether a subject crosses c: authorised for its requirement (a PEP that checks who you are) and holding its
88
+ keys (a lock that checks what you hold); possession means the keys alone decide."""
89
+ if _free(c, reverse, scenario):
90
+ return True
91
+ if c.get("two_person") and not pair:
92
+ return False # one subject alone never crosses a two-person checkpoint
93
+ if not keys_ok(c, held):
94
+ return False
95
+ need, who = c["requires"], c.get("subject", "person")
96
+ if need == "possession":
97
+ return True
98
+ dev_need = c.get("device_requires", need)
99
+ return (who == "device" or need in person) and (who == "person" or dev_need in device)
100
+
101
+
102
+ def reachable(g: dict, starts: set[str], person: set[str], device: set[str] = frozenset(),
103
+ scenario: str = "normal", tailgating: bool = False, held: set[str] = frozenset(),
104
+ anchor: str | None = None, pair: bool = False) -> set[str]:
105
+ """Nodes reachable from starts. tailgating: a checkpoint without tailgate resistance is crossable by anyone next
106
+ to a holder, so presence is widened through it. Keys: the subject holds what was issued to it (anchor) and
107
+ picks up every credential stored in a node it reaches, until nothing new is reached (a fixed point)."""
108
+ held = set(held) | held_keys(g, anchor)
109
+ seen = set(starts)
110
+ while True:
111
+ seen = _bfs(g, seen, person, device, scenario, tailgating, held, pair)
112
+ found = {k["credential_id"] for k in g.get("credentials", []) if k.get("stored_in") in seen
113
+ and k.get("release_requires") in (None, *person) and _usable(k, scenario)}
114
+ found |= {k["backup_of"] for k in g.get("credentials", []) if k["credential_id"] in found and k.get("backup_of")}
115
+ if found <= held:
116
+ return seen
117
+ held |= found
118
+
119
+
120
+ def _usable(k: dict, scenario: str) -> bool:
121
+ """Sealed keys are taken only in the break-glass scenario, never on a routine route."""
122
+ return not k.get("sealed") or scenario == "break_glass"
123
+
124
+
125
+ def held_keys(g: dict, anchor: str | None) -> set[str]:
126
+ """Keys issued to the subject; a sealed backup in hand counts as the key it backs up."""
127
+ own = {k["credential_id"] for k in g.get("credentials", []) if anchor and anchor in k.get("held_by", [])}
128
+ return own | {k["backup_of"] for k in g.get("credentials", []) if k["credential_id"] in own and k.get("backup_of")}
129
+
130
+
131
+ def obtainable_keys(g: dict, person: set[str], device: set[str] = frozenset(), anchor: str | None = None,
132
+ scenario: str = "normal") -> set[str]:
133
+ seen = reachable(g, set(entrances(g)), person, device, scenario, anchor=anchor)
134
+ got = held_keys(g, anchor) | {k["credential_id"] for k in g.get("credentials", []) if k.get("stored_in") in seen
135
+ and k.get("release_requires") in (None, *person) and _usable(k, scenario)}
136
+ return got | {k["backup_of"] for k in g.get("credentials", []) if k["credential_id"] in got and k.get("backup_of")}
137
+
138
+
139
+ def _bfs(g, starts, person, device, scenario, tailgating, held, pair=False) -> set[str]:
140
+ adj = defaultdict(list)
141
+ for a, b, c, rev in edges(g, scenario):
142
+ adj[a].append((b, c, rev))
143
+ seen, todo = set(starts), deque(starts)
144
+ while todo:
145
+ u = todo.popleft()
146
+ for b, c, rev in adj[u]:
147
+ if b in seen:
148
+ continue
149
+ if passes(c, rev, person, device, scenario, held, pair) or (
150
+ tailgating and c.get("tailgate_resistance", "none") == "none" and _kind(g, b) in ("space", "outside")):
151
+ seen.add(b)
152
+ todo.append(b)
153
+ return seen
154
+
155
+
156
+ def _kind(g: dict, nid: str) -> str:
157
+ return _nodes(g).get(nid, {}).get("kind", "")
158
+
159
+
160
+ def possible_presence(g: dict, last_checkpoint: str, person: set[str], device: set[str] = frozenset(),
161
+ scenario: str = "normal", tailgating: bool = True) -> set[str]:
162
+ """Where the subject can be (physical) or reach (digital) after passing last_checkpoint; by default tailgating
163
+ through checkpoints that do not resist it is assumed, because a roll call that ignores it is wrong."""
164
+ c = next((x for x in checkpoints(g) if x["checkpoint_id"] == last_checkpoint), None)
165
+ if c is None:
166
+ raise ValueError(f"unknown checkpoint {last_checkpoint}")
167
+ return reachable(g, {c["to"]}, person, device, scenario, tailgating)
168
+
169
+
170
+ def entrances(g: dict) -> list[str]:
171
+ return [n["node_id"] for n in g.get("nodes", []) if n.get("entrance")]
172
+
173
+
174
+ def _inward(g: dict, scenario: str) -> dict[str, list[tuple[str, dict]]]:
175
+ """The edges a way in may use: every checkpoint forward and both-way ones backwards. An emergency exit is declared
176
+ as its own checkpoint from outside in, requiring exit-hardware, so a propped or forced exit is a way in."""
177
+ adj = defaultdict(list)
178
+ for c in checkpoints(g):
179
+ adj[c["from"]].append((c["to"], c))
180
+ if c.get("direction") == "both":
181
+ adj[c["to"]].append((c["from"], c))
182
+ return adj
183
+
184
+
185
+ def layers(g: dict, path: list[dict], scenario: str = "normal") -> int:
186
+ """Independent layers on a way in: checkpoints that require something (in this scenario) grouped by shared
187
+ dependencies; a portable container's lock is delay only."""
188
+ ns = _nodes(g)
189
+ parent: dict[str, str] = {}
190
+
191
+ def find(x):
192
+ while parent.setdefault(x, x) != x:
193
+ x = parent[x]
194
+ return x
195
+
196
+ groups = set()
197
+ counted = [c for c in path if not _free(c, False, scenario) and not ns.get(c["to"], {}).get("portable")]
198
+ for c in counted:
199
+ deps = _deps(g, c)
200
+ for d in deps[1:]:
201
+ parent[find(d)] = find(deps[0])
202
+ for c in counted:
203
+ groups.add(find(_deps(g, c)[0]))
204
+ return len(groups)
205
+
206
+
207
+ def _deps(g: dict, c: dict) -> list[str]:
208
+ """What a checkpoint fails with: its declared dependencies and every key that opens it, with that key's backups,
209
+ so one master key across many doors is one layer."""
210
+ ks = key_spec(c)[1]
211
+ backups = [b["credential_id"] for b in g.get("credentials", []) for k in ks if b.get("backup_of") == k]
212
+ return (c.get("depends_on") or [c["checkpoint_id"]]) + [f"key:{k}" for k in ks + backups]
213
+
214
+
215
+ def weakest(g: dict, target: str, scenario: str = "normal") -> dict:
216
+ """The way in with the fewest independent layers, by depth-first search with branch and bound (layers never shrink
217
+ along a way, so a branch at or above the best is cut); truncated is set when the length bound cut a search, which
218
+ is a finding. two_person_everywhere: no way in avoids every two-person checkpoint."""
219
+ adj = _inward(g, scenario)
220
+ best = {"layers": None, "path": [], "truncated": False}
221
+
222
+ def walk(u: str, seen: frozenset, trail: list):
223
+ if len(trail) > R["max_path_length"]:
224
+ best["truncated"] = True
225
+ return
226
+ cur = layers(g, trail, scenario)
227
+ if best["layers"] is not None and cur >= best["layers"]:
228
+ return
229
+ if u == target:
230
+ best["layers"], best["path"] = cur, [c["checkpoint_id"] for c in trail]
231
+ return
232
+ for b, c in adj[u]:
233
+ if b not in seen:
234
+ walk(b, seen | {b}, trail + [c])
235
+
236
+ for e in entrances(g):
237
+ walk(e, frozenset({e}), [])
238
+ best["any"] = best["layers"] is not None
239
+ seen, todo = set(entrances(g)), deque(entrances(g))
240
+ while todo: # can the target be reached without crossing any two-person checkpoint?
241
+ u = todo.popleft()
242
+ for b, c in adj[u]:
243
+ if b not in seen and not (c.get("two_person") and not _free(c, False, scenario)):
244
+ seen.add(b)
245
+ todo.append(b)
246
+ best["two_person_everywhere"] = target not in seen
247
+ return best
248
+
249
+
250
+ def route(g: dict, target: str, person: set[str], device: set[str] = frozenset(), requester: str | None = None,
251
+ others: dict[str, set[str]] | None = None, scenario: str = "normal") -> dict:
252
+ """The way in with the fewest gaps. An escort holds the whole rest of the way from the first gap and is not the
253
+ requester; a broker gap (digital) is bridged only by a recording broker service with an approver other than the
254
+ requester, never by sharing someone's entitlement."""
255
+ adj = _inward(g, scenario)
256
+ found: list[list[dict]] = []
257
+
258
+ def walk(u, seen, trail):
259
+ if len(trail) > R["max_path_length"]:
260
+ return
261
+ if u == target:
262
+ found.append(trail)
263
+ return
264
+ for b, c in adj[u]:
265
+ if b not in seen:
266
+ walk(b, seen | {b}, trail + [c])
267
+
268
+ for e in entrances(g):
269
+ walk(e, {e}, [])
270
+ if not found:
271
+ return {"reachable": False, "path": [], "missing": [], "prerequisites": [], "escorts": [], "broker": [],
272
+ "obligations": []}
273
+ held = obtainable_keys(g, person, device, requester, scenario)
274
+ p, gaps = min(((p, [c for c in p if not passes(c, False, person, device, scenario, held)]) for p in found),
275
+ key=lambda x: (len(x[1]), len(x[0])))
276
+ rest = p[p.index(gaps[0]):] if gaps else []
277
+ physical_gaps = [c for c in gaps if c["layer"] == "physical"]
278
+ escorts = []
279
+ if physical_gaps and all(c.get("escort") == "allowed" for c in physical_gaps):
280
+ need = {c["requires"] for c in rest if c["layer"] == "physical" and not _free(c, False, scenario)
281
+ and c["requires"] != "possession"}
282
+ keyed = [c for c in rest if c["layer"] == "physical" and key_spec(c)[1]]
283
+ escorts = sorted(a for a, ents in (others or {}).items() if need <= ents and a != requester
284
+ and all(keys_ok(c, obtainable_keys(g, ents, ents, a, scenario)) for c in keyed))
285
+ broker = [c["checkpoint_id"] for c in gaps if c.get("broker")]
286
+ stores = {k["credential_id"]: k.get("stored_in") or "issued" for k in g.get("credentials", [])}
287
+ second_person = [c["checkpoint_id"] for c in p if c.get("two_person") and not _free(c, False, scenario)]
288
+ missing_keys = {c["checkpoint_id"]: {k: stores.get(k) for k in key_spec(c)[1] if k not in held}
289
+ for c in gaps if key_spec(c)[1] and not keys_ok(c, held)}
290
+ return {"reachable": not gaps, "path": [c["checkpoint_id"] for c in p], "missing": [c["checkpoint_id"] for c in gaps],
291
+ "prerequisites": sorted({c["requires"] for c in gaps if c["requires"] not in ("possession", *person)}),
292
+ "escorts": escorts,
293
+ "broker": broker, "missing_keys": missing_keys, "second_person": second_person,
294
+ "obligations": obligations(p)[0]}
295
+
296
+
297
+ def obligations(path: list[dict]) -> tuple[list[str], list[str]]:
298
+ """(obligations, problems): they add up; an obligation outside the vocabulary, one the checkpoint's PEP cannot
299
+ enforce, or two that conflict is a problem, and a problem denies."""
300
+ vocab, conflicts = set(R["obligations"]), [set(x) for x in R["obligation_conflicts"]]
301
+ got, problems = set(), []
302
+ for c in path:
303
+ for o in c.get("obligations", []):
304
+ if o not in vocab:
305
+ problems.append(f"{c['checkpoint_id']}: unknown obligation {o}")
306
+ if "enforces" in c and o not in c["enforces"]:
307
+ problems.append(f"{c['checkpoint_id']}: {c['pep']} cannot enforce {o}")
308
+ got.add(o)
309
+ problems += [f"conflicting obligations {sorted(x)}" for x in conflicts if x <= got]
310
+ return sorted(got), problems
311
+
312
+
313
+ def check(g: dict, catalogue: set[str] | None = None, people: dict[str, set[str]] | None = None) -> list[str]:
314
+ errs = []
315
+ ns = _nodes(g)
316
+ if not entrances(g):
317
+ errs.append("no entrance node")
318
+ for c in g.get("checkpoints", []):
319
+ miss = [f for f in REQUIRED if f not in c]
320
+ if miss:
321
+ errs.append(f"{c.get('checkpoint_id', '?')}: missing {miss}")
322
+ continue
323
+ for end in (c["from"], c["to"]):
324
+ if end not in ns:
325
+ errs.append(f"{c['checkpoint_id']}: unknown node {end}")
326
+ if catalogue is not None and c["requires"] not in ("free", "exit-hardware", "possession") and c["requires"] not in catalogue:
327
+ errs.append(f"{c['checkpoint_id']}: requires {c['requires']}, which is not in the catalogue")
328
+ errs += obligations([c])[1]
329
+ if errs:
330
+ return errs
331
+ for n in ns.values():
332
+ if n.get("hosted_in") and n["hosted_in"] not in ns:
333
+ errs.append(f"{n['node_id']}: hosted in unknown node {n['hosted_in']}")
334
+ for scenario in R["scenarios"]:
335
+ tag = "" if scenario == "normal" else f" ({scenario})"
336
+ for n in ns.values():
337
+ if n["kind"] == "space" and scenario == "normal" and not reachable_free_exit(g, n["node_id"]):
338
+ errs.append(f"{n['node_id']}: no free way out to an entrance (life safety)")
339
+ label = n.get("label")
340
+ if not label:
341
+ continue
342
+ w = weakest(g, n["node_id"], scenario)
343
+ if w["truncated"]:
344
+ errs.append(f"{n['node_id']}: the search hit the path length bound{tag}; the weakest way in is unknown")
345
+ if not w["any"]:
346
+ continue
347
+ need = R["min_independent_layers"][label]
348
+ if w["layers"] < need:
349
+ errs.append(f"{n['node_id']}: {label} needs {need} independent layers, the weakest way in has "
350
+ f"{w['layers']}{tag}: {' > '.join(w['path'])}")
351
+ if label in R["two_person_for"] and not w["two_person_everywhere"]:
352
+ errs.append(f"{n['node_id']}: a way in without a two-person checkpoint{tag}")
353
+ from keyclerk import keys
354
+ from keyclerk import pools
355
+ errs += keys.check(g, people)
356
+ errs += pools.check(g, people)
357
+ return list(dict.fromkeys(errs))
358
+
359
+
360
+ def reachable_free_exit(g: dict, start: str) -> bool:
361
+ seen, todo = {start}, deque([start])
362
+ ents = set(entrances(g))
363
+ adj = edges(g)
364
+ while todo:
365
+ u = todo.popleft()
366
+ if u in ents:
367
+ return True
368
+ for a, b, c, rev in adj:
369
+ if a == u and b not in seen and _free(c, rev):
370
+ seen.add(b)
371
+ todo.append(b)
372
+ return False
373
+
374
+
375
+ def roster(grants_file: Path) -> dict[str, set[str]]:
376
+ """Every person of the realm with the entitlements they hold now through groups, so the dual-control and pool
377
+ checks run for real people."""
378
+ from datetime import datetime, timezone
379
+ from keyclerk import delegation
380
+ from keyclerk import records
381
+ doc = records.load(grants_file)
382
+ now = datetime.now(timezone.utc)
383
+ return {a: set(delegation.effective_entitlements(doc, a, now)) for p in doc.get("persons", []) for a in p["anchors"]}
384
+
385
+
386
+ def main(argv: list[str]) -> int:
387
+ """python -m keyclerk.access_paths GRAPH.yaml [--records RECORDS.yaml]: check a graph; with records, against their
388
+ catalogue and with every person's entitlements (the dual-control and pool checks)."""
389
+ args = list(argv)
390
+ records_file = None
391
+ if "--records" in args:
392
+ i = args.index("--records")
393
+ records_file = Path(args[i + 1])
394
+ del args[i:i + 2]
395
+ bad = 0
396
+ for f in [Path(a) for a in args]:
397
+ cat, people = None, None
398
+ if records_file:
399
+ doc = yaml.safe_load(records_file.read_text(encoding="utf-8")) or {}
400
+ cat = {e["entitlement"] for e in doc.get("catalogue", [])}
401
+ people = roster(records_file)
402
+ for e in check(yaml.safe_load(f.read_text(encoding="utf-8")), cat, people):
403
+ print(f"{f}: {e}", file=sys.stderr)
404
+ bad += 1
405
+ return 1 if bad else 0
406
+
407
+
408
+ if __name__ == "__main__":
409
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: EUPL-1.2
2
+ """Derived access state. Nothing here is stored: every state is a pure function of
3
+ the signed records, the time and the evidence, so a lost event or a restart never loses state.
4
+
5
+ Three axes:
6
+ intent what the records say: granted, revoked, suspended
7
+ validity whether the window holds now: pending, valid, ended
8
+ delivery whether the broker matches (reported by tools/grants.py plan; it never widens)
9
+ Effective status follows midPoint's shape with one change: an administrative enable never overrides the end of the
10
+ window, because nothing outlives its end.
11
+
12
+ Clock skew only ever narrows: a widening is judged as if the clock were wrong in the unsafe direction (the grant must
13
+ have started at now minus skew and still run at now plus skew), a reduction as if wrong the other way.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime, timedelta
18
+
19
+ from keyclerk.grants import _ts
20
+
21
+ SKEW = timedelta(seconds=30) # the clock-drift alarm threshold of the deployment's edge sites
22
+ MAX_WAKE = timedelta(hours=1)
23
+
24
+
25
+ def revoked_ids(doc: dict, at: datetime | None = None) -> set[str]:
26
+ """Revoked grant ids. Live (at None): every revocation, whatever its date, because earlier is the safe side.
27
+ Historical (at given, for the timeline): only revocations made by then, so the past is never rewritten."""
28
+ return {r["grant_id"] for r in doc.get("revocations", []) if at is None or _ts(r["at"]) <= at}
29
+
30
+
31
+ def suspended(doc: dict, anchor: str, at: datetime) -> bool:
32
+ """A suspension holds from its start until two different people other than the suspender lift it."""
33
+ for s in doc.get("suspensions", []):
34
+ if s["anchor"] != anchor or _ts(s["at"]) > at:
35
+ continue
36
+ lift = s.get("lifted")
37
+ if not lift or _ts(lift["at"]) > at:
38
+ return True
39
+ lifters = set(lift.get("by", []))
40
+ if len(lifters) < 2 or s["by"] in lifters or anchor in lifters:
41
+ return True
42
+ return False
43
+
44
+
45
+ def grant_state(g: dict, doc: dict, at: datetime, historical: bool = False) -> dict:
46
+ """historical=True answers 'what was true at that moment' for the timeline; decisions use the default."""
47
+ gid, anchor = g["grant_id"], g["subject"]["anchor"]
48
+ start, end = _ts(g["start"]), _ts(g["end"])
49
+ if gid in revoked_ids(doc, at if historical else None):
50
+ intent = "revoked"
51
+ elif suspended(doc, anchor, at):
52
+ intent = "suspended"
53
+ else:
54
+ intent = "granted"
55
+ validity = "pending" if at < start else ("ended" if at >= end else "valid")
56
+ if intent == "revoked":
57
+ effective = "revoked"
58
+ elif validity == "ended":
59
+ effective = "ended"
60
+ elif intent == "suspended":
61
+ effective = "suspended"
62
+ elif validity == "pending":
63
+ effective = "pending"
64
+ else:
65
+ effective = "active"
66
+ return {"grant_id": gid, "intent": intent, "validity": validity, "effective": effective}
67
+
68
+
69
+ def may_widen(g: dict, doc: dict, at: datetime, skew: timedelta = SKEW) -> bool:
70
+ """True only if the grant is active whichever way the clock is wrong by up to skew."""
71
+ return (grant_state(g, doc, at - skew)["effective"] == "active"
72
+ and grant_state(g, doc, at + skew)["effective"] == "active")
73
+
74
+
75
+ def must_reduce(g: dict, doc: dict, at: datetime, skew: timedelta = SKEW) -> bool:
76
+ """True if the grant may no longer be active at any time within skew of now."""
77
+ return not may_widen(g, doc, at, skew)
78
+
79
+
80
+ def effective_grants(doc: dict, at: datetime, skew: timedelta = SKEW) -> list[dict]:
81
+ """The grants a decision or a plan may treat as active now (the skew-safe set)."""
82
+ return [g for g in doc.get("grants", []) if may_widen(g, doc, at, skew)]
83
+
84
+
85
+ def persona_state(doc: dict, anchor: str, persona: str, at: datetime) -> str:
86
+ """An admin persona is active only while an eligibility of the person is effectively active."""
87
+ if persona != "admin":
88
+ raise ValueError("only the admin persona is derived from grants")
89
+ mine = [g for g in doc.get("grants", []) if g["subject"]["anchor"] == anchor and g["class"] == "eligibility"]
90
+ if not mine:
91
+ return "absent"
92
+ if any(may_widen(g, doc, at) for g in mine):
93
+ return "active"
94
+ if any(grant_state(g, doc, at)["effective"] == "pending" for g in mine):
95
+ return "pending"
96
+ return "blocked"
97
+
98
+
99
+ def lease_state(ls: dict, at: datetime, xr: dict) -> str:
100
+ start, end = _ts(ls["start"]), _ts(ls["end"])
101
+ delete_after = timedelta(days=xr["steps"]["offboarding"]["retention"]["account_delete_days"])
102
+ first_reminder = max(xr["steps"]["during"]["reminder_days"])
103
+ if at < start:
104
+ return "approved"
105
+ if at >= end + delete_after:
106
+ return "purged"
107
+ if at >= end:
108
+ return "ended"
109
+ if end - at <= timedelta(days=first_reminder):
110
+ return "ending"
111
+ return "active"
112
+
113
+
114
+ def next_boundary(doc: dict, at: datetime, xr: dict | None = None) -> datetime:
115
+ """When the reconciler must look again: the earliest future start, end, reminder or purge, at most an hour away
116
+ (it sleeps on a monotonic clock and re-checks the wall clock on waking)."""
117
+ cands = []
118
+ for g in doc.get("grants", []):
119
+ cands += [_ts(g["start"]) - SKEW, _ts(g["end"]) - SKEW]
120
+ if xr:
121
+ delete_after = timedelta(days=xr["steps"]["offboarding"]["retention"]["account_delete_days"])
122
+ for ls in doc.get("leases", []):
123
+ end = _ts(ls["end"])
124
+ cands += [_ts(ls["start"]), end, end + delete_after]
125
+ cands += [end - timedelta(days=d) for d in xr["steps"]["during"]["reminder_days"]]
126
+ future = [c for c in cands if c > at]
127
+ return min(future + [at + MAX_WAKE])