fastapi-rbac-lite 0.1.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_lite/__init__.py +22 -0
- fastapi_rbac_lite/auth.py +57 -0
- fastapi_rbac_lite/interfaces.py +10 -0
- fastapi_rbac_lite/resolvers.py +46 -0
- fastapi_rbac_lite/verifiers.py +65 -0
- fastapi_rbac_lite-0.1.0.dist-info/METADATA +32 -0
- fastapi_rbac_lite-0.1.0.dist-info/RECORD +9 -0
- fastapi_rbac_lite-0.1.0.dist-info/WHEEL +5 -0
- fastapi_rbac_lite-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""fastapi-rbac-lite
|
|
2
|
+
|
|
3
|
+
Pluggable RBAC permission checking for FastAPI: bring your own token
|
|
4
|
+
verifier and your own permission store, get a clean require_any_permission
|
|
5
|
+
dependency for your routes.
|
|
6
|
+
"""
|
|
7
|
+
from .auth import RBAC
|
|
8
|
+
from .interfaces import PermissionResolver, TokenVerifier
|
|
9
|
+
from .resolvers import HTTPPermissionResolver, StaticPermissionResolver
|
|
10
|
+
from .verifiers import JWKSVerifier, default_jwks_url_resolver
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"RBAC",
|
|
16
|
+
"TokenVerifier",
|
|
17
|
+
"PermissionResolver",
|
|
18
|
+
"JWKSVerifier",
|
|
19
|
+
"default_jwks_url_resolver",
|
|
20
|
+
"HTTPPermissionResolver",
|
|
21
|
+
"StaticPermissionResolver",
|
|
22
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, HTTPException, status
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
5
|
+
|
|
6
|
+
from .interfaces import PermissionResolver, TokenVerifier
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RBAC:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
verifier: TokenVerifier,
|
|
13
|
+
resolver: PermissionResolver,
|
|
14
|
+
groups_claim: str = "groups",
|
|
15
|
+
bypass: Callable[[dict], bool] | None = None,
|
|
16
|
+
):
|
|
17
|
+
self.verifier = verifier
|
|
18
|
+
self.resolver = resolver
|
|
19
|
+
self.groups_claim = groups_claim
|
|
20
|
+
self.bypass = bypass or (lambda payload: False)
|
|
21
|
+
self._bearer = HTTPBearer()
|
|
22
|
+
|
|
23
|
+
async def get_current_user(
|
|
24
|
+
self, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())
|
|
25
|
+
) -> dict:
|
|
26
|
+
"""Dependency: verify the bearer token only, no permission check."""
|
|
27
|
+
return await self.verifier.verify(credentials.credentials)
|
|
28
|
+
|
|
29
|
+
def require_any_permission(self, permissions: list[str] | None = None):
|
|
30
|
+
"""Return a FastAPI dependency that grants access if the caller has
|
|
31
|
+
ANY of the given permissions. Pass no permissions (or an empty list)
|
|
32
|
+
to require only a valid token, with no permission check.
|
|
33
|
+
"""
|
|
34
|
+
permissions = permissions or []
|
|
35
|
+
|
|
36
|
+
async def _check_permissions(
|
|
37
|
+
credentials: HTTPAuthorizationCredentials = Depends(self._bearer),
|
|
38
|
+
) -> dict:
|
|
39
|
+
payload = await self.verifier.verify(credentials.credentials)
|
|
40
|
+
groups = payload.get(self.groups_claim, [])
|
|
41
|
+
|
|
42
|
+
if self.bypass(payload):
|
|
43
|
+
return payload
|
|
44
|
+
|
|
45
|
+
if not permissions:
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
user_permissions = await self.resolver.resolve(groups)
|
|
49
|
+
if not any(p in user_permissions for p in permissions):
|
|
50
|
+
raise HTTPException(
|
|
51
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
52
|
+
detail=f"Permission denied: one of {permissions} required",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
return _check_permissions
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from fastapi import HTTPException, status
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StaticPermissionResolver:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
mapping:dict[str, set[str]]
|
|
9
|
+
):
|
|
10
|
+
self.mapping = mapping
|
|
11
|
+
|
|
12
|
+
async def resolve(self, groups: list[str]) -> set[str]:
|
|
13
|
+
permissions = set()
|
|
14
|
+
for group in groups:
|
|
15
|
+
permissions.update(self.mapping.get(group, []))
|
|
16
|
+
return permissions
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTTPPermissionResolver:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
base_url: str,
|
|
23
|
+
endpoint: str = "/roles/permissions-bulk",
|
|
24
|
+
request_key: str = "role_names",
|
|
25
|
+
response_key: str = "permissions",
|
|
26
|
+
timeout: float = 5.0,
|
|
27
|
+
):
|
|
28
|
+
self.base_url = base_url
|
|
29
|
+
self.endpoint = endpoint
|
|
30
|
+
self.request_key = request_key
|
|
31
|
+
self.response_key = response_key
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
|
|
34
|
+
async def resolve(self, groups: list[str]) -> set[str]:
|
|
35
|
+
if not groups:
|
|
36
|
+
return set()
|
|
37
|
+
url = f"{self.base_url.rstrip('/')}{self.endpoint}"
|
|
38
|
+
try:
|
|
39
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
40
|
+
response = await client.post(url, json={self.request_key: groups})
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
data = response.json()
|
|
43
|
+
permissions = set(data.get(self.response_key, []))
|
|
44
|
+
return permissions
|
|
45
|
+
except httpx.HTTPError as e:
|
|
46
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"HTTP error: {e}")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
import jwt
|
|
3
|
+
from fastapi import HTTPException, status
|
|
4
|
+
|
|
5
|
+
def default_jwks_url_resolver(issuer: str) -> str:
|
|
6
|
+
return f"{issuer.rstrip('/')}/.well-known/jwks.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JWKSVerifier:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
trusted_issuers: list[str],
|
|
13
|
+
audiences: list[str],
|
|
14
|
+
jwks_url_resolver: Callable[[str], str] = default_jwks_url_resolver,
|
|
15
|
+
algorithms: list[str] | None = None,
|
|
16
|
+
):
|
|
17
|
+
self.trusted_issuers = trusted_issuers
|
|
18
|
+
self.audiences = audiences
|
|
19
|
+
self.jwks_url_resolver = jwks_url_resolver
|
|
20
|
+
self.algorithms = algorithms or ["RS256"]
|
|
21
|
+
self._jwks_clients: dict[str, jwt.PyJWKClient] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_jwks_client(self, issuer:str) -> jwt.PyJWKClient:
|
|
25
|
+
if issuer not in self._jwks_clients:
|
|
26
|
+
jwks_url = self.jwks_url_resolver(issuer)
|
|
27
|
+
self._jwks_clients[issuer] = jwt.PyJWKClient(jwks_url)
|
|
28
|
+
return self._jwks_clients[issuer]
|
|
29
|
+
|
|
30
|
+
async def verify(self, token: str) -> dict:
|
|
31
|
+
try:
|
|
32
|
+
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
33
|
+
except jwt.InvalidTokenError as e:
|
|
34
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Malformed token: {e}")
|
|
35
|
+
iss = unverified.get("iss")
|
|
36
|
+
aud = unverified.get("aud")
|
|
37
|
+
if not iss or not aud:
|
|
38
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
39
|
+
if iss not in self.trusted_issuers:
|
|
40
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Untrusted token issuer")
|
|
41
|
+
try:
|
|
42
|
+
jwks_client = self._get_jwks_client(iss)
|
|
43
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"JWKS fetch failed: {e}")
|
|
46
|
+
try:
|
|
47
|
+
payload = jwt.decode(
|
|
48
|
+
token,
|
|
49
|
+
signing_key.key,
|
|
50
|
+
algorithms=self.algorithms,
|
|
51
|
+
audience=self.audiences,
|
|
52
|
+
issuer=iss,
|
|
53
|
+
)
|
|
54
|
+
return payload
|
|
55
|
+
except jwt.ExpiredSignatureError:
|
|
56
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
|
57
|
+
except jwt.InvalidAudienceError:
|
|
58
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid audience")
|
|
59
|
+
except jwt.InvalidIssuerError:
|
|
60
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid issuer")
|
|
61
|
+
except jwt.InvalidTokenError as e:
|
|
62
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Malformed token: {e}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-rbac-lite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pluggable RBAC permission checking for FastAPI -- bring your own token verifier and permission store.
|
|
5
|
+
Author-email: Athul A <athulbabu.a@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/athul2346/fastapi-rbac-lite
|
|
8
|
+
Project-URL: Repository, https://github.com/athul2346/fastapi-rbac-lite
|
|
9
|
+
Project-URL: Issues, https://github.com/athul2346/fastapi-rbac-lite/issues
|
|
10
|
+
Keywords: fastapi,rbac,jwt,jwks,authorization,authentication,permissions
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Framework :: FastAPI
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: fastapi>=0.100
|
|
23
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
24
|
+
Requires-Dist: httpx>=0.24
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
29
|
+
Requires-Dist: cryptography>=41.0; extra == "dev"
|
|
30
|
+
Requires-Dist: uvicorn>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fastapi_rbac_lite/__init__.py,sha256=Y0uhn0NGkmmIhyEz0oGrb8A-jy0E1t5ri1iHMFL8wbg,625
|
|
2
|
+
fastapi_rbac_lite/auth.py,sha256=qWUxsiIA3ngt4AJO0id1LHGjSX0M8O2VYWwFBhgCf-k,2035
|
|
3
|
+
fastapi_rbac_lite/interfaces.py,sha256=v0F9nkWep7VpIfveKFSPVH3bTqb9BeAQ1D4bfPV2PVQ,229
|
|
4
|
+
fastapi_rbac_lite/resolvers.py,sha256=tNbJ_Oy96soJHRRWyBbdSkPy4I53EMVTO87qSnqW53Y,1504
|
|
5
|
+
fastapi_rbac_lite/verifiers.py,sha256=-eUkpDCXTjQ3BQaZOHVkrCae8663ZIPGCqmXtOQWxF8,2704
|
|
6
|
+
fastapi_rbac_lite-0.1.0.dist-info/METADATA,sha256=lxIDrGHNlOLYczScrft4jnqNkLqvXoGQeYm1N1PLfc0,1416
|
|
7
|
+
fastapi_rbac_lite-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
fastapi_rbac_lite-0.1.0.dist-info/top_level.txt,sha256=zMGwtG45TRXIJroIcqZhNYWt60tAvm1dDtTq_zzfn-c,18
|
|
9
|
+
fastapi_rbac_lite-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_rbac_lite
|