fastapi-guardian 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_guardian/__init__.py +19 -0
- fastapi_guardian/dependencies.py +63 -0
- fastapi_guardian/dto.py +115 -0
- fastapi_guardian/engine.py +66 -0
- fastapi_guardian/exceptions.py +6 -0
- fastapi_guardian/expression.py +213 -0
- fastapi_guardian/ext/sqlalchemy.py +147 -0
- fastapi_guardian/ext/tortoise.py +185 -0
- fastapi_guardian/py.typed +0 -0
- fastapi_guardian/resource.py +4 -0
- fastapi_guardian/tortoise_test.py +14 -0
- fastapi_guardian-0.1.0.dist-info/METADATA +276 -0
- fastapi_guardian-0.1.0.dist-info/RECORD +14 -0
- fastapi_guardian-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .dto import (
|
|
2
|
+
AuthContext,
|
|
3
|
+
AuthPredicate,
|
|
4
|
+
BasePermissionGrant,
|
|
5
|
+
PermissionContext,
|
|
6
|
+
PermissionDefinition,
|
|
7
|
+
PredicateContext,
|
|
8
|
+
Principal,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BasePermissionGrant",
|
|
13
|
+
"PermissionDefinition",
|
|
14
|
+
"AuthPredicate",
|
|
15
|
+
"PredicateContext",
|
|
16
|
+
"Principal",
|
|
17
|
+
"PermissionContext",
|
|
18
|
+
"AuthContext",
|
|
19
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from fastapi.exceptions import HTTPException
|
|
5
|
+
|
|
6
|
+
from fastapi_guardian import exceptions
|
|
7
|
+
from fastapi_guardian.dto import (
|
|
8
|
+
AuthContext,
|
|
9
|
+
AuthPredicatePayload,
|
|
10
|
+
AuthScope,
|
|
11
|
+
Identifier,
|
|
12
|
+
PermissionDefinition,
|
|
13
|
+
Principal,
|
|
14
|
+
)
|
|
15
|
+
from fastapi_guardian.engine import BaseAuthEngine
|
|
16
|
+
from fastapi_guardian.resource import Resource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BasePermission[T: type[Resource], ID: Identifier](abc.ABC):
|
|
20
|
+
__slots__ = ("permission", "auth_engine")
|
|
21
|
+
|
|
22
|
+
auth_engine: BaseAuthEngine
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
resource: T,
|
|
27
|
+
action: str,
|
|
28
|
+
scopes: list[AuthScope] | None = None,
|
|
29
|
+
predicates: list[AuthPredicatePayload[T, ID]] | None = None,
|
|
30
|
+
auth_engine: BaseAuthEngine | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if auth_engine is not None:
|
|
33
|
+
self.auth_engine = auth_engine
|
|
34
|
+
|
|
35
|
+
if self.auth_engine is None:
|
|
36
|
+
raise exceptions.ImproperlyConfigured(
|
|
37
|
+
"auth_engine is required either as argument or as class attribute"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.permission = PermissionDefinition[T, ID](
|
|
41
|
+
resource=resource,
|
|
42
|
+
action=action,
|
|
43
|
+
scopes=scopes or ["global"],
|
|
44
|
+
predicates=predicates or [],
|
|
45
|
+
)
|
|
46
|
+
self.auth_engine.register_permission(permission=self.permission)
|
|
47
|
+
|
|
48
|
+
@abc.abstractmethod
|
|
49
|
+
async def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> AuthContext:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
async def authorize(self, principal: Principal[ID] | None = None) -> AuthContext:
|
|
53
|
+
if principal is None:
|
|
54
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
55
|
+
|
|
56
|
+
context = AuthContext[T, ID](
|
|
57
|
+
principal=principal, current_permission=self.permission
|
|
58
|
+
)
|
|
59
|
+
access_granted = self.auth_engine.has_permission(context=context)
|
|
60
|
+
if access_granted:
|
|
61
|
+
return context
|
|
62
|
+
|
|
63
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
fastapi_guardian/dto.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from pydantic import (
|
|
4
|
+
BaseModel,
|
|
5
|
+
Field,
|
|
6
|
+
SerializerFunctionWrapHandler,
|
|
7
|
+
ValidationError,
|
|
8
|
+
model_serializer,
|
|
9
|
+
model_validator,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from fastapi_guardian.resource import Resource
|
|
13
|
+
|
|
14
|
+
Identifier = str | int
|
|
15
|
+
AuthScope = typing.Literal["global", "conditional", "resource"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BasePermissionGrant(BaseModel):
|
|
19
|
+
resource: str
|
|
20
|
+
action: str
|
|
21
|
+
scope: AuthScope
|
|
22
|
+
|
|
23
|
+
@model_serializer(mode="wrap")
|
|
24
|
+
def to_json(self, handler: SerializerFunctionWrapHandler) -> dict:
|
|
25
|
+
serialized = handler(self)
|
|
26
|
+
resource = serialized.pop("resource")
|
|
27
|
+
action = serialized.pop("action")
|
|
28
|
+
scope = serialized.pop("scope")
|
|
29
|
+
return {
|
|
30
|
+
"resource": resource,
|
|
31
|
+
"action": action,
|
|
32
|
+
"scope": scope,
|
|
33
|
+
"extra": serialized,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_json(cls, data: typing.Any) -> typing.Any:
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
extra = data.pop("extra", {})
|
|
41
|
+
return {**data, **extra}
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GlobalPermissionGrant(BasePermissionGrant):
|
|
46
|
+
scope: typing.Literal["global"] = "global"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConditionalPermissionGrant(BasePermissionGrant):
|
|
50
|
+
scope: typing.Literal["conditional"] = "conditional"
|
|
51
|
+
condition: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ResourcePermissionGrant(BasePermissionGrant):
|
|
55
|
+
scope: typing.Literal["resource"] = "resource"
|
|
56
|
+
resource_id: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
PermissionGrant = (
|
|
60
|
+
GlobalPermissionGrant | ConditionalPermissionGrant | ResourcePermissionGrant
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PermissionContext(BaseModel):
|
|
65
|
+
permissions: list[typing.Annotated[PermissionGrant, Field(discriminator="scope")]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Principal[ID: Identifier](PermissionContext):
|
|
69
|
+
id: ID
|
|
70
|
+
email: str
|
|
71
|
+
username: str
|
|
72
|
+
roles: list[str] = Field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PredicateContext[T: type[Resource], ID: Identifier](BaseModel):
|
|
76
|
+
principal: Principal[ID]
|
|
77
|
+
resource: T
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AuthPredicate[T: type[Resource], ID: Identifier](BaseModel):
|
|
81
|
+
fn: typing.Callable[[PredicateContext[T, ID]], typing.Any]
|
|
82
|
+
name: str
|
|
83
|
+
description: str = Field(default="", min_length=0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AuthPredicatePayload[T: type[Resource], ID: Identifier](typing.TypedDict):
|
|
87
|
+
fn: typing.Callable[[PredicateContext[T, ID]], typing.Any]
|
|
88
|
+
name: str
|
|
89
|
+
description: typing.NotRequired[str]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PermissionDefinition[T: type[Resource], ID: Identifier](BaseModel):
|
|
93
|
+
"""Defines permission required for a given API resource along with all auth metadata."""
|
|
94
|
+
|
|
95
|
+
resource: T
|
|
96
|
+
action: str
|
|
97
|
+
scopes: typing.Annotated[list[AuthScope], Field(default_factory=lambda: ["global"])]
|
|
98
|
+
predicates: typing.Annotated[
|
|
99
|
+
list[AuthPredicate[T, ID]], Field(default_factory=list)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="after")
|
|
103
|
+
def validate_predicates(self) -> typing.Any:
|
|
104
|
+
if "conditional" in self.scopes and not self.predicates:
|
|
105
|
+
raise ValidationError(
|
|
106
|
+
"Conditional permissions must have at least one predicate"
|
|
107
|
+
)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AuthContext[T: type[Resource], ID: Identifier](BaseModel):
|
|
112
|
+
"""Defines all related info to make authorization decision."""
|
|
113
|
+
|
|
114
|
+
principal: Principal
|
|
115
|
+
current_permission: PermissionDefinition[T, ID]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
from fastapi_guardian import exceptions
|
|
4
|
+
from fastapi_guardian.dto import AuthContext, PermissionDefinition, PermissionGrant
|
|
5
|
+
from fastapi_guardian.resource import Resource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseAuthEngine:
|
|
9
|
+
_permissions: list[PermissionDefinition]
|
|
10
|
+
# resource -> action -> PermissionDefinition
|
|
11
|
+
_permission_tree: dict[str, dict[str, PermissionDefinition]]
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._permissions = []
|
|
15
|
+
self._permission_tree = defaultdict(dict)
|
|
16
|
+
|
|
17
|
+
def has_permission(self, *, context: AuthContext) -> bool:
|
|
18
|
+
"""Check if the principal has ANY permission for the given resource and action."""
|
|
19
|
+
decision = bool(self.matching_grants(context=context))
|
|
20
|
+
self.log_decision(context=context, decision=decision)
|
|
21
|
+
return decision
|
|
22
|
+
|
|
23
|
+
def matching_grants(self, *, context: AuthContext) -> list[PermissionGrant]:
|
|
24
|
+
"""Get all permission grants that match the given principal, resource and action."""
|
|
25
|
+
return [
|
|
26
|
+
grant
|
|
27
|
+
for grant in context.principal.permissions
|
|
28
|
+
if grant.resource == context.current_permission.resource.__resource_code__
|
|
29
|
+
and grant.action == context.current_permission.action
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def log_decision(self, *, context: AuthContext, decision: bool) -> None:
|
|
33
|
+
"""Implement in subclass to log access decision."""
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def register_permission(self, permission: PermissionDefinition) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Register new permission into catalog of this engine.
|
|
39
|
+
Can be used later for introspection and administration (e.g., show list of supported permissions in UI, modify OpenAPI schema, etc.)
|
|
40
|
+
"""
|
|
41
|
+
resource_code = permission.resource.__resource_code__
|
|
42
|
+
if (
|
|
43
|
+
self._permission_tree.get(resource_code, {}).get(permission.action)
|
|
44
|
+
is not None
|
|
45
|
+
):
|
|
46
|
+
raise exceptions.ImproperlyConfigured(
|
|
47
|
+
f"Permission {permission.action} already registered for resource {resource_code}."
|
|
48
|
+
"To avoid confusion, it's currently impossible to re-register the same permission."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self._permissions.append(permission)
|
|
52
|
+
self._permission_tree[resource_code][permission.action] = permission
|
|
53
|
+
|
|
54
|
+
def permissions_for(self, resource: type[Resource]) -> list[PermissionDefinition]:
|
|
55
|
+
"""Retuns all registered permissions for the given resource."""
|
|
56
|
+
return list(self._permission_tree[resource.__resource_code__].values())
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def permissions_by_resource(self) -> dict[str, list[PermissionDefinition]]:
|
|
60
|
+
"""Returns all registered permissions grouped by resource."""
|
|
61
|
+
return {k: list(v.values()) for k, v in self._permission_tree.items()}
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def permissions(self) -> list[PermissionDefinition]:
|
|
65
|
+
"""Returns all registered permissions."""
|
|
66
|
+
return self._permissions
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module contains microlanguage definition for permission conditions.
|
|
3
|
+
|
|
4
|
+
Grammar is sufficient to parse permission conditions like:
|
|
5
|
+
'self or (attachment_cv or attachment_doc and not supervisor)'
|
|
6
|
+
... and convert to SQLAlchemy expression.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from lark import Lark, Token, Transformer
|
|
12
|
+
from lark.exceptions import UnexpectedInput, VisitError
|
|
13
|
+
|
|
14
|
+
from fastapi_guardian.dto import AuthContext, AuthPredicate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExpressionError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExpressionParsingError(ExpressionError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidPredicateError(ExpressionError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExpressionEvaluationError(ExpressionError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
expression_grammar = Lark(
|
|
34
|
+
r"""
|
|
35
|
+
?expression: or_expression
|
|
36
|
+
|
|
37
|
+
?or_expression: and_expression (OR and_expression)*
|
|
38
|
+
?and_expression: not_expression (AND not_expression)*
|
|
39
|
+
?not_expression: NOT not_expression -> not_expression
|
|
40
|
+
| atom
|
|
41
|
+
|
|
42
|
+
?atom: PREDICATE -> predicate
|
|
43
|
+
| "(" expression ")"
|
|
44
|
+
|
|
45
|
+
OR.2: "or"
|
|
46
|
+
AND.2: "and"
|
|
47
|
+
NOT.2: "not"
|
|
48
|
+
PREDICATE: /[A-Za-z_][A-Za-z0-9_]*/
|
|
49
|
+
|
|
50
|
+
%import common.WS
|
|
51
|
+
%ignore WS
|
|
52
|
+
""",
|
|
53
|
+
parser="lalr",
|
|
54
|
+
start="expression",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Operator precedence used to decide whether to wrap a child node in parentheses
|
|
59
|
+
# during string serialization. Higher number = binds tighter.
|
|
60
|
+
_OR_PRECEDENCE = 1
|
|
61
|
+
_AND_PRECEDENCE = 2
|
|
62
|
+
_NOT_PRECEDENCE = 3
|
|
63
|
+
_ATOM_PRECEDENCE = 4
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AbstractPredicateNode:
|
|
67
|
+
precedence: typing.ClassVar[int] = _ATOM_PRECEDENCE
|
|
68
|
+
predicate: AuthPredicate
|
|
69
|
+
|
|
70
|
+
__slots__ = ("predicate",)
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, predicate: AuthPredicate) -> None:
|
|
73
|
+
self.predicate = predicate
|
|
74
|
+
|
|
75
|
+
def to_string(self) -> str:
|
|
76
|
+
return self.predicate.name
|
|
77
|
+
|
|
78
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AbstractNotNode:
|
|
83
|
+
precedence: typing.ClassVar[int] = _NOT_PRECEDENCE
|
|
84
|
+
child: AnyAbstractNode
|
|
85
|
+
|
|
86
|
+
__slots__ = ("child",)
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, child: AnyAbstractNode) -> None:
|
|
89
|
+
self.child = child
|
|
90
|
+
|
|
91
|
+
def to_string(self) -> str:
|
|
92
|
+
return f"not {_render_child(self.child, self.precedence)}"
|
|
93
|
+
|
|
94
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AbstractAndNode:
|
|
99
|
+
precedence: typing.ClassVar[int] = _AND_PRECEDENCE
|
|
100
|
+
children: tuple[AnyAbstractNode, ...]
|
|
101
|
+
|
|
102
|
+
__slots__ = ("children",)
|
|
103
|
+
|
|
104
|
+
def __init__(self, *, children: tuple[AnyAbstractNode, ...]) -> None:
|
|
105
|
+
self.children = children
|
|
106
|
+
|
|
107
|
+
def to_string(self) -> str:
|
|
108
|
+
return " and ".join(
|
|
109
|
+
_render_child(child, self.precedence) for child in self.children
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AbstractOrNode:
|
|
117
|
+
precedence: typing.ClassVar[int] = _OR_PRECEDENCE
|
|
118
|
+
children: tuple[AnyAbstractNode, ...]
|
|
119
|
+
|
|
120
|
+
__slots__ = ("children",)
|
|
121
|
+
|
|
122
|
+
def __init__(self, *, children: tuple[AnyAbstractNode, ...]) -> None:
|
|
123
|
+
self.children = children
|
|
124
|
+
|
|
125
|
+
def to_string(self) -> str:
|
|
126
|
+
return " or ".join(
|
|
127
|
+
_render_child(child, self.precedence) for child in self.children
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
AnyAbstractNode = (
|
|
135
|
+
AbstractPredicateNode | AbstractNotNode | AbstractAndNode | AbstractOrNode
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _render_child(child: AnyAbstractNode, parent_precedence: int) -> str:
|
|
140
|
+
rendered = child.to_string()
|
|
141
|
+
if child.precedence < parent_precedence:
|
|
142
|
+
return f"({rendered})"
|
|
143
|
+
return rendered
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ExpressionTransformer[NodeT: AnyAbstractNode](Transformer[Token, NodeT]):
|
|
147
|
+
"""Transform Lark parse tree into AST of `Node` instances."""
|
|
148
|
+
|
|
149
|
+
or_node: type[AbstractOrNode] = AbstractOrNode
|
|
150
|
+
and_node: type[AbstractAndNode] = AbstractAndNode
|
|
151
|
+
not_node: type[AbstractNotNode] = AbstractNotNode
|
|
152
|
+
predicate_node: type[AbstractPredicateNode] = AbstractPredicateNode
|
|
153
|
+
|
|
154
|
+
def __init__(self, *, predicates: list[AuthPredicate]) -> None:
|
|
155
|
+
super().__init__()
|
|
156
|
+
self._predicates_by_name = {
|
|
157
|
+
predicate.name: predicate for predicate in predicates
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def predicate(self, items: list[Token]) -> AbstractPredicateNode:
|
|
161
|
+
name = items[0].value
|
|
162
|
+
predicate = self._predicates_by_name.get(name)
|
|
163
|
+
if predicate is None:
|
|
164
|
+
raise InvalidPredicateError(f"Unknown predicate '{name}'")
|
|
165
|
+
return self.predicate_node(predicate=predicate)
|
|
166
|
+
|
|
167
|
+
def not_expression(self, items: list[typing.Any]) -> AbstractNotNode:
|
|
168
|
+
return self.not_node(child=items[1])
|
|
169
|
+
|
|
170
|
+
def and_expression(self, items: list[typing.Any]) -> AbstractAndNode:
|
|
171
|
+
return self.and_node(
|
|
172
|
+
children=tuple(item for item in items if not isinstance(item, Token))
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def or_expression(self, items: list[typing.Any]) -> AbstractOrNode:
|
|
176
|
+
return self.or_node(
|
|
177
|
+
children=tuple(item for item in items if not isinstance(item, Token))
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PermissionExpression:
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
expression: str,
|
|
186
|
+
predicates: list[AuthPredicate],
|
|
187
|
+
transformer_class: type[ExpressionTransformer],
|
|
188
|
+
) -> None:
|
|
189
|
+
self.expression = expression
|
|
190
|
+
self.predicates = predicates
|
|
191
|
+
try:
|
|
192
|
+
parse_tree = expression_grammar.parse(expression)
|
|
193
|
+
except UnexpectedInput as exc:
|
|
194
|
+
raise ExpressionParsingError(str(exc)) from exc
|
|
195
|
+
try:
|
|
196
|
+
self._root: AnyAbstractNode = transformer_class(
|
|
197
|
+
predicates=predicates
|
|
198
|
+
).transform(parse_tree)
|
|
199
|
+
except VisitError as exc:
|
|
200
|
+
if isinstance(exc.orig_exc, ExpressionError):
|
|
201
|
+
raise exc.orig_exc from exc
|
|
202
|
+
raise ExpressionParsingError(str(exc.orig_exc)) from exc
|
|
203
|
+
|
|
204
|
+
def __repr__(self) -> str:
|
|
205
|
+
return self._root.to_string()
|
|
206
|
+
|
|
207
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
208
|
+
try:
|
|
209
|
+
return self._root.evaluate(context)
|
|
210
|
+
except ExpressionError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise ExpressionEvaluationError(str(exc)) from exc
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Select, and_, false, not_, or_, true
|
|
4
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
5
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
6
|
+
|
|
7
|
+
from fastapi_guardian import exceptions
|
|
8
|
+
from fastapi_guardian.dto import AuthContext, AuthPredicate, PredicateContext
|
|
9
|
+
from fastapi_guardian.engine import BaseAuthEngine
|
|
10
|
+
from fastapi_guardian.expression import (
|
|
11
|
+
AbstractAndNode,
|
|
12
|
+
AbstractNotNode,
|
|
13
|
+
AbstractOrNode,
|
|
14
|
+
AbstractPredicateNode,
|
|
15
|
+
ExpressionTransformer,
|
|
16
|
+
PermissionExpression,
|
|
17
|
+
)
|
|
18
|
+
from fastapi_guardian.resource import Resource
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SqlAlchemyPredicateNode(AbstractPredicateNode):
|
|
22
|
+
def evaluate(self, context: AuthContext) -> ColumnElement[bool]:
|
|
23
|
+
predicate_context = PredicateContext[type[Resource], typing.Any](
|
|
24
|
+
principal=context.principal, resource=context.current_permission.resource
|
|
25
|
+
)
|
|
26
|
+
return typing.cast("ColumnElement[bool]", self.predicate.fn(predicate_context))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SqlAlchemyNotNode(AbstractNotNode):
|
|
30
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
31
|
+
return not_(self.child.evaluate(context))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SqlAlchemyAndNode(AbstractAndNode):
|
|
35
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
36
|
+
return and_(*(child.evaluate(context) for child in self.children))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SqlAlchemyOrNode(AbstractOrNode):
|
|
40
|
+
def evaluate(self, context: AuthContext) -> typing.Any:
|
|
41
|
+
return or_(*(child.evaluate(context) for child in self.children))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
AnySqlAlchemyNode = (
|
|
45
|
+
SqlAlchemyPredicateNode | SqlAlchemyNotNode | SqlAlchemyAndNode | SqlAlchemyOrNode
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SqlAlchemyExpressionTransformer(ExpressionTransformer[AnySqlAlchemyNode]):
|
|
50
|
+
predicate_node: type[SqlAlchemyPredicateNode] = SqlAlchemyPredicateNode
|
|
51
|
+
not_node: type[SqlAlchemyNotNode] = SqlAlchemyNotNode
|
|
52
|
+
and_node: type[SqlAlchemyAndNode] = SqlAlchemyAndNode
|
|
53
|
+
or_node: type[SqlAlchemyOrNode] = SqlAlchemyOrNode
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SqlalchemyPermissionExpression(PermissionExpression):
|
|
57
|
+
def __init__(self, *, expression: str, predicates: list[AuthPredicate]) -> None:
|
|
58
|
+
super().__init__(
|
|
59
|
+
expression=expression,
|
|
60
|
+
predicates=predicates,
|
|
61
|
+
transformer_class=SqlAlchemyExpressionTransformer,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SqlalchemyResource(Resource):
|
|
66
|
+
__resource_id_column__: str = "id"
|
|
67
|
+
|
|
68
|
+
def __init_subclass__(cls, __resource_abstract__: bool = False) -> None:
|
|
69
|
+
if __resource_abstract__:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
resource_name = getattr(cls, "__resource_name__", None) or getattr(
|
|
73
|
+
cls, "__tablename__", None
|
|
74
|
+
)
|
|
75
|
+
if resource_name is None:
|
|
76
|
+
raise exceptions.ImproperlyConfigured(
|
|
77
|
+
"Either __resource_name__ or __tablename__ must be set"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not isinstance(getattr(cls, "__resource_app_name__", None), str):
|
|
81
|
+
raise exceptions.ImproperlyConfigured(
|
|
82
|
+
"__resource_app_name__ string must be set for non-abstract resources"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
cls.__resource_name__ = resource_name
|
|
86
|
+
cls.__resource_code__ = (
|
|
87
|
+
f"{cls.__resource_app_name__}.{cls.__resource_name__}".lower()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SqlalchemyAuthEngine(BaseAuthEngine):
|
|
92
|
+
def filter_query[RowT: tuple[typing.Any, ...]](
|
|
93
|
+
self,
|
|
94
|
+
context: AuthContext[type[SqlalchemyResource], typing.Any],
|
|
95
|
+
query: Select[RowT],
|
|
96
|
+
) -> Select[RowT]:
|
|
97
|
+
"""
|
|
98
|
+
Filter the query based on the principal's permissions for the given resource and action.
|
|
99
|
+
|
|
100
|
+
New filter applied as a chained filter() call,
|
|
101
|
+
in current sqlalchemy implementation it's AND'ed with the existing filters,
|
|
102
|
+
so for chained filter calls it should "Just Work™".
|
|
103
|
+
"""
|
|
104
|
+
expression = self._build_scoped_filter(context=context)
|
|
105
|
+
return query.filter(expression)
|
|
106
|
+
|
|
107
|
+
def _build_scoped_filter(self, *, context: AuthContext) -> ColumnElement[bool]:
|
|
108
|
+
grants = self.matching_grants(context=context)
|
|
109
|
+
if not grants:
|
|
110
|
+
return false()
|
|
111
|
+
|
|
112
|
+
expressions: list[ColumnElement[bool]] = []
|
|
113
|
+
resource_ids: list[str] = []
|
|
114
|
+
|
|
115
|
+
for grant in grants:
|
|
116
|
+
match grant.scope:
|
|
117
|
+
case "global":
|
|
118
|
+
return true()
|
|
119
|
+
case "resource":
|
|
120
|
+
resource_ids.append(grant.resource_id)
|
|
121
|
+
case "conditional":
|
|
122
|
+
expression = SqlalchemyPermissionExpression(
|
|
123
|
+
expression=grant.condition,
|
|
124
|
+
predicates=context.current_permission.predicates,
|
|
125
|
+
)
|
|
126
|
+
if expression is not None:
|
|
127
|
+
expressions.append(expression.evaluate(context=context))
|
|
128
|
+
|
|
129
|
+
if resource_ids:
|
|
130
|
+
id_column = getattr(
|
|
131
|
+
context.current_permission.resource,
|
|
132
|
+
context.current_permission.resource.__resource_id_column__,
|
|
133
|
+
None,
|
|
134
|
+
)
|
|
135
|
+
if id_column is None:
|
|
136
|
+
raise exceptions.ImproperlyConfigured(
|
|
137
|
+
f"Resource '{context.current_permission.resource.__resource_code__}' "
|
|
138
|
+
f"has no id column '{context.current_permission.resource.__resource_id_column__}' but resource scoped permission provided"
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
id_column = typing.cast("InstrumentedAttribute[typing.Any]", id_column)
|
|
142
|
+
expressions.append(id_column.in_(resource_ids))
|
|
143
|
+
|
|
144
|
+
if not expressions:
|
|
145
|
+
return false()
|
|
146
|
+
|
|
147
|
+
return or_(*expressions)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import typing
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from tortoise.expressions import Q
|
|
6
|
+
from tortoise.models import Model
|
|
7
|
+
from tortoise.queryset import QuerySet
|
|
8
|
+
|
|
9
|
+
from fastapi_guardian import exceptions
|
|
10
|
+
from fastapi_guardian.dto import AuthContext, AuthPredicate, PredicateContext
|
|
11
|
+
from fastapi_guardian.engine import BaseAuthEngine
|
|
12
|
+
from fastapi_guardian.expression import (
|
|
13
|
+
AbstractAndNode,
|
|
14
|
+
AbstractNotNode,
|
|
15
|
+
AbstractOrNode,
|
|
16
|
+
AbstractPredicateNode,
|
|
17
|
+
ExpressionTransformer,
|
|
18
|
+
PermissionExpression,
|
|
19
|
+
)
|
|
20
|
+
from fastapi_guardian.resource import Resource
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TortoisePredicateNode(AbstractPredicateNode):
|
|
24
|
+
def evaluate(self, context: AuthContext) -> Q:
|
|
25
|
+
predicate_context = PredicateContext[type[Resource], typing.Any](
|
|
26
|
+
principal=context.principal, resource=context.current_permission.resource
|
|
27
|
+
)
|
|
28
|
+
return typing.cast("Q", self.predicate.fn(predicate_context))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TortoiseNotNode(AbstractNotNode):
|
|
32
|
+
def evaluate(self, context: AuthContext) -> Q:
|
|
33
|
+
return ~self.child.evaluate(context)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TortoiseAndNode(AbstractAndNode):
|
|
37
|
+
def evaluate(self, context: AuthContext) -> Q:
|
|
38
|
+
return functools.reduce(
|
|
39
|
+
lambda left, right: left & right,
|
|
40
|
+
(child.evaluate(context) for child in self.children),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TortoiseOrNode(AbstractOrNode):
|
|
45
|
+
def evaluate(self, context: AuthContext) -> Q:
|
|
46
|
+
return functools.reduce(
|
|
47
|
+
lambda left, right: left | right,
|
|
48
|
+
(child.evaluate(context) for child in self.children),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
AnyTortoiseNode = (
|
|
53
|
+
TortoisePredicateNode | TortoiseNotNode | TortoiseAndNode | TortoiseOrNode
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TortoiseExpressionTransformer(ExpressionTransformer[AnyTortoiseNode]):
|
|
58
|
+
predicate_node: type[TortoisePredicateNode] = TortoisePredicateNode
|
|
59
|
+
not_node: type[TortoiseNotNode] = TortoiseNotNode
|
|
60
|
+
and_node: type[TortoiseAndNode] = TortoiseAndNode
|
|
61
|
+
or_node: type[TortoiseOrNode] = TortoiseOrNode
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TortoisePermissionExpression(PermissionExpression):
|
|
65
|
+
def __init__(self, *, expression: str, predicates: list[AuthPredicate]) -> None:
|
|
66
|
+
super().__init__(
|
|
67
|
+
expression=expression,
|
|
68
|
+
predicates=predicates,
|
|
69
|
+
transformer_class=TortoiseExpressionTransformer,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LazyAttribute[T]:
|
|
74
|
+
def __init__(self, *, getter: Callable[[type], T]) -> None:
|
|
75
|
+
self.getter = getter
|
|
76
|
+
|
|
77
|
+
def __get__(self, instance: typing.Any, owner: type[Model]) -> T:
|
|
78
|
+
return self.getter(owner)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_resource_name(owner: type[Model]) -> str:
|
|
82
|
+
resource_name = owner._meta.db_table
|
|
83
|
+
if resource_name:
|
|
84
|
+
return resource_name
|
|
85
|
+
|
|
86
|
+
raise exceptions.ImproperlyConfigured(
|
|
87
|
+
"Either __resource_name__ or Tortoise _meta.db_table must be available. Perhaps, you forgot to call Tortoise.init()?"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_app_name(cls: type[Model]) -> str:
|
|
92
|
+
resource_app_name = cls._meta.app
|
|
93
|
+
if resource_app_name:
|
|
94
|
+
return resource_app_name
|
|
95
|
+
|
|
96
|
+
raise exceptions.ImproperlyConfigured(
|
|
97
|
+
"Either __resource_app_name__ or Tortoise _meta.app must be available. Perhaps, you forgot to call Tortoise.init()?"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_resource_code(owner: type[Resource]) -> str:
|
|
102
|
+
return f"{owner.__resource_app_name__}.{owner.__resource_name__}".lower()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TortoiseResource(Resource):
|
|
106
|
+
"""
|
|
107
|
+
Base class for Tortoise-backed resources.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
Due to Tortoise paradigm of deferred model configuration, resource name and app name are resolved lazily. If no explicit override is set,
|
|
111
|
+
they come from ``_meta.db_table`` and ``_meta.app`` and must be accessed only after ``Tortoise.init()`` has been called.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
__resource_abstract__: typing.ClassVar[bool] = True
|
|
115
|
+
__resource_name__: typing.ClassVar[str] = typing.cast(
|
|
116
|
+
"str", LazyAttribute(getter=get_resource_name)
|
|
117
|
+
)
|
|
118
|
+
__resource_app_name__: typing.ClassVar[str] = typing.cast(
|
|
119
|
+
"str", LazyAttribute(getter=get_app_name)
|
|
120
|
+
)
|
|
121
|
+
__resource_code__: typing.ClassVar[str] = typing.cast(
|
|
122
|
+
"str", LazyAttribute(getter=get_resource_code)
|
|
123
|
+
)
|
|
124
|
+
__resource_id_column__: typing.ClassVar[str] = "id"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TortoiseAuthEngine(BaseAuthEngine):
|
|
128
|
+
def filter_query[T: Model](
|
|
129
|
+
self,
|
|
130
|
+
context: AuthContext[type[TortoiseResource], typing.Any],
|
|
131
|
+
query: QuerySet[T],
|
|
132
|
+
) -> QuerySet[T]:
|
|
133
|
+
"""
|
|
134
|
+
Filter the query based on the principal's permissions for the given resource and action.
|
|
135
|
+
|
|
136
|
+
New filter is applied as a chained filter() call. Tortoise ANDs chained
|
|
137
|
+
filters with any user-provided filters already present on the query.
|
|
138
|
+
"""
|
|
139
|
+
expression = self._build_scoped_filter(context=context)
|
|
140
|
+
return query.filter(expression)
|
|
141
|
+
|
|
142
|
+
def _build_scoped_filter(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
context: AuthContext[type[TortoiseResource], typing.Any],
|
|
146
|
+
) -> Q:
|
|
147
|
+
grants = self.matching_grants(context=context)
|
|
148
|
+
if not grants:
|
|
149
|
+
return Q(pk__in=[])
|
|
150
|
+
|
|
151
|
+
expressions: list[Q] = []
|
|
152
|
+
resource_ids: list[str] = []
|
|
153
|
+
|
|
154
|
+
for grant in grants:
|
|
155
|
+
match grant.scope:
|
|
156
|
+
case "global":
|
|
157
|
+
return Q()
|
|
158
|
+
case "resource":
|
|
159
|
+
resource_ids.append(grant.resource_id)
|
|
160
|
+
case "conditional":
|
|
161
|
+
expression = TortoisePermissionExpression(
|
|
162
|
+
expression=grant.condition,
|
|
163
|
+
predicates=context.current_permission.predicates,
|
|
164
|
+
)
|
|
165
|
+
expressions.append(expression.evaluate(context=context))
|
|
166
|
+
|
|
167
|
+
if resource_ids:
|
|
168
|
+
id_column = context.current_permission.resource.__resource_id_column__
|
|
169
|
+
if (
|
|
170
|
+
id_column
|
|
171
|
+
not in typing.cast(
|
|
172
|
+
type[Model], context.current_permission.resource
|
|
173
|
+
)._meta.fields_map
|
|
174
|
+
):
|
|
175
|
+
raise exceptions.ImproperlyConfigured(
|
|
176
|
+
f"Resource '{context.current_permission.resource.__resource_code__}' "
|
|
177
|
+
f"has no id column '{id_column}' but resource scoped permission provided"
|
|
178
|
+
)
|
|
179
|
+
filter_kwargs: typing.Any = {f"{id_column}__in": resource_ids}
|
|
180
|
+
expressions.append(Q(**filter_kwargs))
|
|
181
|
+
|
|
182
|
+
if not expressions:
|
|
183
|
+
return Q(pk__in=[])
|
|
184
|
+
|
|
185
|
+
return functools.reduce(lambda left, right: left | right, expressions)
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from tortoise import fields, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class User(models.Model):
|
|
5
|
+
id = fields.IntField(primary_key=True)
|
|
6
|
+
name = fields.CharField(max_length=255)
|
|
7
|
+
email = fields.CharField(max_length=255)
|
|
8
|
+
password = fields.CharField(max_length=255)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Post(models.Model):
|
|
12
|
+
id = fields.IntField(primary_key=True)
|
|
13
|
+
title = fields.CharField(max_length=255)
|
|
14
|
+
content = fields.TextField()
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-guardian
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Flexible permissions for FastAPI
|
|
5
|
+
Keywords: fastapi,authorization,security
|
|
6
|
+
Author: achopik
|
|
7
|
+
Author-email: achopik <artikchopik@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Requires-Dist: fastapi>=0.130.0
|
|
15
|
+
Requires-Dist: lark>=1.3.1
|
|
16
|
+
Requires-Dist: pydantic>=2.12.0
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.10 ; extra == 'sqlalchemy'
|
|
18
|
+
Requires-Dist: tortoise-orm>=1.1.0 ; extra == 'tortoise'
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Project-URL: Repository, https://github.com/achopik/fastapi-guardian
|
|
21
|
+
Provides-Extra: sqlalchemy
|
|
22
|
+
Provides-Extra: tortoise
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# fastapi-guardian
|
|
26
|
+
|
|
27
|
+
```fastapi-guardian``` is a WIP Python library created for flexible permission management in FastAPI applications. It provides a generic engine and ORM bindings to perform access checks and DB-level filtering operations.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Generic engine for permission management
|
|
32
|
+
- SQLAlchemy and Tortoise ORM bindings for access filtering
|
|
33
|
+
- Database-agnostic core decision engine
|
|
34
|
+
- Expression mini-DSL for permission conditions (AND, OR, NOT) with custom dev-defined predicates (e.g, 'self', 'only_drafts')
|
|
35
|
+
- 3 scopes of permissions: global, resource-based (access to specific resource instance denoted by ID) and conditional (access to specific resource instance based on custom conditions defined in application code)
|
|
36
|
+
- FastAPI-native dependency injection via `Permission` dependency.
|
|
37
|
+
- Fully typed library definitions, especially user-facing interfaces.
|
|
38
|
+
- 🚧 Minimal AI involvement and fully covered with tests.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Supported Python version
|
|
42
|
+
|
|
43
|
+
Currently, library is designed to work with Python 3.14+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install fastapi-guardian
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```py
|
|
55
|
+
import enum
|
|
56
|
+
import typing
|
|
57
|
+
|
|
58
|
+
import uvicorn
|
|
59
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
60
|
+
from pydantic import BaseModel
|
|
61
|
+
from sqlalchemy.engine import create_engine
|
|
62
|
+
from sqlalchemy.orm import (
|
|
63
|
+
DeclarativeBase,
|
|
64
|
+
Mapped,
|
|
65
|
+
Session,
|
|
66
|
+
mapped_column,
|
|
67
|
+
relationship,
|
|
68
|
+
sessionmaker,
|
|
69
|
+
)
|
|
70
|
+
from sqlalchemy.schema import ForeignKey
|
|
71
|
+
from sqlalchemy.sql import select
|
|
72
|
+
from sqlalchemy.types import JSON, Integer, String
|
|
73
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
74
|
+
|
|
75
|
+
from fastapi_guardian.dependencies import BasePermission
|
|
76
|
+
from fastapi_guardian.dto import AuthContext, Principal
|
|
77
|
+
from fastapi_guardian.ext.sqlalchemy import SqlalchemyAuthEngine, SqlalchemyResource
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# 1. Define your sqlalchemy models, inherit SqlalchemyResource
|
|
82
|
+
class Base(DeclarativeBase, SqlalchemyResource, __resource_abstract__=True):
|
|
83
|
+
__resource_app_name__ = "example"
|
|
84
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Role(Base):
|
|
88
|
+
__tablename__ = "roles"
|
|
89
|
+
|
|
90
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
91
|
+
permission_grants: Mapped[list["RolePermissionGrant"]] = relationship(
|
|
92
|
+
"RolePermissionGrant", back_populates="role"
|
|
93
|
+
)
|
|
94
|
+
users: Mapped[list["UserRole"]] = relationship("UserRole", back_populates="role")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RolePermissionGrant(Base):
|
|
98
|
+
__tablename__ = "role_permission_grants"
|
|
99
|
+
|
|
100
|
+
role_id: Mapped[int] = mapped_column(Integer, ForeignKey("roles.id"))
|
|
101
|
+
role: Mapped["Role"] = relationship("Role", back_populates="permission_grants")
|
|
102
|
+
resource: Mapped[str] = mapped_column(String(255))
|
|
103
|
+
action: Mapped[str] = mapped_column(String(255))
|
|
104
|
+
scope: Mapped[str] = mapped_column(String(255))
|
|
105
|
+
extra: Mapped[dict] = mapped_column(JSON)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class User(Base):
|
|
109
|
+
__tablename__ = "users"
|
|
110
|
+
|
|
111
|
+
username: Mapped[str] = mapped_column(String(255))
|
|
112
|
+
email: Mapped[str] = mapped_column(String(255))
|
|
113
|
+
password: Mapped[str] = mapped_column(String(255))
|
|
114
|
+
|
|
115
|
+
articles: Mapped[list["Article"]] = relationship("Article", back_populates="author")
|
|
116
|
+
roles: Mapped[list["UserRole"]] = relationship("UserRole", back_populates="user")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Article(Base):
|
|
120
|
+
__tablename__ = "articles"
|
|
121
|
+
|
|
122
|
+
title: Mapped[str] = mapped_column(String(255))
|
|
123
|
+
content: Mapped[str] = mapped_column(String(255))
|
|
124
|
+
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
125
|
+
author: Mapped["User"] = relationship("User", back_populates="articles")
|
|
126
|
+
category: Mapped[str] = mapped_column(String(255))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# 2. Create your auth engine
|
|
131
|
+
auth_engine = SqlalchemyAuthEngine[Base]()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# 3. Create your permission dependency class and authentication logic
|
|
135
|
+
def get_authorized_principal(
|
|
136
|
+
request: Request, db: Session = Depends(get_db)
|
|
137
|
+
) -> Principal[int] | None:
|
|
138
|
+
user_id = request.session.get("user_id")
|
|
139
|
+
if user_id is None:
|
|
140
|
+
return None # Alternatively, you can raise an HTTPException here, but None is handled by permission itself
|
|
141
|
+
user = db.query(User).filter(User.id == user_id).first()
|
|
142
|
+
if user is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
permissions = (
|
|
146
|
+
db.query(RolePermissionGrant)
|
|
147
|
+
.join(Role, Role.id == RolePermissionGrant.role_id)
|
|
148
|
+
.join(UserRole, UserRole.role_id == Role.id)
|
|
149
|
+
.filter(UserRole.user_id == user_id)
|
|
150
|
+
.all()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return Principal(
|
|
154
|
+
id=user.id,
|
|
155
|
+
email=user.email,
|
|
156
|
+
username=user.username,
|
|
157
|
+
permissions=[
|
|
158
|
+
{
|
|
159
|
+
"resource": permission.resource,
|
|
160
|
+
"action": permission.action,
|
|
161
|
+
"scope": permission.scope,
|
|
162
|
+
**permission.extra,
|
|
163
|
+
}
|
|
164
|
+
for permission in permissions
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AppPermission[T: type[Base]](BasePermission[T, int]):
|
|
170
|
+
auth_engine = auth_engine
|
|
171
|
+
|
|
172
|
+
async def __call__(self, principal: typing.Annotated[Principal, Depends(get_authorized_principal)]):
|
|
173
|
+
return await self.authorize(principal=principal)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Note: You can use any string value as an action, enum prefered here for typed suggestions and consistency across the application.
|
|
177
|
+
class AuthAction(enum.StrEnum):
|
|
178
|
+
READ = "read"
|
|
179
|
+
CREATE = "create"
|
|
180
|
+
UPDATE = "update"
|
|
181
|
+
DELETE = "delete"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# 4. Define your API endpoints
|
|
185
|
+
@app.get("/users")
|
|
186
|
+
async def get_users(
|
|
187
|
+
auth_ctx: AuthContext = Depends(
|
|
188
|
+
AppPermission(
|
|
189
|
+
resource=User,
|
|
190
|
+
action=AuthAction.READ,
|
|
191
|
+
scopes=["global", "resource", "conditional"],
|
|
192
|
+
predicates=[
|
|
193
|
+
{
|
|
194
|
+
"name": "self",
|
|
195
|
+
"fn": lambda ctx: ctx.resource.id == ctx.principal.id,
|
|
196
|
+
"description": "Allow access to own user resource",
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
db: Session = Depends(get_db),
|
|
202
|
+
) -> list[UserDto]:
|
|
203
|
+
query = select(User)
|
|
204
|
+
query = auth_engine.filter_query(context=auth_ctx, query=query)
|
|
205
|
+
users = db.execute(query).scalars().all()
|
|
206
|
+
return [
|
|
207
|
+
UserDto(id=user.id, username=user.username, email=user.email) for user in users
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.post("/users")
|
|
212
|
+
async def create_user(
|
|
213
|
+
body: UserCreateDto,
|
|
214
|
+
# By default, permission assumes only global scope, which is the case for most of CREATE actions
|
|
215
|
+
_auth: AuthContext = Depends(
|
|
216
|
+
AppPermission(resource=User, action=AuthAction.CREATE)
|
|
217
|
+
),
|
|
218
|
+
db: Session = Depends(get_db),
|
|
219
|
+
) -> UserDto:
|
|
220
|
+
user = User(username=body.username, email=body.email, password=body.password)
|
|
221
|
+
db.add(user)
|
|
222
|
+
db.commit()
|
|
223
|
+
db.refresh(user)
|
|
224
|
+
return UserDto(id=user.id, username=user.username, email=user.email)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.get("/articles")
|
|
228
|
+
async def get_articles(
|
|
229
|
+
auth_ctx: AuthContext = Depends(
|
|
230
|
+
AppPermission(
|
|
231
|
+
resource=Article,
|
|
232
|
+
action=AuthAction.READ,
|
|
233
|
+
scopes=["global", "resource", "conditional"],
|
|
234
|
+
predicates=[
|
|
235
|
+
{
|
|
236
|
+
"name": "self",
|
|
237
|
+
"fn": lambda ctx: ctx.resource.author_id == ctx.principal.id,
|
|
238
|
+
"description": "Allow access to own articles",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "only_published",
|
|
242
|
+
"fn": lambda ctx: ctx.resource.category == "published",
|
|
243
|
+
"description": "Allow access to published articles only",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
db: Session = Depends(get_db),
|
|
249
|
+
) -> list[ArticleDto]:
|
|
250
|
+
query = select(Article)
|
|
251
|
+
# Note: Apply filter to query manually here. Expression will be added as AND statement to the existing filters.
|
|
252
|
+
query = auth_engine.filter_query(context=auth_ctx, query=query)
|
|
253
|
+
articles = db.execute(query).scalars().all()
|
|
254
|
+
return [
|
|
255
|
+
ArticleDto(
|
|
256
|
+
id=article.id,
|
|
257
|
+
title=article.title,
|
|
258
|
+
content=article.content,
|
|
259
|
+
author_id=article.author_id,
|
|
260
|
+
category=article.category,
|
|
261
|
+
)
|
|
262
|
+
for article in articles
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
# 5. Create and store permission grants somewhere (completely up to you, check examples for to-go model definitions, auth engine only cares about Principal DTO):
|
|
266
|
+
grant = RolePermissionGrant(
|
|
267
|
+
role_id=author_role.id,
|
|
268
|
+
resource=Article.__resource_code__,
|
|
269
|
+
action=AuthAction.READ,
|
|
270
|
+
scope="conditional",
|
|
271
|
+
# Notice that we can use expression mini-DSL here to build complex conditions.
|
|
272
|
+
extra={"condition": "self or only_published"},
|
|
273
|
+
)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For detailed ready-to-run examples, see [examples](examples) directory.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
fastapi_guardian/__init__.py,sha256=i7DwHhN-J0w61Xz2BlBUwGsxy52JqW25vSZO18UbHBs,344
|
|
2
|
+
fastapi_guardian/dependencies.py,sha256=Hh-V5D_8KJxGe8LblG_2QB26Vi2c4U8RoNLvC3CBJmw,1934
|
|
3
|
+
fastapi_guardian/dto.py,sha256=G6bgJguHKCSKmvwyZuuwexgSUtFNyRiJ3zJ3sWJFgo4,3219
|
|
4
|
+
fastapi_guardian/engine.py,sha256=aLNCVJ8iNsmbW6RoSON9e7_UHImYLd0nHze5tMyddd4,2915
|
|
5
|
+
fastapi_guardian/exceptions.py,sha256=fNXEMl7qV3jVMB4JqGOgylSzTKiPehvOqv-K5TzWAlM,103
|
|
6
|
+
fastapi_guardian/expression.py,sha256=QaUGRnj382M-w7Q8TBJMCbQvlD6DTMIQ0J0_Bq616Zs,6063
|
|
7
|
+
fastapi_guardian/ext/sqlalchemy.py,sha256=IH5QKIuJiOAOrg0gIXy1E8_D0sNIkfD1m5rIgACHfn0,5487
|
|
8
|
+
fastapi_guardian/ext/tortoise.py,sha256=FTzUSE8MyLd1v_3fmAupjMEnuqnGbP1e-wgA9jUNEz4,6391
|
|
9
|
+
fastapi_guardian/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
fastapi_guardian/resource.py,sha256=457pBTFK6irXqyPpIhDT0ktPFr-HUeEsCBoIl3kLtG4,101
|
|
11
|
+
fastapi_guardian/tortoise_test.py,sha256=CGHsBYHO5exeGSq-SGH7MBC-rNXDhyazU23rJEvxHx4,393
|
|
12
|
+
fastapi_guardian-0.1.0.dist-info/WHEEL,sha256=Qb5DWjqM6GZuPp3VmTlAFSGqNRK8vceyVqRFxrfa8YA,80
|
|
13
|
+
fastapi_guardian-0.1.0.dist-info/METADATA,sha256=tpGX5PVvOuU-s3dGzTRlAkHUh3hPvVSRwXDe69bI1q0,9346
|
|
14
|
+
fastapi_guardian-0.1.0.dist-info/RECORD,,
|