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.
@@ -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")
@@ -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,6 @@
1
+ class GuardianException(Exception):
2
+ pass
3
+
4
+
5
+ class ImproperlyConfigured(GuardianException):
6
+ pass
@@ -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,4 @@
1
+ class Resource:
2
+ __resource_name__: str
3
+ __resource_app_name__: str
4
+ __resource_code__: str
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any