tba-concrete-authority-graph 0.4.0.dev2__tar.gz

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,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,6 @@
1
+ # tba-concrete-authority-graph
2
+
3
+ Deterministic, storage-neutral authority graph traversal, closure, integrity,
4
+ monotonicity, and least-authority algorithms.
5
+
6
+ This layer does not own database tables, sessions, providers, or protocols.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "tba-concrete-authority-graph"
3
+ version = "0.4.0.dev2"
4
+ description = "Deterministic authority graph and closure algorithms."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "Apache-2.0"
8
+ dependencies = ["tba-contracts-identity==0.4.0.dev2"]
9
+
10
+ [build-system]
11
+ requires = ["poetry-core>=1.0.0"]
12
+ build-backend = "poetry.core.masonry.api"
13
+
14
+ [tool.poetry]
15
+ packages = [{ include = "tba_concrete_authority_graph", from = "src" }]
@@ -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
+ ]