cl4im 0.2.0__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.
- cl4im/__init__.py +27 -0
- cl4im/adapters/__init__.py +1 -0
- cl4im/adapters/django.py +145 -0
- cl4im/adapters/fastapi.py +181 -0
- cl4im/adapters/flask.py +139 -0
- cl4im/client.py +316 -0
- cl4im/decorators.py +81 -0
- cl4im/exceptions.py +25 -0
- cl4im/middleware.py +75 -0
- cl4im/models.py +49 -0
- cl4im-0.2.0.dist-info/METADATA +187 -0
- cl4im-0.2.0.dist-info/RECORD +14 -0
- cl4im-0.2.0.dist-info/WHEEL +4 -0
- cl4im-0.2.0.dist-info/licenses/LICENSE +26 -0
cl4im/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""cl4im — Python client for the Identora authorizer."""
|
|
2
|
+
|
|
3
|
+
from .client import Cl4imClient
|
|
4
|
+
from .decorators import operation
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
Cl4imAuthError,
|
|
7
|
+
Cl4imConfigError,
|
|
8
|
+
Cl4imError,
|
|
9
|
+
Cl4imForbiddenError,
|
|
10
|
+
Cl4imSyncError,
|
|
11
|
+
Cl4imTokenError,
|
|
12
|
+
)
|
|
13
|
+
from .models import OperationDescriptor, OperationWithGroups, TokenClaims
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Cl4imClient",
|
|
17
|
+
"OperationDescriptor",
|
|
18
|
+
"OperationWithGroups",
|
|
19
|
+
"TokenClaims",
|
|
20
|
+
"Cl4imError",
|
|
21
|
+
"Cl4imAuthError",
|
|
22
|
+
"Cl4imTokenError",
|
|
23
|
+
"Cl4imForbiddenError",
|
|
24
|
+
"Cl4imSyncError",
|
|
25
|
+
"Cl4imConfigError",
|
|
26
|
+
"operation",
|
|
27
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cl4im adapters
|
cl4im/adapters/django.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Django adapter for cl4im."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from ..client import Cl4imClient
|
|
9
|
+
from ..decorators import get_registry, operation # re-export
|
|
10
|
+
from ..exceptions import Cl4imTokenError
|
|
11
|
+
from ..models import TokenClaims
|
|
12
|
+
|
|
13
|
+
__all__ = ["Cl4imMiddleware", "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, "__cl4im_id__", None)
|
|
30
|
+
op_method = getattr(candidate, "__cl4im_method__", None)
|
|
31
|
+
level = getattr(candidate, "__cl4im_level__", None)
|
|
32
|
+
if identifier and op_method and level:
|
|
33
|
+
return (identifier, op_method, level)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Cl4imMiddleware:
|
|
38
|
+
"""Django WSGI middleware for cl4im.
|
|
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
|
+
"cl4im.adapters.django.Cl4imMiddleware",
|
|
49
|
+
...
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
Required Django settings::
|
|
53
|
+
|
|
54
|
+
CL4IM_AUTHORIZER_URL = "http://localhost:4000/api"
|
|
55
|
+
CL4IM_APP_ID = "<uuid>"
|
|
56
|
+
CL4IM_SECRET = "<secret>"
|
|
57
|
+
|
|
58
|
+
Mark views with :func:`cl4im.decorators.operation`::
|
|
59
|
+
|
|
60
|
+
from django.http import JsonResponse
|
|
61
|
+
from cl4im.adapters.django import operation
|
|
62
|
+
|
|
63
|
+
@operation(id="items.list", level="private")
|
|
64
|
+
def list_items(request):
|
|
65
|
+
user = request.cl4im_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, "CL4IM_AUTHORIZER_URL", "")
|
|
76
|
+
realm_id: str = getattr(settings, "CL4IM_REALM_ID", "")
|
|
77
|
+
app_id: str = getattr(settings, "CL4IM_APP_ID", "")
|
|
78
|
+
secret: str = getattr(settings, "CL4IM_SECRET", "")
|
|
79
|
+
|
|
80
|
+
if not (authorizer_url and realm_id and app_id and secret):
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"cl4im requires CL4IM_AUTHORIZER_URL, CL4IM_REALM_ID, "
|
|
83
|
+
"CL4IM_APP_ID and CL4IM_SECRET in Django settings."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.get_response = get_response
|
|
87
|
+
self.client = Cl4imClient(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 cl4im 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 Cl4imTokenError 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.cl4im_user
|
|
144
|
+
request.cl4im_user = claims
|
|
145
|
+
return None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""FastAPI / Starlette adapter for cl4im."""
|
|
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 Cl4imClient
|
|
13
|
+
from ..decorators import get_registry, operation # re-export
|
|
14
|
+
from ..exceptions import Cl4imForbiddenError, Cl4imTokenError
|
|
15
|
+
from ..middleware import extract_token
|
|
16
|
+
from ..models import TokenClaims
|
|
17
|
+
|
|
18
|
+
__all__ = ["Cl4im", "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 _Cl4imASGIMiddleware:
|
|
69
|
+
"""ASGI middleware that validates Bearer tokens and enforces permissions."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, app: ASGIApp, client: Cl4imClient) -> 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 cl4im metadata
|
|
85
|
+
endpoint = _match_route(self.app, path, method)
|
|
86
|
+
identifier: str | None = getattr(endpoint, "__cl4im_id__", None) if endpoint else None
|
|
87
|
+
op_method: str | None = getattr(endpoint, "__cl4im_method__", None) if endpoint else None
|
|
88
|
+
op_level: str | None = getattr(endpoint, "__cl4im_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 cl4im 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 Cl4imTokenError 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 Cl4im:
|
|
130
|
+
"""FastAPI integration for cl4im.
|
|
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 cl4im.adapters.fastapi import Cl4im, operation
|
|
139
|
+
|
|
140
|
+
app = FastAPI()
|
|
141
|
+
cl4im = Cl4im(
|
|
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 Identora 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 = Cl4imClient(authorizer_url, realm_id, app_id, secret)
|
|
169
|
+
self._app = app
|
|
170
|
+
|
|
171
|
+
# Register startup handler
|
|
172
|
+
app.add_event_handler("startup", self._startup)
|
|
173
|
+
|
|
174
|
+
# Register ASGI middleware — must receive the app instance
|
|
175
|
+
app.add_middleware(_Cl4imASGIMiddleware, 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()
|
cl4im/adapters/flask.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Flask adapter for cl4im."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from ..client import Cl4imClient
|
|
9
|
+
from ..decorators import get_registry, operation # re-export
|
|
10
|
+
from ..exceptions import Cl4imTokenError
|
|
11
|
+
from ..models import TokenClaims
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from flask import Flask
|
|
15
|
+
|
|
16
|
+
__all__ = ["Cl4im", "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, "__cl4im_id__", None)
|
|
32
|
+
op_method = getattr(view_func, "__cl4im_method__", None)
|
|
33
|
+
level = getattr(view_func, "__cl4im_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 Cl4im:
|
|
42
|
+
"""Flask integration for cl4im.
|
|
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 cl4im.adapters.flask import Cl4im, operation
|
|
51
|
+
|
|
52
|
+
app = Flask(__name__)
|
|
53
|
+
cl4im = Cl4im(
|
|
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.cl4im_user # TokenClaims
|
|
64
|
+
return {"user": user.sub}
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
app: The :class:`flask.Flask` application.
|
|
68
|
+
authorizer_url: Base URL of the Identora 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 = Cl4imClient(authorizer_url, realm_id, app_id, secret)
|
|
82
|
+
self._app = app
|
|
83
|
+
self._started: bool = False
|
|
84
|
+
|
|
85
|
+
app.before_request(self._before_request)
|
|
86
|
+
|
|
87
|
+
# ──────────────────────────────────────────────────────────────
|
|
88
|
+
# Internal
|
|
89
|
+
# ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def _startup(self) -> None:
|
|
92
|
+
"""Load operations from the global registry and bootstrap the client (sync)."""
|
|
93
|
+
for op in get_registry():
|
|
94
|
+
self.client.register_operation(op)
|
|
95
|
+
asyncio.run(self.client.startup())
|
|
96
|
+
self._started = True
|
|
97
|
+
|
|
98
|
+
def _before_request(self) -> Any:
|
|
99
|
+
"""Flask ``before_request`` hook: startup + auth enforcement."""
|
|
100
|
+
from flask import g, jsonify, request
|
|
101
|
+
|
|
102
|
+
# Lazy startup on first real request
|
|
103
|
+
if not self._started:
|
|
104
|
+
self._startup()
|
|
105
|
+
|
|
106
|
+
# Resolve operation metadata from the matched endpoint
|
|
107
|
+
op_info = _resolve_operation(request.endpoint, self._app)
|
|
108
|
+
|
|
109
|
+
# No cl4im annotation — let the request pass through
|
|
110
|
+
if op_info is None:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
identifier, op_method, level = op_info
|
|
114
|
+
|
|
115
|
+
# Public operations need no token
|
|
116
|
+
if level == "public":
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Extract Bearer token
|
|
120
|
+
auth: str = request.headers.get("Authorization", "")
|
|
121
|
+
scheme, _, token_str = auth.partition(" ")
|
|
122
|
+
token_str = token_str.strip() if scheme.lower() == "bearer" else ""
|
|
123
|
+
|
|
124
|
+
if not token_str:
|
|
125
|
+
return jsonify({"error": "unauthorized", "detail": "Bearer token required."}), 401
|
|
126
|
+
|
|
127
|
+
# Validate token (sync wrapper)
|
|
128
|
+
try:
|
|
129
|
+
claims: TokenClaims = asyncio.run(self.client.validate_token(token_str))
|
|
130
|
+
except Cl4imTokenError as exc:
|
|
131
|
+
return jsonify({"error": "unauthorized", "detail": str(exc)}), 401
|
|
132
|
+
|
|
133
|
+
# Check permission
|
|
134
|
+
if not self.client.check_permission(claims, identifier, op_method):
|
|
135
|
+
return jsonify({"error": "forbidden", "detail": "Insufficient permissions."}), 403
|
|
136
|
+
|
|
137
|
+
# Inject claims into Flask's request context
|
|
138
|
+
g.cl4im_user = claims
|
|
139
|
+
return None
|
cl4im/client.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Core Cl4imClient — handles auth, JWKS, operations and token validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt.algorithms import RSAAlgorithm
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
Cl4imAuthError,
|
|
14
|
+
Cl4imConfigError,
|
|
15
|
+
Cl4imError,
|
|
16
|
+
Cl4imSyncError,
|
|
17
|
+
Cl4imTokenError,
|
|
18
|
+
)
|
|
19
|
+
from .models import OperationDescriptor, OperationWithGroups, TokenClaims
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Cl4imClient:
|
|
23
|
+
"""Client for the Identora authorizer.
|
|
24
|
+
|
|
25
|
+
Handles app authentication, JWKS caching, operation sync and
|
|
26
|
+
token validation / permission checking.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
authorizer_url: Base URL of the authorizer, e.g. ``http://localhost:4000/api``.
|
|
30
|
+
realm_id: Realm the app belongs to (name or UUID).
|
|
31
|
+
app_id: UUID of the backend app registered in ``auth.apps``.
|
|
32
|
+
secret: Raw secret (plain text) for the app.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
JWKS_TTL: float = 3600.0
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
authorizer_url: str,
|
|
40
|
+
realm_id: str,
|
|
41
|
+
app_id: str,
|
|
42
|
+
secret: str,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._base = authorizer_url.rstrip("/")
|
|
45
|
+
self._realm_id = realm_id
|
|
46
|
+
self._app_id = app_id
|
|
47
|
+
self._secret = secret
|
|
48
|
+
|
|
49
|
+
self._app_token: str | None = None
|
|
50
|
+
self._jwks: dict[str, Any] | None = None
|
|
51
|
+
self._jwks_fetched_at: float | None = None
|
|
52
|
+
self._operations: list[OperationWithGroups] = []
|
|
53
|
+
self._registered_operations: list[OperationDescriptor] = []
|
|
54
|
+
|
|
55
|
+
# ──────────────────────────────────────────────────────────────
|
|
56
|
+
# URL helpers
|
|
57
|
+
# ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _realm_base(self) -> str:
|
|
61
|
+
return f"{self._base}/realms/{self._realm_id}"
|
|
62
|
+
|
|
63
|
+
# ──────────────────────────────────────────────────────────────
|
|
64
|
+
# Public: lifecycle
|
|
65
|
+
# ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def register_operation(self, op: OperationDescriptor) -> None:
|
|
68
|
+
"""Register an operation that this app exposes.
|
|
69
|
+
|
|
70
|
+
Must be called before :meth:`startup`.
|
|
71
|
+
"""
|
|
72
|
+
self._registered_operations.append(op)
|
|
73
|
+
|
|
74
|
+
async def startup(self) -> None:
|
|
75
|
+
"""Bootstrap the client.
|
|
76
|
+
|
|
77
|
+
Authenticates, fetches JWKS, syncs operations and retrieves the
|
|
78
|
+
authorised operation list. Raises :exc:`Cl4imConfigError` if no
|
|
79
|
+
operations have been registered.
|
|
80
|
+
"""
|
|
81
|
+
if not self._registered_operations:
|
|
82
|
+
raise Cl4imConfigError(
|
|
83
|
+
"No operations registered. Call register_operation() before startup()."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
await self.authenticate()
|
|
87
|
+
await self.fetch_jwks()
|
|
88
|
+
await self.sync_operations()
|
|
89
|
+
await self.fetch_operations()
|
|
90
|
+
|
|
91
|
+
# ──────────────────────────────────────────────────────────────
|
|
92
|
+
# Public: remote calls
|
|
93
|
+
# ──────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async def authenticate(self) -> None:
|
|
96
|
+
"""Exchange app credentials for an app-level JWT.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
Cl4imAuthError: If the authorizer rejects the credentials.
|
|
100
|
+
"""
|
|
101
|
+
url = f"{self._realm_base}/apps/token"
|
|
102
|
+
async with httpx.AsyncClient() as http:
|
|
103
|
+
try:
|
|
104
|
+
response = await http.post(
|
|
105
|
+
url,
|
|
106
|
+
json={"app_id": self._app_id, "secret": self._secret},
|
|
107
|
+
timeout=10.0,
|
|
108
|
+
)
|
|
109
|
+
except httpx.RequestError as exc:
|
|
110
|
+
raise Cl4imAuthError(f"Network error during authentication: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
if response.status_code != 200:
|
|
113
|
+
raise Cl4imAuthError(
|
|
114
|
+
f"Authentication failed: HTTP {response.status_code} — {response.text}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
data = response.json()
|
|
118
|
+
token = data.get("access_token")
|
|
119
|
+
if not token:
|
|
120
|
+
raise Cl4imAuthError("Authorizer returned no access_token.")
|
|
121
|
+
|
|
122
|
+
self._app_token = token
|
|
123
|
+
|
|
124
|
+
async def fetch_jwks(self) -> None:
|
|
125
|
+
"""Fetch and cache the JWKS from the authorizer.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
Cl4imError: If the JWKS endpoint is unreachable or returns an error.
|
|
129
|
+
"""
|
|
130
|
+
url = f"{self._realm_base}/protocol/openid-connect/certs"
|
|
131
|
+
async with httpx.AsyncClient() as http:
|
|
132
|
+
try:
|
|
133
|
+
response = await http.get(url, timeout=10.0)
|
|
134
|
+
except httpx.RequestError as exc:
|
|
135
|
+
raise Cl4imError(f"Network error fetching JWKS: {exc}") from exc
|
|
136
|
+
|
|
137
|
+
if response.status_code != 200:
|
|
138
|
+
raise Cl4imError(
|
|
139
|
+
f"JWKS fetch failed: HTTP {response.status_code} — {response.text}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
self._jwks = response.json()
|
|
143
|
+
self._jwks_fetched_at = time.monotonic()
|
|
144
|
+
|
|
145
|
+
async def fetch_operations(self) -> None:
|
|
146
|
+
"""Fetch the operation list (with allowed groups) from the authorizer.
|
|
147
|
+
|
|
148
|
+
Requires a valid app token. Call :meth:`authenticate` first.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
Cl4imError: If the request fails.
|
|
152
|
+
"""
|
|
153
|
+
self._ensure_app_token()
|
|
154
|
+
url = f"{self._realm_base}/apps/operations"
|
|
155
|
+
async with httpx.AsyncClient() as http:
|
|
156
|
+
try:
|
|
157
|
+
response = await http.get(
|
|
158
|
+
url,
|
|
159
|
+
headers={"Authorization": f"Bearer {self._app_token}"},
|
|
160
|
+
timeout=10.0,
|
|
161
|
+
)
|
|
162
|
+
except httpx.RequestError as exc:
|
|
163
|
+
raise Cl4imError(f"Network error fetching operations: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
if response.status_code != 200:
|
|
166
|
+
raise Cl4imError(
|
|
167
|
+
f"Fetch operations failed: HTTP {response.status_code} — {response.text}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
raw = response.json().get("operations", [])
|
|
171
|
+
self._operations = [
|
|
172
|
+
OperationWithGroups(
|
|
173
|
+
identifier=item["identifier"],
|
|
174
|
+
method=item["method"],
|
|
175
|
+
level=item["level"],
|
|
176
|
+
allowed_groups=item.get("allowed_groups", []),
|
|
177
|
+
)
|
|
178
|
+
for item in raw
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
async def sync_operations(self) -> None:
|
|
182
|
+
"""Sync the registered operations catalogue to the authorizer.
|
|
183
|
+
|
|
184
|
+
Requires a valid app token. Call :meth:`authenticate` first.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
Cl4imSyncError: If the sync request fails.
|
|
188
|
+
"""
|
|
189
|
+
self._ensure_app_token()
|
|
190
|
+
url = f"{self._realm_base}/apps/operations/sync"
|
|
191
|
+
payload = {"operations": [op.to_dict() for op in self._registered_operations]}
|
|
192
|
+
async with httpx.AsyncClient() as http:
|
|
193
|
+
try:
|
|
194
|
+
response = await http.post(
|
|
195
|
+
url,
|
|
196
|
+
json=payload,
|
|
197
|
+
headers={"Authorization": f"Bearer {self._app_token}"},
|
|
198
|
+
timeout=10.0,
|
|
199
|
+
)
|
|
200
|
+
except httpx.RequestError as exc:
|
|
201
|
+
raise Cl4imSyncError(f"Network error during sync: {exc}") from exc
|
|
202
|
+
|
|
203
|
+
if response.status_code != 200:
|
|
204
|
+
raise Cl4imSyncError(
|
|
205
|
+
f"Sync failed: HTTP {response.status_code} — {response.text}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ──────────────────────────────────────────────────────────────
|
|
209
|
+
# Public: validation
|
|
210
|
+
# ──────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async def validate_token(self, token: str) -> TokenClaims:
|
|
213
|
+
"""Validate a JWT and return its decoded claims.
|
|
214
|
+
|
|
215
|
+
Refreshes JWKS automatically if the cache has expired.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
token: Raw JWT string (Bearer token from a user or service).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
:class:`TokenClaims` with the verified payload.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
Cl4imTokenError: If the token is invalid, expired, or uses an unknown key.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
header = jwt.get_unverified_header(token)
|
|
228
|
+
except jwt.exceptions.DecodeError as exc:
|
|
229
|
+
raise Cl4imTokenError(f"Malformed token header: {exc}") from exc
|
|
230
|
+
|
|
231
|
+
kid = header.get("kid")
|
|
232
|
+
if not kid:
|
|
233
|
+
raise Cl4imTokenError("Token header missing 'kid'.")
|
|
234
|
+
|
|
235
|
+
now = time.monotonic()
|
|
236
|
+
if (
|
|
237
|
+
self._jwks is None
|
|
238
|
+
or self._jwks_fetched_at is None
|
|
239
|
+
or (now - self._jwks_fetched_at) > self.JWKS_TTL
|
|
240
|
+
):
|
|
241
|
+
await self.fetch_jwks()
|
|
242
|
+
|
|
243
|
+
public_key = self._find_key(kid)
|
|
244
|
+
|
|
245
|
+
if public_key is None:
|
|
246
|
+
await self.fetch_jwks()
|
|
247
|
+
public_key = self._find_key(kid)
|
|
248
|
+
|
|
249
|
+
if public_key is None:
|
|
250
|
+
raise Cl4imTokenError(f"No JWKS key found for kid='{kid}'.")
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
payload: dict[str, Any] = jwt.decode(
|
|
254
|
+
token,
|
|
255
|
+
public_key,
|
|
256
|
+
algorithms=["RS256"],
|
|
257
|
+
options={"verify_exp": True},
|
|
258
|
+
)
|
|
259
|
+
except jwt.ExpiredSignatureError as exc:
|
|
260
|
+
raise Cl4imTokenError("Token has expired.") from exc
|
|
261
|
+
except jwt.InvalidTokenError as exc:
|
|
262
|
+
raise Cl4imTokenError(f"Invalid token: {exc}") from exc
|
|
263
|
+
|
|
264
|
+
return TokenClaims(
|
|
265
|
+
sub=payload["sub"],
|
|
266
|
+
realm_id=payload.get("realm_id", ""),
|
|
267
|
+
groups=payload.get("groups", []),
|
|
268
|
+
exp=payload["exp"],
|
|
269
|
+
raw=payload,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def check_permission(
|
|
273
|
+
self, claims: TokenClaims, identifier: str, method: str
|
|
274
|
+
) -> bool:
|
|
275
|
+
"""Check whether a token's claims authorise access to an operation."""
|
|
276
|
+
op = next(
|
|
277
|
+
(
|
|
278
|
+
o
|
|
279
|
+
for o in self._operations
|
|
280
|
+
if o.identifier == identifier and o.method == method
|
|
281
|
+
),
|
|
282
|
+
None,
|
|
283
|
+
)
|
|
284
|
+
if op is None:
|
|
285
|
+
return False
|
|
286
|
+
if op.level == "public":
|
|
287
|
+
return True
|
|
288
|
+
if op.level == "private":
|
|
289
|
+
return bool(claims.groups)
|
|
290
|
+
if op.level == "protected":
|
|
291
|
+
return bool(set(claims.groups) & set(op.allowed_groups))
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
# ──────────────────────────────────────────────────────────────
|
|
295
|
+
# Internal helpers
|
|
296
|
+
# ──────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def _ensure_app_token(self) -> None:
|
|
299
|
+
if not self._app_token:
|
|
300
|
+
raise Cl4imAuthError(
|
|
301
|
+
"No app token available. Call authenticate() or startup() first."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _find_key(self, kid: str) -> Any | None:
|
|
305
|
+
if not self._jwks:
|
|
306
|
+
return None
|
|
307
|
+
jwk = next(
|
|
308
|
+
(k for k in self._jwks.get("keys", []) if k.get("kid") == kid),
|
|
309
|
+
None,
|
|
310
|
+
)
|
|
311
|
+
if jwk is None:
|
|
312
|
+
return None
|
|
313
|
+
try:
|
|
314
|
+
return RSAAlgorithm.from_jwk(jwk)
|
|
315
|
+
except Exception:
|
|
316
|
+
return None
|
cl4im/decorators.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Operation decorator and global registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .models import OperationDescriptor
|
|
8
|
+
|
|
9
|
+
_registry: list[OperationDescriptor] = []
|
|
10
|
+
|
|
11
|
+
_READ_PREFIXES = ("get_", "list_", "fetch_", "read_")
|
|
12
|
+
_DELETE_PREFIXES = ("delete_", "remove_", "destroy_")
|
|
13
|
+
_STREAM_KEYWORDS = ("stream", "subscribe", "watch", "listen")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _detect_method(func_name: str) -> str:
|
|
17
|
+
"""Infer the operation method from the function name.
|
|
18
|
+
|
|
19
|
+
Rules (in order):
|
|
20
|
+
- Starts with a read prefix → ``'read'``
|
|
21
|
+
- Starts with a delete prefix → ``'delete'``
|
|
22
|
+
- Contains a stream keyword → ``'stream'``
|
|
23
|
+
- Anything else → ``'write'``
|
|
24
|
+
"""
|
|
25
|
+
name = func_name.lower()
|
|
26
|
+
if any(name.startswith(p) for p in _READ_PREFIXES):
|
|
27
|
+
return "read"
|
|
28
|
+
if any(name.startswith(p) for p in _DELETE_PREFIXES):
|
|
29
|
+
return "delete"
|
|
30
|
+
if any(kw in name for kw in _STREAM_KEYWORDS):
|
|
31
|
+
return "stream"
|
|
32
|
+
return "write"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def operation(id: str, level: str) -> Callable[[Any], Any]:
|
|
36
|
+
"""Mark a function as a named operation and register it globally.
|
|
37
|
+
|
|
38
|
+
The operation method is inferred from the function name (see
|
|
39
|
+
:func:`_detect_method`). The function itself is **not wrapped** —
|
|
40
|
+
this decorator is purely declarative.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
id: Unique operation identifier (e.g. ``"users.list"``).
|
|
44
|
+
level: Visibility level — ``'public'``, ``'private'`` or
|
|
45
|
+
``'protected'``.
|
|
46
|
+
|
|
47
|
+
Example::
|
|
48
|
+
|
|
49
|
+
@operation(id="users.list", level="private")
|
|
50
|
+
async def list_users(request: Request):
|
|
51
|
+
...
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def decorator(func: Any) -> Any:
|
|
55
|
+
method = _detect_method(func.__name__)
|
|
56
|
+
desc = OperationDescriptor(
|
|
57
|
+
identifier=id,
|
|
58
|
+
method=method,
|
|
59
|
+
level=level,
|
|
60
|
+
description=func.__doc__,
|
|
61
|
+
)
|
|
62
|
+
_registry.append(desc)
|
|
63
|
+
|
|
64
|
+
# Attach metadata so middleware can inspect the endpoint
|
|
65
|
+
func.__cl4im_id__ = id
|
|
66
|
+
func.__cl4im_method__ = method
|
|
67
|
+
func.__cl4im_level__ = level
|
|
68
|
+
|
|
69
|
+
return func
|
|
70
|
+
|
|
71
|
+
return decorator
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_registry() -> list[OperationDescriptor]:
|
|
75
|
+
"""Return the global list of registered :class:`OperationDescriptor`\\s."""
|
|
76
|
+
return list(_registry)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def clear_registry() -> None:
|
|
80
|
+
"""Clear the global registry (useful in tests)."""
|
|
81
|
+
_registry.clear()
|
cl4im/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception hierarchy for cl4im."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Cl4imError(Exception):
|
|
5
|
+
"""Base exception for all cl4im errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Cl4imAuthError(Cl4imError):
|
|
9
|
+
"""Raised when app authentication against the authorizer fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Cl4imTokenError(Cl4imError):
|
|
13
|
+
"""Raised when a user/service token is invalid or expired."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cl4imForbiddenError(Cl4imError):
|
|
17
|
+
"""Raised when a token is valid but lacks permission for an operation."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Cl4imSyncError(Cl4imError):
|
|
21
|
+
"""Raised when syncing operations to the authorizer fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Cl4imConfigError(Cl4imError):
|
|
25
|
+
"""Raised when the client is misconfigured (e.g. no operations registered)."""
|
cl4im/middleware.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Framework-agnostic middleware helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import OperationDescriptor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def extract_token(request: Any) -> str | None:
|
|
11
|
+
"""Extract a Bearer token from the ``Authorization`` header.
|
|
12
|
+
|
|
13
|
+
Compatible with any request object that exposes ``.headers`` as a
|
|
14
|
+
mapping (Starlette, Django, AIOHTTP, etc.).
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
request: An HTTP request object with a ``headers`` attribute.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The raw token string, or ``None`` if the header is absent or
|
|
21
|
+
not a Bearer scheme.
|
|
22
|
+
"""
|
|
23
|
+
auth: str = request.headers.get("Authorization", "") or request.headers.get(
|
|
24
|
+
"authorization", ""
|
|
25
|
+
)
|
|
26
|
+
if not auth:
|
|
27
|
+
return None
|
|
28
|
+
scheme, _, token = auth.partition(" ")
|
|
29
|
+
if scheme.lower() != "bearer" or not token:
|
|
30
|
+
return None
|
|
31
|
+
return token.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_operation(
|
|
35
|
+
request: Any,
|
|
36
|
+
registered_operations: list[OperationDescriptor],
|
|
37
|
+
) -> tuple[str, str] | None:
|
|
38
|
+
"""Attempt to identify which operation a request maps to.
|
|
39
|
+
|
|
40
|
+
Strategy (in order):
|
|
41
|
+
1. If the route endpoint is already resolved (``request.scope["endpoint"]``
|
|
42
|
+
for Starlette), check for ``__cl4im_id__`` / ``__cl4im_method__``
|
|
43
|
+
attributes set by :func:`cl4im.decorators.operation`.
|
|
44
|
+
2. Otherwise return ``None``.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
request: HTTP request object. Must expose ``scope`` (Starlette) or
|
|
48
|
+
``route`` attribute for proper resolution.
|
|
49
|
+
registered_operations: List of :class:`OperationDescriptor` objects
|
|
50
|
+
registered via the decorator.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
``(identifier, method)`` tuple, or ``None`` if unresolvable.
|
|
54
|
+
"""
|
|
55
|
+
# Starlette / FastAPI: scope["endpoint"] is set after routing
|
|
56
|
+
scope = getattr(request, "scope", {})
|
|
57
|
+
endpoint = scope.get("endpoint")
|
|
58
|
+
|
|
59
|
+
if endpoint is not None:
|
|
60
|
+
cl4im_id = getattr(endpoint, "__cl4im_id__", None)
|
|
61
|
+
cl4im_method = getattr(endpoint, "__cl4im_method__", None)
|
|
62
|
+
if cl4im_id and cl4im_method:
|
|
63
|
+
# Confirm it's in the registered list
|
|
64
|
+
match = next(
|
|
65
|
+
(
|
|
66
|
+
op
|
|
67
|
+
for op in registered_operations
|
|
68
|
+
if op.identifier == cl4im_id and op.method == cl4im_method
|
|
69
|
+
),
|
|
70
|
+
None,
|
|
71
|
+
)
|
|
72
|
+
if match:
|
|
73
|
+
return (cl4im_id, cl4im_method)
|
|
74
|
+
|
|
75
|
+
return None
|
cl4im/models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Domain models for cl4im."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class OperationDescriptor:
|
|
11
|
+
"""Describes an operation exposed by a backend app."""
|
|
12
|
+
|
|
13
|
+
identifier: str
|
|
14
|
+
method: str # 'read' | 'write' | 'delete' | 'stream'
|
|
15
|
+
level: str # 'public' | 'private' | 'protected'
|
|
16
|
+
protocol: str | None = None
|
|
17
|
+
native_ref: str | None = None
|
|
18
|
+
description: str | None = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
"identifier": self.identifier,
|
|
23
|
+
"method": self.method,
|
|
24
|
+
"level": self.level,
|
|
25
|
+
"protocol": self.protocol,
|
|
26
|
+
"native_ref": self.native_ref,
|
|
27
|
+
"description": self.description,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class OperationWithGroups:
|
|
33
|
+
"""An operation with its authorized group IDs."""
|
|
34
|
+
|
|
35
|
+
identifier: str
|
|
36
|
+
method: str
|
|
37
|
+
level: str
|
|
38
|
+
allowed_groups: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TokenClaims:
|
|
43
|
+
"""Decoded and verified JWT claims."""
|
|
44
|
+
|
|
45
|
+
sub: str
|
|
46
|
+
realm_id: str
|
|
47
|
+
groups: list[str]
|
|
48
|
+
exp: int
|
|
49
|
+
raw: dict[str, Any]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cl4im
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the Identora authorizer (OIDC + RBAC)
|
|
5
|
+
Project-URL: Homepage, https://github.com/xlucvvs/cl4im-python
|
|
6
|
+
Project-URL: Repository, https://github.com/xlucvvs/cl4im-python
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/xlucvvs/cl4im-python/issues
|
|
8
|
+
Author-email: Lucas Ribeiro <lucasribeiro.sec@gmail.com>
|
|
9
|
+
License: CC0 1.0 Universal
|
|
10
|
+
|
|
11
|
+
Statement of Purpose
|
|
12
|
+
|
|
13
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
14
|
+
exclusive Copyright and Related Rights (defined below) upon the creator and
|
|
15
|
+
subsequent owner(s) of an original work of authorship and/or a database
|
|
16
|
+
(each, a "Work").
|
|
17
|
+
|
|
18
|
+
Certain owners wish to permanently relinquish those rights to a Work for the
|
|
19
|
+
purpose of contributing to a commons of creative, cultural and scientific works
|
|
20
|
+
that the public can reliably and without fear of infringement build upon,
|
|
21
|
+
modify, incorporate in other works, cite, and distribute, as freely as
|
|
22
|
+
possible, without legal restriction.
|
|
23
|
+
|
|
24
|
+
To the greatest extent permitted by, but not in contravention of, applicable
|
|
25
|
+
law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
|
26
|
+
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
|
27
|
+
and Related Rights and associated claims and causes of action, in the Work.
|
|
28
|
+
|
|
29
|
+
Should any part of this dedication be judged legally invalid or ineffective
|
|
30
|
+
under applicable law, the dedication shall be preserved to the maximum extent
|
|
31
|
+
permitted by law.
|
|
32
|
+
|
|
33
|
+
For more information, please see:
|
|
34
|
+
https://creativecommons.org/publicdomain/zero/1.0/
|
|
35
|
+
License-File: LICENSE
|
|
36
|
+
Keywords: auth,authorization,identora,jwt,oidc,rbac
|
|
37
|
+
Classifier: Development Status :: 4 - Beta
|
|
38
|
+
Classifier: Framework :: Django
|
|
39
|
+
Classifier: Framework :: FastAPI
|
|
40
|
+
Classifier: Framework :: Flask
|
|
41
|
+
Classifier: Intended Audience :: Developers
|
|
42
|
+
Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
|
43
|
+
Classifier: Programming Language :: Python :: 3
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
47
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
48
|
+
Classifier: Topic :: Security
|
|
49
|
+
Requires-Python: >=3.11
|
|
50
|
+
Requires-Dist: httpx>=0.27
|
|
51
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
52
|
+
Requires-Dist: python-jose[cryptography]>=3.3
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
55
|
+
Requires-Dist: django>=4.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: flask>=2.0; extra == 'dev'
|
|
57
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
60
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
61
|
+
Provides-Extra: django
|
|
62
|
+
Requires-Dist: django>=4.0; extra == 'django'
|
|
63
|
+
Provides-Extra: fastapi
|
|
64
|
+
Requires-Dist: fastapi>=0.111; extra == 'fastapi'
|
|
65
|
+
Requires-Dist: starlette>=0.37; extra == 'fastapi'
|
|
66
|
+
Provides-Extra: flask
|
|
67
|
+
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
68
|
+
Description-Content-Type: text/markdown
|
|
69
|
+
|
|
70
|
+
# cl4im
|
|
71
|
+
|
|
72
|
+
Python client for the **Identora** authorizer — handles app authentication,
|
|
73
|
+
JWT validation, permission checking and automatic operation sync.
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install cl4im
|
|
79
|
+
# With FastAPI support
|
|
80
|
+
pip install "cl4im[fastapi]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Quick start with FastAPI
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fastapi import FastAPI, Request
|
|
87
|
+
from cl4im.adapters.fastapi import Cl4im, operation
|
|
88
|
+
|
|
89
|
+
app = FastAPI()
|
|
90
|
+
|
|
91
|
+
cl4im = Cl4im(
|
|
92
|
+
app,
|
|
93
|
+
authorizer_url="http://localhost:4000/api",
|
|
94
|
+
app_id="your-app-uuid",
|
|
95
|
+
secret="your-app-secret",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.get("/public")
|
|
100
|
+
@operation(id="public.info", level="public")
|
|
101
|
+
async def public_info():
|
|
102
|
+
"""Anyone can call this — no token needed."""
|
|
103
|
+
return {"message": "Hello, world!"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.get("/items")
|
|
107
|
+
@operation(id="items.list", level="private")
|
|
108
|
+
async def list_items(request: Request):
|
|
109
|
+
"""Requires any authenticated user (token with non-empty groups)."""
|
|
110
|
+
user = request.state.user # TokenClaims
|
|
111
|
+
return {"user": user.sub, "items": []}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.delete("/items/{item_id}")
|
|
115
|
+
@operation(id="items.delete", level="protected")
|
|
116
|
+
async def delete_item(item_id: str, request: Request):
|
|
117
|
+
"""Requires a user whose group has explicit permission for items.delete."""
|
|
118
|
+
return {"deleted": item_id}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Standalone client
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import asyncio
|
|
125
|
+
from cl4im import Cl4imClient, OperationDescriptor
|
|
126
|
+
|
|
127
|
+
client = Cl4imClient(
|
|
128
|
+
authorizer_url="http://localhost:4000/api",
|
|
129
|
+
app_id="your-app-uuid",
|
|
130
|
+
secret="your-app-secret",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
client.register_operation(
|
|
134
|
+
OperationDescriptor(identifier="items.list", method="read", level="private")
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def main():
|
|
138
|
+
await client.startup()
|
|
139
|
+
|
|
140
|
+
# Validate a token from an incoming request
|
|
141
|
+
claims = await client.validate_token("<bearer-token>")
|
|
142
|
+
|
|
143
|
+
# Check whether the token has permission
|
|
144
|
+
allowed = client.check_permission(claims, "items.list", "read")
|
|
145
|
+
print(f"Allowed: {allowed}")
|
|
146
|
+
|
|
147
|
+
asyncio.run(main())
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Operation levels
|
|
151
|
+
|
|
152
|
+
| Level | Who can access |
|
|
153
|
+
|-------------|----------------|
|
|
154
|
+
| `public` | Everyone — no token required |
|
|
155
|
+
| `private` | Any authenticated user (token with at least one group) |
|
|
156
|
+
| `protected` | Only users whose groups intersect the operation's `allowed_groups` |
|
|
157
|
+
|
|
158
|
+
## Method auto-detection
|
|
159
|
+
|
|
160
|
+
The `@operation` decorator infers the method from the function name:
|
|
161
|
+
|
|
162
|
+
| Function name prefix/keyword | Detected method |
|
|
163
|
+
|------------------------------|-----------------|
|
|
164
|
+
| `get_`, `list_`, `fetch_`, `read_` | `read` |
|
|
165
|
+
| `delete_`, `remove_`, `destroy_` | `delete` |
|
|
166
|
+
| contains `stream`, `subscribe`, `watch` | `stream` |
|
|
167
|
+
| anything else | `write` |
|
|
168
|
+
|
|
169
|
+
## Configuration
|
|
170
|
+
|
|
171
|
+
| Parameter | Description |
|
|
172
|
+
|-----------|-------------|
|
|
173
|
+
| `authorizer_url` | Base URL of Identora, including the API prefix (e.g. `http://host/api`) |
|
|
174
|
+
| `app_id` | UUID of the app registered in `auth.apps` |
|
|
175
|
+
| `secret` | Plain-text app secret (never committed — use env vars) |
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import os
|
|
179
|
+
from cl4im.adapters.fastapi import Cl4im
|
|
180
|
+
|
|
181
|
+
cl4im = Cl4im(
|
|
182
|
+
app,
|
|
183
|
+
authorizer_url=os.environ["AUTHORIZER_URL"],
|
|
184
|
+
app_id=os.environ["APP_ID"],
|
|
185
|
+
secret=os.environ["APP_SECRET"],
|
|
186
|
+
)
|
|
187
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cl4im/__init__.py,sha256=A66fLK3g4N6_jNkAwLCFnkLfGGh_5779JkNQSRNw7vY,612
|
|
2
|
+
cl4im/client.py,sha256=9GKStWk3bx_oq5HWWc6QJQ0rrKtUlLqqvSVCE3F0aj4,11791
|
|
3
|
+
cl4im/decorators.py,sha256=rbdrpZLa1nlOeR9IaQDkDErc63xmufBE_Y5moAumabc,2363
|
|
4
|
+
cl4im/exceptions.py,sha256=zjBvjCnEmHdcXFvqZYwBTcr3UAyD35GaXXl-oMieLeM,667
|
|
5
|
+
cl4im/middleware.py,sha256=8UwBUamu7a0UrrDmbQDCM--PiUigW01TOE3lgDZe8FE,2447
|
|
6
|
+
cl4im/models.py,sha256=keZYtanjMeu3R_1UdHk_M6bhqlliR6hIODgfbmZgpkE,1160
|
|
7
|
+
cl4im/adapters/__init__.py,sha256=IAuWPxVHYzdg5-NSAFraG3j7U71kVG_IoTjoOFntpdw,17
|
|
8
|
+
cl4im/adapters/django.py,sha256=p7znQTBHiUJAwXGBXsn5JMenh_C7P7ELd8htTDfZp_U,4981
|
|
9
|
+
cl4im/adapters/fastapi.py,sha256=eitml2-doRqoc2Jm4dK6GrBEeF9h28hoFrZ0B3ePy14,6146
|
|
10
|
+
cl4im/adapters/flask.py,sha256=OXvC-9B50BTPGCUeuek7hlOqHL0Lo8NY5XSq16bb_4E,4490
|
|
11
|
+
cl4im-0.2.0.dist-info/METADATA,sha256=UxoMDjEDSjpusCU06U26YnE6oeu3UwDaO0F5I28BC-A,6231
|
|
12
|
+
cl4im-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
cl4im-0.2.0.dist-info/licenses/LICENSE,sha256=1PM2LvmD4zNHtGPCd0hpxq0w1wmRlERaYLBuvIHYY10,1176
|
|
14
|
+
cl4im-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
CC0 1.0 Universal
|
|
2
|
+
|
|
3
|
+
Statement of Purpose
|
|
4
|
+
|
|
5
|
+
The laws of most jurisdictions throughout the world automatically confer
|
|
6
|
+
exclusive Copyright and Related Rights (defined below) upon the creator and
|
|
7
|
+
subsequent owner(s) of an original work of authorship and/or a database
|
|
8
|
+
(each, a "Work").
|
|
9
|
+
|
|
10
|
+
Certain owners wish to permanently relinquish those rights to a Work for the
|
|
11
|
+
purpose of contributing to a commons of creative, cultural and scientific works
|
|
12
|
+
that the public can reliably and without fear of infringement build upon,
|
|
13
|
+
modify, incorporate in other works, cite, and distribute, as freely as
|
|
14
|
+
possible, without legal restriction.
|
|
15
|
+
|
|
16
|
+
To the greatest extent permitted by, but not in contravention of, applicable
|
|
17
|
+
law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
|
18
|
+
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
|
19
|
+
and Related Rights and associated claims and causes of action, in the Work.
|
|
20
|
+
|
|
21
|
+
Should any part of this dedication be judged legally invalid or ineffective
|
|
22
|
+
under applicable law, the dedication shall be preserved to the maximum extent
|
|
23
|
+
permitted by law.
|
|
24
|
+
|
|
25
|
+
For more information, please see:
|
|
26
|
+
https://creativecommons.org/publicdomain/zero/1.0/
|