tba-concrete-authority-graph 0.4.0.dev2__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,360 @@
|
|
|
1
|
+
"""Authority graph, role, and closure helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from tba_contracts_identity.authority import (
|
|
8
|
+
AuthorityClosure,
|
|
9
|
+
AuthorityEdge,
|
|
10
|
+
AuthorityGraphIntegrityReport,
|
|
11
|
+
AuthorityMonotonicityReport,
|
|
12
|
+
AuthorityMutationKind,
|
|
13
|
+
AuthorityNode,
|
|
14
|
+
AuthorityPath,
|
|
15
|
+
AuthorityReachabilityProof,
|
|
16
|
+
AuthorityRole,
|
|
17
|
+
AuthorityScope,
|
|
18
|
+
LeastAuthorityDiff,
|
|
19
|
+
authority_matches,
|
|
20
|
+
)
|
|
21
|
+
def normalize_authority_roles(values: Iterable[str | AuthorityRole] = ()) -> tuple[str, ...]:
|
|
22
|
+
normalized = {str(value.value if isinstance(value, AuthorityRole) else value).strip() for value in values}
|
|
23
|
+
normalized.discard("")
|
|
24
|
+
return tuple(sorted(normalized))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def has_admin_authority(values: Iterable[str | AuthorityRole] = (), *, principal_kind: str | None = None) -> bool:
|
|
28
|
+
roles = normalize_authority_roles(values)
|
|
29
|
+
return AuthorityRole.ADMIN.value in roles or principal_kind == AuthorityRole.ADMIN.value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def has_owner_authority(values: Iterable[str | AuthorityRole] = ()) -> bool:
|
|
33
|
+
return AuthorityRole.OWNER.value in normalize_authority_roles(values)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def has_superuser_authority(values: Iterable[str | AuthorityRole] = ()) -> bool:
|
|
37
|
+
return AuthorityRole.SUPERUSER.value in normalize_authority_roles(values)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthorityDerivationGraph:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
nodes: Iterable[AuthorityNode] = (),
|
|
45
|
+
edges: Iterable[AuthorityEdge] = (),
|
|
46
|
+
) -> None:
|
|
47
|
+
self._nodes: dict[str, AuthorityNode] = {}
|
|
48
|
+
self._edges: dict[str, AuthorityEdge] = {}
|
|
49
|
+
for node in nodes:
|
|
50
|
+
self.add_node(node)
|
|
51
|
+
for edge in edges:
|
|
52
|
+
self.add_edge(edge)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def nodes(self) -> Mapping[str, AuthorityNode]:
|
|
56
|
+
return dict(self._nodes)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def edges(self) -> Mapping[str, AuthorityEdge]:
|
|
60
|
+
return dict(self._edges)
|
|
61
|
+
|
|
62
|
+
def add_node(self, node: AuthorityNode) -> AuthorityNode:
|
|
63
|
+
if node.node_id in self._nodes:
|
|
64
|
+
raise ValueError(f"duplicate authority node {node.node_id!r}")
|
|
65
|
+
self._nodes[node.node_id] = node
|
|
66
|
+
return node
|
|
67
|
+
|
|
68
|
+
def add_edge(self, edge: AuthorityEdge) -> AuthorityEdge:
|
|
69
|
+
if edge.edge_id in self._edges:
|
|
70
|
+
raise ValueError(f"duplicate authority edge {edge.edge_id!r}")
|
|
71
|
+
if edge.source not in self._nodes:
|
|
72
|
+
raise KeyError(f"unknown authority edge source {edge.source!r}")
|
|
73
|
+
if edge.target not in self._nodes:
|
|
74
|
+
raise KeyError(f"unknown authority edge target {edge.target!r}")
|
|
75
|
+
self._edges[edge.edge_id] = edge
|
|
76
|
+
return edge
|
|
77
|
+
|
|
78
|
+
def outgoing(self, node_id: str) -> tuple[AuthorityEdge, ...]:
|
|
79
|
+
return tuple(
|
|
80
|
+
sorted(
|
|
81
|
+
(edge for edge in self._edges.values() if edge.source == node_id and edge.active),
|
|
82
|
+
key=lambda edge: edge.edge_id,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def derive_paths(self, subject: str) -> tuple[AuthorityPath, ...]:
|
|
87
|
+
if subject not in self._nodes:
|
|
88
|
+
raise KeyError(f"unknown authority subject {subject!r}")
|
|
89
|
+
paths: list[AuthorityPath] = []
|
|
90
|
+
stack: list[tuple[str, tuple[str, ...], tuple[AuthorityScope, ...], tuple[str, ...]]] = [
|
|
91
|
+
(subject, (), (), (subject,))
|
|
92
|
+
]
|
|
93
|
+
while stack:
|
|
94
|
+
node_id, edge_ids, inherited_scopes, node_path = stack.pop()
|
|
95
|
+
for edge in reversed(self.outgoing(node_id)):
|
|
96
|
+
edge_scopes = (
|
|
97
|
+
edge.scopes
|
|
98
|
+
if not inherited_scopes
|
|
99
|
+
else tuple(
|
|
100
|
+
scope
|
|
101
|
+
for scope in edge.scopes
|
|
102
|
+
if any(parent.covers(scope) for parent in inherited_scopes)
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
if not edge_scopes:
|
|
106
|
+
continue
|
|
107
|
+
next_edge_ids = edge_ids + (edge.edge_id,)
|
|
108
|
+
path = AuthorityPath(
|
|
109
|
+
subject=subject,
|
|
110
|
+
target=edge.target,
|
|
111
|
+
edge_ids=next_edge_ids,
|
|
112
|
+
scopes=edge_scopes,
|
|
113
|
+
)
|
|
114
|
+
paths.append(path)
|
|
115
|
+
if edge.target not in node_path:
|
|
116
|
+
stack.append((edge.target, next_edge_ids, edge_scopes, node_path + (edge.target,)))
|
|
117
|
+
return tuple(sorted(paths, key=lambda path: (path.target, path.edge_ids)))
|
|
118
|
+
|
|
119
|
+
def effective_scopes(self, subject: str) -> tuple[AuthorityScope, ...]:
|
|
120
|
+
scopes = {scope.key: scope for path in self.derive_paths(subject) for scope in path.scopes}
|
|
121
|
+
return tuple(scopes[key] for key in sorted(scopes))
|
|
122
|
+
|
|
123
|
+
def prove_reachability(self, subject: str, requested: AuthorityScope) -> AuthorityReachabilityProof:
|
|
124
|
+
paths = tuple(
|
|
125
|
+
path
|
|
126
|
+
for path in self.derive_paths(subject)
|
|
127
|
+
if any(scope.covers(requested) for scope in path.scopes)
|
|
128
|
+
)
|
|
129
|
+
failures = () if paths else (f"{subject!r} cannot reach {requested.action!r} in tenant {requested.tenant_id!r}",)
|
|
130
|
+
return AuthorityReachabilityProof(
|
|
131
|
+
subject=subject,
|
|
132
|
+
requested=requested,
|
|
133
|
+
reachable=bool(paths),
|
|
134
|
+
paths=paths,
|
|
135
|
+
failures=failures,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _field(row: object, key: str, default: object = None) -> object:
|
|
140
|
+
if isinstance(row, Mapping):
|
|
141
|
+
return row.get(key, default)
|
|
142
|
+
return getattr(row, key, default)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _scope_payload(scope: AuthorityScope) -> dict[str, str]:
|
|
146
|
+
return {
|
|
147
|
+
"tenant_id": scope.tenant_id,
|
|
148
|
+
"realm": scope.realm,
|
|
149
|
+
"action": scope.action,
|
|
150
|
+
"resource": scope.resource,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _scope_from_payload(payload: Mapping[str, object]) -> AuthorityScope:
|
|
155
|
+
return AuthorityScope(
|
|
156
|
+
tenant_id=str(payload.get("tenant_id") or ""),
|
|
157
|
+
realm=str(payload.get("realm") or ""),
|
|
158
|
+
action=str(payload.get("action") or ""),
|
|
159
|
+
resource=str(payload.get("resource") or "*"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def authority_node_to_graph_node_payload(node: AuthorityNode, *, graph_id: object | None = None) -> dict[str, object]:
|
|
164
|
+
payload: dict[str, object] = {
|
|
165
|
+
"node_key": node.node_id,
|
|
166
|
+
"kind": node.kind,
|
|
167
|
+
"tenant_id": node.tenant_id or None,
|
|
168
|
+
"realm": node.realm or None,
|
|
169
|
+
"node_metadata": dict(node.attributes),
|
|
170
|
+
}
|
|
171
|
+
if graph_id is not None:
|
|
172
|
+
payload["graph_id"] = graph_id
|
|
173
|
+
return payload
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def authority_edge_to_graph_edge_payload(
|
|
177
|
+
edge: AuthorityEdge,
|
|
178
|
+
*,
|
|
179
|
+
graph_id: object | None = None,
|
|
180
|
+
src_id: object | None = None,
|
|
181
|
+
dst_id: object | None = None,
|
|
182
|
+
) -> dict[str, object]:
|
|
183
|
+
payload: dict[str, object] = {
|
|
184
|
+
"edge_key": edge.edge_id,
|
|
185
|
+
"kind": edge.edge_type,
|
|
186
|
+
"policy_version": edge.policy_version or None,
|
|
187
|
+
"provenance_id": edge.provenance_id or None,
|
|
188
|
+
"scopes": [_scope_payload(scope) for scope in edge.scopes],
|
|
189
|
+
"constraints": list(edge.constraints),
|
|
190
|
+
"active": edge.active,
|
|
191
|
+
"revoked": not edge.active,
|
|
192
|
+
"edge_metadata": {
|
|
193
|
+
"source": edge.source,
|
|
194
|
+
"target": edge.target,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
if edge.scopes:
|
|
198
|
+
payload["tenant_id"] = edge.scopes[0].tenant_id
|
|
199
|
+
payload["realm"] = edge.scopes[0].realm or None
|
|
200
|
+
if graph_id is not None:
|
|
201
|
+
payload["graph_id"] = graph_id
|
|
202
|
+
if src_id is not None:
|
|
203
|
+
payload["src_id"] = src_id
|
|
204
|
+
if dst_id is not None:
|
|
205
|
+
payload["dst_id"] = dst_id
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def graph_node_to_authority_node(row: object) -> AuthorityNode:
|
|
210
|
+
return AuthorityNode(
|
|
211
|
+
node_id=str(_field(row, "node_key") or ""),
|
|
212
|
+
kind=str(_field(row, "kind") or ""),
|
|
213
|
+
tenant_id=str(_field(row, "tenant_id") or ""),
|
|
214
|
+
realm=str(_field(row, "realm") or ""),
|
|
215
|
+
attributes=dict(_field(row, "node_metadata", {}) or {}),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def graph_edge_to_authority_edge(
|
|
220
|
+
row: object,
|
|
221
|
+
*,
|
|
222
|
+
source: str | None = None,
|
|
223
|
+
target: str | None = None,
|
|
224
|
+
) -> AuthorityEdge:
|
|
225
|
+
metadata = dict(_field(row, "edge_metadata", {}) or {})
|
|
226
|
+
scopes = tuple(_scope_from_payload(scope) for scope in (_field(row, "scopes", ()) or ()))
|
|
227
|
+
return AuthorityEdge(
|
|
228
|
+
edge_id=str(_field(row, "edge_key") or ""),
|
|
229
|
+
source=source or str(metadata.get("source") or ""),
|
|
230
|
+
target=target or str(metadata.get("target") or ""),
|
|
231
|
+
edge_type=str(_field(row, "kind") or ""),
|
|
232
|
+
scopes=scopes,
|
|
233
|
+
policy_version=str(_field(row, "policy_version") or ""),
|
|
234
|
+
provenance_id=str(_field(row, "provenance_id") or ""),
|
|
235
|
+
constraints=tuple(str(value) for value in (_field(row, "constraints", ()) or ())),
|
|
236
|
+
active=bool(_field(row, "active", True)) and not bool(_field(row, "revoked", False)),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_authority_graph_integrity(graph: AuthorityDerivationGraph) -> AuthorityGraphIntegrityReport:
|
|
241
|
+
semantic_versions: dict[tuple[str, str, tuple[tuple[str, str, str, str], ...]], set[str]] = {}
|
|
242
|
+
failures: list[str] = []
|
|
243
|
+
for edge in graph.edges.values():
|
|
244
|
+
semantic_key = (edge.source, edge.target, tuple(scope.key for scope in edge.scopes))
|
|
245
|
+
semantic_versions.setdefault(semantic_key, set()).add(edge.policy_version)
|
|
246
|
+
for scope in edge.scopes:
|
|
247
|
+
source = graph.nodes[edge.source]
|
|
248
|
+
target = graph.nodes[edge.target]
|
|
249
|
+
if source.tenant_id and scope.tenant_id != source.tenant_id:
|
|
250
|
+
failures.append(f"edge {edge.edge_id!r} scope crosses source tenant {source.tenant_id!r}")
|
|
251
|
+
if target.tenant_id and scope.tenant_id != target.tenant_id:
|
|
252
|
+
failures.append(f"edge {edge.edge_id!r} scope crosses target tenant {target.tenant_id!r}")
|
|
253
|
+
for (source, target, _scopes), versions in semantic_versions.items():
|
|
254
|
+
non_empty_versions = {version for version in versions if version}
|
|
255
|
+
if len(non_empty_versions) > 1:
|
|
256
|
+
failures.append(f"conflicting policy versions for edge {source!r}->{target!r}")
|
|
257
|
+
return AuthorityGraphIntegrityReport(
|
|
258
|
+
passed=not failures,
|
|
259
|
+
failures=tuple(sorted(set(failures))),
|
|
260
|
+
checked_edge_count=len(graph.edges),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def compute_authority_closure(graph: AuthorityDerivationGraph, subject: str) -> AuthorityClosure:
|
|
265
|
+
try:
|
|
266
|
+
paths = graph.derive_paths(subject)
|
|
267
|
+
except KeyError:
|
|
268
|
+
paths = ()
|
|
269
|
+
scopes: dict[tuple[str, str, str, str], AuthorityScope] = {}
|
|
270
|
+
provenance: dict[tuple[str, str, str, str], set[str]] = {}
|
|
271
|
+
for path in paths:
|
|
272
|
+
for scope in path.scopes:
|
|
273
|
+
scopes[scope.key] = scope
|
|
274
|
+
provenance.setdefault(scope.key, set()).update(path.edge_ids)
|
|
275
|
+
return AuthorityClosure(
|
|
276
|
+
subject=subject,
|
|
277
|
+
scopes=tuple(scopes[key] for key in sorted(scopes)),
|
|
278
|
+
provenance={key: tuple(sorted(edge_ids)) for key, edge_ids in provenance.items()},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def compare_authority_monotonicity(
|
|
283
|
+
before: AuthorityClosure,
|
|
284
|
+
after: AuthorityClosure,
|
|
285
|
+
*,
|
|
286
|
+
mutation_kind: AuthorityMutationKind,
|
|
287
|
+
) -> AuthorityMonotonicityReport:
|
|
288
|
+
mutation_kind = AuthorityMutationKind(mutation_kind)
|
|
289
|
+
before_by_key = {scope.key: scope for scope in before.scopes}
|
|
290
|
+
after_by_key = {scope.key: scope for scope in after.scopes}
|
|
291
|
+
added_keys = set(after_by_key) - set(before_by_key)
|
|
292
|
+
removed_keys = set(before_by_key) - set(after_by_key)
|
|
293
|
+
failures: list[str] = []
|
|
294
|
+
if mutation_kind == AuthorityMutationKind.GRANT and removed_keys:
|
|
295
|
+
failures.append("grant mutation removed existing authority")
|
|
296
|
+
if mutation_kind == AuthorityMutationKind.REVOKE and added_keys:
|
|
297
|
+
failures.append("revoke mutation added authority")
|
|
298
|
+
if mutation_kind == AuthorityMutationKind.REPLACE and not (added_keys or removed_keys):
|
|
299
|
+
failures.append("replace mutation did not change authority")
|
|
300
|
+
return AuthorityMonotonicityReport(
|
|
301
|
+
passed=not failures,
|
|
302
|
+
mutation_kind=mutation_kind,
|
|
303
|
+
added=tuple(after_by_key[key] for key in sorted(added_keys)),
|
|
304
|
+
removed=tuple(before_by_key[key] for key in sorted(removed_keys)),
|
|
305
|
+
failures=tuple(failures),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def diff_least_authority(
|
|
310
|
+
*,
|
|
311
|
+
required: tuple[AuthorityScope, ...],
|
|
312
|
+
effective: AuthorityClosure,
|
|
313
|
+
) -> LeastAuthorityDiff:
|
|
314
|
+
required_keys = {scope.key: scope for scope in required}
|
|
315
|
+
effective_keys = {scope.key: scope for scope in effective.scopes}
|
|
316
|
+
missing: dict[tuple[str, str, str, str], AuthorityScope] = {}
|
|
317
|
+
excess: dict[tuple[str, str, str, str], AuthorityScope] = {}
|
|
318
|
+
for key, required_scope in required_keys.items():
|
|
319
|
+
if not any(scope.covers(required_scope) for scope in effective.scopes):
|
|
320
|
+
missing[key] = required_scope
|
|
321
|
+
for key, effective_scope in effective_keys.items():
|
|
322
|
+
if not any(required_scope.covers(effective_scope) for required_scope in required):
|
|
323
|
+
excess[key] = effective_scope
|
|
324
|
+
return LeastAuthorityDiff(
|
|
325
|
+
passed=not missing and not excess,
|
|
326
|
+
required=tuple(required_keys[key] for key in sorted(required_keys)),
|
|
327
|
+
effective=effective.scopes,
|
|
328
|
+
missing=tuple(missing[key] for key in sorted(missing)),
|
|
329
|
+
excess=tuple(excess[key] for key in sorted(excess)),
|
|
330
|
+
excess_provenance={key: effective.provenance.get(key, ()) for key in sorted(excess)},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
__all__ = [
|
|
335
|
+
"AuthorityClosure",
|
|
336
|
+
"AuthorityDerivationGraph",
|
|
337
|
+
"AuthorityEdge",
|
|
338
|
+
"AuthorityGraphIntegrityReport",
|
|
339
|
+
"AuthorityMonotonicityReport",
|
|
340
|
+
"AuthorityMutationKind",
|
|
341
|
+
"AuthorityNode",
|
|
342
|
+
"AuthorityPath",
|
|
343
|
+
"AuthorityReachabilityProof",
|
|
344
|
+
"AuthorityRole",
|
|
345
|
+
"AuthorityScope",
|
|
346
|
+
"LeastAuthorityDiff",
|
|
347
|
+
"authority_matches",
|
|
348
|
+
"authority_edge_to_graph_edge_payload",
|
|
349
|
+
"authority_node_to_graph_node_payload",
|
|
350
|
+
"compare_authority_monotonicity",
|
|
351
|
+
"compute_authority_closure",
|
|
352
|
+
"diff_least_authority",
|
|
353
|
+
"graph_edge_to_authority_edge",
|
|
354
|
+
"graph_node_to_authority_node",
|
|
355
|
+
"has_admin_authority",
|
|
356
|
+
"has_owner_authority",
|
|
357
|
+
"has_superuser_authority",
|
|
358
|
+
"normalize_authority_roles",
|
|
359
|
+
"validate_authority_graph_integrity",
|
|
360
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tba-concrete-authority-graph
|
|
3
|
+
Version: 0.4.0.dev2
|
|
4
|
+
Summary: Deterministic authority graph and closure algorithms.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10,<3.15
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# tba-concrete-authority-graph
|
|
17
|
+
|
|
18
|
+
Deterministic, storage-neutral authority graph traversal, closure, integrity,
|
|
19
|
+
monotonicity, and least-authority algorithms.
|
|
20
|
+
|
|
21
|
+
This layer does not own database tables, sessions, providers, or protocols.
|
|
22
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
tba_concrete_authority_graph/__init__.py,sha256=gcl6Erg4ak-U5d_gKwm2yzJGORu3HXefE-sAbT6yhq8,13947
|
|
2
|
+
tba_concrete_authority_graph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
tba_concrete_authority_graph-0.4.0.dev2.dist-info/METADATA,sha256=tmZrGVoGo2DKLoCPcIKui7Dae-TXEkHSrpKz74z3RbQ,832
|
|
4
|
+
tba_concrete_authority_graph-0.4.0.dev2.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
5
|
+
tba_concrete_authority_graph-0.4.0.dev2.dist-info/RECORD,,
|