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.
- keyclerk/__init__.py +3 -0
- keyclerk/access_paths.py +409 -0
- keyclerk/access_state.py +127 -0
- keyclerk/anchors.py +177 -0
- keyclerk/api.py +652 -0
- keyclerk/apply_engine.py +241 -0
- keyclerk/approvals.py +310 -0
- keyclerk/attest.py +339 -0
- keyclerk/clock.py +37 -0
- keyclerk/config.py +90 -0
- keyclerk/connectors/__init__.py +4 -0
- keyclerk/connectors/contract.py +155 -0
- keyclerk/connectors/keycloak.py +383 -0
- keyclerk/connectors/physical.py +63 -0
- keyclerk/connectors/sources.py +112 -0
- keyclerk/console.py +365 -0
- keyclerk/delegation.py +1279 -0
- keyclerk/desk.py +3319 -0
- keyclerk/dpop.py +97 -0
- keyclerk/edge.py +802 -0
- keyclerk/edge_cli.py +311 -0
- keyclerk/edge_conformance.py +71 -0
- keyclerk/edge_records.py +299 -0
- keyclerk/edge_store.py +240 -0
- keyclerk/events.py +135 -0
- keyclerk/fullcheck.py +244 -0
- keyclerk/grants.py +218 -0
- keyclerk/graph.py +283 -0
- keyclerk/guard.py +209 -0
- keyclerk/health.py +206 -0
- keyclerk/httpserve.py +39 -0
- keyclerk/init.py +310 -0
- keyclerk/keys.py +154 -0
- keyclerk/leases.py +217 -0
- keyclerk/log_conformance.py +64 -0
- keyclerk/metrics.py +180 -0
- keyclerk/oidc.py +318 -0
- keyclerk/piam/__init__.py +20 -0
- keyclerk/piam/core.py +118 -0
- keyclerk/piam/osdp_controller.py +126 -0
- keyclerk/piam/pacs.py +116 -0
- keyclerk/places.py +194 -0
- keyclerk/policy/__init__.py +1 -0
- keyclerk/policy/access.rego +97 -0
- keyclerk/policy/access_test.rego +91 -0
- keyclerk/policy/ceiling.rego +55 -0
- keyclerk/policy/ceiling.wasm +0 -0
- keyclerk/policy/ceiling.wasm.build +6 -0
- keyclerk/policy/delegation.rego +170 -0
- keyclerk/policy/delegation_test.rego +75 -0
- keyclerk/policy/edge.rego +93 -0
- keyclerk/policy/edge.wasm +0 -0
- keyclerk/policy/edge.wasm.build +11 -0
- keyclerk/policy/edge_test.rego +54 -0
- keyclerk/policy/fallback.rego +93 -0
- keyclerk/policy/fallback_test.rego +28 -0
- keyclerk/policy/identity.rego +85 -0
- keyclerk/policy/identity.wasm +0 -0
- keyclerk/policy/identity.wasm.build +10 -0
- keyclerk/policy/identity_test.rego +56 -0
- keyclerk/policy/second_key.rego +88 -0
- keyclerk/policy/second_key.wasm +0 -0
- keyclerk/policy/second_key.wasm.build +6 -0
- keyclerk/policy/second_key_test.rego +54 -0
- keyclerk/policy/tree.rego +40 -0
- keyclerk/policy/tree.wasm +0 -0
- keyclerk/policy/tree.wasm.build +6 -0
- keyclerk/policy/tree_test.rego +42 -0
- keyclerk/policy/view.rego +70 -0
- keyclerk/policy/view.wasm +0 -0
- keyclerk/policy/view.wasm.build +6 -0
- keyclerk/policy/view_test.rego +50 -0
- keyclerk/policy/visits.rego +321 -0
- keyclerk/policy/visits.wasm +0 -0
- keyclerk/policy/visits.wasm.build +13 -0
- keyclerk/policy/visits_test.rego +192 -0
- keyclerk/policy_wasm.py +177 -0
- keyclerk/pools.py +92 -0
- keyclerk/qr.py +119 -0
- keyclerk/reconcile.py +560 -0
- keyclerk/records.py +379 -0
- keyclerk/relay.py +104 -0
- keyclerk/remedies.py +134 -0
- keyclerk/risk_port.py +99 -0
- keyclerk/schema/__init__.py +1 -0
- keyclerk/schema/api.openapi.json +8221 -0
- keyclerk/schema/config.schema.json +993 -0
- keyclerk/schema/records.schema.json +2295 -0
- keyclerk/schema/trust-input.schema.json +153 -0
- keyclerk/scope.py +699 -0
- keyclerk/signer.py +1148 -0
- keyclerk/situation.py +345 -0
- keyclerk/store/__init__.py +178 -0
- keyclerk/store/desk_store.py +69 -0
- keyclerk/store/follow.py +111 -0
- keyclerk/store/link.py +283 -0
- keyclerk/store/log_desk.py +421 -0
- keyclerk/store/log_reconcile.py +224 -0
- keyclerk/store/memory.py +215 -0
- keyclerk/store/merkle.py +57 -0
- keyclerk/store/migrate.py +468 -0
- keyclerk/store/replay.py +175 -0
- keyclerk/store/sqlite.py +521 -0
- keyclerk/store/working.py +288 -0
- keyclerk/ui/app.css +444 -0
- keyclerk/ui/app.js +1995 -0
- keyclerk/ui/apple-touch-icon.png +0 -0
- keyclerk/ui/favicon.ico +0 -0
- keyclerk/ui/favicon.svg +1 -0
- keyclerk/ui/index.html +35 -0
- keyclerk/ui/mark-dark.svg +1 -0
- keyclerk/ui/mark-light.svg +1 -0
- keyclerk/ui/personkey.js +85 -0
- keyclerk/ui/vendor/cytoscape.LICENSE +19 -0
- keyclerk/ui/vendor/cytoscape.min.js +32 -0
- keyclerk/ui/vendor/vendor.json +10 -0
- keyclerk/visits.py +407 -0
- keyclerk-0.26.3.dist-info/METADATA +310 -0
- keyclerk-0.26.3.dist-info/RECORD +123 -0
- keyclerk-0.26.3.dist-info/WHEEL +5 -0
- keyclerk-0.26.3.dist-info/licenses/LICENSE +190 -0
- keyclerk-0.26.3.dist-info/licenses/NOTICE +8 -0
- keyclerk-0.26.3.dist-info/top_level.txt +1 -0
keyclerk/__init__.py
ADDED
keyclerk/access_paths.py
ADDED
|
@@ -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:]))
|
keyclerk/access_state.py
ADDED
|
@@ -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])
|