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/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,9 @@
1
+ """UI module for RBAC authorization visualization."""
2
+
3
+ from fastapi_rbac.ui.routes import create_ui_router
4
+ from fastapi_rbac.ui.schema import build_ui_schema
5
+
6
+ __all__ = [
7
+ "create_ui_router",
8
+ "build_ui_schema",
9
+ ]
@@ -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
+ )