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 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
@@ -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()
@@ -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()