fastapi-rbac-lite 0.1.0__tar.gz

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,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"
File without changes
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastapi-rbac-lite"
7
+ version = "0.1.0"
8
+ description = "Pluggable RBAC permission checking for FastAPI -- bring your own token verifier and permission store."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Athul A", email = "athulbabu.a@gmail.com" }
14
+ ]
15
+ keywords = ["fastapi", "rbac", "jwt", "jwks", "authorization", "authentication", "permissions"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Framework :: FastAPI",
25
+ "Topic :: Security",
26
+ ]
27
+ dependencies = [
28
+ "fastapi>=0.100",
29
+ "pyjwt[crypto]>=2.8",
30
+ "httpx>=0.24",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7.4",
36
+ "pytest-asyncio>=0.21",
37
+ "httpx>=0.24",
38
+ "cryptography>=41.0",
39
+ "uvicorn>=0.23",
40
+ "build>=1.0",
41
+ "twine>=4.0",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/athul2346/fastapi-rbac-lite"
46
+ Repository = "https://github.com/athul2346/fastapi-rbac-lite"
47
+ Issues = "https://github.com/athul2346/fastapi-rbac-lite/issues"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,10 @@
1
+ from typing import Protocol
2
+
3
+ class TokenVerifier(Protocol):
4
+ async def verify(self, token: str) -> dict:
5
+ ...
6
+
7
+
8
+ class PermissionResolver(Protocol):
9
+ async def resolve(self, groups: list[str]) -> set[str]:
10
+ ...
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/fastapi_rbac_lite/__init__.py
4
+ src/fastapi_rbac_lite/auth.py
5
+ src/fastapi_rbac_lite/interfaces.py
6
+ src/fastapi_rbac_lite/resolvers.py
7
+ src/fastapi_rbac_lite/verifiers.py
8
+ src/fastapi_rbac_lite.egg-info/PKG-INFO
9
+ src/fastapi_rbac_lite.egg-info/SOURCES.txt
10
+ src/fastapi_rbac_lite.egg-info/dependency_links.txt
11
+ src/fastapi_rbac_lite.egg-info/requires.txt
12
+ src/fastapi_rbac_lite.egg-info/top_level.txt
13
+ tests/test_rbac.py
14
+ tests/test_verifiers.py
@@ -0,0 +1,12 @@
1
+ fastapi>=0.100
2
+ pyjwt[crypto]>=2.8
3
+ httpx>=0.24
4
+
5
+ [dev]
6
+ pytest>=7.4
7
+ pytest-asyncio>=0.21
8
+ httpx>=0.24
9
+ cryptography>=41.0
10
+ uvicorn>=0.23
11
+ build>=1.0
12
+ twine>=4.0
@@ -0,0 +1 @@
1
+ fastapi_rbac_lite
@@ -0,0 +1,73 @@
1
+ import pytest
2
+ from fastapi import HTTPException
3
+ from src.fastapi_rbac_lite.auth import RBAC
4
+ from src.fastapi_rbac_lite.resolvers import StaticPermissionResolver
5
+
6
+
7
+ class FakeVerifier:
8
+ def __init__(self, tokens: dict[str, dict]):
9
+ self.tokens = tokens
10
+
11
+ async def verify(self, token: str) -> dict:
12
+ if token not in self.tokens:
13
+ raise HTTPException(401, detail="invalid token")
14
+ return self.tokens[token]
15
+
16
+
17
+ class FakeCredentials:
18
+ def __init__(self, token: str):
19
+ self.credentials = token
20
+
21
+
22
+ @pytest.fixture
23
+ def rbac():
24
+ verifier = FakeVerifier({
25
+ "admin-token": {"sub": "u1", "groups": ["admins"]},
26
+ "viewer-token": {"sub": "u2", "groups": ["viewers"]},
27
+ "m2m-token": {"sub": "svc", "groups": [], "preferred_username": "internal-svc"},
28
+ })
29
+ resolver = StaticPermissionResolver({
30
+ "admins": {"invoice:read", "invoice:approve"},
31
+ "viewers": {"invoice:read"},
32
+ })
33
+ return RBAC(
34
+ verifier=verifier,
35
+ resolver=resolver,
36
+ bypass=lambda payload: payload.get("preferred_username") == "internal-svc",
37
+ )
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_admin_has_permission(rbac):
42
+ dep = rbac.require_any_permission(["invoice:approve"])
43
+ payload = await dep(FakeCredentials("admin-token"))
44
+ assert payload["sub"] == "u1"
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_viewer_denied_for_approve(rbac):
49
+ dep = rbac.require_any_permission(["invoice:approve"])
50
+ with pytest.raises(HTTPException) as exc_info:
51
+ await dep(FakeCredentials("viewer-token"))
52
+ assert exc_info.value.status_code == 403
53
+
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_viewer_allowed_for_read(rbac):
57
+ dep = rbac.require_any_permission(["invoice:read"])
58
+ payload = await dep(FakeCredentials("viewer-token"))
59
+ assert payload["sub"] == "u2"
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_m2m_bypass_skips_permission_check(rbac):
64
+ dep = rbac.require_any_permission(["invoice:approve"])
65
+ payload = await dep(FakeCredentials("m2m-token"))
66
+ assert payload["sub"] == "svc"
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_no_permissions_required(rbac):
71
+ dep = rbac.require_any_permission([])
72
+ payload = await dep(FakeCredentials("viewer-token"))
73
+ assert payload["sub"] == "u2"
@@ -0,0 +1,32 @@
1
+ import pytest
2
+ from fastapi import HTTPException
3
+ from src.fastapi_rbac_lite.verifiers import JWKSVerifier
4
+ from fixtures.sign_test_token import sign_test_token
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_valid_token_verifies(fake_idp_url):
8
+ verifier = JWKSVerifier(trusted_issuers=[fake_idp_url], audiences=["test-audience"])
9
+ payload = {
10
+ "sub": "test-user",
11
+ "groups": ["test-user"],
12
+ "iss": fake_idp_url,
13
+ "aud": "test-audience"
14
+ }
15
+ token = sign_test_token(payload)
16
+ result = await verifier.verify(token)
17
+ assert result == payload
18
+
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_wrong_audience_rejected(fake_idp_url):
22
+ verifier = JWKSVerifier(trusted_issuers=[fake_idp_url], audiences=["test-audience"])
23
+ payload = {
24
+ "sub": "test-user",
25
+ "groups": ["admins"],
26
+ "iss": fake_idp_url,
27
+ "aud": "fake-aud"
28
+ }
29
+ token = sign_test_token(payload)
30
+ with pytest.raises(HTTPException) as exc_info:
31
+ await verifier.verify(token)
32
+ assert exc_info.value.status_code == 401