AccessGate 0.2.0a1__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,5 @@
1
+ """Optional framework adapters.
2
+
3
+ Adapters are intentionally excluded from the top-level import so the core has
4
+ no framework dependency.
5
+ """
@@ -0,0 +1,177 @@
1
+ """Django integration for AccessGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from functools import wraps
8
+ from inspect import isawaitable
9
+ from typing import TypeVar, cast
10
+
11
+ from asgiref.sync import iscoroutinefunction
12
+ from django.core.exceptions import PermissionDenied
13
+ from django.http import HttpRequest
14
+
15
+ from ..engine import AuthorizationEngine
16
+ from ..types import AuthorizationRequest, Decision, Resource, Subject
17
+
18
+ View = TypeVar("View", bound=Callable[..., object])
19
+ Resolved = TypeVar("Resolved")
20
+ SubjectResolverFunction = Callable[[HttpRequest], Subject | Awaitable[Subject]]
21
+ ResourceResolverFunction = Callable[..., Resource | None | Awaitable[Resource | None]]
22
+ ContextResolverFunction = Callable[
23
+ [HttpRequest],
24
+ Mapping[str, object] | Awaitable[Mapping[str, object]],
25
+ ]
26
+ ActionAttributesResolverFunction = Callable[
27
+ [HttpRequest, str],
28
+ Mapping[str, object] | Awaitable[Mapping[str, object]],
29
+ ]
30
+ DenialHandler = Callable[[HttpRequest, Decision], object | Awaitable[object]]
31
+
32
+
33
+ def default_subject_resolver(request: HttpRequest) -> Subject:
34
+ """Resolve Django's user without assuming a project-specific user model.
35
+
36
+ Applications can expose ``access_gate_roles`` and
37
+ ``access_gate_attributes`` on their user object, or replace this resolver.
38
+ """
39
+
40
+ user = getattr(request, "user", None)
41
+ authenticated = bool(user is not None and getattr(user, "is_authenticated", False))
42
+ identifier_value = getattr(user, "pk", None)
43
+ if identifier_value is None:
44
+ identifier_value = getattr(user, "id", None)
45
+ roles_value = getattr(user, "access_gate_roles", ())
46
+ attributes_value = getattr(user, "access_gate_attributes", {})
47
+ if isinstance(roles_value, str):
48
+ roles_value = (roles_value,)
49
+ if not isinstance(attributes_value, Mapping):
50
+ attributes_value = {}
51
+ return Subject(
52
+ identifier="" if identifier_value is None else str(identifier_value),
53
+ authenticated=authenticated,
54
+ roles=frozenset(str(role) for role in roles_value),
55
+ attributes=dict(attributes_value),
56
+ )
57
+
58
+
59
+ def default_context_resolver(request: HttpRequest) -> Mapping[str, object]:
60
+ return {
61
+ "method": getattr(request, "method", ""),
62
+ "path": getattr(request, "path", ""),
63
+ }
64
+
65
+
66
+ def default_action_attributes_resolver(
67
+ request: HttpRequest,
68
+ action: str,
69
+ ) -> Mapping[str, object]:
70
+ del request, action
71
+ return {}
72
+
73
+
74
+ def raise_permission_denied(request: HttpRequest, decision: Decision) -> object:
75
+ del request
76
+ raise PermissionDenied(decision.reason)
77
+
78
+
79
+ def _sync_value(value: Resolved | Awaitable[Resolved], *, resolver: str) -> Resolved:
80
+ if isawaitable(value):
81
+ close = getattr(value, "close", None)
82
+ if callable(close):
83
+ close()
84
+ raise TypeError(f"{resolver} returned an awaitable for a synchronous Django view.")
85
+ return value
86
+
87
+
88
+ async def _async_value(value: Resolved | Awaitable[Resolved]) -> Resolved:
89
+ if isawaitable(value):
90
+ return await value
91
+ return value
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class DjangoAuthorizer:
96
+ """Create authorization decorators from a configured engine and resolvers."""
97
+
98
+ engine: AuthorizationEngine
99
+ subject_resolver: SubjectResolverFunction = default_subject_resolver
100
+ context_resolver: ContextResolverFunction = default_context_resolver
101
+ action_attributes_resolver: ActionAttributesResolverFunction = (
102
+ default_action_attributes_resolver
103
+ )
104
+ denial_handler: DenialHandler = raise_permission_denied
105
+
106
+ def require(
107
+ self,
108
+ action: str,
109
+ *,
110
+ resource_resolver: ResourceResolverFunction | None = None,
111
+ ) -> Callable[[View], View]:
112
+ """Decorate a Django view with an authorization requirement."""
113
+
114
+ def decorator(view: View) -> View:
115
+ if iscoroutinefunction(view):
116
+
117
+ @wraps(view)
118
+ async def async_wrapped(
119
+ request: HttpRequest,
120
+ *args: object,
121
+ **kwargs: object,
122
+ ) -> object:
123
+ resource = None
124
+ if resource_resolver is not None:
125
+ resource = await _async_value(resource_resolver(request, *args, **kwargs))
126
+ authorization_request = AuthorizationRequest(
127
+ subject=await _async_value(self.subject_resolver(request)),
128
+ action=action,
129
+ resource=resource,
130
+ context=await _async_value(self.context_resolver(request)),
131
+ action_attributes=await _async_value(
132
+ self.action_attributes_resolver(request, action)
133
+ ),
134
+ )
135
+ decision = self.engine.decide(authorization_request)
136
+ if not decision.allowed:
137
+ return await _async_value(self.denial_handler(request, decision))
138
+ async_view = cast(Callable[..., Awaitable[object]], view)
139
+ return await async_view(request, *args, **kwargs)
140
+
141
+ return cast(View, async_wrapped)
142
+
143
+ @wraps(view)
144
+ def wrapped(request: HttpRequest, *args: object, **kwargs: object) -> object:
145
+ resource = None
146
+ if resource_resolver is not None:
147
+ resource = _sync_value(
148
+ resource_resolver(request, *args, **kwargs),
149
+ resolver="resource_resolver",
150
+ )
151
+ authorization_request = AuthorizationRequest(
152
+ subject=_sync_value(
153
+ self.subject_resolver(request),
154
+ resolver="subject_resolver",
155
+ ),
156
+ action=action,
157
+ resource=resource,
158
+ context=_sync_value(
159
+ self.context_resolver(request),
160
+ resolver="context_resolver",
161
+ ),
162
+ action_attributes=_sync_value(
163
+ self.action_attributes_resolver(request, action),
164
+ resolver="action_attributes_resolver",
165
+ ),
166
+ )
167
+ decision = self.engine.decide(authorization_request)
168
+ if not decision.allowed:
169
+ return _sync_value(
170
+ self.denial_handler(request, decision),
171
+ resolver="denial_handler",
172
+ )
173
+ return view(request, *args, **kwargs)
174
+
175
+ return cast(View, wrapped)
176
+
177
+ return decorator
access_gate/engine.py ADDED
@@ -0,0 +1,224 @@
1
+ """The framework-neutral authorization engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Iterable, Mapping
7
+ from enum import Enum
8
+ from inspect import isawaitable
9
+ from typing import cast
10
+
11
+ from .exceptions import AuthorizationDenied
12
+ from .protocols import AuditSink, DecisionStrategy, Policy
13
+ from .strategies import DenyOverrides
14
+ from .types import AuthorizationRequest, Decision, Resource, Subject
15
+
16
+
17
+ class ErrorMode(str, Enum):
18
+ """How extension failures are handled."""
19
+
20
+ DENY = "deny"
21
+ RAISE = "raise"
22
+ IGNORE = "ignore"
23
+
24
+
25
+ class AuthorizationEngine:
26
+ """Evaluate policies with secure defaults and explicit extension points."""
27
+
28
+ def __init__(
29
+ self,
30
+ policies: Iterable[Policy],
31
+ *,
32
+ strategy: DecisionStrategy | None = None,
33
+ audit_sink: AuditSink | None = None,
34
+ policy_error_mode: ErrorMode = ErrorMode.DENY,
35
+ strategy_error_mode: ErrorMode = ErrorMode.DENY,
36
+ audit_error_mode: ErrorMode = ErrorMode.DENY,
37
+ policy_set_id: str | None = None,
38
+ ) -> None:
39
+ if not isinstance(policy_error_mode, ErrorMode):
40
+ raise TypeError("policy_error_mode must be an ErrorMode value.")
41
+ if not isinstance(strategy_error_mode, ErrorMode):
42
+ raise TypeError("strategy_error_mode must be an ErrorMode value.")
43
+ if strategy_error_mode is ErrorMode.IGNORE:
44
+ raise ValueError("strategy_error_mode cannot ignore a missing final decision.")
45
+ if not isinstance(audit_error_mode, ErrorMode):
46
+ raise TypeError("audit_error_mode must be an ErrorMode value.")
47
+ if policy_set_id is not None and (
48
+ not isinstance(policy_set_id, str)
49
+ or len(policy_set_id) > 256
50
+ or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/+-]*", policy_set_id) is None
51
+ ):
52
+ raise ValueError(
53
+ "policy_set_id must be a bounded identifier containing safe characters."
54
+ )
55
+ self._policies = tuple(policies)
56
+ policy_names: list[str] = []
57
+ for policy in self._policies:
58
+ name = getattr(policy, "name", None)
59
+ if not isinstance(name, str) or not name:
60
+ raise TypeError("Every policy must have a non-empty string name.")
61
+ policy_names.append(name)
62
+ if len(set(policy_names)) != len(policy_names):
63
+ raise ValueError("Policy names must be unique within an authorization engine.")
64
+ global_policies: list[tuple[int, Policy]] = []
65
+ policies_by_action: dict[str, list[tuple[int, Policy]]] = {}
66
+ for index, policy in enumerate(self._policies):
67
+ actions = getattr(policy, "actions", None)
68
+ if isinstance(actions, frozenset) and actions and all(
69
+ isinstance(action, str) and action for action in actions
70
+ ):
71
+ for action in actions:
72
+ policies_by_action.setdefault(action, []).append((index, policy))
73
+ else:
74
+ global_policies.append((index, policy))
75
+ self._global_policies = tuple(global_policies)
76
+ self._policies_by_action = {
77
+ action: tuple(indexed) for action, indexed in policies_by_action.items()
78
+ }
79
+ self._strategy = strategy or DenyOverrides()
80
+ self._audit_sink = audit_sink
81
+ self._policy_error_mode = policy_error_mode
82
+ self._strategy_error_mode = strategy_error_mode
83
+ self._audit_error_mode = audit_error_mode
84
+ self._policy_set_id = policy_set_id
85
+
86
+ @property
87
+ def policies(self) -> tuple[Policy, ...]:
88
+ return self._policies
89
+
90
+ @property
91
+ def policy_set_id(self) -> str | None:
92
+ return self._policy_set_id
93
+
94
+ def decide(self, request: AuthorizationRequest) -> Decision:
95
+ decisions: list[Decision] = []
96
+ for policy in self._policies_for_action(request.action):
97
+ try:
98
+ decision = policy.evaluate(request)
99
+ if decision is not None and not isinstance(decision, Decision):
100
+ self._close_awaitable(decision)
101
+ raise TypeError("A policy returned an invalid decision value.")
102
+ except Exception as error:
103
+ if self._policy_error_mode is ErrorMode.RAISE:
104
+ raise
105
+ if self._policy_error_mode is ErrorMode.DENY:
106
+ decisions.append(
107
+ Decision.deny(
108
+ "A policy failed during evaluation.",
109
+ policy=getattr(policy, "name", type(policy).__name__),
110
+ metadata={"error_type": type(error).__name__},
111
+ )
112
+ )
113
+ continue
114
+ if decision is not None:
115
+ decisions.append(decision)
116
+
117
+ try:
118
+ final = self._strategy.combine(request, decisions)
119
+ if not isinstance(final, Decision):
120
+ self._close_awaitable(final)
121
+ raise TypeError("The decision strategy returned an invalid value.")
122
+ except Exception as error:
123
+ if self._strategy_error_mode is ErrorMode.RAISE:
124
+ raise
125
+ final = Decision.deny(
126
+ "The decision strategy failed.",
127
+ policy="access_gate.strategy",
128
+ metadata={"error_type": type(error).__name__},
129
+ )
130
+ return self._record(request, self._annotate(final))
131
+
132
+ def decide_many(self, requests: Iterable[AuthorizationRequest]) -> tuple[Decision, ...]:
133
+ """Evaluate a batch while preserving request order and audit behavior."""
134
+
135
+ return tuple(self.decide(request) for request in requests)
136
+
137
+ def authorize(
138
+ self,
139
+ subject: Subject,
140
+ action: str,
141
+ resource: Resource | None = None,
142
+ *,
143
+ context: Mapping[str, object] | None = None,
144
+ action_attributes: Mapping[str, object] | None = None,
145
+ ) -> Decision:
146
+ """Convenience API for callers that do not need to construct a request."""
147
+
148
+ return self.decide(
149
+ AuthorizationRequest(
150
+ subject=subject,
151
+ action=action,
152
+ resource=resource,
153
+ context=context or {},
154
+ action_attributes=action_attributes or {},
155
+ )
156
+ )
157
+
158
+ def enforce(self, request: AuthorizationRequest) -> Decision:
159
+ """Return an allowed decision or raise :class:`AuthorizationDenied`."""
160
+
161
+ decision = self.decide(request)
162
+ if not decision.allowed:
163
+ raise AuthorizationDenied(decision)
164
+ return decision
165
+
166
+ def _record(self, request: AuthorizationRequest, decision: Decision) -> Decision:
167
+ if self._audit_sink is None:
168
+ return decision
169
+ try:
170
+ audit_record = cast(
171
+ Callable[[AuthorizationRequest, Decision], object],
172
+ self._audit_sink.record,
173
+ )
174
+ audit_result = audit_record(request, decision)
175
+ if audit_result is not None:
176
+ self._close_awaitable(audit_result)
177
+ raise TypeError("The audit sink returned an invalid value.")
178
+ except Exception as error:
179
+ if self._audit_error_mode is ErrorMode.RAISE:
180
+ raise
181
+ if self._audit_error_mode is ErrorMode.DENY:
182
+ return self._annotate(
183
+ Decision.deny(
184
+ "The authorization decision could not be audited.",
185
+ policy="access_gate.audit",
186
+ metadata={"error_type": type(error).__name__},
187
+ )
188
+ )
189
+ return decision
190
+
191
+ def _annotate(self, decision: Decision) -> Decision:
192
+ if self._policy_set_id is None:
193
+ return decision
194
+ metadata = dict(decision.metadata)
195
+ metadata["policy_set_id"] = self._policy_set_id
196
+ return Decision(decision.effect, decision.reason, decision.policy, metadata)
197
+
198
+ @staticmethod
199
+ def _close_awaitable(value: object) -> None:
200
+ if not isawaitable(value):
201
+ return
202
+ close = getattr(value, "close", None)
203
+ if callable(close):
204
+ close()
205
+
206
+ def _policies_for_action(self, action: str) -> tuple[Policy, ...]:
207
+ targeted = self._policies_by_action.get(action, ())
208
+ if not targeted:
209
+ return tuple(policy for _, policy in self._global_policies)
210
+ selected: list[Policy] = []
211
+ global_index = 0
212
+ targeted_index = 0
213
+ while global_index < len(self._global_policies) and targeted_index < len(targeted):
214
+ global_item = self._global_policies[global_index]
215
+ targeted_item = targeted[targeted_index]
216
+ if global_item[0] < targeted_item[0]:
217
+ selected.append(global_item[1])
218
+ global_index += 1
219
+ else:
220
+ selected.append(targeted_item[1])
221
+ targeted_index += 1
222
+ selected.extend(policy for _, policy in self._global_policies[global_index:])
223
+ selected.extend(policy for _, policy in targeted[targeted_index:])
224
+ return tuple(selected)
@@ -0,0 +1,13 @@
1
+ """AccessGate exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .types import Decision
6
+
7
+
8
+ class AuthorizationDenied(PermissionError):
9
+ """Raised when enforcement receives a denied decision."""
10
+
11
+ def __init__(self, decision: Decision) -> None:
12
+ self.decision = decision
13
+ super().__init__(decision.reason)
@@ -0,0 +1,262 @@
1
+ """Deterministic policy identity and externally supplied signature verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import json
9
+ import re
10
+ from collections.abc import Iterable, Mapping
11
+ from inspect import isawaitable
12
+ from typing import Protocol, cast
13
+
14
+ from .abac import (
15
+ MAX_POLICY_DOCUMENT_CHARS,
16
+ AttributePolicy,
17
+ OperatorRegistry,
18
+ PolicySchemaError,
19
+ attribute_policy_from_dict,
20
+ )
21
+
22
+ CANONICAL_POLICY_FORMAT = "access_gate.canonical-json.v1"
23
+ POLICY_SET_SCHEMA_VERSION = "access_gate.policy_set.v1"
24
+ SIGNED_POLICY_ENVELOPE_VERSION = "access_gate.signed_policy.v1"
25
+ MAX_SIGNED_POLICY_DOCUMENT_CHARS = MAX_POLICY_DOCUMENT_CHARS + 32_768
26
+ MAX_SIGNATURE_BYTES = 16_384
27
+
28
+ _SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]*$")
29
+ _BASE64URL = re.compile(r"^[A-Za-z0-9_-]+$")
30
+
31
+
32
+ class PolicyIntegrityError(PolicySchemaError):
33
+ """Raised when policy identity or signature verification fails."""
34
+
35
+
36
+ class PolicySigner(Protocol):
37
+ """Sign canonical bytes using keys managed outside AccessGate."""
38
+
39
+ key_id: str
40
+ algorithm: str
41
+
42
+ def sign(self, payload: bytes) -> bytes: ...
43
+
44
+
45
+ class PolicySignatureVerifier(Protocol):
46
+ """Verify canonical bytes using an application-controlled trust store."""
47
+
48
+ def verify(
49
+ self,
50
+ payload: bytes,
51
+ signature: bytes,
52
+ *,
53
+ key_id: str,
54
+ algorithm: str,
55
+ ) -> bool: ...
56
+
57
+
58
+ def _canonical_json_bytes(value: object) -> bytes:
59
+ try:
60
+ document = json.dumps(
61
+ value,
62
+ allow_nan=False,
63
+ ensure_ascii=False,
64
+ separators=(",", ":"),
65
+ sort_keys=True,
66
+ )
67
+ except (TypeError, ValueError) as error:
68
+ raise PolicyIntegrityError("The policy is not canonically serializable.") from error
69
+ return document.encode("utf-8")
70
+
71
+
72
+ def canonical_policy_bytes(policy: AttributePolicy) -> bytes:
73
+ """Return deterministic UTF-8 bytes for one validated ABAC policy."""
74
+
75
+ if not isinstance(policy, AttributePolicy):
76
+ raise TypeError("policy must be an AttributePolicy.")
77
+ return _canonical_json_bytes(policy.to_dict())
78
+
79
+
80
+ def policy_fingerprint(policy: AttributePolicy) -> str:
81
+ """Return a stable, algorithm-labelled digest for a policy."""
82
+
83
+ return f"sha256:{hashlib.sha256(canonical_policy_bytes(policy)).hexdigest()}"
84
+
85
+
86
+ def policy_set_fingerprint(policies: Iterable[AttributePolicy]) -> str:
87
+ """Fingerprint an ordered ABAC policy set, including evaluation order."""
88
+
89
+ normalized = tuple(policies)
90
+ if not all(isinstance(policy, AttributePolicy) for policy in normalized):
91
+ raise TypeError("policies must contain only AttributePolicy instances.")
92
+ names = [policy.name for policy in normalized]
93
+ if len(set(names)) != len(names):
94
+ raise PolicyIntegrityError("Policy names must be unique within a policy set.")
95
+ payload = {
96
+ "schema_version": POLICY_SET_SCHEMA_VERSION,
97
+ "policies": [policy.to_dict() for policy in normalized],
98
+ }
99
+ return f"sha256:{hashlib.sha256(_canonical_json_bytes(payload)).hexdigest()}"
100
+
101
+
102
+ def _validated_identifier(value: object, *, field_name: str, max_length: int) -> str:
103
+ if (
104
+ not isinstance(value, str)
105
+ or not value
106
+ or len(value) > max_length
107
+ or _SAFE_IDENTIFIER.fullmatch(value) is None
108
+ ):
109
+ raise PolicyIntegrityError(
110
+ f"{field_name} must be a bounded identifier containing only safe characters."
111
+ )
112
+ return value
113
+
114
+
115
+ def _protected_document(
116
+ policy: AttributePolicy,
117
+ *,
118
+ key_id: str,
119
+ algorithm: str,
120
+ ) -> dict[str, object]:
121
+ return {
122
+ "envelope_version": SIGNED_POLICY_ENVELOPE_VERSION,
123
+ "canonicalization": CANONICAL_POLICY_FORMAT,
124
+ "key_id": key_id,
125
+ "algorithm": algorithm,
126
+ "policy": policy.to_dict(),
127
+ }
128
+
129
+
130
+ def dumps_signed_policy(policy: AttributePolicy, signer: PolicySigner) -> str:
131
+ """Create a signed policy envelope without owning signing keys or algorithms."""
132
+
133
+ if not isinstance(policy, AttributePolicy):
134
+ raise TypeError("policy must be an AttributePolicy.")
135
+ key_id = _validated_identifier(signer.key_id, field_name="key_id", max_length=512)
136
+ algorithm = _validated_identifier(signer.algorithm, field_name="algorithm", max_length=128)
137
+ protected = _protected_document(policy, key_id=key_id, algorithm=algorithm)
138
+ try:
139
+ signature = signer.sign(_canonical_json_bytes(protected))
140
+ except Exception as error:
141
+ raise PolicyIntegrityError("The policy signer failed.") from error
142
+ if isawaitable(signature):
143
+ _close_awaitable(signature)
144
+ raise PolicyIntegrityError("Policy signers must be synchronous.")
145
+ if not isinstance(signature, bytes):
146
+ raise PolicyIntegrityError("Policy signers must return bytes.")
147
+ if not signature or len(signature) > MAX_SIGNATURE_BYTES:
148
+ raise PolicyIntegrityError("The policy signature has an invalid size.")
149
+ envelope = {
150
+ **protected,
151
+ "signature": base64.urlsafe_b64encode(signature).rstrip(b"=").decode("ascii"),
152
+ }
153
+ document = _canonical_json_bytes(envelope).decode("utf-8")
154
+ if len(document) > MAX_SIGNED_POLICY_DOCUMENT_CHARS:
155
+ raise PolicyIntegrityError("The signed policy document exceeds the size limit.")
156
+ return document
157
+
158
+
159
+ def _decode_signature(value: object) -> bytes:
160
+ if not isinstance(value, str) or not value or _BASE64URL.fullmatch(value) is None:
161
+ raise PolicyIntegrityError("signature must be canonical unpadded base64url.")
162
+ try:
163
+ signature = base64.b64decode(
164
+ value + "=" * (-len(value) % 4),
165
+ altchars=b"-_",
166
+ validate=True,
167
+ )
168
+ except (binascii.Error, ValueError) as error:
169
+ raise PolicyIntegrityError("signature must be canonical unpadded base64url.") from error
170
+ canonical = base64.urlsafe_b64encode(signature).rstrip(b"=").decode("ascii")
171
+ if canonical != value or not signature or len(signature) > MAX_SIGNATURE_BYTES:
172
+ raise PolicyIntegrityError("The policy signature is not canonical or has an invalid size.")
173
+ return signature
174
+
175
+
176
+ def loads_signed_policy(
177
+ document: str,
178
+ verifier: PolicySignatureVerifier,
179
+ *,
180
+ operators: OperatorRegistry | None = None,
181
+ ) -> AttributePolicy:
182
+ """Verify a signed envelope before constructing its policy."""
183
+
184
+ if not isinstance(document, str):
185
+ raise TypeError("signed policy document must be a string.")
186
+ if len(document) > MAX_SIGNED_POLICY_DOCUMENT_CHARS:
187
+ raise PolicyIntegrityError("The signed policy document exceeds the size limit.")
188
+ try:
189
+ value = json.loads(
190
+ document,
191
+ parse_constant=_reject_json_constant,
192
+ object_pairs_hook=_reject_duplicate_json_object,
193
+ )
194
+ except (json.JSONDecodeError, RecursionError, ValueError) as error:
195
+ raise PolicyIntegrityError("The signed policy document is not valid JSON.") from error
196
+ if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value):
197
+ raise PolicyIntegrityError("The signed policy envelope must be an object.")
198
+ envelope = cast(Mapping[str, object], value)
199
+ expected_fields = {
200
+ "envelope_version",
201
+ "canonicalization",
202
+ "key_id",
203
+ "algorithm",
204
+ "policy",
205
+ "signature",
206
+ }
207
+ if set(envelope) != expected_fields:
208
+ raise PolicyIntegrityError("The signed policy envelope has missing or unknown fields.")
209
+ if envelope["envelope_version"] != SIGNED_POLICY_ENVELOPE_VERSION:
210
+ raise PolicyIntegrityError("The signed policy envelope version is unsupported.")
211
+ if envelope["canonicalization"] != CANONICAL_POLICY_FORMAT:
212
+ raise PolicyIntegrityError("The signed policy canonicalization is unsupported.")
213
+ key_id = _validated_identifier(envelope["key_id"], field_name="key_id", max_length=512)
214
+ algorithm = _validated_identifier(envelope["algorithm"], field_name="algorithm", max_length=128)
215
+ raw_policy = envelope["policy"]
216
+ if not isinstance(raw_policy, Mapping) or not all(isinstance(key, str) for key in raw_policy):
217
+ raise PolicyIntegrityError("policy must be an object.")
218
+ policy_document = cast(Mapping[str, object], raw_policy)
219
+ signature = _decode_signature(envelope["signature"])
220
+ protected = {
221
+ "envelope_version": envelope["envelope_version"],
222
+ "canonicalization": envelope["canonicalization"],
223
+ "key_id": key_id,
224
+ "algorithm": algorithm,
225
+ "policy": policy_document,
226
+ }
227
+ try:
228
+ verified = verifier.verify(
229
+ _canonical_json_bytes(protected),
230
+ signature,
231
+ key_id=key_id,
232
+ algorithm=algorithm,
233
+ )
234
+ except Exception as error:
235
+ raise PolicyIntegrityError("The policy signature verifier failed.") from error
236
+ if isawaitable(verified):
237
+ _close_awaitable(verified)
238
+ raise PolicyIntegrityError("Policy signature verifiers must be synchronous.")
239
+ if not isinstance(verified, bool):
240
+ raise PolicyIntegrityError("Policy signature verifiers must return a boolean.")
241
+ if not verified:
242
+ raise PolicyIntegrityError("The policy signature is invalid.")
243
+ return attribute_policy_from_dict(policy_document, operators=operators)
244
+
245
+
246
+ def _reject_json_constant(value: str) -> None:
247
+ raise ValueError(f"Unsupported JSON constant: {value}")
248
+
249
+
250
+ def _reject_duplicate_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
251
+ value: dict[str, object] = {}
252
+ for key, item in pairs:
253
+ if key in value:
254
+ raise ValueError(f"Duplicate JSON object key: {key}")
255
+ value[key] = item
256
+ return value
257
+
258
+
259
+ def _close_awaitable(value: object) -> None:
260
+ close = getattr(value, "close", None)
261
+ if callable(close):
262
+ close()
@@ -0,0 +1 @@
1
+ """AccessGate's dependency-free public MCP server."""