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,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()
|
access_gate/policies.py
ADDED
|
@@ -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)
|
access_gate/protocols.py
ADDED
|
@@ -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
|