caronte-sdk 0.2.4__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.
- argos/__init__.py +27 -0
- argos/adapters/__init__.py +1 -0
- argos/adapters/django.py +145 -0
- argos/adapters/fastapi.py +181 -0
- argos/adapters/flask.py +128 -0
- argos/adapters/strawberry.py +181 -0
- argos/client.py +327 -0
- argos/decorators.py +81 -0
- argos/exceptions.py +25 -0
- argos/middleware.py +75 -0
- argos/models.py +49 -0
- caronte_sdk-0.2.4.dist-info/METADATA +189 -0
- caronte_sdk-0.2.4.dist-info/RECORD +15 -0
- caronte_sdk-0.2.4.dist-info/WHEEL +4 -0
- caronte_sdk-0.2.4.dist-info/licenses/LICENSE +26 -0
argos/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""argos — Python client for the Argos authorizer."""
|
|
2
|
+
|
|
3
|
+
from .client import ArgosClient
|
|
4
|
+
from .decorators import operation
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
ArgosAuthError,
|
|
7
|
+
ArgosConfigError,
|
|
8
|
+
ArgosError,
|
|
9
|
+
ArgosForbiddenError,
|
|
10
|
+
ArgosSyncError,
|
|
11
|
+
ArgosTokenError,
|
|
12
|
+
)
|
|
13
|
+
from .models import OperationDescriptor, OperationWithGroups, TokenClaims
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ArgosClient",
|
|
17
|
+
"OperationDescriptor",
|
|
18
|
+
"OperationWithGroups",
|
|
19
|
+
"TokenClaims",
|
|
20
|
+
"ArgosError",
|
|
21
|
+
"ArgosAuthError",
|
|
22
|
+
"ArgosTokenError",
|
|
23
|
+
"ArgosForbiddenError",
|
|
24
|
+
"ArgosSyncError",
|
|
25
|
+
"ArgosConfigError",
|
|
26
|
+
"operation",
|
|
27
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# argos adapters
|
argos/adapters/django.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Django adapter for argos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from ..client import ArgosClient
|
|
9
|
+
from ..decorators import get_registry, operation # re-export
|
|
10
|
+
from ..exceptions import ArgosTokenError
|
|
11
|
+
from ..models import TokenClaims
|
|
12
|
+
|
|
13
|
+
__all__ = ["ArgosMiddleware", "operation"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _extract_token(request: Any) -> str:
|
|
17
|
+
"""Extract Bearer token from HTTP_AUTHORIZATION."""
|
|
18
|
+
auth: str = request.META.get("HTTP_AUTHORIZATION", "")
|
|
19
|
+
scheme, _, token_str = auth.partition(" ")
|
|
20
|
+
return token_str.strip() if scheme.lower() == "bearer" else ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _operation_info(view_func: Any) -> tuple[str, str, str] | None:
|
|
24
|
+
"""Return ``(identifier, op_method, level)`` from view metadata, or ``None``."""
|
|
25
|
+
# Try the function itself, then __wrapped__ (functools.wraps)
|
|
26
|
+
for candidate in (view_func, getattr(view_func, "__wrapped__", None)):
|
|
27
|
+
if candidate is None:
|
|
28
|
+
continue
|
|
29
|
+
identifier = getattr(candidate, "__argos_id__", None)
|
|
30
|
+
op_method = getattr(candidate, "__argos_method__", None)
|
|
31
|
+
level = getattr(candidate, "__argos_level__", None)
|
|
32
|
+
if identifier and op_method and level:
|
|
33
|
+
return (identifier, op_method, level)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArgosMiddleware:
|
|
38
|
+
"""Django WSGI middleware for argos.
|
|
39
|
+
|
|
40
|
+
Uses Django's ``process_view`` hook — which runs *after* URL resolution
|
|
41
|
+
and receives the matched view function directly — so ``@operation``
|
|
42
|
+
metadata is always available regardless of middleware order.
|
|
43
|
+
|
|
44
|
+
Add to ``MIDDLEWARE`` in ``settings.py``::
|
|
45
|
+
|
|
46
|
+
MIDDLEWARE = [
|
|
47
|
+
...
|
|
48
|
+
"argos.adapters.django.ArgosMiddleware",
|
|
49
|
+
...
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
Required Django settings::
|
|
53
|
+
|
|
54
|
+
ARGOS_AUTHORIZER_URL = "http://localhost:4000/api"
|
|
55
|
+
ARGOS_APP_ID = "<uuid>"
|
|
56
|
+
ARGOS_SECRET = "<secret>"
|
|
57
|
+
|
|
58
|
+
Mark views with :func:`argos.decorators.operation`::
|
|
59
|
+
|
|
60
|
+
from django.http import JsonResponse
|
|
61
|
+
from argos.adapters.django import operation
|
|
62
|
+
|
|
63
|
+
@operation(id="items.list", level="private")
|
|
64
|
+
def list_items(request):
|
|
65
|
+
user = request.argos_user # TokenClaims
|
|
66
|
+
return JsonResponse({"user": user.sub})
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
get_response: The next middleware or view callable (injected by Django).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
73
|
+
from django.conf import settings
|
|
74
|
+
|
|
75
|
+
authorizer_url: str = getattr(settings, "ARGOS_AUTHORIZER_URL", "")
|
|
76
|
+
realm_id: str = getattr(settings, "ARGOS_REALM_ID", "")
|
|
77
|
+
app_id: str = getattr(settings, "ARGOS_APP_ID", "")
|
|
78
|
+
secret: str = getattr(settings, "ARGOS_SECRET", "")
|
|
79
|
+
|
|
80
|
+
if not (authorizer_url and realm_id and app_id and secret):
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"argos requires ARGOS_AUTHORIZER_URL, ARGOS_REALM_ID, "
|
|
83
|
+
"ARGOS_APP_ID and ARGOS_SECRET in Django settings."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.get_response = get_response
|
|
87
|
+
self.client = ArgosClient(authorizer_url, realm_id, app_id, secret)
|
|
88
|
+
|
|
89
|
+
for op in get_registry():
|
|
90
|
+
self.client.register_operation(op)
|
|
91
|
+
asyncio.run(self.client.startup())
|
|
92
|
+
|
|
93
|
+
def __call__(self, request: Any) -> Any:
|
|
94
|
+
"""Pass the request through — auth is enforced in process_view."""
|
|
95
|
+
return self.get_response(request)
|
|
96
|
+
|
|
97
|
+
def process_view(
|
|
98
|
+
self,
|
|
99
|
+
request: Any,
|
|
100
|
+
view_func: Any,
|
|
101
|
+
view_args: Any,
|
|
102
|
+
view_kwargs: Any,
|
|
103
|
+
) -> Any | None:
|
|
104
|
+
"""Enforce authentication and permissions before the view runs.
|
|
105
|
+
|
|
106
|
+
Called by Django after URL resolution. Returns ``None`` to let the
|
|
107
|
+
view proceed, or an :class:`~django.http.HttpResponse` to
|
|
108
|
+
short-circuit the request.
|
|
109
|
+
"""
|
|
110
|
+
from django.http import JsonResponse
|
|
111
|
+
|
|
112
|
+
op_info = _operation_info(view_func)
|
|
113
|
+
|
|
114
|
+
# No argos annotation — pass through
|
|
115
|
+
if op_info is None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
identifier, op_method, level = op_info
|
|
119
|
+
|
|
120
|
+
# Public — no token required
|
|
121
|
+
if level == "public":
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
token_str = _extract_token(request)
|
|
125
|
+
|
|
126
|
+
if not token_str:
|
|
127
|
+
return JsonResponse(
|
|
128
|
+
{"error": "unauthorized", "detail": "Bearer token required."}, status=401
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Validate token (sync wrapper)
|
|
132
|
+
try:
|
|
133
|
+
claims: TokenClaims = asyncio.run(self.client.validate_token(token_str))
|
|
134
|
+
except ArgosTokenError as exc:
|
|
135
|
+
return JsonResponse({"error": "unauthorized", "detail": str(exc)}, status=401)
|
|
136
|
+
|
|
137
|
+
# Check permission
|
|
138
|
+
if not self.client.check_permission(claims, identifier, op_method):
|
|
139
|
+
return JsonResponse(
|
|
140
|
+
{"error": "forbidden", "detail": "Insufficient permissions."}, status=403
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Inject claims — available in views as request.argos_user
|
|
144
|
+
request.argos_user = claims
|
|
145
|
+
return None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""FastAPI / Starlette adapter for argos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from starlette.datastructures import Headers
|
|
9
|
+
from starlette.routing import Match
|
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
from ..client import ArgosClient
|
|
13
|
+
from ..decorators import get_registry, operation # re-export
|
|
14
|
+
from ..exceptions import ArgosForbiddenError, ArgosTokenError
|
|
15
|
+
from ..middleware import extract_token
|
|
16
|
+
from ..models import TokenClaims
|
|
17
|
+
|
|
18
|
+
__all__ = ["Argos", "operation"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json_response(send: Send, status: int, body: dict[str, Any]):
|
|
22
|
+
"""Build a minimal ASGI JSON response without starlette.responses dependency."""
|
|
23
|
+
|
|
24
|
+
async def _send() -> None:
|
|
25
|
+
encoded = json.dumps(body).encode()
|
|
26
|
+
await send(
|
|
27
|
+
{
|
|
28
|
+
"type": "http.response.start",
|
|
29
|
+
"status": status,
|
|
30
|
+
"headers": [
|
|
31
|
+
(b"content-type", b"application/json"),
|
|
32
|
+
(b"content-length", str(len(encoded)).encode()),
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
await send({"type": "http.response.body", "body": encoded})
|
|
37
|
+
|
|
38
|
+
return _send
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _match_route(app: Any, path: str, method: str) -> Any | None:
|
|
42
|
+
"""Walk the app's route tree to find the endpoint for path+method."""
|
|
43
|
+
routes = getattr(app, "routes", None)
|
|
44
|
+
if not routes:
|
|
45
|
+
# Might be wrapped (e.g. ExceptionMiddleware) — try .app
|
|
46
|
+
inner = getattr(app, "app", None)
|
|
47
|
+
if inner is not None:
|
|
48
|
+
return _match_route(inner, path, method)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
scope = {"type": "http", "path": path, "method": method.upper()}
|
|
52
|
+
for route in routes:
|
|
53
|
+
match, _ = route.matches(scope)
|
|
54
|
+
if match == Match.FULL:
|
|
55
|
+
# Could be a sub-application (APIRouter mount)
|
|
56
|
+
endpoint = getattr(route, "endpoint", None)
|
|
57
|
+
if endpoint is not None:
|
|
58
|
+
return endpoint
|
|
59
|
+
# Recurse into mounted sub-app
|
|
60
|
+
sub = getattr(route, "app", None)
|
|
61
|
+
if sub is not None:
|
|
62
|
+
result = _match_route(sub, path, method)
|
|
63
|
+
if result is not None:
|
|
64
|
+
return result
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _ArgosASGIMiddleware:
|
|
69
|
+
"""ASGI middleware that validates Bearer tokens and enforces permissions."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, app: ASGIApp, client: ArgosClient) -> None:
|
|
72
|
+
self.app = app
|
|
73
|
+
self.client = client
|
|
74
|
+
|
|
75
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
76
|
+
if scope["type"] != "http":
|
|
77
|
+
await self.app(scope, receive, send)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
headers = Headers(scope=scope)
|
|
81
|
+
path: str = scope.get("path", "/")
|
|
82
|
+
method: str = scope.get("method", "GET")
|
|
83
|
+
|
|
84
|
+
# Resolve the endpoint and check for argos metadata
|
|
85
|
+
endpoint = _match_route(self.app, path, method)
|
|
86
|
+
identifier: str | None = getattr(endpoint, "__argos_id__", None) if endpoint else None
|
|
87
|
+
op_method: str | None = getattr(endpoint, "__argos_method__", None) if endpoint else None
|
|
88
|
+
op_level: str | None = getattr(endpoint, "__argos_level__", None) if endpoint else None
|
|
89
|
+
|
|
90
|
+
# Extract Bearer token
|
|
91
|
+
auth = headers.get("authorization", "")
|
|
92
|
+
scheme, _, token_str = auth.partition(" ")
|
|
93
|
+
token_str = token_str.strip() if scheme.lower() == "bearer" else ""
|
|
94
|
+
|
|
95
|
+
# If no argos annotation, pass through
|
|
96
|
+
if identifier is None:
|
|
97
|
+
await self.app(scope, receive, send)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Public: no token required
|
|
101
|
+
if op_level == "public":
|
|
102
|
+
await self.app(scope, receive, send)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Token required from this point
|
|
106
|
+
if not token_str:
|
|
107
|
+
await _json_response(send, 401, {"error": "unauthorized", "detail": "Bearer token required."})()
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Validate token
|
|
111
|
+
try:
|
|
112
|
+
claims: TokenClaims = await self.client.validate_token(token_str)
|
|
113
|
+
except ArgosTokenError as exc:
|
|
114
|
+
await _json_response(send, 401, {"error": "unauthorized", "detail": str(exc)})()
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Check permission
|
|
118
|
+
allowed = self.client.check_permission(claims, identifier, op_method or "read")
|
|
119
|
+
if not allowed:
|
|
120
|
+
await _json_response(send, 403, {"error": "forbidden", "detail": "Insufficient permissions."})()
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Inject claims into scope so handlers can access request.state.user
|
|
124
|
+
scope.setdefault("state", {})["user"] = claims
|
|
125
|
+
|
|
126
|
+
await self.app(scope, receive, send)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Argos:
|
|
130
|
+
"""FastAPI integration for argos.
|
|
131
|
+
|
|
132
|
+
Registers a startup handler that loads operations and an ASGI middleware
|
|
133
|
+
that enforces authentication and permissions on every request.
|
|
134
|
+
|
|
135
|
+
Usage::
|
|
136
|
+
|
|
137
|
+
from fastapi import FastAPI
|
|
138
|
+
from argos.adapters.fastapi import Argos, operation
|
|
139
|
+
|
|
140
|
+
app = FastAPI()
|
|
141
|
+
argos = Argos(
|
|
142
|
+
app,
|
|
143
|
+
authorizer_url="http://localhost:4000/api",
|
|
144
|
+
app_id="<uuid>",
|
|
145
|
+
secret="<secret>",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@app.get("/items")
|
|
149
|
+
@operation(id="items.list", level="private")
|
|
150
|
+
async def list_items():
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
app: The :class:`fastapi.FastAPI` (or any Starlette) application.
|
|
155
|
+
authorizer_url: Base URL of the Argos authorizer.
|
|
156
|
+
app_id: UUID of the registered backend app.
|
|
157
|
+
secret: Plain-text app secret.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
app: Any,
|
|
163
|
+
authorizer_url: str,
|
|
164
|
+
realm_id: str,
|
|
165
|
+
app_id: str,
|
|
166
|
+
secret: str,
|
|
167
|
+
) -> None:
|
|
168
|
+
self.client = ArgosClient(authorizer_url, realm_id, app_id, secret)
|
|
169
|
+
self._app = app
|
|
170
|
+
|
|
171
|
+
# Register startup handler (compatible with FastAPI 0.109+)
|
|
172
|
+
app.router.on_startup.append(self._startup)
|
|
173
|
+
|
|
174
|
+
# Register ASGI middleware — must receive the app instance
|
|
175
|
+
app.add_middleware(_ArgosASGIMiddleware, client=self.client)
|
|
176
|
+
|
|
177
|
+
async def _startup(self) -> None:
|
|
178
|
+
"""Load operations from the global registry and bootstrap the client."""
|
|
179
|
+
for op in get_registry():
|
|
180
|
+
self.client.register_operation(op)
|
|
181
|
+
await self.client.startup()
|
argos/adapters/flask.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Flask adapter for argos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ..client import ArgosClient
|
|
9
|
+
from ..decorators import get_registry, operation # re-export
|
|
10
|
+
from ..exceptions import ArgosTokenError
|
|
11
|
+
from ..models import TokenClaims
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from flask import Flask
|
|
15
|
+
|
|
16
|
+
__all__ = ["Argos", "operation"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_operation(
|
|
20
|
+
endpoint_name: str | None,
|
|
21
|
+
app: Any,
|
|
22
|
+
) -> tuple[str, str, str] | None:
|
|
23
|
+
"""Return ``(identifier, op_method, level)`` for the current endpoint, or ``None``."""
|
|
24
|
+
if not endpoint_name:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
view_func = app.view_functions.get(endpoint_name)
|
|
28
|
+
if view_func is None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
identifier = getattr(view_func, "__argos_id__", None)
|
|
32
|
+
op_method = getattr(view_func, "__argos_method__", None)
|
|
33
|
+
level = getattr(view_func, "__argos_level__", None)
|
|
34
|
+
|
|
35
|
+
if identifier and op_method and level:
|
|
36
|
+
return (identifier, op_method, level)
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Argos:
|
|
42
|
+
"""Flask integration for argos.
|
|
43
|
+
|
|
44
|
+
Registers a ``before_request`` hook that handles startup (lazily on the
|
|
45
|
+
first request) and per-request authentication / authorisation.
|
|
46
|
+
|
|
47
|
+
Usage::
|
|
48
|
+
|
|
49
|
+
from flask import Flask, g
|
|
50
|
+
from argos.adapters.flask import Argos, operation
|
|
51
|
+
|
|
52
|
+
app = Flask(__name__)
|
|
53
|
+
argos = Argos(
|
|
54
|
+
app,
|
|
55
|
+
authorizer_url="http://localhost:4000/api",
|
|
56
|
+
app_id="<uuid>",
|
|
57
|
+
secret="<secret>",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@app.get("/items")
|
|
61
|
+
@operation(id="items.list", level="private")
|
|
62
|
+
def list_items():
|
|
63
|
+
user = g.argos_user # TokenClaims
|
|
64
|
+
return {"user": user.sub}
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
app: The :class:`flask.Flask` application.
|
|
68
|
+
authorizer_url: Base URL of the Argos authorizer.
|
|
69
|
+
app_id: UUID of the registered backend app.
|
|
70
|
+
secret: Plain-text app secret.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
app: Flask,
|
|
76
|
+
authorizer_url: str,
|
|
77
|
+
realm_id: str,
|
|
78
|
+
app_id: str,
|
|
79
|
+
secret: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.client = ArgosClient(authorizer_url, realm_id, app_id, secret)
|
|
82
|
+
self._app = app
|
|
83
|
+
|
|
84
|
+
for op in get_registry():
|
|
85
|
+
self.client.register_operation(op)
|
|
86
|
+
asyncio.run(self.client.startup())
|
|
87
|
+
|
|
88
|
+
app.before_request(self._before_request)
|
|
89
|
+
|
|
90
|
+
def _before_request(self) -> Any:
|
|
91
|
+
"""Flask ``before_request`` hook: auth enforcement."""
|
|
92
|
+
from flask import g, jsonify, request
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Resolve operation metadata from the matched endpoint
|
|
96
|
+
op_info = _resolve_operation(request.endpoint, self._app)
|
|
97
|
+
|
|
98
|
+
# No argos annotation — let the request pass through
|
|
99
|
+
if op_info is None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
identifier, op_method, level = op_info
|
|
103
|
+
|
|
104
|
+
# Public operations need no token
|
|
105
|
+
if level == "public":
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Extract Bearer token
|
|
109
|
+
auth: str = request.headers.get("Authorization", "")
|
|
110
|
+
scheme, _, token_str = auth.partition(" ")
|
|
111
|
+
token_str = token_str.strip() if scheme.lower() == "bearer" else ""
|
|
112
|
+
|
|
113
|
+
if not token_str:
|
|
114
|
+
return jsonify({"error": "unauthorized", "detail": "Bearer token required."}), 401
|
|
115
|
+
|
|
116
|
+
# Validate token (sync wrapper)
|
|
117
|
+
try:
|
|
118
|
+
claims: TokenClaims = asyncio.run(self.client.validate_token(token_str))
|
|
119
|
+
except ArgosTokenError as exc:
|
|
120
|
+
return jsonify({"error": "unauthorized", "detail": str(exc)}), 401
|
|
121
|
+
|
|
122
|
+
# Check permission
|
|
123
|
+
if not self.client.check_permission(claims, identifier, op_method):
|
|
124
|
+
return jsonify({"error": "forbidden", "detail": "Insufficient permissions."}), 403
|
|
125
|
+
|
|
126
|
+
# Inject claims into Flask's request context
|
|
127
|
+
g.argos_user = claims
|
|
128
|
+
return None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Strawberry GraphQL adapter for argos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from strawberry.extensions import SchemaExtension
|
|
9
|
+
|
|
10
|
+
from ..client import ArgosClient
|
|
11
|
+
from ..decorators import get_registry, operation # re-export
|
|
12
|
+
from ..exceptions import ArgosTokenError
|
|
13
|
+
from ..models import TokenClaims
|
|
14
|
+
|
|
15
|
+
__all__ = ["Argos", "operation"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_argos_attrs(func: Any) -> tuple[str, str, str] | None:
|
|
19
|
+
"""Extract argos metadata from a resolver function or bound method."""
|
|
20
|
+
for candidate in (func, getattr(func, "__func__", None), getattr(func, "__wrapped__", None)):
|
|
21
|
+
if candidate is None:
|
|
22
|
+
continue
|
|
23
|
+
identifier = getattr(candidate, "__argos_id__", None)
|
|
24
|
+
op_method = getattr(candidate, "__argos_method__", None)
|
|
25
|
+
level = getattr(candidate, "__argos_level__", None)
|
|
26
|
+
if identifier and op_method and level:
|
|
27
|
+
return identifier, op_method, level
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_extension(client: ArgosClient) -> type[SchemaExtension]:
|
|
32
|
+
"""Return a SchemaExtension class bound to the given ArgosClient."""
|
|
33
|
+
|
|
34
|
+
class ArgosExtension(SchemaExtension):
|
|
35
|
+
async def on_executing_start(self) -> None:
|
|
36
|
+
"""Validate Bearer token before any resolver runs.
|
|
37
|
+
|
|
38
|
+
For HTTP requests (queries/mutations): reads from the
|
|
39
|
+
``Authorization`` header.
|
|
40
|
+
|
|
41
|
+
For WebSocket subscriptions (graphql-ws protocol): browsers
|
|
42
|
+
cannot set custom headers on WebSocket connections, so the token
|
|
43
|
+
is expected in the ``connection_init`` payload::
|
|
44
|
+
|
|
45
|
+
{ "type": "connection_init", "payload": { "Authorization": "Bearer <token>" } }
|
|
46
|
+
|
|
47
|
+
Strawberry exposes this payload as ``context["connection_params"]``.
|
|
48
|
+
"""
|
|
49
|
+
context = self.execution_context.context
|
|
50
|
+
claims: TokenClaims | None = None
|
|
51
|
+
token_str = ""
|
|
52
|
+
|
|
53
|
+
# 1. HTTP Authorization header (queries & mutations)
|
|
54
|
+
request = context.get("request")
|
|
55
|
+
if request is not None:
|
|
56
|
+
headers = getattr(request, "headers", {})
|
|
57
|
+
auth: str = headers.get("authorization", "") or headers.get("Authorization", "")
|
|
58
|
+
scheme, _, raw = auth.partition(" ")
|
|
59
|
+
if scheme.lower() == "bearer":
|
|
60
|
+
token_str = raw.strip()
|
|
61
|
+
|
|
62
|
+
# 2. graphql-ws connection_init payload (subscriptions)
|
|
63
|
+
if not token_str:
|
|
64
|
+
params: dict = context.get("connection_params") or {}
|
|
65
|
+
auth = params.get("Authorization", "") or params.get("authorization", "")
|
|
66
|
+
scheme, _, raw = auth.partition(" ")
|
|
67
|
+
if scheme.lower() == "bearer":
|
|
68
|
+
token_str = raw.strip()
|
|
69
|
+
|
|
70
|
+
if token_str:
|
|
71
|
+
try:
|
|
72
|
+
claims = await client.validate_token(token_str)
|
|
73
|
+
except ArgosTokenError:
|
|
74
|
+
claims = None
|
|
75
|
+
|
|
76
|
+
context["argos_user"] = claims
|
|
77
|
+
|
|
78
|
+
def resolve(
|
|
79
|
+
self,
|
|
80
|
+
_next: Callable,
|
|
81
|
+
root: Any,
|
|
82
|
+
info: Any,
|
|
83
|
+
*args: Any,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""Enforce permissions before each resolver that carries argos metadata."""
|
|
87
|
+
attrs = _get_argos_attrs(_next)
|
|
88
|
+
|
|
89
|
+
if attrs is None:
|
|
90
|
+
return _next(root, info, *args, **kwargs)
|
|
91
|
+
|
|
92
|
+
identifier, op_method, level = attrs
|
|
93
|
+
|
|
94
|
+
if level == "public":
|
|
95
|
+
return _next(root, info, *args, **kwargs)
|
|
96
|
+
|
|
97
|
+
claims: TokenClaims | None = info.context.get("argos_user")
|
|
98
|
+
|
|
99
|
+
if claims is None:
|
|
100
|
+
raise PermissionError("Bearer token required.")
|
|
101
|
+
|
|
102
|
+
if not client.check_permission(claims, identifier, op_method):
|
|
103
|
+
raise PermissionError("Insufficient permissions.")
|
|
104
|
+
|
|
105
|
+
return _next(root, info, *args, **kwargs)
|
|
106
|
+
|
|
107
|
+
return ArgosExtension
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Argos:
|
|
111
|
+
"""Strawberry GraphQL integration for argos.
|
|
112
|
+
|
|
113
|
+
Authenticates the app, syncs the operation catalogue, and provides a
|
|
114
|
+
:class:`strawberry.extensions.SchemaExtension` that enforces per-resolver
|
|
115
|
+
authentication and permissions.
|
|
116
|
+
|
|
117
|
+
Usage (with FastAPI lifespan)::
|
|
118
|
+
|
|
119
|
+
import strawberry
|
|
120
|
+
from contextlib import asynccontextmanager
|
|
121
|
+
from fastapi import FastAPI
|
|
122
|
+
from strawberry.fastapi import GraphQLRouter
|
|
123
|
+
from argos.adapters.strawberry import Argos, operation
|
|
124
|
+
|
|
125
|
+
argos = Argos(
|
|
126
|
+
authorizer_url="http://localhost:4000/api",
|
|
127
|
+
realm_id="test-realm",
|
|
128
|
+
app_id="<uuid>",
|
|
129
|
+
secret="<secret>",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@strawberry.type
|
|
133
|
+
class Query:
|
|
134
|
+
@strawberry.field
|
|
135
|
+
@operation(id="tasks:list", level="private")
|
|
136
|
+
async def tasks(self, info: strawberry.types.Info) -> list[Task]:
|
|
137
|
+
user = info.context["argos_user"]
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
schema = strawberry.Schema(query=Query, extensions=[argos.extension])
|
|
141
|
+
|
|
142
|
+
@asynccontextmanager
|
|
143
|
+
async def lifespan(app: FastAPI):
|
|
144
|
+
await argos.startup_async()
|
|
145
|
+
yield
|
|
146
|
+
|
|
147
|
+
app = FastAPI(lifespan=lifespan)
|
|
148
|
+
app.include_router(GraphQLRouter(schema), prefix="/graphql")
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
authorizer_url: Base URL of the Argos authorizer.
|
|
152
|
+
realm_id: Realm the app belongs to (name or UUID).
|
|
153
|
+
app_id: UUID of the registered backend app.
|
|
154
|
+
secret: Plain-text app secret.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
authorizer_url: str,
|
|
160
|
+
realm_id: str,
|
|
161
|
+
app_id: str,
|
|
162
|
+
secret: str,
|
|
163
|
+
) -> None:
|
|
164
|
+
self.client = ArgosClient(authorizer_url, realm_id, app_id, secret)
|
|
165
|
+
self.extension: type[SchemaExtension] = _make_extension(self.client)
|
|
166
|
+
|
|
167
|
+
def startup(self) -> None:
|
|
168
|
+
"""Register operations and bootstrap the client (sync).
|
|
169
|
+
|
|
170
|
+
Call this after all ``@operation`` decorators have been applied,
|
|
171
|
+
i.e. after all resolver modules are imported.
|
|
172
|
+
"""
|
|
173
|
+
for op in get_registry():
|
|
174
|
+
self.client.register_operation(op)
|
|
175
|
+
asyncio.run(self.client.startup())
|
|
176
|
+
|
|
177
|
+
async def startup_async(self) -> None:
|
|
178
|
+
"""Async variant — use with FastAPI lifespan or any async startup hook."""
|
|
179
|
+
for op in get_registry():
|
|
180
|
+
self.client.register_operation(op)
|
|
181
|
+
await self.client.startup()
|