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.
- access_gate/__init__.py +118 -0
- access_gate/abac.py +1081 -0
- access_gate/adapters/__init__.py +5 -0
- access_gate/adapters/django.py +177 -0
- access_gate/engine.py +224 -0
- access_gate/exceptions.py +13 -0
- access_gate/integrity.py +262 -0
- access_gate/mcp/__init__.py +1 -0
- access_gate/mcp/agent-contract.json +419 -0
- access_gate/mcp/server.py +378 -0
- access_gate/policies.py +242 -0
- access_gate/protocols.py +51 -0
- access_gate/py.typed +0 -0
- access_gate/schema/abac-policy-v1.json +113 -0
- access_gate/strategies.py +28 -0
- access_gate/types.py +315 -0
- accessgate-0.2.0a1.dist-info/METADATA +287 -0
- accessgate-0.2.0a1.dist-info/RECORD +22 -0
- accessgate-0.2.0a1.dist-info/WHEEL +5 -0
- accessgate-0.2.0a1.dist-info/entry_points.txt +2 -0
- accessgate-0.2.0a1.dist-info/licenses/LICENSE +21 -0
- accessgate-0.2.0a1.dist-info/top_level.txt +1 -0
|
@@ -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)
|
access_gate/integrity.py
ADDED
|
@@ -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."""
|