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,378 @@
1
+ """Dependency-free JSON-RPC stdio MCP server for AccessGate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ from ..abac import (
13
+ AttributePolicy,
14
+ OperatorRegistry,
15
+ PolicySchemaError,
16
+ load_policy_schema,
17
+ loads_policy,
18
+ )
19
+
20
+ PROTOCOL_VERSION = "2025-06-18"
21
+ CONTRACT_PATH = Path(__file__).with_name("agent-contract.json")
22
+ MAX_MESSAGE_CHARS = 1_100_000
23
+ MAX_JSON_NUMBER_CHARS = 256
24
+
25
+
26
+ def _reject_json_constant(value: str) -> None:
27
+ raise ValueError(f"Unsupported JSON constant: {value}")
28
+
29
+
30
+ def _reject_duplicate_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
31
+ value: dict[str, object] = {}
32
+ for key, item in pairs:
33
+ if key in value:
34
+ raise ValueError(f"Duplicate JSON object key: {key}")
35
+ value[key] = item
36
+ return value
37
+
38
+
39
+ def _parse_json_integer(value: str) -> int:
40
+ if len(value) > MAX_JSON_NUMBER_CHARS:
41
+ raise ValueError("JSON integer exceeds the digit limit.")
42
+ return int(value)
43
+
44
+
45
+ def _parse_json_float(value: str) -> float:
46
+ if len(value) > MAX_JSON_NUMBER_CHARS:
47
+ raise ValueError("JSON number exceeds the digit limit.")
48
+ number = float(value)
49
+ if not math.isfinite(number):
50
+ raise ValueError("JSON numbers must be finite.")
51
+ return number
52
+
53
+
54
+ def parse_message(document: str) -> object:
55
+ """Parse one bounded, unambiguous JSON-RPC message."""
56
+
57
+ if not isinstance(document, str):
58
+ raise TypeError("MCP messages must be strings.")
59
+ if len(document) > MAX_MESSAGE_CHARS:
60
+ raise ValueError("MCP message exceeds the size limit.")
61
+ return json.loads(
62
+ document,
63
+ parse_constant=_reject_json_constant,
64
+ parse_float=_parse_json_float,
65
+ parse_int=_parse_json_integer,
66
+ object_pairs_hook=_reject_duplicate_json_object,
67
+ )
68
+
69
+
70
+ def load_contract() -> dict[str, Any]:
71
+ with CONTRACT_PATH.open("r", encoding="utf-8") as handle:
72
+ return cast(dict[str, Any], json.load(handle))
73
+
74
+
75
+ def compact_json(value: Any) -> str:
76
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
77
+
78
+
79
+ def pretty_json(value: Any) -> str:
80
+ return json.dumps(value, ensure_ascii=False, indent=2)
81
+
82
+
83
+ def write_message(message: dict[str, Any]) -> None:
84
+ sys.stdout.write(compact_json(message) + "\n")
85
+ sys.stdout.flush()
86
+
87
+
88
+ def result(request_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
89
+ return {"jsonrpc": "2.0", "id": request_id, "result": payload}
90
+
91
+
92
+ def error(request_id: Any, code: int, message: str, data: Any | None = None) -> dict[str, Any]:
93
+ body: dict[str, Any] = {"code": code, "message": message}
94
+ if data is not None:
95
+ body["data"] = data
96
+ return {"jsonrpc": "2.0", "id": request_id, "error": body}
97
+
98
+
99
+ def list_resources(contract: dict[str, Any]) -> dict[str, Any]:
100
+ offering_id = contract["offering"]["id"]
101
+ resources = [
102
+ {
103
+ "uri": f"opensource://{offering_id}/agent-contract",
104
+ "name": "agent-contract",
105
+ "title": "Full Agent Contract",
106
+ "description": "The complete public MCP operating contract for AccessGate.",
107
+ "mimeType": "application/json",
108
+ },
109
+ {
110
+ "uri": f"opensource://{offering_id}/policy-schema",
111
+ "name": "policy-schema",
112
+ "title": "ABAC Policy JSON Schema",
113
+ "description": "The packaged access_gate.policy.v1 JSON Schema.",
114
+ "mimeType": "application/schema+json",
115
+ },
116
+ ]
117
+ for resource in contract.get("resources", []):
118
+ resources.append(
119
+ {
120
+ "uri": resource["uri"],
121
+ "name": resource.get("name", resource["uri"].rsplit("/", 1)[-1]),
122
+ "title": resource.get("title", resource.get("name", resource["uri"])),
123
+ "description": resource.get("description", ""),
124
+ "mimeType": resource.get("mimeType", "application/json"),
125
+ }
126
+ )
127
+ return {"resources": resources}
128
+
129
+
130
+ def read_resource(contract: dict[str, Any], uri: str) -> dict[str, Any]:
131
+ offering_id = contract["offering"]["id"]
132
+ if uri == f"opensource://{offering_id}/agent-contract":
133
+ text = pretty_json(contract)
134
+ mime_type = "application/json"
135
+ elif uri == f"opensource://{offering_id}/policy-schema":
136
+ text = pretty_json(load_policy_schema())
137
+ mime_type = "application/schema+json"
138
+ else:
139
+ for resource in contract.get("resources", []):
140
+ if resource.get("uri") == uri:
141
+ value = resource.get("content", resource.get("data", resource))
142
+ text = value if isinstance(value, str) else pretty_json(value)
143
+ mime_type = resource.get("mimeType", "application/json")
144
+ break
145
+ else:
146
+ raise KeyError(uri)
147
+ return {"contents": [{"uri": uri, "mimeType": mime_type, "text": text}]}
148
+
149
+
150
+ def list_tools(contract: dict[str, Any]) -> dict[str, Any]:
151
+ return {
152
+ "tools": [
153
+ {
154
+ "name": tool["name"],
155
+ "title": tool.get("title", tool["name"].replace("_", " ").title()),
156
+ "description": tool.get("description", ""),
157
+ "inputSchema": tool.get("inputSchema", {"type": "object", "properties": {}}),
158
+ "outputSchema": tool.get("outputSchema"),
159
+ "annotations": tool.get("annotations", {}),
160
+ }
161
+ for tool in contract.get("tools", [])
162
+ ]
163
+ }
164
+
165
+
166
+ def _validation_result(arguments: dict[str, Any]) -> dict[str, Any]:
167
+ policy_value = arguments.get("policy")
168
+ try:
169
+ if isinstance(policy_value, str):
170
+ policy = loads_policy(policy_value)
171
+ elif isinstance(policy_value, Mapping):
172
+ policy = AttributePolicy.from_dict(cast(Mapping[str, object], policy_value))
173
+ else:
174
+ raise PolicySchemaError("policy must be a JSON object or JSON string.")
175
+ except PolicySchemaError as validation_error:
176
+ message = str(validation_error)
177
+ return {
178
+ "content": [{"type": "text", "text": f"Policy validation failed: {message}"}],
179
+ "structuredContent": {"valid": False, "errors": [message]},
180
+ "isError": True,
181
+ }
182
+ return {
183
+ "content": [{"type": "text", "text": "Policy is valid for access_gate.policy.v1."}],
184
+ "structuredContent": {"valid": True, "policy": policy.to_dict(), "errors": []},
185
+ "isError": False,
186
+ }
187
+
188
+
189
+ def call_tool(contract: dict[str, Any], name: str, arguments: dict[str, Any]) -> dict[str, Any]:
190
+ configured = next(
191
+ (tool for tool in contract.get("tools", []) if tool.get("name") == name),
192
+ None,
193
+ )
194
+ if configured is None:
195
+ raise KeyError(name)
196
+ if name == "validate_policy_document":
197
+ return _validation_result(arguments)
198
+ if name == "list_builtin_operators":
199
+ names = OperatorRegistry().names
200
+ return {
201
+ "content": [
202
+ {"type": "text", "text": "Built-in ABAC operators: " + ", ".join(names)}
203
+ ],
204
+ "structuredContent": {"operators": list(names)},
205
+ "isError": False,
206
+ }
207
+ if name == "get_policy_schema":
208
+ return {
209
+ "content": [{"type": "text", "text": "AccessGate ABAC policy JSON Schema."}],
210
+ "structuredContent": {"schema": load_policy_schema()},
211
+ "isError": False,
212
+ }
213
+
214
+ structured = {
215
+ "offering": contract["offering"],
216
+ "tool": configured,
217
+ "arguments": arguments,
218
+ "executionMode": configured.get("executionMode", "contract"),
219
+ "access": contract.get("publicAccess", {}),
220
+ "security": contract.get("security", {}),
221
+ }
222
+ return {
223
+ "content": [
224
+ {
225
+ "type": "text",
226
+ "text": (
227
+ f"{configured.get('title', name)} is available for AccessGate.\n\n"
228
+ f"Purpose: {configured.get('description', '')}\n\n"
229
+ "Use the structured content to prepare a reviewed authorization design."
230
+ ),
231
+ }
232
+ ],
233
+ "structuredContent": structured,
234
+ "isError": False,
235
+ }
236
+
237
+
238
+ def list_prompts(contract: dict[str, Any]) -> dict[str, Any]:
239
+ return {
240
+ "prompts": [
241
+ {
242
+ "name": prompt["name"],
243
+ "title": prompt.get("title", prompt["name"].replace("_", " ").title()),
244
+ "description": prompt.get("description", ""),
245
+ "arguments": prompt.get("arguments", []),
246
+ }
247
+ for prompt in contract.get("prompts", [])
248
+ ]
249
+ }
250
+
251
+
252
+ def get_prompt(contract: dict[str, Any], name: str, arguments: dict[str, Any]) -> dict[str, Any]:
253
+ for prompt in contract.get("prompts", []):
254
+ if prompt.get("name") == name:
255
+ text = prompt.get("template", "")
256
+ for key, value in arguments.items():
257
+ text = text.replace("{" + key + "}", str(value))
258
+ return {
259
+ "description": prompt.get("description", ""),
260
+ "messages": [{"role": "user", "content": {"type": "text", "text": text}}],
261
+ }
262
+ raise KeyError(name)
263
+
264
+
265
+ def handle(message: object, contract: dict[str, Any]) -> dict[str, Any] | None:
266
+ if not isinstance(message, Mapping):
267
+ return error(None, -32600, "Invalid Request")
268
+
269
+ is_notification = "id" not in message
270
+ request_id = message.get("id")
271
+ if message.get("jsonrpc") != "2.0":
272
+ return None if is_notification else error(request_id, -32600, "Invalid Request")
273
+ if not is_notification and (
274
+ isinstance(request_id, bool) or not isinstance(request_id, str | int | type(None))
275
+ ):
276
+ return error(None, -32600, "Invalid Request")
277
+ method = message.get("method")
278
+ if not isinstance(method, str) or not method:
279
+ return None if is_notification else error(request_id, -32600, "Invalid Request")
280
+ raw_params = message.get("params", {})
281
+ if raw_params is None:
282
+ raw_params = {}
283
+ if not isinstance(raw_params, Mapping):
284
+ return None if is_notification else error(request_id, -32602, "Invalid params")
285
+ params = cast(Mapping[str, Any], raw_params)
286
+
287
+ response: dict[str, Any]
288
+ try:
289
+ if method == "initialize":
290
+ response = result(
291
+ request_id,
292
+ {
293
+ "protocolVersion": PROTOCOL_VERSION,
294
+ "capabilities": {
295
+ "resources": {"listChanged": False},
296
+ "tools": {"listChanged": False},
297
+ "prompts": {"listChanged": False},
298
+ },
299
+ "serverInfo": {
300
+ "name": contract["server"]["name"],
301
+ "title": contract["server"].get("title", "AccessGate"),
302
+ "version": contract["server"].get("version", "0.2.0a1"),
303
+ },
304
+ "instructions": contract.get("instructions", ""),
305
+ },
306
+ )
307
+ elif method == "resources/list":
308
+ response = result(request_id, list_resources(contract))
309
+ elif method == "resources/read":
310
+ uri = params["uri"]
311
+ if not isinstance(uri, str):
312
+ raise TypeError("uri must be a string")
313
+ response = result(request_id, read_resource(contract, uri))
314
+ elif method == "tools/list":
315
+ response = result(request_id, list_tools(contract))
316
+ elif method == "tools/call":
317
+ name = params["name"]
318
+ arguments = params.get("arguments", {})
319
+ if arguments is None:
320
+ arguments = {}
321
+ if not isinstance(name, str) or not isinstance(arguments, Mapping):
322
+ raise TypeError("tool name and arguments have invalid types")
323
+ response = result(
324
+ request_id,
325
+ call_tool(contract, name, dict(arguments)),
326
+ )
327
+ elif method == "prompts/list":
328
+ response = result(request_id, list_prompts(contract))
329
+ elif method == "prompts/get":
330
+ name = params["name"]
331
+ arguments = params.get("arguments", {})
332
+ if arguments is None:
333
+ arguments = {}
334
+ if not isinstance(name, str) or not isinstance(arguments, Mapping):
335
+ raise TypeError("prompt name and arguments have invalid types")
336
+ response = result(
337
+ request_id,
338
+ get_prompt(contract, name, dict(arguments)),
339
+ )
340
+ else:
341
+ response = error(request_id, -32601, f"Method not found: {method}")
342
+ except (KeyError, TypeError, ValueError) as request_error:
343
+ response = error(request_id, -32602, f"Unknown or invalid parameter: {request_error}")
344
+ except Exception as internal_error: # pragma: no cover - defensive protocol boundary
345
+ response = error(
346
+ request_id,
347
+ -32603,
348
+ "Internal error",
349
+ {"detail": type(internal_error).__name__},
350
+ )
351
+ return None if is_notification else response
352
+
353
+
354
+ def main() -> None:
355
+ contract = load_contract()
356
+ while True:
357
+ line = sys.stdin.readline(MAX_MESSAGE_CHARS + 1)
358
+ if not line:
359
+ break
360
+ if len(line) > MAX_MESSAGE_CHARS:
361
+ while line and not line.endswith("\n"):
362
+ line = sys.stdin.readline(MAX_MESSAGE_CHARS + 1)
363
+ write_message(error(None, -32700, "Message exceeds the size limit"))
364
+ continue
365
+ if not line.strip():
366
+ continue
367
+ try:
368
+ message = parse_message(line)
369
+ except (json.JSONDecodeError, RecursionError, TypeError, ValueError) as parse_error:
370
+ write_message(error(None, -32700, "Parse error", {"detail": str(parse_error)}))
371
+ continue
372
+ response = handle(message, contract)
373
+ if response is not None:
374
+ write_message(response)
375
+
376
+
377
+ if __name__ == "__main__":
378
+ main()
@@ -0,0 +1,242 @@
1
+ """Small, composable policies for common authorization models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Collection
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+
9
+ from .types import AuthorizationRequest, Decision
10
+
11
+
12
+ class MissEffect(str, Enum):
13
+ """What a granting policy should do when its condition does not match."""
14
+
15
+ DENY = "deny"
16
+ ABSTAIN = "abstain"
17
+
18
+
19
+ _MAX_CONFIGURED_NAMES = 4_096
20
+
21
+
22
+ def _targets(action: str, actions: frozenset[str] | None) -> bool:
23
+ return actions is None or action in actions
24
+
25
+
26
+ def _names(
27
+ values: Collection[str],
28
+ *,
29
+ field_name: str,
30
+ allow_empty: bool = False,
31
+ ) -> frozenset[str]:
32
+ if isinstance(values, str):
33
+ raise TypeError(f"{field_name} must be a collection of names, not a string.")
34
+ names = frozenset(values)
35
+ if not allow_empty and not names:
36
+ raise ValueError(f"{field_name} must contain at least one name.")
37
+ if len(names) > _MAX_CONFIGURED_NAMES:
38
+ raise ValueError(f"{field_name} exceeds the maximum item count.")
39
+ if not all(isinstance(value, str) and value for value in names):
40
+ raise TypeError(f"{field_name} must contain only non-empty strings.")
41
+ return names
42
+
43
+
44
+ def _actions(values: Collection[str] | None) -> frozenset[str] | None:
45
+ return None if values is None else _names(values, field_name="actions")
46
+
47
+
48
+ def _policy_name(value: str) -> str:
49
+ if not isinstance(value, str) or not value:
50
+ raise TypeError("policy name must be a non-empty string.")
51
+ return value
52
+
53
+
54
+ @dataclass(frozen=True, slots=True, init=False)
55
+ class FunctionPolicy:
56
+ """Adapt an application callable to the Policy protocol."""
57
+
58
+ name: str
59
+ evaluator: Callable[[AuthorizationRequest], Decision | None]
60
+ actions: frozenset[str] | None = None
61
+
62
+ def __init__(
63
+ self,
64
+ name: str,
65
+ evaluator: Callable[[AuthorizationRequest], Decision | None],
66
+ *,
67
+ actions: Collection[str] | None = None,
68
+ ) -> None:
69
+ object.__setattr__(self, "name", _policy_name(name))
70
+ if not callable(evaluator):
71
+ raise TypeError("policy evaluator must be callable.")
72
+ object.__setattr__(self, "evaluator", evaluator)
73
+ object.__setattr__(self, "actions", _actions(actions))
74
+
75
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
76
+ if not _targets(request.action, self.actions):
77
+ return None
78
+ return self.evaluator(request)
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class RolePolicy:
83
+ """Allow subjects possessing any or all required roles."""
84
+
85
+ required_roles: frozenset[str]
86
+ actions: frozenset[str] | None = None
87
+ require_all: bool = False
88
+ on_miss: MissEffect = MissEffect.DENY
89
+ name: str = "role"
90
+
91
+ def __init__(
92
+ self,
93
+ required_roles: Collection[str],
94
+ *,
95
+ actions: Collection[str] | None = None,
96
+ require_all: bool = False,
97
+ on_miss: MissEffect = MissEffect.DENY,
98
+ name: str = "role",
99
+ ) -> None:
100
+ object.__setattr__(
101
+ self,
102
+ "required_roles",
103
+ _names(required_roles, field_name="required_roles"),
104
+ )
105
+ if not isinstance(require_all, bool):
106
+ raise TypeError("require_all must be a boolean.")
107
+ if not isinstance(on_miss, MissEffect):
108
+ raise TypeError("on_miss must be a MissEffect value.")
109
+ object.__setattr__(self, "actions", _actions(actions))
110
+ object.__setattr__(self, "require_all", require_all)
111
+ object.__setattr__(self, "on_miss", on_miss)
112
+ object.__setattr__(self, "name", _policy_name(name))
113
+
114
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
115
+ if not _targets(request.action, self.actions):
116
+ return None
117
+ roles = request.subject.roles
118
+ matches = (
119
+ self.required_roles <= roles
120
+ if self.require_all
121
+ else bool(self.required_roles & roles)
122
+ )
123
+ if matches:
124
+ return Decision.allow("The subject has a required role.", policy=self.name)
125
+ if self.on_miss is MissEffect.ABSTAIN:
126
+ return None
127
+ return Decision.deny("The subject lacks a required role.", policy=self.name)
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class OwnerPolicy:
132
+ """Allow when a resource owner attribute equals the subject identifier."""
133
+
134
+ actions: frozenset[str] | None = None
135
+ owner_attribute: str = "owner_id"
136
+ on_miss: MissEffect = MissEffect.DENY
137
+ name: str = "owner"
138
+
139
+ def __init__(
140
+ self,
141
+ *,
142
+ actions: Collection[str] | None = None,
143
+ owner_attribute: str = "owner_id",
144
+ on_miss: MissEffect = MissEffect.DENY,
145
+ name: str = "owner",
146
+ ) -> None:
147
+ object.__setattr__(self, "actions", _actions(actions))
148
+ if not isinstance(owner_attribute, str) or not owner_attribute:
149
+ raise TypeError("owner_attribute must be a non-empty string.")
150
+ if not isinstance(on_miss, MissEffect):
151
+ raise TypeError("on_miss must be a MissEffect value.")
152
+ object.__setattr__(self, "owner_attribute", owner_attribute)
153
+ object.__setattr__(self, "on_miss", on_miss)
154
+ object.__setattr__(self, "name", _policy_name(name))
155
+
156
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
157
+ if not _targets(request.action, self.actions):
158
+ return None
159
+ owner_id = None
160
+ if request.resource is not None:
161
+ owner_id = request.resource.attributes.get(self.owner_attribute)
162
+ if (
163
+ request.subject.authenticated
164
+ and request.subject.identifier
165
+ and isinstance(owner_id, str)
166
+ and owner_id == request.subject.identifier
167
+ ):
168
+ return Decision.allow("The subject owns the resource.", policy=self.name)
169
+ if self.on_miss is MissEffect.ABSTAIN:
170
+ return None
171
+ return Decision.deny("The subject does not own the resource.", policy=self.name)
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class TenantBoundaryPolicy:
176
+ """Deny cross-tenant access without granting same-tenant access by itself."""
177
+
178
+ actions: frozenset[str] | None = None
179
+ subject_attribute: str = "tenant_id"
180
+ resource_attribute: str = "tenant_id"
181
+ name: str = "tenant_boundary"
182
+
183
+ def __init__(
184
+ self,
185
+ *,
186
+ actions: Collection[str] | None = None,
187
+ subject_attribute: str = "tenant_id",
188
+ resource_attribute: str = "tenant_id",
189
+ name: str = "tenant_boundary",
190
+ ) -> None:
191
+ object.__setattr__(self, "actions", _actions(actions))
192
+ if not isinstance(subject_attribute, str) or not subject_attribute:
193
+ raise TypeError("subject_attribute must be a non-empty string.")
194
+ if not isinstance(resource_attribute, str) or not resource_attribute:
195
+ raise TypeError("resource_attribute must be a non-empty string.")
196
+ object.__setattr__(self, "subject_attribute", subject_attribute)
197
+ object.__setattr__(self, "resource_attribute", resource_attribute)
198
+ object.__setattr__(self, "name", _policy_name(name))
199
+
200
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
201
+ if not _targets(request.action, self.actions):
202
+ return None
203
+ subject_tenant = request.subject.attributes.get(self.subject_attribute)
204
+ resource_tenant = None
205
+ if request.resource is not None:
206
+ resource_tenant = request.resource.attributes.get(self.resource_attribute)
207
+ if subject_tenant is None or resource_tenant is None:
208
+ return Decision.deny("Tenant scope is missing.", policy=self.name)
209
+ if (
210
+ not isinstance(subject_tenant, str)
211
+ or not subject_tenant
212
+ or not isinstance(resource_tenant, str)
213
+ or not resource_tenant
214
+ ):
215
+ return Decision.deny("Tenant scope is invalid.", policy=self.name)
216
+ if subject_tenant != resource_tenant:
217
+ return Decision.deny("Cross-tenant access is forbidden.", policy=self.name)
218
+ return None
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class AuthenticatedPolicy:
223
+ """Allow authenticated subjects for selected actions."""
224
+
225
+ actions: frozenset[str] | None = field(default=None)
226
+ name: str = "authenticated"
227
+
228
+ def __init__(
229
+ self,
230
+ *,
231
+ actions: Collection[str] | None = None,
232
+ name: str = "authenticated",
233
+ ) -> None:
234
+ object.__setattr__(self, "actions", _actions(actions))
235
+ object.__setattr__(self, "name", _policy_name(name))
236
+
237
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
238
+ if not _targets(request.action, self.actions):
239
+ return None
240
+ if request.subject.authenticated:
241
+ return Decision.allow("The subject is authenticated.", policy=self.name)
242
+ return Decision.deny("Authentication is required.", policy=self.name)
@@ -0,0 +1,51 @@
1
+ """Extension interfaces for application-specific authorization behaviour."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Protocol, TypeVar
7
+
8
+ from .types import AuthorizationRequest, Decision, Resource, Subject
9
+
10
+ SubjectSource = TypeVar("SubjectSource", contravariant=True)
11
+ ResourceSource = TypeVar("ResourceSource", contravariant=True)
12
+
13
+
14
+ class Policy(Protocol):
15
+ """Evaluate one authorization concern.
16
+
17
+ Returning ``None`` means that the policy abstains. Returning a decision
18
+ means that the policy explicitly allows or denies the request.
19
+ """
20
+
21
+ name: str
22
+
23
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None: ...
24
+
25
+
26
+ class DecisionStrategy(Protocol):
27
+ """Combine individual policy results into one final decision."""
28
+
29
+ def combine(
30
+ self,
31
+ request: AuthorizationRequest,
32
+ decisions: Sequence[Decision],
33
+ ) -> Decision: ...
34
+
35
+
36
+ class AuditSink(Protocol):
37
+ """Receive every final authorization decision."""
38
+
39
+ def record(self, request: AuthorizationRequest, decision: Decision) -> None: ...
40
+
41
+
42
+ class SubjectResolver(Protocol[SubjectSource]):
43
+ """Convert an application identity into an AccessGate subject."""
44
+
45
+ def resolve_subject(self, source: SubjectSource) -> Subject: ...
46
+
47
+
48
+ class ResourceResolver(Protocol[ResourceSource]):
49
+ """Convert an application object into an AccessGate resource."""
50
+
51
+ def resolve_resource(self, source: ResourceSource) -> Resource: ...
access_gate/py.typed ADDED
File without changes