fastapi-rbac-authz 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.
- fastapi_rbac/__init__.py +35 -0
- fastapi_rbac/context.py +39 -0
- fastapi_rbac/core.py +111 -0
- fastapi_rbac/dependencies.py +254 -0
- fastapi_rbac/exceptions.py +8 -0
- fastapi_rbac/permissions.py +87 -0
- fastapi_rbac/py.typed +1 -0
- fastapi_rbac/router.py +319 -0
- fastapi_rbac/ui/__init__.py +9 -0
- fastapi_rbac/ui/routes.py +67 -0
- fastapi_rbac/ui/schema.py +253 -0
- fastapi_rbac/ui/static/index.html +1879 -0
- fastapi_rbac_authz-0.2.0.dist-info/METADATA +269 -0
- fastapi_rbac_authz-0.2.0.dist-info/RECORD +15 -0
- fastapi_rbac_authz-0.2.0.dist-info/WHEEL +4 -0
fastapi_rbac/router.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""RBACRouter - FastAPI router with RBAC authorization."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends
|
|
9
|
+
|
|
10
|
+
from fastapi_rbac.context import ContextualAuthz
|
|
11
|
+
from fastapi_rbac.dependencies import create_authz_dependency
|
|
12
|
+
from fastapi_rbac.permissions import WILDCARD
|
|
13
|
+
|
|
14
|
+
# Type alias for context classes
|
|
15
|
+
ContextClass = type[ContextualAuthz[Any]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _contains_wildcard(permission: str) -> bool:
|
|
19
|
+
"""Check if a permission contains a wildcard."""
|
|
20
|
+
return WILDCARD in permission
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_permissions(permissions: set[str] | None, location: str) -> None:
|
|
24
|
+
"""Validate that permissions don't contain wildcards.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
permissions: Set of permission strings to validate.
|
|
28
|
+
location: Description of where the permissions are defined (for error message).
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
RuntimeError: If any permission contains a wildcard.
|
|
32
|
+
"""
|
|
33
|
+
if permissions is None:
|
|
34
|
+
return
|
|
35
|
+
for perm in permissions:
|
|
36
|
+
if _contains_wildcard(perm):
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
f"Wildcard permissions are not allowed in {location}. "
|
|
39
|
+
f"Found '{perm}'. Wildcards should only be used in role grants."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RBACRouter(APIRouter):
|
|
44
|
+
"""FastAPI router with RBAC authorization support.
|
|
45
|
+
|
|
46
|
+
Extends APIRouter to automatically inject authorization checks into endpoints.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
permissions: Default permissions required for all endpoints on this router.
|
|
50
|
+
contexts: Default contextual authorization classes for all endpoints.
|
|
51
|
+
**kwargs: Additional arguments passed to APIRouter.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
router = RBACRouter(
|
|
55
|
+
permissions={"report:read"},
|
|
56
|
+
contexts=[OrganizationMemberContext],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@router.get("/reports")
|
|
60
|
+
async def get_reports(user: User = Depends(AuthUser)):
|
|
61
|
+
return {"reports": [...]}
|
|
62
|
+
|
|
63
|
+
# Override permissions for specific endpoint
|
|
64
|
+
@router.post("/reports", permissions={"report:create"})
|
|
65
|
+
async def create_report(user: User = Depends(AuthUser)):
|
|
66
|
+
return {"id": "new-report"}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
permissions: set[str] | None = None,
|
|
73
|
+
contexts: list[ContextClass] | None = None,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> None:
|
|
76
|
+
# Validate no wildcards in router-level permissions
|
|
77
|
+
_validate_permissions(permissions, "router permissions")
|
|
78
|
+
|
|
79
|
+
super().__init__(**kwargs)
|
|
80
|
+
self.default_permissions: set[str] = permissions or set()
|
|
81
|
+
self.default_contexts: list[ContextClass] = contexts or []
|
|
82
|
+
self.endpoint_metadata: dict[tuple[str, str], dict[str, Any]] = {}
|
|
83
|
+
|
|
84
|
+
def _create_authz_dependency(
|
|
85
|
+
self,
|
|
86
|
+
permissions: set[str],
|
|
87
|
+
contexts: list[ContextClass],
|
|
88
|
+
) -> Callable[..., Any]:
|
|
89
|
+
"""Create an authorization dependency for an endpoint.
|
|
90
|
+
|
|
91
|
+
This dependency will be injected into the endpoint and will check
|
|
92
|
+
permissions before the endpoint handler is called.
|
|
93
|
+
|
|
94
|
+
Uses create_authz_dependency from dependencies.py which supports
|
|
95
|
+
resolving Depends() parameters in context classes.
|
|
96
|
+
"""
|
|
97
|
+
return create_authz_dependency(
|
|
98
|
+
required_permissions=permissions,
|
|
99
|
+
context_classes=contexts,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _resolve_permissions_and_contexts(
|
|
103
|
+
self,
|
|
104
|
+
path: str,
|
|
105
|
+
method: str,
|
|
106
|
+
permissions: set[str] | None,
|
|
107
|
+
contexts: list[ContextClass] | None,
|
|
108
|
+
) -> tuple[set[str], list[ContextClass]]:
|
|
109
|
+
"""Resolve final permissions and contexts for an endpoint.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path: The endpoint path.
|
|
113
|
+
method: The HTTP method (GET, POST, etc.).
|
|
114
|
+
permissions: Endpoint-specific permissions (overrides router default).
|
|
115
|
+
contexts: Endpoint-specific contexts (merges with router default).
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Tuple of (final_permissions, final_contexts).
|
|
119
|
+
"""
|
|
120
|
+
# Validate no wildcards in endpoint-level permissions
|
|
121
|
+
_validate_permissions(permissions, "endpoint permissions")
|
|
122
|
+
|
|
123
|
+
# Resolve final permissions: endpoint overrides router
|
|
124
|
+
final_permissions = permissions if permissions is not None else self.default_permissions
|
|
125
|
+
|
|
126
|
+
# Resolve final contexts: endpoint merges with router
|
|
127
|
+
final_contexts = list(self.default_contexts)
|
|
128
|
+
if contexts:
|
|
129
|
+
final_contexts.extend(contexts)
|
|
130
|
+
|
|
131
|
+
# Store metadata for UI introspection
|
|
132
|
+
self.endpoint_metadata[(path, method)] = {
|
|
133
|
+
"permissions": final_permissions,
|
|
134
|
+
"contexts": final_contexts,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return final_permissions, final_contexts
|
|
138
|
+
|
|
139
|
+
def _wrap_endpoint_with_authz(
|
|
140
|
+
self,
|
|
141
|
+
endpoint: Callable[..., Any],
|
|
142
|
+
authz_dep: Callable[..., Any],
|
|
143
|
+
) -> Callable[..., Any]:
|
|
144
|
+
"""Wrap an endpoint to add authz check after other dependencies.
|
|
145
|
+
|
|
146
|
+
Creates a new function signature that includes the authz dependency
|
|
147
|
+
as an annotated parameter, ensuring it runs after user auth dependencies.
|
|
148
|
+
"""
|
|
149
|
+
# Get the original function's signature
|
|
150
|
+
sig = inspect.signature(endpoint)
|
|
151
|
+
params = list(sig.parameters.values())
|
|
152
|
+
|
|
153
|
+
# Create authz dependency parameter - place it last so it runs after user deps
|
|
154
|
+
authz_param = inspect.Parameter(
|
|
155
|
+
"_rbac_authz_check_",
|
|
156
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
157
|
+
default=None,
|
|
158
|
+
annotation=Annotated[None, Depends(authz_dep)],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Build new parameters: original params + authz param
|
|
162
|
+
new_params = params + [authz_param]
|
|
163
|
+
new_sig = sig.replace(parameters=new_params)
|
|
164
|
+
|
|
165
|
+
# Determine if endpoint is async
|
|
166
|
+
is_async = inspect.iscoroutinefunction(endpoint)
|
|
167
|
+
|
|
168
|
+
if is_async:
|
|
169
|
+
|
|
170
|
+
@wraps(endpoint)
|
|
171
|
+
async def wrapped_async(
|
|
172
|
+
*args: Any,
|
|
173
|
+
_rbac_authz_check_: Annotated[None, Depends(authz_dep)] = None,
|
|
174
|
+
**kwargs: Any,
|
|
175
|
+
) -> Any:
|
|
176
|
+
return await endpoint(*args, **kwargs)
|
|
177
|
+
|
|
178
|
+
wrapped_async.__signature__ = new_sig # type: ignore[attr-defined]
|
|
179
|
+
return wrapped_async
|
|
180
|
+
else:
|
|
181
|
+
|
|
182
|
+
@wraps(endpoint)
|
|
183
|
+
def wrapped_sync(
|
|
184
|
+
*args: Any,
|
|
185
|
+
_rbac_authz_check_: Annotated[None, Depends(authz_dep)] = None,
|
|
186
|
+
**kwargs: Any,
|
|
187
|
+
) -> Any:
|
|
188
|
+
return endpoint(*args, **kwargs)
|
|
189
|
+
|
|
190
|
+
wrapped_sync.__signature__ = new_sig # type: ignore[attr-defined]
|
|
191
|
+
return wrapped_sync
|
|
192
|
+
|
|
193
|
+
def _add_route_with_authz(
|
|
194
|
+
self,
|
|
195
|
+
path: str,
|
|
196
|
+
method: str,
|
|
197
|
+
endpoint: Callable[..., Any],
|
|
198
|
+
permissions: set[str] | None,
|
|
199
|
+
contexts: list[ContextClass] | None,
|
|
200
|
+
parent_method: Callable[..., Any],
|
|
201
|
+
**kwargs: Any,
|
|
202
|
+
) -> Callable[..., Any]:
|
|
203
|
+
"""Add a route with authorization dependency.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
path: The endpoint path.
|
|
207
|
+
method: The HTTP method name (GET, POST, etc.).
|
|
208
|
+
endpoint: The original endpoint function.
|
|
209
|
+
permissions: Endpoint-specific permissions (overrides router default).
|
|
210
|
+
contexts: Endpoint-specific contexts (merges with router default).
|
|
211
|
+
parent_method: The parent APIRouter method to call.
|
|
212
|
+
**kwargs: Additional route kwargs.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The decorated endpoint.
|
|
216
|
+
"""
|
|
217
|
+
final_permissions, final_contexts = self._resolve_permissions_and_contexts(path, method, permissions, contexts)
|
|
218
|
+
|
|
219
|
+
# If there are permissions or contexts, wrap the endpoint
|
|
220
|
+
if final_permissions or final_contexts:
|
|
221
|
+
authz_dep = self._create_authz_dependency(final_permissions, final_contexts)
|
|
222
|
+
endpoint = self._wrap_endpoint_with_authz(endpoint, authz_dep)
|
|
223
|
+
|
|
224
|
+
# Attach RBAC metadata to endpoint for later introspection
|
|
225
|
+
# This allows schema building to work even if router was included
|
|
226
|
+
# before RBACAuthz was initialized (bypassing _rbac_routers_ tracking)
|
|
227
|
+
endpoint._rbac_metadata_ = { # type: ignore[attr-defined]
|
|
228
|
+
"permissions": final_permissions,
|
|
229
|
+
"contexts": final_contexts,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Register the route
|
|
233
|
+
result: Callable[..., Any] = parent_method(path, **kwargs)(endpoint)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
def get( # type: ignore[override]
|
|
237
|
+
self,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
permissions: set[str] | None = None,
|
|
241
|
+
contexts: list[ContextClass] | None = None,
|
|
242
|
+
**kwargs: Any,
|
|
243
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
244
|
+
"""Register a GET endpoint with optional permission overrides."""
|
|
245
|
+
|
|
246
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
247
|
+
return self._add_route_with_authz(
|
|
248
|
+
path, "GET", func, permissions, contexts, super(RBACRouter, self).get, **kwargs
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return decorator
|
|
252
|
+
|
|
253
|
+
def post( # type: ignore[override]
|
|
254
|
+
self,
|
|
255
|
+
path: str,
|
|
256
|
+
*,
|
|
257
|
+
permissions: set[str] | None = None,
|
|
258
|
+
contexts: list[ContextClass] | None = None,
|
|
259
|
+
**kwargs: Any,
|
|
260
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
261
|
+
"""Register a POST endpoint with optional permission overrides."""
|
|
262
|
+
|
|
263
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
264
|
+
return self._add_route_with_authz(
|
|
265
|
+
path, "POST", func, permissions, contexts, super(RBACRouter, self).post, **kwargs
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return decorator
|
|
269
|
+
|
|
270
|
+
def put( # type: ignore[override]
|
|
271
|
+
self,
|
|
272
|
+
path: str,
|
|
273
|
+
*,
|
|
274
|
+
permissions: set[str] | None = None,
|
|
275
|
+
contexts: list[ContextClass] | None = None,
|
|
276
|
+
**kwargs: Any,
|
|
277
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
278
|
+
"""Register a PUT endpoint with optional permission overrides."""
|
|
279
|
+
|
|
280
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
281
|
+
return self._add_route_with_authz(
|
|
282
|
+
path, "PUT", func, permissions, contexts, super(RBACRouter, self).put, **kwargs
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return decorator
|
|
286
|
+
|
|
287
|
+
def patch( # type: ignore[override]
|
|
288
|
+
self,
|
|
289
|
+
path: str,
|
|
290
|
+
*,
|
|
291
|
+
permissions: set[str] | None = None,
|
|
292
|
+
contexts: list[ContextClass] | None = None,
|
|
293
|
+
**kwargs: Any,
|
|
294
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
295
|
+
"""Register a PATCH endpoint with optional permission overrides."""
|
|
296
|
+
|
|
297
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
298
|
+
return self._add_route_with_authz(
|
|
299
|
+
path, "PATCH", func, permissions, contexts, super(RBACRouter, self).patch, **kwargs
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return decorator
|
|
303
|
+
|
|
304
|
+
def delete( # type: ignore[override]
|
|
305
|
+
self,
|
|
306
|
+
path: str,
|
|
307
|
+
*,
|
|
308
|
+
permissions: set[str] | None = None,
|
|
309
|
+
contexts: list[ContextClass] | None = None,
|
|
310
|
+
**kwargs: Any,
|
|
311
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
312
|
+
"""Register a DELETE endpoint with optional permission overrides."""
|
|
313
|
+
|
|
314
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
315
|
+
return self._add_route_with_authz(
|
|
316
|
+
path, "DELETE", func, permissions, contexts, super(RBACRouter, self).delete, **kwargs
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return decorator
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Routes for RBAC UI visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Request
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
10
|
+
|
|
11
|
+
from fastapi_rbac.ui.schema import build_ui_schema
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi_rbac.core import RBACAuthz
|
|
15
|
+
|
|
16
|
+
# Path to static files
|
|
17
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_ui_router(ui_path: str) -> APIRouter:
|
|
21
|
+
"""Create a router for the RBAC UI visualization.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
ui_path: The base path for the UI (e.g., "/_rbac").
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
An APIRouter with routes for the UI HTML and schema JSON.
|
|
28
|
+
"""
|
|
29
|
+
router = APIRouter(tags=["rbac-ui"])
|
|
30
|
+
|
|
31
|
+
@router.get(
|
|
32
|
+
"",
|
|
33
|
+
response_class=HTMLResponse,
|
|
34
|
+
summary="RBAC Visualization UI",
|
|
35
|
+
description="Interactive visualization of roles, permissions, and endpoints.",
|
|
36
|
+
include_in_schema=False,
|
|
37
|
+
)
|
|
38
|
+
async def get_ui(request: Request) -> HTMLResponse:
|
|
39
|
+
"""Serve the RBAC visualization HTML page."""
|
|
40
|
+
html_file = STATIC_DIR / "index.html"
|
|
41
|
+
if not html_file.exists():
|
|
42
|
+
return HTMLResponse(
|
|
43
|
+
content="<html><body><h1>UI not found</h1></body></html>",
|
|
44
|
+
status_code=500,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
content = html_file.read_text()
|
|
48
|
+
# Replace placeholder with actual schema endpoint path
|
|
49
|
+
schema_url = f"{ui_path}/schema"
|
|
50
|
+
content = content.replace("{{SCHEMA_URL}}", schema_url)
|
|
51
|
+
|
|
52
|
+
return HTMLResponse(content=content)
|
|
53
|
+
|
|
54
|
+
@router.get(
|
|
55
|
+
"/schema",
|
|
56
|
+
response_class=JSONResponse,
|
|
57
|
+
summary="RBAC Schema",
|
|
58
|
+
description="JSON schema of all roles, permissions, endpoints, and contexts.",
|
|
59
|
+
include_in_schema=False,
|
|
60
|
+
)
|
|
61
|
+
async def get_schema(request: Request) -> JSONResponse:
|
|
62
|
+
"""Return the RBAC schema as JSON."""
|
|
63
|
+
rbac: RBACAuthz[Any] = request.app.state.rbac
|
|
64
|
+
schema = build_ui_schema(request.app, rbac)
|
|
65
|
+
return JSONResponse(content=schema.model_dump())
|
|
66
|
+
|
|
67
|
+
return router
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Schema introspection for RBAC UI visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from fastapi_rbac.permissions import PermissionGrant
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from starlette.routing import BaseRoute
|
|
14
|
+
|
|
15
|
+
from fastapi_rbac.core import RBACAuthz
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RoleSchema(BaseModel):
|
|
19
|
+
"""Schema for a role in the RBAC system."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
permissions: list[PermissionGrantSchema]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionGrantSchema(BaseModel):
|
|
26
|
+
"""Schema for a permission grant."""
|
|
27
|
+
|
|
28
|
+
permission: str
|
|
29
|
+
scope: str # "global" or "contextual"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PermissionSchema(BaseModel):
|
|
33
|
+
"""Schema for a permission with information about which roles grant it."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
granted_by: list[GrantedBySchema]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GrantedBySchema(BaseModel):
|
|
40
|
+
"""Schema for role-to-permission grant relationship."""
|
|
41
|
+
|
|
42
|
+
role: str
|
|
43
|
+
scope: str # "global" or "contextual"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContextSchema(BaseModel):
|
|
47
|
+
"""Schema for a context class."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
description: str | None
|
|
51
|
+
used_by: list[str] # List of endpoint identifiers (e.g., "GET /reports")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EndpointSchema(BaseModel):
|
|
55
|
+
"""Schema for an endpoint with RBAC configuration."""
|
|
56
|
+
|
|
57
|
+
path: str
|
|
58
|
+
method: str
|
|
59
|
+
summary: str | None
|
|
60
|
+
description: str | None
|
|
61
|
+
tags: list[str]
|
|
62
|
+
permissions: list[str]
|
|
63
|
+
contexts: list[str]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UISchema(BaseModel):
|
|
67
|
+
"""Complete schema for RBAC UI visualization."""
|
|
68
|
+
|
|
69
|
+
roles: list[RoleSchema]
|
|
70
|
+
permissions: list[PermissionSchema]
|
|
71
|
+
endpoints: list[EndpointSchema]
|
|
72
|
+
contexts: list[ContextSchema]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_grants_from_role(
|
|
76
|
+
role_name: str,
|
|
77
|
+
grants: set[PermissionGrant],
|
|
78
|
+
) -> list[PermissionGrantSchema]:
|
|
79
|
+
"""Extract permission grant schemas from a role's grants."""
|
|
80
|
+
return [
|
|
81
|
+
PermissionGrantSchema(
|
|
82
|
+
permission=grant.permission,
|
|
83
|
+
scope=grant.scope.value,
|
|
84
|
+
)
|
|
85
|
+
for grant in grants
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_roles_schema(
|
|
90
|
+
permissions_map: dict[str, set[PermissionGrant]],
|
|
91
|
+
) -> list[RoleSchema]:
|
|
92
|
+
"""Build role schemas from the permissions map."""
|
|
93
|
+
roles = []
|
|
94
|
+
for role_name, grants in sorted(permissions_map.items()):
|
|
95
|
+
role = RoleSchema(
|
|
96
|
+
name=role_name,
|
|
97
|
+
permissions=_extract_grants_from_role(role_name, grants),
|
|
98
|
+
)
|
|
99
|
+
roles.append(role)
|
|
100
|
+
return roles
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_permissions_schema(
|
|
104
|
+
permissions_map: dict[str, set[PermissionGrant]],
|
|
105
|
+
) -> list[PermissionSchema]:
|
|
106
|
+
"""Build permission schemas showing which roles grant each permission."""
|
|
107
|
+
# Collect all unique permissions and their granting roles
|
|
108
|
+
permission_grants: dict[str, list[GrantedBySchema]] = {}
|
|
109
|
+
|
|
110
|
+
for role_name, grants in permissions_map.items():
|
|
111
|
+
for grant in grants:
|
|
112
|
+
if grant.permission not in permission_grants:
|
|
113
|
+
permission_grants[grant.permission] = []
|
|
114
|
+
permission_grants[grant.permission].append(
|
|
115
|
+
GrantedBySchema(
|
|
116
|
+
role=role_name,
|
|
117
|
+
scope=grant.scope.value,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return [
|
|
122
|
+
PermissionSchema(name=perm, granted_by=granted_by) for perm, granted_by in sorted(permission_grants.items())
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_rbac_metadata_from_route(route: BaseRoute) -> dict[str, Any] | None:
|
|
127
|
+
"""Extract RBAC metadata from a route's endpoint function.
|
|
128
|
+
|
|
129
|
+
RBACRouter stores metadata in the endpoint's _rbac_metadata_ attribute.
|
|
130
|
+
"""
|
|
131
|
+
endpoint = getattr(route, "endpoint", None)
|
|
132
|
+
if endpoint is None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
# Check if the endpoint has RBAC metadata attached
|
|
136
|
+
return getattr(endpoint, "_rbac_metadata_", None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_endpoints_schema(app: FastAPI) -> tuple[list[EndpointSchema], dict[str, type]]:
|
|
140
|
+
"""Build endpoint schemas from the app's routes.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Tuple of (endpoints, context_classes_map) where context_classes_map
|
|
144
|
+
maps context class names to their actual class objects.
|
|
145
|
+
"""
|
|
146
|
+
endpoints: list[EndpointSchema] = []
|
|
147
|
+
context_classes: dict[str, type] = {}
|
|
148
|
+
seen_endpoints: set[tuple[str, str]] = set()
|
|
149
|
+
|
|
150
|
+
# Get all registered RBAC routers from app state
|
|
151
|
+
rbac_routers: list[tuple[str, Any]] = getattr(app.state, "_rbac_routers_", [])
|
|
152
|
+
|
|
153
|
+
# Build metadata map from all registered routers
|
|
154
|
+
metadata_map: dict[tuple[str, str], dict[str, Any]] = {}
|
|
155
|
+
for prefix, router in rbac_routers:
|
|
156
|
+
for (path, method), meta in router.endpoint_metadata.items():
|
|
157
|
+
full_path = prefix + path
|
|
158
|
+
metadata_map[(full_path, method)] = meta
|
|
159
|
+
|
|
160
|
+
# Iterate through all app routes
|
|
161
|
+
for route in app.routes:
|
|
162
|
+
if not hasattr(route, "methods") or not hasattr(route, "path"):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
route_path = route.path
|
|
166
|
+
methods = route.methods or {"GET"}
|
|
167
|
+
|
|
168
|
+
for method in methods:
|
|
169
|
+
if method == "HEAD":
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
key = (route_path, method)
|
|
173
|
+
if key in seen_endpoints:
|
|
174
|
+
continue
|
|
175
|
+
seen_endpoints.add(key)
|
|
176
|
+
|
|
177
|
+
# Get metadata from router registry OR from endpoint directly
|
|
178
|
+
# This fallback ensures endpoints work even if router was included
|
|
179
|
+
# before RBACAuthz was initialized (bypassing _rbac_routers_ tracking)
|
|
180
|
+
meta = metadata_map.get(key)
|
|
181
|
+
if meta is None:
|
|
182
|
+
# Fall back to checking endpoint metadata directly
|
|
183
|
+
meta = _get_rbac_metadata_from_route(route)
|
|
184
|
+
if not meta:
|
|
185
|
+
meta = {}
|
|
186
|
+
permissions = meta.get("permissions", set())
|
|
187
|
+
contexts = meta.get("contexts", [])
|
|
188
|
+
|
|
189
|
+
# Skip non-RBAC endpoints
|
|
190
|
+
if not permissions and not contexts:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Track context classes for docstring extraction
|
|
194
|
+
for ctx_class in contexts:
|
|
195
|
+
context_classes[ctx_class.__name__] = ctx_class
|
|
196
|
+
|
|
197
|
+
endpoint_schema = EndpointSchema(
|
|
198
|
+
path=route_path,
|
|
199
|
+
method=method,
|
|
200
|
+
summary=getattr(route, "summary", None),
|
|
201
|
+
description=getattr(route, "description", None),
|
|
202
|
+
tags=list(getattr(route, "tags", []) or []),
|
|
203
|
+
permissions=sorted(permissions),
|
|
204
|
+
contexts=[ctx.__name__ for ctx in contexts],
|
|
205
|
+
)
|
|
206
|
+
endpoints.append(endpoint_schema)
|
|
207
|
+
|
|
208
|
+
return endpoints, context_classes
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_contexts_schema(
|
|
212
|
+
endpoints: list[EndpointSchema],
|
|
213
|
+
context_classes: dict[str, type],
|
|
214
|
+
) -> list[ContextSchema]:
|
|
215
|
+
"""Build context schemas showing which endpoints use each context."""
|
|
216
|
+
context_usage: dict[str, list[str]] = {}
|
|
217
|
+
|
|
218
|
+
for endpoint in endpoints:
|
|
219
|
+
endpoint_id = f"{endpoint.method} {endpoint.path}"
|
|
220
|
+
for context_name in endpoint.contexts:
|
|
221
|
+
if context_name not in context_usage:
|
|
222
|
+
context_usage[context_name] = []
|
|
223
|
+
context_usage[context_name].append(endpoint_id)
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
ContextSchema(
|
|
227
|
+
name=name,
|
|
228
|
+
description=context_classes.get(name).__doc__ if name in context_classes else None,
|
|
229
|
+
used_by=sorted(used_by),
|
|
230
|
+
)
|
|
231
|
+
for name, used_by in sorted(context_usage.items())
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_ui_schema(app: FastAPI, rbac: RBACAuthz[Any]) -> UISchema:
|
|
236
|
+
"""Build the complete UI schema for RBAC visualization.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
app: The FastAPI application instance.
|
|
240
|
+
rbac: The RBACAuthz configuration.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
UISchema containing roles, permissions, endpoints, and contexts.
|
|
244
|
+
"""
|
|
245
|
+
# Build endpoints first (we need them for context schema)
|
|
246
|
+
endpoints, context_classes = _build_endpoints_schema(app)
|
|
247
|
+
|
|
248
|
+
return UISchema(
|
|
249
|
+
roles=_build_roles_schema(rbac.permissions),
|
|
250
|
+
permissions=_build_permissions_schema(rbac.permissions),
|
|
251
|
+
endpoints=endpoints,
|
|
252
|
+
contexts=_build_contexts_schema(endpoints, context_classes),
|
|
253
|
+
)
|