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.
@@ -0,0 +1,35 @@
1
+ """FastAPI RBAC Authorization - Role-based access control with contextual authorization."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from fastapi_rbac.context import ContextualAuthz
6
+ from fastapi_rbac.core import RBACAuthz
7
+ from fastapi_rbac.dependencies import (
8
+ RBACUser,
9
+ create_auth_dependency,
10
+ create_authz_dependency,
11
+ evaluate_permissions,
12
+ )
13
+ from fastapi_rbac.exceptions import Forbidden
14
+ from fastapi_rbac.permissions import (
15
+ Contextual,
16
+ Global,
17
+ PermissionGrant,
18
+ PermissionScope,
19
+ )
20
+ from fastapi_rbac.router import RBACRouter
21
+
22
+ __all__ = [
23
+ "RBACAuthz",
24
+ "RBACRouter",
25
+ "RBACUser",
26
+ "ContextualAuthz",
27
+ "Global",
28
+ "Contextual",
29
+ "PermissionGrant",
30
+ "PermissionScope",
31
+ "Forbidden",
32
+ "create_auth_dependency",
33
+ "create_authz_dependency",
34
+ "evaluate_permissions",
35
+ ]
@@ -0,0 +1,39 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Generic, TypeVar
3
+
4
+ UserT = TypeVar("UserT")
5
+
6
+
7
+ class ContextualAuthz(ABC, Generic[UserT]):
8
+ """Base class for contextual authorization checks.
9
+
10
+ Subclasses are FastAPI dependencies - use standard Depends() for
11
+ additional dependencies like database sessions.
12
+
13
+ Example:
14
+ class MyContext(ContextualAuthz[User]):
15
+ def __init__(
16
+ self,
17
+ user: User,
18
+ request: Request,
19
+ db: AsyncSession = Depends(get_db),
20
+ ):
21
+ self.user = user
22
+ self.request = request
23
+ self.db = db
24
+
25
+ async def has_permissions(self) -> bool:
26
+ # Check access using self.user, self.request, self.db
27
+ return True
28
+ """
29
+
30
+ user: UserT
31
+
32
+ @abstractmethod
33
+ async def has_permissions(self) -> bool:
34
+ """Check if the user has permission in this context.
35
+
36
+ Returns:
37
+ True if access should be granted, False otherwise.
38
+ """
39
+ ...
fastapi_rbac/core.py ADDED
@@ -0,0 +1,111 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
3
+
4
+ from fastapi import APIRouter, FastAPI
5
+
6
+ from fastapi_rbac.dependencies import _rbac_user_dependency_placeholder
7
+ from fastapi_rbac.permissions import PermissionGrant
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+ UserT = TypeVar("UserT")
13
+
14
+
15
+ def _wrap_include_router(app: FastAPI, original_include_router: Callable[..., None]) -> Callable[..., None]:
16
+ """Wrap FastAPI's include_router to track RBACRouters."""
17
+
18
+ def wrapped_include_router(
19
+ router: APIRouter,
20
+ *,
21
+ prefix: str = "",
22
+ **kwargs: Any,
23
+ ) -> None:
24
+ # Import here to avoid circular import
25
+ from fastapi_rbac.router import RBACRouter
26
+
27
+ # Track RBAC routers in app state
28
+ if isinstance(router, RBACRouter):
29
+ if not hasattr(app.state, "_rbac_routers_"):
30
+ app.state._rbac_routers_ = []
31
+ app.state._rbac_routers_.append((prefix, router))
32
+
33
+ # Call original method
34
+ return original_include_router(router, prefix=prefix, **kwargs)
35
+
36
+ return wrapped_include_router
37
+
38
+
39
+ class RBACAuthz(Generic[UserT]):
40
+ """Main RBAC authorization configuration.
41
+
42
+ Attaches to a FastAPI application and provides authorization
43
+ configuration for RBACRouter endpoints.
44
+
45
+ Args:
46
+ app: The FastAPI application instance.
47
+ get_roles: Callable that extracts role strings from a user object.
48
+ permissions: Mapping of role names to sets of permission grants.
49
+ user_dependency: Optional FastAPI dependency that returns the authenticated user.
50
+ When provided, RBAC-protected endpoints will automatically run this dependency
51
+ and store the result in request.state.user before authorization checks.
52
+ ui_path: Optional path to mount the authorization UI (e.g., "/_rbac").
53
+ ui_permissions: Optional set of permissions required to access the UI.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ app: FastAPI,
59
+ get_roles: Callable[[UserT], set[str]],
60
+ permissions: dict[str, set[PermissionGrant]],
61
+ user_dependency: Callable[..., UserT] | Callable[..., Awaitable[UserT]] | None = None,
62
+ ui_path: str | None = None,
63
+ ui_permissions: set[str] | None = None,
64
+ ) -> None:
65
+ self.app = app
66
+ self.get_roles = get_roles
67
+ self.permissions = permissions
68
+ self.user_dependency = user_dependency
69
+ self.ui_path = ui_path
70
+ self.ui_permissions = ui_permissions
71
+
72
+ # Initialize router tracking list
73
+ if not hasattr(app.state, "_rbac_routers_"):
74
+ app.state._rbac_routers_ = []
75
+
76
+ # Attach to app state for access from routers
77
+ app.state.rbac = self
78
+
79
+ # If user_dependency is provided, override the placeholder dependency
80
+ # This allows the user's auth dependency to be injected into all
81
+ # RBAC-protected endpoints with proper FastAPI dependency resolution
82
+ if user_dependency is not None:
83
+ app.dependency_overrides[_rbac_user_dependency_placeholder] = user_dependency
84
+
85
+ # Wrap include_router to track RBAC routers
86
+ self._wrap_app_include_router()
87
+
88
+ # Mount UI if path specified
89
+ if ui_path:
90
+ self._mount_ui()
91
+
92
+ def _wrap_app_include_router(self) -> None:
93
+ """Wrap the app's include_router method to track RBACRouters."""
94
+ # Only wrap if not already wrapped
95
+ if hasattr(self.app, "_rbac_include_router_wrapped_"):
96
+ return
97
+
98
+ original_include_router = self.app.include_router
99
+ self.app.include_router = _wrap_include_router(self.app, original_include_router) # type: ignore[method-assign]
100
+ self.app._rbac_include_router_wrapped_ = True # type: ignore[attr-defined]
101
+
102
+ def _mount_ui(self) -> None:
103
+ """Mount the authorization visualization UI."""
104
+ if not self.ui_path:
105
+ return
106
+
107
+ # Import here to avoid circular import
108
+ from fastapi_rbac.ui.routes import create_ui_router
109
+
110
+ ui_router = create_ui_router(self.ui_path)
111
+ self.app.include_router(ui_router, prefix=self.ui_path)
@@ -0,0 +1,254 @@
1
+ import inspect
2
+ from collections.abc import Awaitable, Callable, Coroutine
3
+ from typing import TYPE_CHECKING, Annotated, Any, TypeVar
4
+
5
+ from fastapi import Depends, Request
6
+
7
+ from fastapi_rbac.context import ContextualAuthz
8
+ from fastapi_rbac.exceptions import Forbidden
9
+ from fastapi_rbac.permissions import (
10
+ has_global_permission,
11
+ has_permission,
12
+ resolve_grants,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from fastapi_rbac.core import RBACAuthz
17
+
18
+ UserT = TypeVar("UserT")
19
+
20
+ # Type alias for context classes
21
+ ContextClass = type[ContextualAuthz[Any]]
22
+
23
+
24
+ def RBACUser(request: Request) -> Any:
25
+ """Get the current authenticated user for use in context classes.
26
+
27
+ This dependency reads the user from request.state.user, which is set
28
+ by the auth dependency created via create_auth_dependency().
29
+
30
+ Usage in context classes:
31
+ class MyContext(ContextualAuthz[User]):
32
+ def __init__(
33
+ self,
34
+ user: Annotated[User, Depends(RBACUser)],
35
+ request: Request,
36
+ ):
37
+ self.user = user
38
+ self.request = request
39
+
40
+ async def has_permissions(self) -> bool:
41
+ # Check permissions based on user and request context
42
+ return self.user.id in allowed_users
43
+
44
+ Returns:
45
+ The authenticated user object from request.state.user.
46
+
47
+ Raises:
48
+ Forbidden: If no user is found in request state.
49
+ """
50
+ user = getattr(request.state, "user", None)
51
+ if user is None:
52
+ raise Forbidden("User not authenticated")
53
+ return user
54
+
55
+
56
+ def create_auth_dependency(
57
+ rbac: "RBACAuthz[UserT]", # noqa: ARG001 - kept for API consistency with RBACRouter
58
+ user_dependency: Callable[..., UserT] | Callable[..., Awaitable[UserT]],
59
+ ) -> Callable[..., Coroutine[Any, Any, UserT]]:
60
+ """Create a typed auth dependency for use in endpoints.
61
+
62
+ Args:
63
+ rbac: The RBACAuthz configuration instance. Currently unused but kept for
64
+ API consistency - RBACRouter stores the rbac reference for permission
65
+ evaluation.
66
+ user_dependency: A FastAPI dependency that returns the authenticated user.
67
+
68
+ Returns:
69
+ A dependency that can be used with Depends() in endpoint signatures.
70
+ """
71
+
72
+ async def auth_dependency(
73
+ request: Request,
74
+ user: Annotated[UserT, Depends(user_dependency)],
75
+ ) -> UserT:
76
+ # Store user in request state for authz dependency
77
+ request.state.user = user
78
+ return user
79
+
80
+ return auth_dependency
81
+
82
+
83
+ async def _rbac_user_dependency_placeholder(request: Request) -> Any:
84
+ """Placeholder dependency for user authentication.
85
+
86
+ This placeholder is used in the authz_dependency signature and gets replaced
87
+ at runtime via FastAPI's dependency_overrides mechanism when RBACAuthz is
88
+ initialized with a user_dependency.
89
+
90
+ If no user_dependency is configured, this falls back to reading from
91
+ request.state.user (which must be set by the user's own auth mechanism).
92
+ """
93
+ return getattr(request.state, "user", None)
94
+
95
+
96
+ def create_authz_dependency(
97
+ required_permissions: set[str],
98
+ context_classes: list[ContextClass],
99
+ ) -> Callable[..., Coroutine[Any, Any, None]]:
100
+ """Create an authorization dependency that uses FastAPI's full DI for contexts.
101
+
102
+ This function creates a FastAPI dependency that:
103
+ 1. Resolves user via the injected user_dependency (or placeholder fallback)
104
+ 2. Gets RBAC config from app.state.rbac
105
+ 3. Uses FastAPI's Depends(context_class) to instantiate each context
106
+ - Context classes can use ANY FastAPI dependency patterns in __init__:
107
+ - user: AuthUser (resolved via Depends)
108
+ - request: Request (built-in)
109
+ - org_id: str (from path parameter)
110
+ - body: SomeModel (from request body)
111
+ - db: Db (via Depends)
112
+ 4. Calls has_permissions() on each resolved context
113
+
114
+ The user_dependency is injected via FastAPI's dependency_overrides mechanism.
115
+ When RBACAuthz is initialized with a user_dependency, it overrides the
116
+ _rbac_user_dependency_placeholder with the provided dependency.
117
+
118
+ Args:
119
+ required_permissions: Set of permission strings required for access.
120
+ context_classes: List of ContextualAuthz subclasses to check.
121
+
122
+ Returns:
123
+ An async dependency function for use with FastAPI's Depends().
124
+ """
125
+ # Build context parameters - each context class becomes a Depends(context_class)
126
+ # FastAPI will resolve all __init__ parameters automatically
127
+ context_params: list[inspect.Parameter] = []
128
+ for i, ctx_class in enumerate(context_classes):
129
+ param = inspect.Parameter(
130
+ f"_rbac_ctx_{i}_",
131
+ inspect.Parameter.KEYWORD_ONLY,
132
+ default=None,
133
+ annotation=Annotated[ctx_class, Depends(ctx_class)],
134
+ )
135
+ context_params.append(param)
136
+
137
+ async def authz_dependency(
138
+ request: Request,
139
+ _rbac_user_: Annotated[Any, Depends(_rbac_user_dependency_placeholder)],
140
+ **kwargs: Any,
141
+ ) -> None:
142
+ """Authorization dependency that checks permissions and contexts."""
143
+ # Get RBAC config from app state
144
+ rbac = getattr(request.app.state, "rbac", None)
145
+ if rbac is None:
146
+ raise RuntimeError("RBACAuthz not configured. Make sure to create an RBACAuthz instance with your app.")
147
+
148
+ # User is resolved by the injected user_dependency (or placeholder)
149
+ user = _rbac_user_
150
+ if user is None:
151
+ raise Forbidden("User not authenticated")
152
+
153
+ # Check permissions first
154
+ if not required_permissions and not context_classes:
155
+ raise RuntimeError("Endpoint must be protected with permissions or contexts")
156
+
157
+ roles = rbac.get_roles(user)
158
+ grants = resolve_grants(roles, rbac.permissions)
159
+
160
+ if not grants:
161
+ raise Forbidden()
162
+
163
+ need_contextual_check = not required_permissions
164
+
165
+ for required in required_permissions:
166
+ if has_global_permission(grants, required):
167
+ # Global permission - no need for contextual check for this permission
168
+ continue
169
+
170
+ need_contextual_check = True
171
+ if not has_permission(grants, required):
172
+ raise Forbidden()
173
+
174
+ # Run contextual checks if needed
175
+ # Context instances are already resolved by FastAPI via Depends(context_class)
176
+ if need_contextual_check:
177
+ for i in range(len(context_classes)):
178
+ context = kwargs.get(f"_rbac_ctx_{i}_")
179
+ if context is not None and not await context.has_permissions():
180
+ raise Forbidden()
181
+
182
+ # Build the final signature with context params
183
+ base_params = [
184
+ inspect.Parameter(
185
+ "request",
186
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
187
+ annotation=Request,
188
+ ),
189
+ inspect.Parameter(
190
+ "_rbac_user_",
191
+ inspect.Parameter.KEYWORD_ONLY,
192
+ default=None,
193
+ annotation=Annotated[Any, Depends(_rbac_user_dependency_placeholder)],
194
+ ),
195
+ ]
196
+
197
+ new_sig = inspect.Signature(parameters=base_params + context_params)
198
+ authz_dependency.__signature__ = new_sig # type: ignore[attr-defined]
199
+
200
+ return authz_dependency
201
+
202
+
203
+ async def evaluate_permissions(
204
+ user: Any,
205
+ request: Request,
206
+ rbac: "RBACAuthz[Any]",
207
+ required_permissions: set[str],
208
+ context_classes: list[ContextClass],
209
+ ) -> None:
210
+ """Directly evaluate permissions without FastAPI dependency injection.
211
+
212
+ This function is useful for testing or when you need to check permissions
213
+ outside of a FastAPI endpoint context. Unlike create_authz_dependency(),
214
+ this function instantiates context classes directly with user and request.
215
+
216
+ Args:
217
+ user: The authenticated user object.
218
+ request: The current HTTP request.
219
+ rbac: The RBACAuthz configuration instance.
220
+ required_permissions: Set of permission strings required for access.
221
+ context_classes: List of ContextualAuthz subclasses to check.
222
+
223
+ Raises:
224
+ Forbidden: If the user does not have the required permissions.
225
+ RuntimeError: If no permissions or contexts are specified.
226
+ """
227
+ if not required_permissions and not context_classes:
228
+ raise RuntimeError("Endpoint must be protected with permissions or contexts")
229
+
230
+ roles = rbac.get_roles(user)
231
+ grants = resolve_grants(roles, rbac.permissions)
232
+
233
+ if not grants:
234
+ raise Forbidden()
235
+
236
+ need_contextual_check = not required_permissions
237
+
238
+ for required in required_permissions:
239
+ if has_global_permission(grants, required):
240
+ # Global permission - no need for contextual check for this permission
241
+ continue
242
+
243
+ need_contextual_check = True
244
+ if not has_permission(grants, required):
245
+ raise Forbidden()
246
+
247
+ # Run contextual checks if needed
248
+ # Instantiate context classes directly with user and request
249
+ if need_contextual_check:
250
+ for ctx_class in context_classes:
251
+ # Context classes are expected to accept user and request in their __init__
252
+ context = ctx_class(user=user, request=request) # type: ignore[call-arg]
253
+ if not await context.has_permissions():
254
+ raise Forbidden()
@@ -0,0 +1,8 @@
1
+ from fastapi import HTTPException
2
+
3
+
4
+ class Forbidden(HTTPException):
5
+ """403 Forbidden - user lacks required permissions."""
6
+
7
+ def __init__(self, detail: str = "Forbidden") -> None:
8
+ super().__init__(status_code=403, detail=detail)
@@ -0,0 +1,87 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class PermissionScope(StrEnum):
5
+ GLOBAL = "global"
6
+ CONTEXTUAL = "contextual"
7
+
8
+
9
+ class PermissionGrant:
10
+ """A permission grant with a scope (global or contextual)."""
11
+
12
+ __slots__ = ("permission", "scope")
13
+
14
+ def __init__(self, permission: str, scope: PermissionScope) -> None:
15
+ self.permission = permission
16
+ self.scope = scope
17
+
18
+ def __eq__(self, other: object) -> bool:
19
+ if not isinstance(other, PermissionGrant):
20
+ return NotImplemented
21
+ return self.permission == other.permission and self.scope == other.scope
22
+
23
+ def __hash__(self) -> int:
24
+ return hash((self.permission, self.scope))
25
+
26
+ def __repr__(self) -> str:
27
+ return f"{self.__class__.__name__}({self.permission!r})"
28
+
29
+
30
+ class Global(PermissionGrant):
31
+ """A global permission grant - bypasses contextual checks."""
32
+
33
+ def __init__(self, permission: str) -> None:
34
+ super().__init__(permission, PermissionScope.GLOBAL)
35
+
36
+
37
+ class Contextual(PermissionGrant):
38
+ """A contextual permission grant - requires context checks to pass."""
39
+
40
+ def __init__(self, permission: str) -> None:
41
+ super().__init__(permission, PermissionScope.CONTEXTUAL)
42
+
43
+
44
+ WILDCARD = "*"
45
+ SEPARATOR = ":"
46
+
47
+
48
+ def implies(held: str, required: str) -> bool:
49
+ """Check if a held permission implies (grants) a required permission.
50
+
51
+ Supports wildcards: 'report:*' implies 'report:read', 'report:delete', etc.
52
+ Global wildcard '*' implies everything.
53
+ """
54
+ held_parts = held.split(SEPARATOR)
55
+ required_parts = required.split(SEPARATOR)
56
+
57
+ for i, held_part in enumerate(held_parts):
58
+ if held_part == WILDCARD:
59
+ return True
60
+ if i >= len(required_parts):
61
+ return False
62
+ if held_part != required_parts[i]:
63
+ return False
64
+
65
+ return len(held_parts) == len(required_parts)
66
+
67
+
68
+ def resolve_grants(
69
+ roles: set[str],
70
+ permissions_map: dict[str, set[PermissionGrant]],
71
+ ) -> list[PermissionGrant]:
72
+ """Resolve all permission grants for a set of roles."""
73
+ grants: list[PermissionGrant] = []
74
+ for role in roles:
75
+ for grant in permissions_map.get(role, set()):
76
+ grants.append(grant)
77
+ return grants
78
+
79
+
80
+ def has_permission(grants: list[PermissionGrant], required: str) -> bool:
81
+ """Check if any grant satisfies the required permission."""
82
+ return any(implies(grant.permission, required) for grant in grants)
83
+
84
+
85
+ def has_global_permission(grants: list[PermissionGrant], required: str) -> bool:
86
+ """Check if any GLOBAL grant satisfies the required permission."""
87
+ return any(grant.scope == PermissionScope.GLOBAL and implies(grant.permission, required) for grant in grants)
fastapi_rbac/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561