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 +37 -0
- fastpermit/backends/__init__.py +4 -0
- fastpermit/backends/base.py +17 -0
- fastpermit/backends/memory.py +42 -0
- fastpermit/core/__init__.py +12 -0
- fastpermit/core/context.py +35 -0
- fastpermit/core/evaluator.py +84 -0
- fastpermit/core/helpers.py +24 -0
- fastpermit/core/permission.py +164 -0
- fastpermit/integrations/__init__.py +141 -0
- fastpermit/permissions.py +108 -0
- fastpermit/principal.py +38 -0
- fastpermit/py.typed +0 -0
- fastpermit-0.1.0.dist-info/METADATA +383 -0
- fastpermit-0.1.0.dist-info/RECORD +18 -0
- fastpermit-0.1.0.dist-info/WHEEL +5 -0
- fastpermit-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastpermit-0.1.0.dist-info/top_level.txt +1 -0
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,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})"
|
fastpermit/principal.py
ADDED
|
@@ -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
|
+
[](https://github.com/Vir2S/fastpermit/actions/workflows/ci.yml)
|
|
41
|
+
[](https://www.python.org/)
|
|
42
|
+
[](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,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
|