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 +26 -0
- cl4im-0.2.0/PKG-INFO +187 -0
- cl4im-0.2.0/README.md +118 -0
- cl4im-0.2.0/cl4im/__init__.py +27 -0
- cl4im-0.2.0/cl4im/adapters/__init__.py +1 -0
- cl4im-0.2.0/cl4im/adapters/django.py +145 -0
- cl4im-0.2.0/cl4im/adapters/fastapi.py +181 -0
- cl4im-0.2.0/cl4im/adapters/flask.py +139 -0
- cl4im-0.2.0/cl4im/client.py +316 -0
- cl4im-0.2.0/cl4im/decorators.py +81 -0
- cl4im-0.2.0/cl4im/exceptions.py +25 -0
- cl4im-0.2.0/cl4im/middleware.py +75 -0
- cl4im-0.2.0/cl4im/models.py +49 -0
- cl4im-0.2.0/pyproject.toml +67 -0
- cl4im-0.2.0/tests/__init__.py +0 -0
- cl4im-0.2.0/tests/test_client.py +218 -0
- cl4im-0.2.0/tests/test_decorators.py +127 -0
- cl4im-0.2.0/tests/test_django_adapter.py +144 -0
- cl4im-0.2.0/tests/test_flask_adapter.py +178 -0
- cl4im-0.2.0/tests/test_middleware.py +157 -0
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()
|