tba-protocol-authzen 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,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: tba-protocol-authzen
3
+ Version: 0.4.0.dev2
4
+ Summary: OpenID AuthZEN Authorization API protocol package.
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-capability-policy-evaluation (==0.4.0.dev2)
14
+ Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "tba-protocol-authzen"
3
+ version = "0.4.0.dev2"
4
+ description = "OpenID AuthZEN Authorization API protocol package."
5
+ requires-python = ">=3.10,<3.15"
6
+ license = "Apache-2.0"
7
+ dependencies = [
8
+ "tba-contracts-identity==0.4.0.dev2",
9
+ "tba-capability-policy-evaluation==0.4.0.dev2",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["poetry-core>=1.0.0"]
14
+ build-backend = "poetry.core.masonry.api"
15
+
16
+ [tool.poetry]
17
+ packages = [{ include = "tba_protocol_authzen", from = "src" }]
@@ -0,0 +1,66 @@
1
+ """Versioned OpenID AuthZEN Authorization API ownership."""
2
+
3
+ from tba_contracts_identity.protocol_processing import (
4
+ build_protocol_capability_report,
5
+ )
6
+
7
+ from .bindings import CAPABILITY_REQUIREMENTS
8
+ from .claims import AUTHZEN_CLAIM_CLASSES
9
+ from .compatibility import (
10
+ COMPATIBILITY_PATHS,
11
+ AuthzenCompatibility,
12
+ compatibility,
13
+ )
14
+ from .errors import (
15
+ AuthzenOperationUnavailableError,
16
+ AuthzenProtocolError,
17
+ AuthzenSchemaError,
18
+ )
19
+ from .features import FEATURES_BY_VERSION, supports
20
+ from .migrations import migrate_evaluation
21
+ from .protocol import AuthzenProtocol
22
+ from .schemas import (
23
+ AuthzenPdpMetadata,
24
+ parse_evaluation_request,
25
+ parse_search_request,
26
+ serialize_evaluation_result,
27
+ serialize_search_result,
28
+ )
29
+ from .versions import AuthzenVersion, CURRENT_VERSION, VERSION_HISTORY, select_version
30
+
31
+
32
+ def capability_report() -> dict[str, object]:
33
+ return build_protocol_capability_report(
34
+ protocol="authzen",
35
+ revision=CURRENT_VERSION.identifier,
36
+ features=tuple(FEATURES_BY_VERSION[CURRENT_VERSION.identifier]),
37
+ evidence_links=("tests/unit/test_versioned_oid4vc_authzen_protocols.py",),
38
+ extra_requirements=CAPABILITY_REQUIREMENTS,
39
+ include_default_artifact_requirements=False,
40
+ )
41
+
42
+
43
+ __all__ = [
44
+ "AUTHZEN_CLAIM_CLASSES",
45
+ "CAPABILITY_REQUIREMENTS",
46
+ "COMPATIBILITY_PATHS",
47
+ "CURRENT_VERSION",
48
+ "FEATURES_BY_VERSION",
49
+ "VERSION_HISTORY",
50
+ "AuthzenCompatibility",
51
+ "AuthzenOperationUnavailableError",
52
+ "AuthzenPdpMetadata",
53
+ "AuthzenProtocol",
54
+ "AuthzenProtocolError",
55
+ "AuthzenSchemaError",
56
+ "AuthzenVersion",
57
+ "capability_report",
58
+ "compatibility",
59
+ "migrate_evaluation",
60
+ "parse_evaluation_request",
61
+ "parse_search_request",
62
+ "select_version",
63
+ "serialize_evaluation_result",
64
+ "serialize_search_result",
65
+ "supports",
66
+ ]
@@ -0,0 +1,79 @@
1
+ """Authorization API 1.0 operations mapped to reportable capabilities."""
2
+
3
+ from tba_contracts_identity.capabilities import ProtocolCapabilityRequirement
4
+
5
+ from .versions import CURRENT_VERSION
6
+
7
+ CAPABILITY_REQUIREMENTS = (
8
+ ProtocolCapabilityRequirement(
9
+ "authzen",
10
+ CURRENT_VERSION.identifier,
11
+ "access-evaluation",
12
+ "/access/v1/evaluation",
13
+ "policy.evaluation",
14
+ "evaluate",
15
+ "policy-evaluation",
16
+ ),
17
+ ProtocolCapabilityRequirement(
18
+ "authzen",
19
+ CURRENT_VERSION.identifier,
20
+ "access-evaluations",
21
+ "/access/v1/evaluations",
22
+ "policy.evaluation",
23
+ "evaluate_many",
24
+ "policy-evaluations",
25
+ False,
26
+ ),
27
+ ProtocolCapabilityRequirement(
28
+ "authzen",
29
+ CURRENT_VERSION.identifier,
30
+ "search-subject",
31
+ "/access/v1/search/subject",
32
+ "policy.evaluation",
33
+ "search_entities",
34
+ "policy-subjects",
35
+ False,
36
+ ),
37
+ ProtocolCapabilityRequirement(
38
+ "authzen",
39
+ CURRENT_VERSION.identifier,
40
+ "search-resource",
41
+ "/access/v1/search/resource",
42
+ "policy.evaluation",
43
+ "search_entities",
44
+ "policy-resources",
45
+ False,
46
+ ),
47
+ ProtocolCapabilityRequirement(
48
+ "authzen",
49
+ CURRENT_VERSION.identifier,
50
+ "search-action",
51
+ "/access/v1/search/action",
52
+ "policy.evaluation",
53
+ "search_entities",
54
+ "policy-actions",
55
+ False,
56
+ ),
57
+ ProtocolCapabilityRequirement(
58
+ "authzen",
59
+ CURRENT_VERSION.identifier,
60
+ "pdp-configuration",
61
+ "/.well-known/authzen-configuration",
62
+ "policy.evaluation",
63
+ "describe",
64
+ "policy-service-description",
65
+ False,
66
+ ),
67
+ ProtocolCapabilityRequirement(
68
+ "authzen",
69
+ CURRENT_VERSION.identifier,
70
+ "signed-metadata",
71
+ "signed_metadata",
72
+ "artifact.processing",
73
+ "validate",
74
+ "verified-authzen-metadata",
75
+ False,
76
+ ),
77
+ )
78
+
79
+ __all__ = ["CAPABILITY_REQUIREMENTS"]
@@ -0,0 +1,10 @@
1
+ """AuthZEN claim-set ownership declaration.
2
+
3
+ Authorization API 1.0 exchanges policy entities and decisions, not a JWT-style
4
+ registered claim set. Signed PDP metadata is a JWT carrier whose generic claims
5
+ remain owned by the JWT protocol package.
6
+ """
7
+
8
+ AUTHZEN_CLAIM_CLASSES: tuple[type[object], ...] = ()
9
+
10
+ __all__ = ["AUTHZEN_CLAIM_CLASSES"]
@@ -0,0 +1,38 @@
1
+ """Authorization API revision compatibility paths."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .versions import CURRENT_VERSION, VERSION_HISTORY
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class AuthzenCompatibility:
10
+ source: str
11
+ target: str
12
+ compatible: bool
13
+ lossless: bool
14
+ migration_required: bool
15
+
16
+
17
+ COMPATIBILITY_PATHS = tuple(
18
+ AuthzenCompatibility(
19
+ version.identifier,
20
+ CURRENT_VERSION.identifier,
21
+ True,
22
+ True,
23
+ version.identifier != CURRENT_VERSION.identifier,
24
+ )
25
+ for version in VERSION_HISTORY
26
+ )
27
+
28
+
29
+ def compatibility(
30
+ source: str, target: str = CURRENT_VERSION.identifier
31
+ ) -> AuthzenCompatibility:
32
+ for path in COMPATIBILITY_PATHS:
33
+ if path.source == source and path.target == target:
34
+ return path
35
+ return AuthzenCompatibility(source, target, False, False, False)
36
+
37
+
38
+ __all__ = ["COMPATIBILITY_PATHS", "AuthzenCompatibility", "compatibility"]
@@ -0,0 +1,20 @@
1
+ """Authorization API 1.0 mapping errors."""
2
+
3
+
4
+ class AuthzenProtocolError(ValueError):
5
+ """Base error for invalid AuthZEN protocol material."""
6
+
7
+
8
+ class AuthzenSchemaError(AuthzenProtocolError):
9
+ """Raised when an AuthZEN entity or message has the wrong shape."""
10
+
11
+
12
+ class AuthzenOperationUnavailableError(AuthzenProtocolError):
13
+ """Raised when an optional PDP operation is not configured."""
14
+
15
+
16
+ __all__ = [
17
+ "AuthzenOperationUnavailableError",
18
+ "AuthzenProtocolError",
19
+ "AuthzenSchemaError",
20
+ ]
@@ -0,0 +1,25 @@
1
+ _EVALUATION = frozenset({"access-evaluation"})
2
+ _BATCH = _EVALUATION | {"access-evaluations"}
3
+ _SEARCH = _BATCH | {"search-subject", "search-resource", "search-action"}
4
+ _DISCOVERY = _SEARCH | {"configuration", "capabilities", "signed-metadata"}
5
+
6
+ FEATURES_BY_VERSION = {
7
+ "draft-00": _EVALUATION,
8
+ "draft-01": _EVALUATION,
9
+ "implementers-draft-1": _EVALUATION,
10
+ "draft-02": _BATCH,
11
+ "draft-03": _SEARCH,
12
+ "draft-04": _DISCOVERY,
13
+ "draft-05": _DISCOVERY,
14
+ "1.0": _DISCOVERY,
15
+ }
16
+
17
+
18
+ def supports(feature: str, version: str = "1.0") -> bool:
19
+ try:
20
+ return feature in FEATURES_BY_VERSION[version]
21
+ except KeyError as exc:
22
+ raise ValueError(f"unsupported AuthZEN version: {version}") from exc
23
+
24
+
25
+ __all__ = ["FEATURES_BY_VERSION", "supports"]
@@ -0,0 +1,28 @@
1
+ from collections.abc import Mapping
2
+
3
+
4
+ def migrate_evaluation(
5
+ payload: Mapping[str, object], from_version: str, to_version: str = "1.0"
6
+ ) -> dict[str, object]:
7
+ result = dict(payload)
8
+ if from_version == to_version:
9
+ return result
10
+ if (
11
+ from_version
12
+ in {
13
+ "draft-00",
14
+ "draft-01",
15
+ "implementers-draft-1",
16
+ "draft-02",
17
+ "draft-03",
18
+ "draft-04",
19
+ "draft-05",
20
+ }
21
+ and to_version == "1.0"
22
+ ):
23
+ result.setdefault("context", {})
24
+ return result
25
+ raise ValueError(f"no AuthZEN migration path from {from_version} to {to_version}")
26
+
27
+
28
+ __all__ = ["migrate_evaluation"]
@@ -0,0 +1,103 @@
1
+ """Authorization API 1.0 mapping to neutral policy operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Protocol
7
+
8
+ from tba_contracts_identity.policy import (
9
+ PolicyEntitySearchRequest,
10
+ PolicyEntitySearchResult,
11
+ PolicyEntitySearchTarget,
12
+ PolicyEvaluationRequest,
13
+ PolicyEvaluationResult,
14
+ )
15
+
16
+ from .errors import AuthzenOperationUnavailableError, AuthzenSchemaError
17
+ from .schemas import (
18
+ parse_evaluation_request,
19
+ parse_search_request,
20
+ serialize_evaluation_result,
21
+ serialize_search_result,
22
+ )
23
+
24
+
25
+ class _PolicyOperations(Protocol):
26
+ def evaluate(self, request: PolicyEvaluationRequest) -> PolicyEvaluationResult: ...
27
+
28
+
29
+ def _merged_evaluations(
30
+ payload: Mapping[str, object],
31
+ ) -> tuple[Mapping[str, object], ...]:
32
+ evaluations = payload.get("evaluations")
33
+ if evaluations is None or evaluations == []:
34
+ return (payload,)
35
+ if not isinstance(evaluations, Sequence) or isinstance(evaluations, (str, bytes)):
36
+ raise AuthzenSchemaError("AuthZEN evaluations must be an array")
37
+ defaults = {
38
+ name: payload[name]
39
+ for name in ("subject", "action", "resource", "context")
40
+ if name in payload
41
+ }
42
+ merged = []
43
+ for item in evaluations:
44
+ if not isinstance(item, Mapping):
45
+ raise AuthzenSchemaError("each AuthZEN evaluation must be an object")
46
+ merged.append({**defaults, **item})
47
+ return tuple(merged)
48
+
49
+
50
+ class AuthzenProtocol:
51
+ version = "1.0"
52
+
53
+ def __init__(self, capability: _PolicyOperations):
54
+ self._capability = capability
55
+
56
+ def access_evaluation(self, payload: Mapping[str, object]) -> dict[str, object]:
57
+ return serialize_evaluation_result(
58
+ self._capability.evaluate(parse_evaluation_request(payload))
59
+ )
60
+
61
+ def access_evaluations(self, payload: Mapping[str, object]) -> dict[str, object]:
62
+ merged = _merged_evaluations(payload)
63
+ if len(merged) == 1 and not payload.get("evaluations"):
64
+ return self.access_evaluation(merged[0])
65
+
66
+ requests = tuple(parse_evaluation_request(item) for item in merged)
67
+ evaluate_many = getattr(self._capability, "evaluate_many", None)
68
+ results = (
69
+ evaluate_many(requests)
70
+ if callable(evaluate_many)
71
+ else tuple(self._capability.evaluate(request) for request in requests)
72
+ )
73
+ return {
74
+ "evaluations": [serialize_evaluation_result(result) for result in results]
75
+ }
76
+
77
+ def _search(
78
+ self,
79
+ payload: Mapping[str, object],
80
+ target: PolicyEntitySearchTarget,
81
+ ) -> dict[str, object]:
82
+ search = getattr(self._capability, "search_entities", None)
83
+ if not callable(search):
84
+ raise AuthzenOperationUnavailableError(
85
+ f"AuthZEN {target.value} search is not configured"
86
+ )
87
+ request: PolicyEntitySearchRequest = parse_search_request(payload, target)
88
+ result = search(request)
89
+ if not isinstance(result, PolicyEntitySearchResult):
90
+ raise TypeError("policy search must return PolicyEntitySearchResult")
91
+ return serialize_search_result(result)
92
+
93
+ def search_subject(self, payload: Mapping[str, object]) -> dict[str, object]:
94
+ return self._search(payload, PolicyEntitySearchTarget.SUBJECT)
95
+
96
+ def search_resource(self, payload: Mapping[str, object]) -> dict[str, object]:
97
+ return self._search(payload, PolicyEntitySearchTarget.RESOURCE)
98
+
99
+ def search_action(self, payload: Mapping[str, object]) -> dict[str, object]:
100
+ return self._search(payload, PolicyEntitySearchTarget.ACTION)
101
+
102
+
103
+ __all__ = ["AuthzenProtocol"]
@@ -0,0 +1,191 @@
1
+ """Authorization API 1.0 wire-schema normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+
8
+ from tba_contracts_identity.policy import (
9
+ PolicyEntity,
10
+ PolicyEntityChain,
11
+ PolicyEntitySearchRequest,
12
+ PolicyEntitySearchResult,
13
+ PolicyEntitySearchTarget,
14
+ PolicyEvaluationRequest,
15
+ PolicyEvaluationResult,
16
+ )
17
+
18
+ from .errors import AuthzenSchemaError
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class AuthzenPdpMetadata:
23
+ policy_decision_point: str
24
+ access_evaluation_endpoint: str
25
+ access_evaluations_endpoint: str | None = None
26
+ search_subject_endpoint: str | None = None
27
+ search_resource_endpoint: str | None = None
28
+ search_action_endpoint: str | None = None
29
+ capabilities: tuple[str, ...] = ()
30
+ signed_metadata: str | None = None
31
+
32
+ def to_payload(self) -> dict[str, object]:
33
+ payload: dict[str, object] = {
34
+ "policy_decision_point": self.policy_decision_point,
35
+ "access_evaluation_endpoint": self.access_evaluation_endpoint,
36
+ }
37
+ for name in (
38
+ "access_evaluations_endpoint",
39
+ "search_subject_endpoint",
40
+ "search_resource_endpoint",
41
+ "search_action_endpoint",
42
+ "signed_metadata",
43
+ ):
44
+ if (value := getattr(self, name)) is not None:
45
+ payload[name] = value
46
+ if self.capabilities:
47
+ payload["capabilities"] = list(self.capabilities)
48
+ return payload
49
+
50
+
51
+ def _object(value: object, name: str) -> Mapping[str, object]:
52
+ if not isinstance(value, Mapping):
53
+ raise AuthzenSchemaError(f"AuthZEN {name} must be an object")
54
+ return value
55
+
56
+
57
+ def _properties(value: Mapping[str, object], name: str) -> Mapping[str, object]:
58
+ properties = value.get("properties", {})
59
+ if not isinstance(properties, Mapping):
60
+ raise AuthzenSchemaError(f"AuthZEN {name} properties must be an object")
61
+ return dict(properties)
62
+
63
+
64
+ def _entity(
65
+ value: object,
66
+ name: str,
67
+ *,
68
+ identifier_required: bool = True,
69
+ ) -> PolicyEntity:
70
+ item = _object(value, name)
71
+ if name == "action":
72
+ identifier = item.get("name")
73
+ if identifier_required and not isinstance(identifier, str):
74
+ raise AuthzenSchemaError("AuthZEN action requires name")
75
+ return PolicyEntity(
76
+ "action",
77
+ identifier if isinstance(identifier, str) else "",
78
+ _properties(item, name),
79
+ )
80
+
81
+ entity_type = item.get("type")
82
+ identifier = item.get("id")
83
+ if not isinstance(entity_type, str):
84
+ raise AuthzenSchemaError(f"AuthZEN {name} requires type")
85
+ if identifier_required and not isinstance(identifier, str):
86
+ raise AuthzenSchemaError(f"AuthZEN {name} requires id")
87
+ return PolicyEntity(
88
+ entity_type,
89
+ identifier if isinstance(identifier, str) else "",
90
+ _properties(item, name),
91
+ )
92
+
93
+
94
+ def parse_evaluation_request(
95
+ payload: Mapping[str, object],
96
+ ) -> PolicyEvaluationRequest:
97
+ context = payload.get("context", {})
98
+ if not isinstance(context, Mapping):
99
+ raise AuthzenSchemaError("AuthZEN context must be an object")
100
+ return PolicyEvaluationRequest(
101
+ PolicyEntityChain((_entity(payload.get("subject"), "subject"),)),
102
+ PolicyEntityChain((_entity(payload.get("action"), "action"),)),
103
+ PolicyEntityChain((_entity(payload.get("resource"), "resource"),)),
104
+ dict(context),
105
+ )
106
+
107
+
108
+ def parse_search_request(
109
+ payload: Mapping[str, object],
110
+ target: PolicyEntitySearchTarget,
111
+ ) -> PolicyEntitySearchRequest:
112
+ context = payload.get("context", {})
113
+ if not isinstance(context, Mapping):
114
+ raise AuthzenSchemaError("AuthZEN context must be an object")
115
+ page = payload.get("page", {})
116
+ if not isinstance(page, Mapping):
117
+ raise AuthzenSchemaError("AuthZEN page must be an object")
118
+ token = page.get("token")
119
+ if token is not None and not isinstance(token, str):
120
+ raise AuthzenSchemaError("AuthZEN page token must be a string")
121
+
122
+ subject = _entity(
123
+ payload.get("subject"),
124
+ "subject",
125
+ identifier_required=target is not PolicyEntitySearchTarget.SUBJECT,
126
+ )
127
+ resource = _entity(
128
+ payload.get("resource"),
129
+ "resource",
130
+ identifier_required=target is not PolicyEntitySearchTarget.RESOURCE,
131
+ )
132
+ action = (
133
+ None
134
+ if target is PolicyEntitySearchTarget.ACTION
135
+ else _entity(payload.get("action"), "action")
136
+ )
137
+ return PolicyEntitySearchRequest(
138
+ target=target,
139
+ subject=subject,
140
+ action=action,
141
+ resource=resource,
142
+ context=dict(context),
143
+ page_token=token,
144
+ )
145
+
146
+
147
+ def serialize_evaluation_result(result: PolicyEvaluationResult) -> dict[str, object]:
148
+ payload: dict[str, object] = {"decision": result.decision.allowed}
149
+ context: dict[str, object] = {}
150
+ if result.decision.reason:
151
+ context["reason"] = result.decision.reason
152
+ if result.obligations:
153
+ context["obligations"] = list(result.obligations)
154
+ if result.advice:
155
+ context["advice"] = list(result.advice)
156
+ if context:
157
+ payload["context"] = context
158
+ return payload
159
+
160
+
161
+ def serialize_search_result(result: PolicyEntitySearchResult) -> dict[str, object]:
162
+ entities = []
163
+ for entity in result.entities:
164
+ item: dict[str, object]
165
+ if entity.entity_type == "action":
166
+ item = {"name": entity.identifier}
167
+ else:
168
+ item = {"type": entity.entity_type, "id": entity.identifier}
169
+ if entity.properties:
170
+ item["properties"] = dict(entity.properties)
171
+ entities.append(item)
172
+ payload: dict[str, object] = {"results": entities}
173
+ page: dict[str, object] = {}
174
+ if result.next_page_token is not None:
175
+ page["next_token"] = result.next_page_token
176
+ if result.total is not None:
177
+ page["total"] = result.total
178
+ if page:
179
+ payload["page"] = page
180
+ if result.context:
181
+ payload["context"] = dict(result.context)
182
+ return payload
183
+
184
+
185
+ __all__ = [
186
+ "AuthzenPdpMetadata",
187
+ "parse_evaluation_request",
188
+ "parse_search_request",
189
+ "serialize_evaluation_result",
190
+ "serialize_search_result",
191
+ ]
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class AuthzenVersion:
6
+ identifier: str
7
+ status: str
8
+ published: str
9
+
10
+
11
+ VERSION_HISTORY = (
12
+ AuthzenVersion("draft-00", "superseded-draft", "2024-08-14"),
13
+ AuthzenVersion("draft-01", "superseded-draft", "2024-09-06"),
14
+ AuthzenVersion(
15
+ "implementers-draft-1", "superseded-implementers-draft", "2024-11-15"
16
+ ),
17
+ AuthzenVersion("draft-02", "superseded-draft", "2025-01-23"),
18
+ AuthzenVersion("draft-03", "superseded-draft", "2025-03-18"),
19
+ AuthzenVersion("draft-04", "superseded-draft", "2025-10-06"),
20
+ AuthzenVersion("draft-05", "proposed-final-draft", "2025-11-08"),
21
+ AuthzenVersion("1.0", "final", "2026-01-11"),
22
+ )
23
+ CURRENT_VERSION = VERSION_HISTORY[-1]
24
+
25
+
26
+ def select_version(identifier: str = "1.0") -> AuthzenVersion:
27
+ for version in VERSION_HISTORY:
28
+ if version.identifier == identifier:
29
+ return version
30
+ raise ValueError(f"unsupported AuthZEN version: {identifier}")
31
+
32
+
33
+ __all__ = ["AuthzenVersion", "CURRENT_VERSION", "VERSION_HISTORY", "select_version"]