fastapi-guardian 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_guardian-0.1.0/PKG-INFO +276 -0
- fastapi_guardian-0.1.0/README.md +252 -0
- fastapi_guardian-0.1.0/pyproject.toml +65 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/__init__.py +19 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/dependencies.py +63 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/dto.py +115 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/engine.py +66 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/exceptions.py +6 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/expression.py +213 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/ext/sqlalchemy.py +147 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/ext/tortoise.py +185 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/py.typed +0 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/resource.py +4 -0
- fastapi_guardian-0.1.0/src/fastapi_guardian/tortoise_test.py +14 -0
|
@@ -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,252 @@
|
|
|
1
|
+
# fastapi-guardian
|
|
2
|
+
|
|
3
|
+
```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.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Generic engine for permission management
|
|
8
|
+
- SQLAlchemy and Tortoise ORM bindings for access filtering
|
|
9
|
+
- Database-agnostic core decision engine
|
|
10
|
+
- Expression mini-DSL for permission conditions (AND, OR, NOT) with custom dev-defined predicates (e.g, 'self', 'only_drafts')
|
|
11
|
+
- 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)
|
|
12
|
+
- FastAPI-native dependency injection via `Permission` dependency.
|
|
13
|
+
- Fully typed library definitions, especially user-facing interfaces.
|
|
14
|
+
- 🚧 Minimal AI involvement and fully covered with tests.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Supported Python version
|
|
18
|
+
|
|
19
|
+
Currently, library is designed to work with Python 3.14+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install fastapi-guardian
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```py
|
|
31
|
+
import enum
|
|
32
|
+
import typing
|
|
33
|
+
|
|
34
|
+
import uvicorn
|
|
35
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
36
|
+
from pydantic import BaseModel
|
|
37
|
+
from sqlalchemy.engine import create_engine
|
|
38
|
+
from sqlalchemy.orm import (
|
|
39
|
+
DeclarativeBase,
|
|
40
|
+
Mapped,
|
|
41
|
+
Session,
|
|
42
|
+
mapped_column,
|
|
43
|
+
relationship,
|
|
44
|
+
sessionmaker,
|
|
45
|
+
)
|
|
46
|
+
from sqlalchemy.schema import ForeignKey
|
|
47
|
+
from sqlalchemy.sql import select
|
|
48
|
+
from sqlalchemy.types import JSON, Integer, String
|
|
49
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
50
|
+
|
|
51
|
+
from fastapi_guardian.dependencies import BasePermission
|
|
52
|
+
from fastapi_guardian.dto import AuthContext, Principal
|
|
53
|
+
from fastapi_guardian.ext.sqlalchemy import SqlalchemyAuthEngine, SqlalchemyResource
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# 1. Define your sqlalchemy models, inherit SqlalchemyResource
|
|
58
|
+
class Base(DeclarativeBase, SqlalchemyResource, __resource_abstract__=True):
|
|
59
|
+
__resource_app_name__ = "example"
|
|
60
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Role(Base):
|
|
64
|
+
__tablename__ = "roles"
|
|
65
|
+
|
|
66
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
67
|
+
permission_grants: Mapped[list["RolePermissionGrant"]] = relationship(
|
|
68
|
+
"RolePermissionGrant", back_populates="role"
|
|
69
|
+
)
|
|
70
|
+
users: Mapped[list["UserRole"]] = relationship("UserRole", back_populates="role")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RolePermissionGrant(Base):
|
|
74
|
+
__tablename__ = "role_permission_grants"
|
|
75
|
+
|
|
76
|
+
role_id: Mapped[int] = mapped_column(Integer, ForeignKey("roles.id"))
|
|
77
|
+
role: Mapped["Role"] = relationship("Role", back_populates="permission_grants")
|
|
78
|
+
resource: Mapped[str] = mapped_column(String(255))
|
|
79
|
+
action: Mapped[str] = mapped_column(String(255))
|
|
80
|
+
scope: Mapped[str] = mapped_column(String(255))
|
|
81
|
+
extra: Mapped[dict] = mapped_column(JSON)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class User(Base):
|
|
85
|
+
__tablename__ = "users"
|
|
86
|
+
|
|
87
|
+
username: Mapped[str] = mapped_column(String(255))
|
|
88
|
+
email: Mapped[str] = mapped_column(String(255))
|
|
89
|
+
password: Mapped[str] = mapped_column(String(255))
|
|
90
|
+
|
|
91
|
+
articles: Mapped[list["Article"]] = relationship("Article", back_populates="author")
|
|
92
|
+
roles: Mapped[list["UserRole"]] = relationship("UserRole", back_populates="user")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Article(Base):
|
|
96
|
+
__tablename__ = "articles"
|
|
97
|
+
|
|
98
|
+
title: Mapped[str] = mapped_column(String(255))
|
|
99
|
+
content: Mapped[str] = mapped_column(String(255))
|
|
100
|
+
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
101
|
+
author: Mapped["User"] = relationship("User", back_populates="articles")
|
|
102
|
+
category: Mapped[str] = mapped_column(String(255))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# 2. Create your auth engine
|
|
107
|
+
auth_engine = SqlalchemyAuthEngine[Base]()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# 3. Create your permission dependency class and authentication logic
|
|
111
|
+
def get_authorized_principal(
|
|
112
|
+
request: Request, db: Session = Depends(get_db)
|
|
113
|
+
) -> Principal[int] | None:
|
|
114
|
+
user_id = request.session.get("user_id")
|
|
115
|
+
if user_id is None:
|
|
116
|
+
return None # Alternatively, you can raise an HTTPException here, but None is handled by permission itself
|
|
117
|
+
user = db.query(User).filter(User.id == user_id).first()
|
|
118
|
+
if user is None:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
permissions = (
|
|
122
|
+
db.query(RolePermissionGrant)
|
|
123
|
+
.join(Role, Role.id == RolePermissionGrant.role_id)
|
|
124
|
+
.join(UserRole, UserRole.role_id == Role.id)
|
|
125
|
+
.filter(UserRole.user_id == user_id)
|
|
126
|
+
.all()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return Principal(
|
|
130
|
+
id=user.id,
|
|
131
|
+
email=user.email,
|
|
132
|
+
username=user.username,
|
|
133
|
+
permissions=[
|
|
134
|
+
{
|
|
135
|
+
"resource": permission.resource,
|
|
136
|
+
"action": permission.action,
|
|
137
|
+
"scope": permission.scope,
|
|
138
|
+
**permission.extra,
|
|
139
|
+
}
|
|
140
|
+
for permission in permissions
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AppPermission[T: type[Base]](BasePermission[T, int]):
|
|
146
|
+
auth_engine = auth_engine
|
|
147
|
+
|
|
148
|
+
async def __call__(self, principal: typing.Annotated[Principal, Depends(get_authorized_principal)]):
|
|
149
|
+
return await self.authorize(principal=principal)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Note: You can use any string value as an action, enum prefered here for typed suggestions and consistency across the application.
|
|
153
|
+
class AuthAction(enum.StrEnum):
|
|
154
|
+
READ = "read"
|
|
155
|
+
CREATE = "create"
|
|
156
|
+
UPDATE = "update"
|
|
157
|
+
DELETE = "delete"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# 4. Define your API endpoints
|
|
161
|
+
@app.get("/users")
|
|
162
|
+
async def get_users(
|
|
163
|
+
auth_ctx: AuthContext = Depends(
|
|
164
|
+
AppPermission(
|
|
165
|
+
resource=User,
|
|
166
|
+
action=AuthAction.READ,
|
|
167
|
+
scopes=["global", "resource", "conditional"],
|
|
168
|
+
predicates=[
|
|
169
|
+
{
|
|
170
|
+
"name": "self",
|
|
171
|
+
"fn": lambda ctx: ctx.resource.id == ctx.principal.id,
|
|
172
|
+
"description": "Allow access to own user resource",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
),
|
|
177
|
+
db: Session = Depends(get_db),
|
|
178
|
+
) -> list[UserDto]:
|
|
179
|
+
query = select(User)
|
|
180
|
+
query = auth_engine.filter_query(context=auth_ctx, query=query)
|
|
181
|
+
users = db.execute(query).scalars().all()
|
|
182
|
+
return [
|
|
183
|
+
UserDto(id=user.id, username=user.username, email=user.email) for user in users
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.post("/users")
|
|
188
|
+
async def create_user(
|
|
189
|
+
body: UserCreateDto,
|
|
190
|
+
# By default, permission assumes only global scope, which is the case for most of CREATE actions
|
|
191
|
+
_auth: AuthContext = Depends(
|
|
192
|
+
AppPermission(resource=User, action=AuthAction.CREATE)
|
|
193
|
+
),
|
|
194
|
+
db: Session = Depends(get_db),
|
|
195
|
+
) -> UserDto:
|
|
196
|
+
user = User(username=body.username, email=body.email, password=body.password)
|
|
197
|
+
db.add(user)
|
|
198
|
+
db.commit()
|
|
199
|
+
db.refresh(user)
|
|
200
|
+
return UserDto(id=user.id, username=user.username, email=user.email)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/articles")
|
|
204
|
+
async def get_articles(
|
|
205
|
+
auth_ctx: AuthContext = Depends(
|
|
206
|
+
AppPermission(
|
|
207
|
+
resource=Article,
|
|
208
|
+
action=AuthAction.READ,
|
|
209
|
+
scopes=["global", "resource", "conditional"],
|
|
210
|
+
predicates=[
|
|
211
|
+
{
|
|
212
|
+
"name": "self",
|
|
213
|
+
"fn": lambda ctx: ctx.resource.author_id == ctx.principal.id,
|
|
214
|
+
"description": "Allow access to own articles",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"name": "only_published",
|
|
218
|
+
"fn": lambda ctx: ctx.resource.category == "published",
|
|
219
|
+
"description": "Allow access to published articles only",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
),
|
|
224
|
+
db: Session = Depends(get_db),
|
|
225
|
+
) -> list[ArticleDto]:
|
|
226
|
+
query = select(Article)
|
|
227
|
+
# Note: Apply filter to query manually here. Expression will be added as AND statement to the existing filters.
|
|
228
|
+
query = auth_engine.filter_query(context=auth_ctx, query=query)
|
|
229
|
+
articles = db.execute(query).scalars().all()
|
|
230
|
+
return [
|
|
231
|
+
ArticleDto(
|
|
232
|
+
id=article.id,
|
|
233
|
+
title=article.title,
|
|
234
|
+
content=article.content,
|
|
235
|
+
author_id=article.author_id,
|
|
236
|
+
category=article.category,
|
|
237
|
+
)
|
|
238
|
+
for article in articles
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
# 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):
|
|
242
|
+
grant = RolePermissionGrant(
|
|
243
|
+
role_id=author_role.id,
|
|
244
|
+
resource=Article.__resource_code__,
|
|
245
|
+
action=AuthAction.READ,
|
|
246
|
+
scope="conditional",
|
|
247
|
+
# Notice that we can use expression mini-DSL here to build complex conditions.
|
|
248
|
+
extra={"condition": "self or only_published"},
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
For detailed ready-to-run examples, see [examples](examples) directory.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-guardian"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Flexible permissions for FastAPI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "achopik", email = "artikchopik@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Programming Language :: Python",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
keywords = [
|
|
19
|
+
"fastapi", "authorization", "security"
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"fastapi>=0.130.0",
|
|
23
|
+
"lark>=1.3.1",
|
|
24
|
+
"pydantic>=2.12.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/achopik/fastapi-guardian"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
sqlalchemy = [
|
|
33
|
+
"sqlalchemy>=2.0.10"
|
|
34
|
+
]
|
|
35
|
+
tortoise = [
|
|
36
|
+
"tortoise-orm>=1.1.0"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"ruff==0.15.11",
|
|
42
|
+
"pytest==9.0.3",
|
|
43
|
+
"pytest-asyncio==1.3.0",
|
|
44
|
+
"mypy==1.20.0",
|
|
45
|
+
"types-python-dateutil==2.9.0.20250516",
|
|
46
|
+
"pytest-cov>=7.1.0",
|
|
47
|
+
"factory-boy>=3.3.3",
|
|
48
|
+
"pytest-factoryboy>=2.8.1",
|
|
49
|
+
"uvicorn>=0.46.0",
|
|
50
|
+
"itsdangerous>=2.2.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[build-system]
|
|
54
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
55
|
+
build-backend = "uv_build"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.14"
|
|
60
|
+
show_error_codes = true
|
|
61
|
+
disable_error_code = ["misc"]
|
|
62
|
+
ignore_missing_imports = true
|
|
63
|
+
plugins = ["pydantic.mypy"]
|
|
64
|
+
exclude = ["alembic/versions"]
|
|
65
|
+
check_untyped_defs = true
|
|
@@ -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")
|