fastpermit 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.
fastpermit/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from fastpermit.backends import InMemoryBackend, PermissionBackend
2
+ from fastpermit.core import (
3
+ BasePermission,
4
+ PermissionContext,
5
+ PermissionEvaluator,
6
+ all_of,
7
+ any_of,
8
+ )
9
+ from fastpermit.integrations import FastPermit
10
+ from fastpermit.permissions import (
11
+ AllowAny,
12
+ DenyAll,
13
+ HasPermission,
14
+ HasRole,
15
+ IsAuthenticated,
16
+ )
17
+ from fastpermit.principal import BasicPrincipal, Principal
18
+
19
+ __all__ = [
20
+ "AllowAny",
21
+ "BasePermission",
22
+ "BasicPrincipal",
23
+ "DenyAll",
24
+ "FastPermit",
25
+ "HasPermission",
26
+ "HasRole",
27
+ "InMemoryBackend",
28
+ "IsAuthenticated",
29
+ "PermissionBackend",
30
+ "PermissionContext",
31
+ "PermissionEvaluator",
32
+ "Principal",
33
+ "all_of",
34
+ "any_of",
35
+ ]
36
+
37
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from fastpermit.backends.base import PermissionBackend
2
+ from fastpermit.backends.memory import InMemoryBackend
3
+
4
+ __all__ = ["InMemoryBackend", "PermissionBackend"]
@@ -0,0 +1,17 @@
1
+ from collections.abc import Mapping
2
+ from typing import AbstractSet, Any, Protocol
3
+
4
+ from fastpermit.principal import Principal
5
+
6
+
7
+ class PermissionBackend(Protocol):
8
+ """Storage adapter used to resolve effective permission codes for a principal."""
9
+
10
+ async def get_permissions(
11
+ self,
12
+ principal: Principal,
13
+ *,
14
+ scope: Mapping[str, Any],
15
+ ) -> AbstractSet[str]:
16
+ """Return effective permission codes for the principal within the supplied scope."""
17
+ ...
@@ -0,0 +1,42 @@
1
+ from collections.abc import Iterable, Mapping
2
+ from typing import AbstractSet, Any, Hashable
3
+
4
+ from fastpermit.principal import Principal
5
+
6
+
7
+ class InMemoryBackend:
8
+ """Mutable in-memory permission backend intended for tests and prototypes."""
9
+
10
+ def __init__(
11
+ self,
12
+ permissions: Mapping[Hashable, Iterable[str]] | None = None,
13
+ ) -> None:
14
+ self._permissions: dict[Hashable, set[str]] = {
15
+ principal_id: set(codes)
16
+ for principal_id, codes in (permissions or {}).items()
17
+ }
18
+
19
+ async def get_permissions(
20
+ self,
21
+ principal: Principal,
22
+ *,
23
+ scope: Mapping[str, Any],
24
+ ) -> AbstractSet[str]:
25
+ del scope
26
+ return frozenset(self._permissions.get(principal.id, set()))
27
+
28
+ def set_permissions(
29
+ self,
30
+ principal_id: Hashable,
31
+ permissions: Iterable[str],
32
+ ) -> None:
33
+ self._permissions[principal_id] = set(permissions)
34
+
35
+ def grant(self, principal_id: Hashable, *permissions: str) -> None:
36
+ self._permissions.setdefault(principal_id, set()).update(permissions)
37
+
38
+ def revoke(self, principal_id: Hashable, *permissions: str) -> None:
39
+ current = self._permissions.get(principal_id)
40
+ if current is None:
41
+ return
42
+ current.difference_update(permissions)
@@ -0,0 +1,12 @@
1
+ from fastpermit.core.context import PermissionContext
2
+ from fastpermit.core.evaluator import PermissionEvaluator
3
+ from fastpermit.core.helpers import all_of, any_of
4
+ from fastpermit.core.permission import BasePermission
5
+
6
+ __all__ = [
7
+ "BasePermission",
8
+ "PermissionContext",
9
+ "PermissionEvaluator",
10
+ "all_of",
11
+ "any_of",
12
+ ]
@@ -0,0 +1,35 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Hashable
4
+
5
+ from fastpermit.backends.base import PermissionBackend
6
+ from fastpermit.principal import Principal
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class PermissionContext:
11
+ """Evaluation context shared by every rule in one authorization decision."""
12
+
13
+ backend: PermissionBackend
14
+ scope: Mapping[str, Any] = field(default_factory=dict)
15
+ attributes: Mapping[str, Any] = field(default_factory=dict)
16
+ _permissions_cache: dict[Hashable, frozenset[str]] = field(
17
+ default_factory=dict,
18
+ init=False,
19
+ repr=False,
20
+ )
21
+
22
+ async def get_permissions(self, principal: Principal) -> frozenset[str]:
23
+ """Resolve effective permissions once per principal and evaluation context."""
24
+ cached = self._permissions_cache.get(principal.id)
25
+ if cached is not None:
26
+ return cached
27
+
28
+ resolved = frozenset(
29
+ await self.backend.get_permissions(
30
+ principal,
31
+ scope=self.scope,
32
+ )
33
+ )
34
+ self._permissions_cache[principal.id] = resolved
35
+ return resolved
@@ -0,0 +1,84 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from fastpermit.backends.base import PermissionBackend
6
+ from fastpermit.core.context import PermissionContext
7
+ from fastpermit.core.permission import BasePermission, Decision
8
+ from fastpermit.principal import Principal
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class PermissionEvaluator:
13
+ """Evaluate permission expressions independently from any web framework."""
14
+
15
+ backend: PermissionBackend
16
+
17
+ async def decision(
18
+ self,
19
+ permission: BasePermission,
20
+ principal: Principal | None,
21
+ *,
22
+ scope: Mapping[str, Any] | None = None,
23
+ attributes: Mapping[str, Any] | None = None,
24
+ ) -> Decision:
25
+ context = self._context(scope=scope, attributes=attributes)
26
+ return await permission.evaluate(principal, context)
27
+
28
+ async def check(
29
+ self,
30
+ permission: BasePermission,
31
+ principal: Principal | None,
32
+ *,
33
+ scope: Mapping[str, Any] | None = None,
34
+ attributes: Mapping[str, Any] | None = None,
35
+ ) -> bool:
36
+ decision = await self.decision(
37
+ permission,
38
+ principal,
39
+ scope=scope,
40
+ attributes=attributes,
41
+ )
42
+ return decision is not False
43
+
44
+ async def object_decision(
45
+ self,
46
+ permission: BasePermission,
47
+ principal: Principal | None,
48
+ obj: Any,
49
+ *,
50
+ scope: Mapping[str, Any] | None = None,
51
+ attributes: Mapping[str, Any] | None = None,
52
+ ) -> Decision:
53
+ context = self._context(scope=scope, attributes=attributes)
54
+ return await permission.evaluate(principal, context, obj=obj)
55
+
56
+ async def check_object(
57
+ self,
58
+ permission: BasePermission,
59
+ principal: Principal | None,
60
+ obj: Any,
61
+ *,
62
+ scope: Mapping[str, Any] | None = None,
63
+ attributes: Mapping[str, Any] | None = None,
64
+ ) -> bool:
65
+ decision = await self.object_decision(
66
+ permission,
67
+ principal,
68
+ obj,
69
+ scope=scope,
70
+ attributes=attributes,
71
+ )
72
+ return decision is not False
73
+
74
+ def _context(
75
+ self,
76
+ *,
77
+ scope: Mapping[str, Any] | None,
78
+ attributes: Mapping[str, Any] | None,
79
+ ) -> PermissionContext:
80
+ return PermissionContext(
81
+ backend=self.backend,
82
+ scope=scope or {},
83
+ attributes=attributes or {},
84
+ )
@@ -0,0 +1,24 @@
1
+ from fastpermit.core.permission import BasePermission
2
+ from fastpermit.permissions import AllowAny, DenyAll
3
+
4
+
5
+ def all_of(*permissions: BasePermission) -> BasePermission:
6
+ """Combine rules with logical AND; an empty set allows access."""
7
+ if not permissions:
8
+ return AllowAny()
9
+
10
+ result = permissions[0]
11
+ for permission in permissions[1:]:
12
+ result = result & permission
13
+ return result
14
+
15
+
16
+ def any_of(*permissions: BasePermission) -> BasePermission:
17
+ """Combine rules with logical OR; an empty set denies access."""
18
+ if not permissions:
19
+ return DenyAll()
20
+
21
+ result = permissions[0]
22
+ for permission in permissions[1:]:
23
+ result = result | permission
24
+ return result
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Final, TypeAlias
4
+
5
+ from fastpermit.core.context import PermissionContext
6
+ from fastpermit.principal import Principal
7
+
8
+ Decision: TypeAlias = bool | None
9
+ _OBJECT_MISSING: Final = object()
10
+
11
+
12
+ def _and_decisions(left: Decision, right: Decision) -> Decision:
13
+ if left is False or right is False:
14
+ return False
15
+ if left is True and right is True:
16
+ return True
17
+ return None
18
+
19
+
20
+ def _or_decisions(left: Decision, right: Decision) -> Decision:
21
+ if left is True or right is True:
22
+ return True
23
+ if left is False and right is False:
24
+ return False
25
+ return None
26
+
27
+
28
+ def _not_decision(value: Decision) -> Decision:
29
+ if value is None:
30
+ return None
31
+ return not value
32
+
33
+
34
+ def _combine_phases(request: Decision, object_: Decision) -> Decision:
35
+ if request is False or object_ is False:
36
+ return False
37
+ if request is None and object_ is None:
38
+ return None
39
+ return True
40
+
41
+
42
+ class BasePermission:
43
+ """Base class for request-level, object-level, and composite permission rules.
44
+
45
+ Returning ``None`` means the rule is neutral for that evaluation phase. This allows
46
+ request-only and object-only rules to compose correctly with AND, OR, and NOT.
47
+ """
48
+
49
+ message = "Permission denied."
50
+
51
+ async def has_permission(
52
+ self,
53
+ principal: Principal | None,
54
+ context: PermissionContext,
55
+ ) -> Decision:
56
+ """Evaluate a request-level rule or return None when the rule is object-only."""
57
+ del principal, context
58
+ return None
59
+
60
+ async def has_object_permission(
61
+ self,
62
+ principal: Principal | None,
63
+ obj: Any,
64
+ context: PermissionContext,
65
+ ) -> Decision:
66
+ """Evaluate an object-level rule or return None when the rule is request-only."""
67
+ del principal, obj, context
68
+ return None
69
+
70
+ async def evaluate(
71
+ self,
72
+ principal: Principal | None,
73
+ context: PermissionContext,
74
+ *,
75
+ obj: Any = _OBJECT_MISSING,
76
+ ) -> Decision:
77
+ """Evaluate this rule for the current request and optional resource object."""
78
+ request_decision = await self.has_permission(principal, context)
79
+ if obj is _OBJECT_MISSING:
80
+ return request_decision
81
+
82
+ object_decision = await self.has_object_permission(principal, obj, context)
83
+ return _combine_phases(request_decision, object_decision)
84
+
85
+ def __and__(self, other: BasePermission) -> BasePermission:
86
+ if not isinstance(other, BasePermission):
87
+ raise TypeError("AND operand must be a BasePermission instance.")
88
+ return _AndPermission(self, other)
89
+
90
+ def __or__(self, other: BasePermission) -> BasePermission:
91
+ if not isinstance(other, BasePermission):
92
+ raise TypeError("OR operand must be a BasePermission instance.")
93
+ return _OrPermission(self, other)
94
+
95
+ def __invert__(self) -> BasePermission:
96
+ return _NotPermission(self)
97
+
98
+ def __repr__(self) -> str:
99
+ return self.__class__.__name__
100
+
101
+
102
+ class _BinaryPermission(BasePermission):
103
+ operator = "?"
104
+
105
+ def __init__(self, left: BasePermission, right: BasePermission) -> None:
106
+ self.left = left
107
+ self.right = right
108
+
109
+ def __repr__(self) -> str:
110
+ return f"({self.left!r} {self.operator} {self.right!r})"
111
+
112
+
113
+ class _AndPermission(_BinaryPermission):
114
+ operator = "&"
115
+
116
+ async def evaluate(
117
+ self,
118
+ principal: Principal | None,
119
+ context: PermissionContext,
120
+ *,
121
+ obj: Any = _OBJECT_MISSING,
122
+ ) -> Decision:
123
+ left = await self.left.evaluate(principal, context, obj=obj)
124
+ if left is False:
125
+ return False
126
+
127
+ right = await self.right.evaluate(principal, context, obj=obj)
128
+ return _and_decisions(left, right)
129
+
130
+
131
+ class _OrPermission(_BinaryPermission):
132
+ operator = "|"
133
+
134
+ async def evaluate(
135
+ self,
136
+ principal: Principal | None,
137
+ context: PermissionContext,
138
+ *,
139
+ obj: Any = _OBJECT_MISSING,
140
+ ) -> Decision:
141
+ left = await self.left.evaluate(principal, context, obj=obj)
142
+ if left is True:
143
+ return True
144
+
145
+ right = await self.right.evaluate(principal, context, obj=obj)
146
+ return _or_decisions(left, right)
147
+
148
+
149
+ class _NotPermission(BasePermission):
150
+ def __init__(self, permission: BasePermission) -> None:
151
+ self.permission = permission
152
+
153
+ async def evaluate(
154
+ self,
155
+ principal: Principal | None,
156
+ context: PermissionContext,
157
+ *,
158
+ obj: Any = _OBJECT_MISSING,
159
+ ) -> Decision:
160
+ decision = await self.permission.evaluate(principal, context, obj=obj)
161
+ return _not_decision(decision)
162
+
163
+ def __repr__(self) -> str:
164
+ return f"~{self.permission!r}"
@@ -0,0 +1,141 @@
1
+ from collections.abc import Callable, Mapping
2
+ from dataclasses import dataclass
3
+ from typing import Any, TypeAlias, cast
4
+
5
+ from fastapi import Depends, HTTPException, Request, status
6
+
7
+ from fastpermit.backends.base import PermissionBackend
8
+ from fastpermit.core.context import PermissionContext
9
+ from fastpermit.core.evaluator import PermissionEvaluator
10
+ from fastpermit.core.permission import BasePermission
11
+ from fastpermit.permissions import HasPermission
12
+ from fastpermit.principal import Principal
13
+
14
+ PermissionSpec: TypeAlias = BasePermission | str
15
+ PrincipalLoader: TypeAlias = Callable[..., Any]
16
+ ObjectLoader: TypeAlias = Callable[..., Any]
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class _AuthorizationState:
21
+ principal: Principal | None
22
+ context: PermissionContext
23
+
24
+
25
+ class FastPermit:
26
+ """Framework integration for FastPermit permission expressions."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ backend: PermissionBackend,
32
+ principal_loader: PrincipalLoader,
33
+ ) -> None:
34
+ self.backend = backend
35
+ self.principal_loader = principal_loader
36
+ self.evaluator = PermissionEvaluator(backend)
37
+
38
+ def require(
39
+ self,
40
+ permission: PermissionSpec,
41
+ *,
42
+ scope: Mapping[str, Any] | None = None,
43
+ ) -> Callable[..., Any]:
44
+ """Create a dependency that returns the authorized principal."""
45
+ compiled = self._compile(permission)
46
+ principal_loader = self.principal_loader
47
+
48
+ async def dependency(
49
+ request: Request,
50
+ principal: Any = Depends(principal_loader),
51
+ ) -> Any:
52
+ typed_principal = cast(Principal | None, principal)
53
+ allowed = await self.evaluator.check(
54
+ compiled,
55
+ typed_principal,
56
+ scope=scope,
57
+ attributes={"request": request},
58
+ )
59
+ if not allowed:
60
+ self._raise_access_error(typed_principal, compiled)
61
+ return principal
62
+
63
+ return dependency
64
+
65
+ def require_object(
66
+ self,
67
+ permission: PermissionSpec,
68
+ *,
69
+ loader: ObjectLoader,
70
+ scope: Mapping[str, Any] | None = None,
71
+ ) -> Callable[..., Any]:
72
+ """Pre-check access, load an object, and authorize it."""
73
+ compiled = self._compile(permission)
74
+ principal_loader = self.principal_loader
75
+
76
+ async def precheck(
77
+ request: Request,
78
+ principal: Any = Depends(principal_loader),
79
+ ) -> _AuthorizationState:
80
+ typed_principal = cast(Principal | None, principal)
81
+ context = PermissionContext(
82
+ backend=self.backend,
83
+ scope=scope or {},
84
+ attributes={"request": request},
85
+ )
86
+ decision = await compiled.evaluate(typed_principal, context)
87
+ if decision is False:
88
+ self._raise_access_error(typed_principal, compiled)
89
+ return _AuthorizationState(
90
+ principal=typed_principal,
91
+ context=context,
92
+ )
93
+
94
+ async def guarded_loader(
95
+ state: _AuthorizationState = Depends(precheck),
96
+ obj: Any = Depends(loader),
97
+ ) -> Any:
98
+ del state
99
+ return obj
100
+
101
+ async def dependency(
102
+ state: _AuthorizationState = Depends(precheck),
103
+ obj: Any = Depends(guarded_loader),
104
+ ) -> Any:
105
+ decision = await compiled.evaluate(
106
+ state.principal,
107
+ state.context,
108
+ obj=obj,
109
+ )
110
+ if decision is False:
111
+ self._raise_access_error(state.principal, compiled)
112
+ return obj
113
+
114
+ return dependency
115
+
116
+ @staticmethod
117
+ def _compile(permission: PermissionSpec) -> BasePermission:
118
+ if isinstance(permission, str):
119
+ return HasPermission(permission)
120
+ if isinstance(permission, BasePermission):
121
+ return permission
122
+ raise TypeError("permission must be a permission code or BasePermission instance.")
123
+
124
+ @staticmethod
125
+ def _raise_access_error(
126
+ principal: Principal | None,
127
+ permission: BasePermission,
128
+ ) -> None:
129
+ if principal is None or not principal.is_authenticated:
130
+ raise HTTPException(
131
+ status_code=status.HTTP_401_UNAUTHORIZED,
132
+ detail="Authentication required.",
133
+ )
134
+
135
+ raise HTTPException(
136
+ status_code=status.HTTP_403_FORBIDDEN,
137
+ detail=permission.message,
138
+ )
139
+
140
+
141
+ __all__ = ["FastPermit"]
@@ -0,0 +1,108 @@
1
+ from collections.abc import Iterable
2
+ from typing import Literal, cast
3
+
4
+ from fastpermit.core.context import PermissionContext
5
+ from fastpermit.core.permission import BasePermission, Decision
6
+ from fastpermit.principal import Principal
7
+
8
+ Mode = Literal["all", "any"]
9
+
10
+
11
+ def _validate_mode(mode: str) -> Mode:
12
+ if mode not in ("all", "any"):
13
+ raise ValueError("mode must be 'all' or 'any'.")
14
+ return cast(Mode, mode)
15
+
16
+
17
+ class AllowAny(BasePermission):
18
+ """Always allow the current request."""
19
+
20
+ async def has_permission(
21
+ self,
22
+ principal: Principal | None,
23
+ context: PermissionContext,
24
+ ) -> bool:
25
+ del principal, context
26
+ return True
27
+
28
+
29
+ class DenyAll(BasePermission):
30
+ """Always deny the current request."""
31
+
32
+ async def has_permission(
33
+ self,
34
+ principal: Principal | None,
35
+ context: PermissionContext,
36
+ ) -> bool:
37
+ del principal, context
38
+ return False
39
+
40
+
41
+ class IsAuthenticated(BasePermission):
42
+ """Allow only authenticated principals."""
43
+
44
+ message = "Authentication required."
45
+
46
+ async def has_permission(
47
+ self,
48
+ principal: Principal | None,
49
+ context: PermissionContext,
50
+ ) -> bool:
51
+ del context
52
+ return principal is not None and principal.is_authenticated
53
+
54
+
55
+ class HasRole(BasePermission):
56
+ """Require one or more roles already attached to the principal."""
57
+
58
+ def __init__(self, *roles: str, mode: str = "any") -> None:
59
+ if not roles:
60
+ raise ValueError("HasRole requires at least one role.")
61
+
62
+ self.roles = frozenset(roles)
63
+ self.mode = _validate_mode(mode)
64
+
65
+ async def has_permission(
66
+ self,
67
+ principal: Principal | None,
68
+ context: PermissionContext,
69
+ ) -> bool:
70
+ del context
71
+ if principal is None:
72
+ return False
73
+
74
+ if self.mode == "all":
75
+ return self.roles.issubset(principal.roles)
76
+ return bool(self.roles.intersection(principal.roles))
77
+
78
+ def __repr__(self) -> str:
79
+ roles = ", ".join(sorted(self.roles))
80
+ return f"HasRole({roles}; mode={self.mode})"
81
+
82
+
83
+ class HasPermission(BasePermission):
84
+ """Require effective capability codes resolved by the configured backend."""
85
+
86
+ def __init__(self, *permissions: str, mode: str = "all") -> None:
87
+ if not permissions:
88
+ raise ValueError("HasPermission requires at least one permission code.")
89
+
90
+ self.permissions = frozenset(permissions)
91
+ self.mode = _validate_mode(mode)
92
+
93
+ async def has_permission(
94
+ self,
95
+ principal: Principal | None,
96
+ context: PermissionContext,
97
+ ) -> Decision:
98
+ if principal is None:
99
+ return False
100
+
101
+ effective = await context.get_permissions(principal)
102
+ if self.mode == "all":
103
+ return self.permissions.issubset(effective)
104
+ return bool(self.permissions.intersection(effective))
105
+
106
+ def __repr__(self) -> str:
107
+ permissions = ", ".join(sorted(self.permissions))
108
+ return f"HasPermission({permissions}; mode={self.mode})"
@@ -0,0 +1,38 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import AbstractSet, Any, Hashable, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class Principal(Protocol):
8
+ """Minimal read-only identity contract consumed by FastPermit."""
9
+
10
+ @property
11
+ def id(self) -> Hashable:
12
+ """Stable identifier used by permission backends."""
13
+ ...
14
+
15
+ @property
16
+ def roles(self) -> AbstractSet[str]:
17
+ """Roles already attached to the principal by the application."""
18
+ ...
19
+
20
+ @property
21
+ def attributes(self) -> Mapping[str, Any]:
22
+ """Application-defined identity attributes available to custom rules."""
23
+ ...
24
+
25
+ @property
26
+ def is_authenticated(self) -> bool:
27
+ """Whether the application considers this principal authenticated."""
28
+ ...
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class BasicPrincipal:
33
+ """Small concrete principal implementation for common applications and tests."""
34
+
35
+ id: Hashable
36
+ roles: frozenset[str] = field(default_factory=frozenset)
37
+ attributes: Mapping[str, Any] = field(default_factory=dict)
38
+ is_authenticated: bool = True
fastpermit/py.typed ADDED
File without changes
@@ -0,0 +1,383 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastpermit
3
+ Version: 0.1.0
4
+ Summary: Composable, backend-agnostic authorization for FastAPI.
5
+ Author: Vitalii Semotiuk
6
+ Maintainer: Vitalii Semotiuk
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/Vir2S/fastpermit
9
+ Project-URL: Repository, https://github.com/Vir2S/fastpermit
10
+ Project-URL: Issues, https://github.com/Vir2S/fastpermit/issues
11
+ Project-URL: Changelog, https://github.com/Vir2S/fastpermit/blob/master/CHANGELOG.md
12
+ Keywords: fastapi,authorization,permissions,rbac,abac,access-control
13
+ Classifier: Development Status :: 2 - Pre-Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: fastapi>=0.115
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == "dev"
29
+ Requires-Dist: httpx>=0.28; extra == "dev"
30
+ Requires-Dist: mypy>=1.15; extra == "dev"
31
+ Requires-Dist: pytest>=8.3; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.25; extra == "dev"
33
+ Requires-Dist: pytest-cov>=6.0; extra == "dev"
34
+ Requires-Dist: ruff>=0.11; extra == "dev"
35
+ Requires-Dist: twine>=6.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # FastPermit
39
+
40
+ [![CI](https://github.com/Vir2S/fastpermit/actions/workflows/ci.yml/badge.svg)](https://github.com/Vir2S/fastpermit/actions/workflows/ci.yml)
41
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://www.python.org/)
42
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
43
+
44
+ **Composable, backend-agnostic authorization for FastAPI.**
45
+
46
+ FastPermit keeps authentication and authorization separate. Your application authenticates a
47
+ principal with JWT, OAuth2, Auth0, Keycloak, FastAPI Users, or a custom mechanism. FastPermit then
48
+ decides whether that principal may perform an action.
49
+
50
+ The core is deliberately small:
51
+
52
+ - composable permissions with `&`, `|`, and `~`;
53
+ - request-level and object-level authorization;
54
+ - role-based access control (RBAC) primitives;
55
+ - pluggable permission backends;
56
+ - async-first execution;
57
+ - FastAPI dependency integration;
58
+ - no ORM or cache dependency in the core.
59
+
60
+ > Status: `0.1.0` — first public release.
61
+
62
+ ## Project status
63
+
64
+ FastPermit is in active early development. The `0.1.x` line focuses on a small, typed,
65
+ backend-agnostic core before adding optional persistence and caching adapters.
66
+
67
+ Planned next steps:
68
+
69
+ - SQLAlchemy 2 / PostgreSQL adapter;
70
+ - Redis permission cache and invalidation hooks;
71
+ - tenant and resource scopes;
72
+ - audit and observability hooks.
73
+
74
+ ## Installation
75
+
76
+ Install from PyPI:
77
+
78
+ ```bash
79
+ pip install fastpermit
80
+ ```
81
+
82
+ For development:
83
+
84
+ ```bash
85
+ git clone https://github.com/Vir2S/fastpermit.git
86
+ cd fastpermit
87
+ python -m venv .venv
88
+ source .venv/bin/activate
89
+ pip install -e '.[dev]'
90
+ make check
91
+ ```
92
+
93
+ ## Quick start
94
+
95
+ ```python
96
+ from typing import Annotated
97
+
98
+ from fastapi import Depends, FastAPI
99
+
100
+ from fastpermit import BasicPrincipal, FastPermit, HasPermission, InMemoryBackend
101
+
102
+ app = FastAPI()
103
+
104
+ backend = InMemoryBackend(
105
+ {
106
+ "user-1": {"project:read", "project:update"},
107
+ }
108
+ )
109
+
110
+
111
+ async def get_current_principal() -> BasicPrincipal:
112
+ # Replace this with JWT, OAuth2, Auth0, Keycloak, or your own authentication.
113
+ return BasicPrincipal(id="user-1", roles=frozenset({"developer"}))
114
+
115
+
116
+ permit = FastPermit(
117
+ backend=backend,
118
+ principal_loader=get_current_principal,
119
+ )
120
+
121
+
122
+ @app.get("/projects")
123
+ async def list_projects(
124
+ principal: Annotated[
125
+ BasicPrincipal,
126
+ Depends(permit.require("project:read")),
127
+ ],
128
+ ) -> dict[str, str]:
129
+ return {"principal_id": str(principal.id)}
130
+ ```
131
+
132
+ A string passed to `require()` is shorthand for `HasPermission(...)`:
133
+
134
+ ```python
135
+ Depends(permit.require("project:read"))
136
+ ```
137
+
138
+ is equivalent to:
139
+
140
+ ```python
141
+ Depends(permit.require(HasPermission("project:read")))
142
+ ```
143
+
144
+ ## Permission algebra
145
+
146
+ Permissions can be combined without putting authorization branches in route handlers:
147
+
148
+ ```python
149
+ from fastpermit import HasPermission, HasRole, IsAuthenticated
150
+
151
+ permission = (
152
+ IsAuthenticated()
153
+ & HasPermission("project:update")
154
+ & (
155
+ HasRole("admin")
156
+ | HasPermission("project:update:any")
157
+ )
158
+ )
159
+ ```
160
+
161
+ Supported operators:
162
+
163
+ ```python
164
+ A() & B() # AND
165
+ A() | B() # OR
166
+ ~A() # NOT
167
+ ```
168
+
169
+ The helpers `all_of()` and `any_of()` are available for larger expressions:
170
+
171
+ ```python
172
+ from fastpermit import all_of, any_of
173
+
174
+ permission = all_of(
175
+ IsAuthenticated(),
176
+ HasPermission("project:update"),
177
+ any_of(
178
+ HasRole("admin"),
179
+ HasRole("manager"),
180
+ ),
181
+ )
182
+ ```
183
+
184
+ ## Object-level permissions
185
+
186
+ Object rules are intentionally separate from loading the object. A custom rule only needs to
187
+ implement `has_object_permission()`:
188
+
189
+ ```python
190
+ from typing import Any
191
+
192
+ from fastpermit import BasePermission, PermissionContext, Principal
193
+
194
+
195
+ class IsOwner(BasePermission):
196
+ async def has_object_permission(
197
+ self,
198
+ principal: Principal | None,
199
+ obj: Any,
200
+ context: PermissionContext,
201
+ ) -> bool:
202
+ return principal is not None and obj.owner_id == principal.id
203
+ ```
204
+
205
+ Then combine it with ordinary permissions:
206
+
207
+ ```python
208
+ edit_project = (
209
+ HasPermission("project:update:any")
210
+ | (
211
+ HasPermission("project:update")
212
+ & IsOwner()
213
+ )
214
+ )
215
+ ```
216
+
217
+ Use it with a FastAPI loader dependency:
218
+
219
+ ```python
220
+ @app.patch("/projects/{project_id}")
221
+ async def update_project(
222
+ project=Depends(
223
+ permit.require_object(
224
+ edit_project,
225
+ loader=get_project,
226
+ )
227
+ ),
228
+ ):
229
+ return project
230
+ ```
231
+
232
+ FastPermit evaluates both request-level and object-level branches as a single expression. Rules
233
+ that do not apply during a phase are neutral rather than implicitly allowing or denying it. This
234
+ keeps expressions such as `HasRole("admin") | IsOwner()` and `~IsOwner()` logically correct.
235
+
236
+ ## Principals
237
+
238
+ FastPermit uses a small `Principal` protocol rather than a concrete user model. The included
239
+ `BasicPrincipal` is convenient for most applications:
240
+
241
+ ```python
242
+ from fastpermit import BasicPrincipal
243
+
244
+ principal = BasicPrincipal(
245
+ id="user-42",
246
+ roles=frozenset({"manager", "reviewer"}),
247
+ attributes={"organization_id": "org-1"},
248
+ )
249
+ ```
250
+
251
+ You may return your own object from the authentication dependency as long as it exposes:
252
+
253
+ ```text
254
+ id
255
+ roles
256
+ attributes
257
+ is_authenticated
258
+ ```
259
+
260
+ ## Backends
261
+
262
+ A backend answers one question: which permission codes are effective for this principal in this
263
+ scope?
264
+
265
+ ```python
266
+ from collections.abc import Mapping
267
+ from typing import AbstractSet, Any
268
+
269
+ from fastpermit import PermissionBackend, Principal
270
+
271
+
272
+ class MyBackend(PermissionBackend):
273
+ async def get_permissions(
274
+ self,
275
+ principal: Principal,
276
+ *,
277
+ scope: Mapping[str, Any],
278
+ ) -> AbstractSet[str]:
279
+ ...
280
+ ```
281
+
282
+ `InMemoryBackend` is included for tests, prototypes, and examples. SQLAlchemy/PostgreSQL and Redis
283
+ adapters are intentionally planned as optional integrations instead of core requirements.
284
+
285
+ ## Request context and scopes
286
+
287
+ A permission receives `PermissionContext`, which contains:
288
+
289
+ - the configured backend;
290
+ - a scope mapping;
291
+ - integration-specific attributes;
292
+ - a per-evaluation permission cache.
293
+
294
+ FastAPI integration exposes the current `Request` as `context.attributes["request"]` without
295
+ making the authorization core depend on FastAPI.
296
+
297
+ Static scope can be attached to a dependency:
298
+
299
+ ```python
300
+ Depends(
301
+ permit.require(
302
+ "billing:read",
303
+ scope={"tenant": "global"},
304
+ )
305
+ )
306
+ ```
307
+
308
+ Dynamic tenant scopes are planned for the next integration iteration.
309
+
310
+ ## HTTP semantics
311
+
312
+ FastPermit does not authenticate requests. Authentication remains the responsibility of your
313
+ principal loader.
314
+
315
+ When a FastPermit rule denies access:
316
+
317
+ - an absent or unauthenticated principal produces `401 Unauthorized`;
318
+ - an authenticated principal without sufficient authorization produces `403 Forbidden`.
319
+
320
+ ## Design principles
321
+
322
+ 1. Authentication and authorization are separate concerns.
323
+ 2. Routes should describe required access, not implement role branches.
324
+ 3. Permission codes are stable capabilities such as `project:update`.
325
+ 4. Roles aggregate capabilities; application code should not be coupled to role names where a
326
+ capability is the real requirement.
327
+ 5. Object-level rules belong in permissions, not route handlers.
328
+ 6. Storage and caching are adapters, not core concerns.
329
+ 7. Authorization expressions must preserve correct semantics across request and object phases.
330
+
331
+ ## Roadmap
332
+
333
+ ### 0.1
334
+
335
+ - [x] permission core;
336
+ - [x] `AND`, `OR`, `NOT` composition;
337
+ - [x] `all_of()` / `any_of()`;
338
+ - [x] `IsAuthenticated`;
339
+ - [x] `HasRole`;
340
+ - [x] `HasPermission`;
341
+ - [x] object-level permissions;
342
+ - [x] backend protocol;
343
+ - [x] in-memory backend;
344
+ - [x] FastAPI integration;
345
+ - [x] typed package;
346
+ - [x] tests and CI.
347
+
348
+ ### 0.2
349
+
350
+ - [ ] SQLAlchemy 2.x adapter;
351
+ - [ ] PostgreSQL RBAC reference models;
352
+ - [ ] Alembic examples;
353
+ - [ ] user-role and role-permission repositories.
354
+
355
+ ### 0.3
356
+
357
+ - [ ] Redis cache adapter;
358
+ - [ ] cache invalidation primitives;
359
+ - [ ] cache versioning;
360
+ - [ ] configurable TTL policies.
361
+
362
+ ### 0.4
363
+
364
+ - [ ] dynamic tenant scopes;
365
+ - [ ] attribute-based access control helpers;
366
+ - [ ] resource scopes;
367
+ - [ ] policy metadata.
368
+
369
+ ### 0.5
370
+
371
+ - [ ] authorization audit events;
372
+ - [ ] observability hooks;
373
+ - [ ] OpenTelemetry integration.
374
+
375
+ ## License
376
+
377
+ MIT
378
+
379
+ ## Maintainer
380
+
381
+ Created and maintained by [Vitaly Sem](https://github.com/Vir2S).
382
+
383
+ FastPermit is an independent open-source project developed with support from Born2CodeLab.
@@ -0,0 +1,18 @@
1
+ fastpermit/__init__.py,sha256=ITC6V8tcDc0akgujfgcqfklQTFcmXRwlLwuDH-ngFrw,751
2
+ fastpermit/permissions.py,sha256=pAxPAw2zqxOcD8vnhxzEYw3KTropa-XB46TbK3Q2bpc,3079
3
+ fastpermit/principal.py,sha256=qD96eEPARLRaONhlpdzUAY7O2wvnu5PuCNrNPa-Mzjo,1152
4
+ fastpermit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ fastpermit/backends/__init__.py,sha256=6DzNSiWm-5WVrqVHKpKvMgcYG4CEbUKZIhngzsFthAQ,162
6
+ fastpermit/backends/base.py,sha256=DyASNE25oy-p_VqNXF4ouekzB0O727Yd46t8M4d5AB8,502
7
+ fastpermit/backends/memory.py,sha256=KEXuYieO38aSbzukNQTBkYv2tMvsxSzyNUMp5C3LAqU,1320
8
+ fastpermit/core/__init__.py,sha256=Jn4e076nQS5i5MCKtzbOQg0p01XP8zFyHda1WMgcEn4,334
9
+ fastpermit/core/context.py,sha256=VJLfeSbCDemC_b45fP5YZgwZgvm2P6-hDsn6_HZ7apU,1161
10
+ fastpermit/core/evaluator.py,sha256=97R5IqriyqfwzfdKRQbgaxCjP9S1mzygUsapDZ8fhdw,2470
11
+ fastpermit/core/helpers.py,sha256=y5N625glHRgdMFKbb6FXowKP2GYRWQtgjpzvLKthzYY,715
12
+ fastpermit/core/permission.py,sha256=cXeJH6rCjGP-WIKfD0NWhxG2dSeTn-H7ng20x65RR-w,4809
13
+ fastpermit/integrations/__init__.py,sha256=KQQbeqNk5tJKars7NNYh6OdPYy2oxzJ-g5_9eQXVTGw,4551
14
+ fastpermit-0.1.0.dist-info/licenses/LICENSE,sha256=hMcq3Ijq5-76qGi09IR2l5VfLIBGG8j6qrPEarwowQc,1067
15
+ fastpermit-0.1.0.dist-info/METADATA,sha256=k8-u9iuMC9rcaQuJayLdWXv7m4ORAtIFC9wN6GREK5U,9816
16
+ fastpermit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ fastpermit-0.1.0.dist-info/top_level.txt,sha256=nMh8TqMFPremb5Q-AVhz1R2OvUKMaOJtUQfza7aUrPU,11
18
+ fastpermit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vitaly Sem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fastpermit