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 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
@@ -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()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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/