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/__init__.py
ADDED
|
@@ -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
|
+
]
|
fastapi_rbac/context.py
ADDED
|
@@ -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,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
|