cl4im 0.2.0__tar.gz

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-0.2.0/LICENSE ADDED
@@ -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/
cl4im-0.2.0/PKG-INFO ADDED
@@ -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
+ ```
cl4im-0.2.0/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # cl4im
2
+
3
+ Python client for the **Identora** authorizer — handles app authentication,
4
+ JWT validation, permission checking and automatic operation sync.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install cl4im
10
+ # With FastAPI support
11
+ pip install "cl4im[fastapi]"
12
+ ```
13
+
14
+ ## Quick start with FastAPI
15
+
16
+ ```python
17
+ from fastapi import FastAPI, Request
18
+ from cl4im.adapters.fastapi import Cl4im, operation
19
+
20
+ app = FastAPI()
21
+
22
+ cl4im = Cl4im(
23
+ app,
24
+ authorizer_url="http://localhost:4000/api",
25
+ app_id="your-app-uuid",
26
+ secret="your-app-secret",
27
+ )
28
+
29
+
30
+ @app.get("/public")
31
+ @operation(id="public.info", level="public")
32
+ async def public_info():
33
+ """Anyone can call this — no token needed."""
34
+ return {"message": "Hello, world!"}
35
+
36
+
37
+ @app.get("/items")
38
+ @operation(id="items.list", level="private")
39
+ async def list_items(request: Request):
40
+ """Requires any authenticated user (token with non-empty groups)."""
41
+ user = request.state.user # TokenClaims
42
+ return {"user": user.sub, "items": []}
43
+
44
+
45
+ @app.delete("/items/{item_id}")
46
+ @operation(id="items.delete", level="protected")
47
+ async def delete_item(item_id: str, request: Request):
48
+ """Requires a user whose group has explicit permission for items.delete."""
49
+ return {"deleted": item_id}
50
+ ```
51
+
52
+ ## Standalone client
53
+
54
+ ```python
55
+ import asyncio
56
+ from cl4im import Cl4imClient, OperationDescriptor
57
+
58
+ client = Cl4imClient(
59
+ authorizer_url="http://localhost:4000/api",
60
+ app_id="your-app-uuid",
61
+ secret="your-app-secret",
62
+ )
63
+
64
+ client.register_operation(
65
+ OperationDescriptor(identifier="items.list", method="read", level="private")
66
+ )
67
+
68
+ async def main():
69
+ await client.startup()
70
+
71
+ # Validate a token from an incoming request
72
+ claims = await client.validate_token("<bearer-token>")
73
+
74
+ # Check whether the token has permission
75
+ allowed = client.check_permission(claims, "items.list", "read")
76
+ print(f"Allowed: {allowed}")
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ ## Operation levels
82
+
83
+ | Level | Who can access |
84
+ |-------------|----------------|
85
+ | `public` | Everyone — no token required |
86
+ | `private` | Any authenticated user (token with at least one group) |
87
+ | `protected` | Only users whose groups intersect the operation's `allowed_groups` |
88
+
89
+ ## Method auto-detection
90
+
91
+ The `@operation` decorator infers the method from the function name:
92
+
93
+ | Function name prefix/keyword | Detected method |
94
+ |------------------------------|-----------------|
95
+ | `get_`, `list_`, `fetch_`, `read_` | `read` |
96
+ | `delete_`, `remove_`, `destroy_` | `delete` |
97
+ | contains `stream`, `subscribe`, `watch` | `stream` |
98
+ | anything else | `write` |
99
+
100
+ ## Configuration
101
+
102
+ | Parameter | Description |
103
+ |-----------|-------------|
104
+ | `authorizer_url` | Base URL of Identora, including the API prefix (e.g. `http://host/api`) |
105
+ | `app_id` | UUID of the app registered in `auth.apps` |
106
+ | `secret` | Plain-text app secret (never committed — use env vars) |
107
+
108
+ ```python
109
+ import os
110
+ from cl4im.adapters.fastapi import Cl4im
111
+
112
+ cl4im = Cl4im(
113
+ app,
114
+ authorizer_url=os.environ["AUTHORIZER_URL"],
115
+ app_id=os.environ["APP_ID"],
116
+ secret=os.environ["APP_SECRET"],
117
+ )
118
+ ```
@@ -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()