fastapi-admin-kit 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_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""SQLAlchemy models for admin auth: roles, users, permissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
Boolean,
|
|
7
|
+
Column,
|
|
8
|
+
DateTime,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
Integer,
|
|
11
|
+
String,
|
|
12
|
+
Table,
|
|
13
|
+
Text,
|
|
14
|
+
UniqueConstraint,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.orm import relationship
|
|
17
|
+
from sqlalchemy.sql import func
|
|
18
|
+
|
|
19
|
+
from fastapi_admin_kit.modeladmin import ModelAdmin
|
|
20
|
+
from fastapi_admin_kit.models.base import Base
|
|
21
|
+
|
|
22
|
+
# Junction table — no ORM model needed
|
|
23
|
+
admin_user_roles = Table(
|
|
24
|
+
"admin_user_roles",
|
|
25
|
+
Base.metadata,
|
|
26
|
+
Column(
|
|
27
|
+
"user_id",
|
|
28
|
+
Integer,
|
|
29
|
+
ForeignKey("admin_users.id", ondelete="CASCADE"),
|
|
30
|
+
primary_key=True,
|
|
31
|
+
),
|
|
32
|
+
Column(
|
|
33
|
+
"role_id",
|
|
34
|
+
Integer,
|
|
35
|
+
ForeignKey("admin_roles.id", ondelete="CASCADE"),
|
|
36
|
+
primary_key=True,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AdminRole(Base):
|
|
42
|
+
__tablename__ = "admin_roles"
|
|
43
|
+
|
|
44
|
+
id = Column(Integer, primary_key=True)
|
|
45
|
+
name = Column(String(100), unique=True, nullable=False)
|
|
46
|
+
description = Column(Text)
|
|
47
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
48
|
+
|
|
49
|
+
users = relationship(
|
|
50
|
+
"AdminUser", secondary=admin_user_roles, back_populates="roles"
|
|
51
|
+
)
|
|
52
|
+
permissions = relationship(
|
|
53
|
+
"AdminPermission", back_populates="role", cascade="all, delete-orphan"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str:
|
|
57
|
+
return str(self.name)
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"<AdminRole {self.name!r}>"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AdminUser(Base):
|
|
64
|
+
__tablename__ = "admin_users"
|
|
65
|
+
|
|
66
|
+
id = Column(Integer, primary_key=True)
|
|
67
|
+
email = Column(String(255), unique=True, nullable=False)
|
|
68
|
+
hashed_password = Column(String(255), nullable=False)
|
|
69
|
+
full_name = Column(String(255))
|
|
70
|
+
is_superuser = Column(Boolean, default=False)
|
|
71
|
+
is_active = Column(Boolean, default=True)
|
|
72
|
+
last_login = Column(DateTime(timezone=True))
|
|
73
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
74
|
+
password_changed_at = Column(DateTime(timezone=True), nullable=True)
|
|
75
|
+
|
|
76
|
+
# Many-to-many roles
|
|
77
|
+
roles = relationship(
|
|
78
|
+
"AdminRole", secondary=admin_user_roles, back_populates="users"
|
|
79
|
+
)
|
|
80
|
+
# Direct permission overrides
|
|
81
|
+
direct_permissions = relationship(
|
|
82
|
+
"AdminUserPermission",
|
|
83
|
+
back_populates="user",
|
|
84
|
+
cascade="all, delete-orphan",
|
|
85
|
+
)
|
|
86
|
+
refresh_tokens = relationship(
|
|
87
|
+
"AdminRefreshToken", back_populates="user", cascade="all, delete-orphan"
|
|
88
|
+
)
|
|
89
|
+
totp = relationship(
|
|
90
|
+
"AdminUserTOTP",
|
|
91
|
+
back_populates="user",
|
|
92
|
+
uselist=False,
|
|
93
|
+
cascade="all, delete-orphan",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def role_ids(self) -> list[int]:
|
|
98
|
+
return [r.id for r in self.roles]
|
|
99
|
+
|
|
100
|
+
def __str__(self) -> str:
|
|
101
|
+
return str(self.email)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"<AdminUser {self.email!r}>"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AdminPermission(Base):
|
|
108
|
+
"""Permission matrix per role per model."""
|
|
109
|
+
|
|
110
|
+
__tablename__ = "admin_permissions"
|
|
111
|
+
__table_args__ = (
|
|
112
|
+
UniqueConstraint(
|
|
113
|
+
"role_id", "table_name", name="uq_admin_perm_role_table"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
id = Column(Integer, primary_key=True)
|
|
118
|
+
role_id = Column(
|
|
119
|
+
Integer,
|
|
120
|
+
ForeignKey("admin_roles.id", ondelete="CASCADE"),
|
|
121
|
+
nullable=False,
|
|
122
|
+
)
|
|
123
|
+
table_name = Column(String(255), nullable=False)
|
|
124
|
+
can_view = Column(Boolean, default=False)
|
|
125
|
+
can_create = Column(Boolean, default=False)
|
|
126
|
+
can_edit = Column(Boolean, default=False)
|
|
127
|
+
can_delete = Column(Boolean, default=False)
|
|
128
|
+
|
|
129
|
+
role = relationship("AdminRole", back_populates="permissions")
|
|
130
|
+
|
|
131
|
+
def __str__(self) -> str:
|
|
132
|
+
return f"{self.table_name} (role {self.role_id})"
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
return (
|
|
136
|
+
f"<AdminPermission role={self.role_id} table={self.table_name!r}>"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AdminUserPermission(Base):
|
|
141
|
+
"""Direct per-user permission overrides — merged with role permissions."""
|
|
142
|
+
|
|
143
|
+
__tablename__ = "admin_user_permissions"
|
|
144
|
+
__table_args__ = (
|
|
145
|
+
UniqueConstraint(
|
|
146
|
+
"user_id", "table_name", name="uq_admin_user_perm_user_table"
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
id = Column(Integer, primary_key=True)
|
|
151
|
+
user_id = Column(
|
|
152
|
+
Integer,
|
|
153
|
+
ForeignKey("admin_users.id", ondelete="CASCADE"),
|
|
154
|
+
nullable=False,
|
|
155
|
+
)
|
|
156
|
+
table_name = Column(String(255), nullable=False)
|
|
157
|
+
can_view = Column(Boolean, default=False)
|
|
158
|
+
can_create = Column(Boolean, default=False)
|
|
159
|
+
can_edit = Column(Boolean, default=False)
|
|
160
|
+
can_delete = Column(Boolean, default=False)
|
|
161
|
+
|
|
162
|
+
user = relationship("AdminUser", back_populates="direct_permissions")
|
|
163
|
+
|
|
164
|
+
def __str__(self) -> str:
|
|
165
|
+
return f"{self.table_name} (user {self.user_id})"
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return f"<AdminUserPermission user={self.user_id} table={self.table_name!r}>"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AdminRefreshToken(Base):
|
|
172
|
+
__tablename__ = "admin_refresh_tokens"
|
|
173
|
+
|
|
174
|
+
id = Column(Integer, primary_key=True)
|
|
175
|
+
user_id = Column(
|
|
176
|
+
Integer,
|
|
177
|
+
ForeignKey("admin_users.id", ondelete="CASCADE"),
|
|
178
|
+
nullable=False,
|
|
179
|
+
)
|
|
180
|
+
token_hash = Column(String(64), nullable=False, index=True)
|
|
181
|
+
expires_at = Column(DateTime(timezone=True), nullable=False)
|
|
182
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
183
|
+
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
|
184
|
+
|
|
185
|
+
user = relationship("AdminUser", back_populates="refresh_tokens")
|
|
186
|
+
|
|
187
|
+
__table_args__ = (
|
|
188
|
+
UniqueConstraint(
|
|
189
|
+
"user_id", "token_hash", name="uq_admin_refresh_token"
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def __str__(self) -> str:
|
|
194
|
+
return f"Token {self.token_hash[:8]}..."
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
return f"<AdminRefreshToken user={self.user_id}>"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AdminRefreshTokenAdmin(ModelAdmin):
|
|
201
|
+
exclude = ["user"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class AdminUserTOTP(Base):
|
|
205
|
+
__tablename__ = "admin_user_totp"
|
|
206
|
+
|
|
207
|
+
id = Column(Integer, primary_key=True)
|
|
208
|
+
user_id = Column(
|
|
209
|
+
Integer,
|
|
210
|
+
ForeignKey("admin_users.id", ondelete="CASCADE"),
|
|
211
|
+
unique=True,
|
|
212
|
+
nullable=False,
|
|
213
|
+
)
|
|
214
|
+
secret_key = Column(String(255), nullable=False)
|
|
215
|
+
enabled = Column(Boolean, default=False)
|
|
216
|
+
backup_codes = Column(Text, nullable=True)
|
|
217
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
218
|
+
|
|
219
|
+
user = relationship("AdminUser", back_populates="totp")
|
|
220
|
+
|
|
221
|
+
def __str__(self) -> str:
|
|
222
|
+
return f"TOTP for user {self.user_id}"
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return f"<AdminUserTOTP user={self.user_id}>"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AdminLoginAttempt(Base):
|
|
229
|
+
__tablename__ = "admin_login_attempts"
|
|
230
|
+
|
|
231
|
+
id = Column(Integer, primary_key=True)
|
|
232
|
+
email = Column(String(255), nullable=False, index=True)
|
|
233
|
+
ip_address = Column(String(45), nullable=False)
|
|
234
|
+
user_agent = Column(String(512), nullable=True)
|
|
235
|
+
success = Column(Boolean, default=False)
|
|
236
|
+
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
|
237
|
+
|
|
238
|
+
__table_args__ = (UniqueConstraint("id", name="uq_admin_login_attempt_id"),)
|
|
239
|
+
|
|
240
|
+
def __str__(self) -> str:
|
|
241
|
+
return f"{self.email} - {'success' if self.success else 'failed'}"
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
return (
|
|
245
|
+
f"<AdminLoginAttempt email={self.email!r} success={self.success}>"
|
|
246
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Password strength validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_password_strength(
|
|
9
|
+
password: str,
|
|
10
|
+
*,
|
|
11
|
+
min_length: int = 12,
|
|
12
|
+
require_uppercase: bool = True,
|
|
13
|
+
require_lowercase: bool = True,
|
|
14
|
+
require_digit: bool = True,
|
|
15
|
+
require_special: bool = True,
|
|
16
|
+
) -> list[str]:
|
|
17
|
+
"""Validate password strength. Returns a list of error messages (empty = valid)."""
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
|
|
20
|
+
if len(password) < min_length:
|
|
21
|
+
errors.append(f"Password must be at least {min_length} characters long.")
|
|
22
|
+
|
|
23
|
+
if require_uppercase and not re.search(r"[A-Z]", password):
|
|
24
|
+
errors.append("Password must contain at least one uppercase letter.")
|
|
25
|
+
|
|
26
|
+
if require_lowercase and not re.search(r"[a-z]", password):
|
|
27
|
+
errors.append("Password must contain at least one lowercase letter.")
|
|
28
|
+
|
|
29
|
+
if require_digit and not re.search(r"\d", password):
|
|
30
|
+
errors.append("Password must contain at least one digit.")
|
|
31
|
+
|
|
32
|
+
if require_special and not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?]", password):
|
|
33
|
+
errors.append("Password must contain at least one special character.")
|
|
34
|
+
|
|
35
|
+
return errors
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""RBAC permission checker — per-request, with in-memory caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi_admin_kit.auth.models import AdminPermission, AdminUserPermission
|
|
8
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from fastapi_admin_kit.auth.protocol import AdminUserProtocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PermissionChecker:
|
|
17
|
+
"""Per-request permission checker.
|
|
18
|
+
|
|
19
|
+
Instantiated once per request via ``Depends(get_permission_checker)``.
|
|
20
|
+
Caches permission results in-memory for the lifetime of the request.
|
|
21
|
+
|
|
22
|
+
Merges permissions from:
|
|
23
|
+
1. All assigned roles (M2M via admin_user_roles)
|
|
24
|
+
2. Direct per-user overrides (AdminUserPermission)
|
|
25
|
+
|
|
26
|
+
Role permissions are OR'd together, then direct overrides are OR'd on top.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session: AsyncSession,
|
|
32
|
+
user: AdminUserProtocol,
|
|
33
|
+
*,
|
|
34
|
+
user_snapshot: dict[str, object] | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.session = session
|
|
37
|
+
self.user = user
|
|
38
|
+
snap = user_snapshot or {}
|
|
39
|
+
self._is_superuser: bool = (
|
|
40
|
+
bool(snap["is_superuser"]) if "is_superuser" in snap else bool(user.is_superuser)
|
|
41
|
+
)
|
|
42
|
+
self._role_ids: list[int] = (
|
|
43
|
+
snap["role_ids"] if "role_ids" in snap else getattr(user, "role_ids", [])
|
|
44
|
+
)
|
|
45
|
+
self._user_id: int | str | None = (
|
|
46
|
+
snap.get("id") if snap else getattr(user, "id", None)
|
|
47
|
+
)
|
|
48
|
+
self._role_cache: dict[str, PermissionSet | None] | None = None
|
|
49
|
+
self._direct_cache: dict[str, PermissionSet | None] | None = None
|
|
50
|
+
self._cache: dict[tuple[str, str], bool] = {}
|
|
51
|
+
self._field_cache: dict[tuple[str, str], set[str] | None] = {}
|
|
52
|
+
|
|
53
|
+
async def _load_role_permissions(self) -> dict[str, PermissionSet | None]:
|
|
54
|
+
"""Load and cache all role-based permissions, merged with OR logic."""
|
|
55
|
+
if self._role_cache is not None:
|
|
56
|
+
return self._role_cache
|
|
57
|
+
|
|
58
|
+
self._role_cache = {}
|
|
59
|
+
if not self._role_ids:
|
|
60
|
+
return self._role_cache
|
|
61
|
+
|
|
62
|
+
from sqlalchemy import select
|
|
63
|
+
|
|
64
|
+
result = await self.session.execute(
|
|
65
|
+
select(AdminPermission).where(
|
|
66
|
+
AdminPermission.role_id.in_(self._role_ids)
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
for perm in result.scalars():
|
|
70
|
+
table = perm.table_name
|
|
71
|
+
if table not in self._role_cache:
|
|
72
|
+
self._role_cache[table] = PermissionSet()
|
|
73
|
+
ps = self._role_cache[table]
|
|
74
|
+
if perm.can_view:
|
|
75
|
+
ps.can_view = True
|
|
76
|
+
if perm.can_create:
|
|
77
|
+
ps.can_create = True
|
|
78
|
+
if perm.can_edit:
|
|
79
|
+
ps.can_edit = True
|
|
80
|
+
if perm.can_delete:
|
|
81
|
+
ps.can_delete = True
|
|
82
|
+
|
|
83
|
+
return self._role_cache
|
|
84
|
+
|
|
85
|
+
async def _load_direct_permissions(self) -> dict[str, PermissionSet | None]:
|
|
86
|
+
"""Load and cache direct per-user permission overrides."""
|
|
87
|
+
if self._direct_cache is not None:
|
|
88
|
+
return self._direct_cache
|
|
89
|
+
|
|
90
|
+
self._direct_cache = {}
|
|
91
|
+
if self._user_id is None:
|
|
92
|
+
return self._direct_cache
|
|
93
|
+
|
|
94
|
+
from sqlalchemy import select
|
|
95
|
+
|
|
96
|
+
result = await self.session.execute(
|
|
97
|
+
select(AdminUserPermission).where(
|
|
98
|
+
AdminUserPermission.user_id == self._user_id
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
for perm in result.scalars():
|
|
102
|
+
table = perm.table_name
|
|
103
|
+
if table not in self._direct_cache:
|
|
104
|
+
self._direct_cache[table] = PermissionSet()
|
|
105
|
+
ps = self._direct_cache[table]
|
|
106
|
+
if perm.can_view:
|
|
107
|
+
ps.can_view = True
|
|
108
|
+
if perm.can_create:
|
|
109
|
+
ps.can_create = True
|
|
110
|
+
if perm.can_edit:
|
|
111
|
+
ps.can_edit = True
|
|
112
|
+
if perm.can_delete:
|
|
113
|
+
ps.can_delete = True
|
|
114
|
+
|
|
115
|
+
return self._direct_cache
|
|
116
|
+
|
|
117
|
+
async def _get_merged_permission(self, table_name: str, action: str) -> bool:
|
|
118
|
+
"""Get merged permission for a table+action across roles and direct overrides."""
|
|
119
|
+
role_perms = await self._load_role_permissions()
|
|
120
|
+
direct_perms = await self._load_direct_permissions()
|
|
121
|
+
|
|
122
|
+
attr = f"can_{action}"
|
|
123
|
+
|
|
124
|
+
role_ps = role_perms.get(table_name)
|
|
125
|
+
direct_ps = direct_perms.get(table_name)
|
|
126
|
+
|
|
127
|
+
role_val = getattr(role_ps, attr, False) if role_ps else False
|
|
128
|
+
direct_val = getattr(direct_ps, attr, False) if direct_ps else False
|
|
129
|
+
|
|
130
|
+
return role_val or direct_val
|
|
131
|
+
|
|
132
|
+
async def has_permission(self, table_name: str, action: str) -> bool:
|
|
133
|
+
"""Return True if the current user may perform *action* on *table_name*.
|
|
134
|
+
|
|
135
|
+
Actions: ``"view"`` | ``"create"`` | ``"edit"`` | ``"delete"``
|
|
136
|
+
|
|
137
|
+
Superusers always return True. Results are cached per-request.
|
|
138
|
+
"""
|
|
139
|
+
if self._is_superuser:
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
cache_key = (table_name, action)
|
|
143
|
+
if cache_key in self._cache:
|
|
144
|
+
return self._cache[cache_key]
|
|
145
|
+
|
|
146
|
+
result_bool = await self._get_merged_permission(table_name, action)
|
|
147
|
+
self._cache[cache_key] = result_bool
|
|
148
|
+
return result_bool
|
|
149
|
+
|
|
150
|
+
async def get_allowed_fields(self, table_name: str, mode: str) -> set[str] | None:
|
|
151
|
+
"""Return the set of field names the user may access, or ``None``.
|
|
152
|
+
|
|
153
|
+
mode: ``"view"`` | ``"edit"``
|
|
154
|
+
|
|
155
|
+
Semantics:
|
|
156
|
+
- ``None`` → no field-level restrictions exist → all fields allowed.
|
|
157
|
+
- Empty ``set()`` → restriction rows exist but none grant access → no fields.
|
|
158
|
+
- Non-empty ``set()`` → only those field names are permitted.
|
|
159
|
+
"""
|
|
160
|
+
if self._is_superuser:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
cache_key = (table_name, mode)
|
|
164
|
+
if cache_key in self._field_cache:
|
|
165
|
+
return self._field_cache[cache_key]
|
|
166
|
+
|
|
167
|
+
# No role and no direct permissions → all fields restricted
|
|
168
|
+
if not self._role_ids and self._user_id is None:
|
|
169
|
+
self._field_cache[cache_key] = set()
|
|
170
|
+
return set()
|
|
171
|
+
|
|
172
|
+
# No field-level restrictions in this system anymore
|
|
173
|
+
self._field_cache[cache_key] = None
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def permission_set(self, table_name: str) -> PermissionSet:
|
|
177
|
+
"""Return a :class:`PermissionSet` for convenient template / UI use.
|
|
178
|
+
|
|
179
|
+
Note: This is a sync convenience wrapper. For async contexts,
|
|
180
|
+
use the individual async methods directly.
|
|
181
|
+
"""
|
|
182
|
+
if self._is_superuser:
|
|
183
|
+
return PermissionSet(
|
|
184
|
+
can_view=True,
|
|
185
|
+
can_create=True,
|
|
186
|
+
can_edit=True,
|
|
187
|
+
can_delete=True,
|
|
188
|
+
)
|
|
189
|
+
return PermissionSet(
|
|
190
|
+
can_view=self._cache.get((table_name, "view"), False),
|
|
191
|
+
can_create=self._cache.get((table_name, "create"), False),
|
|
192
|
+
can_edit=self._cache.get((table_name, "edit"), False),
|
|
193
|
+
can_delete=self._cache.get((table_name, "delete"), False),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def load_permissions(self, table_name: str) -> PermissionSet:
|
|
197
|
+
"""Async method to load and cache all permissions for a table.
|
|
198
|
+
|
|
199
|
+
Call this before using ``permission_set()`` to ensure the cache is populated.
|
|
200
|
+
"""
|
|
201
|
+
await self.has_permission(table_name, "view")
|
|
202
|
+
await self.has_permission(table_name, "create")
|
|
203
|
+
await self.has_permission(table_name, "edit")
|
|
204
|
+
await self.has_permission(table_name, "delete")
|
|
205
|
+
return self.permission_set(table_name)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Protocol for user models that can be used as the admin auth model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class AdminUserProtocol(Protocol):
|
|
10
|
+
"""
|
|
11
|
+
Any user model passed as auth_model= must satisfy this interface.
|
|
12
|
+
These are the only attributes the admin framework reads from the user object.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
id: int | str # primary key (any type)
|
|
16
|
+
email: str # used for audit log denormalization
|
|
17
|
+
is_active: bool # inactive users are refused login
|
|
18
|
+
is_superuser: bool # bypasses all permission checks if True
|
|
19
|
+
|
|
20
|
+
# Many-to-many roles — the admin reads this to look up permissions.
|
|
21
|
+
# Must be an iterable of role objects, each with an `id` attribute.
|
|
22
|
+
roles: list # list of AdminRole objects (or compatible)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""In-memory sliding window rate limiter — no external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimiter:
|
|
13
|
+
"""Sliding-window rate limiter.
|
|
14
|
+
|
|
15
|
+
Tracks request timestamps per key and rejects when the count exceeds
|
|
16
|
+
``max_attempts`` within ``window_seconds``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
max_attempts: int = 5,
|
|
22
|
+
window_seconds: int = 900,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.max_attempts = max_attempts
|
|
25
|
+
self.window_seconds = window_seconds
|
|
26
|
+
self._attempts: dict[str, list[float]] = defaultdict(list)
|
|
27
|
+
self._lock = Lock()
|
|
28
|
+
|
|
29
|
+
def _cleanup(self, key: str, now: float) -> None:
|
|
30
|
+
"""Remove expired entries for *key*."""
|
|
31
|
+
cutoff = now - self.window_seconds
|
|
32
|
+
attempts = self._attempts[key]
|
|
33
|
+
self._attempts[key] = [t for t in attempts if t > cutoff]
|
|
34
|
+
|
|
35
|
+
def is_rate_limited(self, key: str) -> bool:
|
|
36
|
+
"""Return True if *key* has exceeded the allowed attempts."""
|
|
37
|
+
now = time.monotonic()
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._cleanup(key, now)
|
|
40
|
+
if len(self._attempts[key]) >= self.max_attempts:
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def record_attempt(self, key: str) -> None:
|
|
45
|
+
"""Record a request attempt for *key*."""
|
|
46
|
+
now = time.monotonic()
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._cleanup(key, now)
|
|
49
|
+
self._attempts[key].append(now)
|
|
50
|
+
|
|
51
|
+
def reset(self, key: str) -> None:
|
|
52
|
+
"""Clear all attempts for *key* (e.g. on successful login)."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._attempts.pop(key, None)
|
|
55
|
+
|
|
56
|
+
def remaining_seconds(self, key: str) -> int:
|
|
57
|
+
"""Seconds until the oldest attempt in the window expires."""
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._cleanup(key, now)
|
|
61
|
+
attempts = self._attempts[key]
|
|
62
|
+
if not attempts:
|
|
63
|
+
return 0
|
|
64
|
+
return max(0, int(self.window_seconds - (now - attempts[0])) + 1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _client_ip(request: Request) -> str:
|
|
68
|
+
"""Extract client IP, respecting X-Forwarded-For."""
|
|
69
|
+
forwarded = request.headers.get("x-forwarded-for")
|
|
70
|
+
if forwarded:
|
|
71
|
+
return forwarded.split(",")[0].strip()
|
|
72
|
+
if request.client:
|
|
73
|
+
return request.client.host
|
|
74
|
+
return "unknown"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_rate_limit(
|
|
78
|
+
limiter: RateLimiter,
|
|
79
|
+
key: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Raise 429 if *key* is rate-limited."""
|
|
82
|
+
if limiter.is_rate_limited(key):
|
|
83
|
+
retry = limiter.remaining_seconds(key)
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status_code=429,
|
|
86
|
+
detail="Too many attempts. Please try again later.",
|
|
87
|
+
headers={"Retry-After": str(retry)},
|
|
88
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Session backend — ABC + signed-cookie implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from itsdangerous import (
|
|
10
|
+
BadSignature,
|
|
11
|
+
SignatureExpired,
|
|
12
|
+
URLSafeTimedSerializer,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionBackend(ABC):
|
|
17
|
+
"""Abstract session backend — encode/decode session payloads."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def encode(self, payload: dict[str, Any]) -> str:
|
|
21
|
+
"""Sign *payload* and return a token string suitable for a cookie."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def decode(self, token: str | None) -> dict[str, Any] | None:
|
|
26
|
+
"""Verify *token* and return the payload dict, or ``None`` if invalid/expired."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SignedCookieSessionBackend(SessionBackend):
|
|
31
|
+
"""Session backend that signs a JSON payload using ``itsdangerous``.
|
|
32
|
+
|
|
33
|
+
The cookie value is a ``URLSafeTimedSerializer``-serialized string — the JSON
|
|
34
|
+
payload is base64-encoded so its output is safe for ``Cookie`` headers (unlike
|
|
35
|
+
raw JSON, which contains ``{``, ``}``, ``\"`` and other characters that break
|
|
36
|
+
the ``http.cookies.SimpleCookie`` parser).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
COOKIE_NAME = "admin_session"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
secret_key: str,
|
|
44
|
+
session_ttl: int = 28800,
|
|
45
|
+
cookie_name: str = COOKIE_NAME,
|
|
46
|
+
secure: bool = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._secret_key = secret_key
|
|
49
|
+
self._serializer = URLSafeTimedSerializer(
|
|
50
|
+
secret_key, salt="admin-session"
|
|
51
|
+
)
|
|
52
|
+
self._session_ttl = session_ttl
|
|
53
|
+
self.cookie_name = cookie_name
|
|
54
|
+
self.secure = secure
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def secret_key(self) -> str:
|
|
58
|
+
"""The signing key used by this backend.
|
|
59
|
+
|
|
60
|
+
Public accessor so CSRF / JWT signing can share the same key without
|
|
61
|
+
reaching into the (private) itsdangerous signer. Swapping the session
|
|
62
|
+
backend no longer silently breaks CSRF.
|
|
63
|
+
"""
|
|
64
|
+
return self._secret_key
|
|
65
|
+
|
|
66
|
+
def encode(self, payload: dict[str, Any]) -> str:
|
|
67
|
+
"""Sign *payload* and return the signed token."""
|
|
68
|
+
if "iat" not in payload:
|
|
69
|
+
payload["iat"] = time.time()
|
|
70
|
+
return self._serializer.dumps(payload)
|
|
71
|
+
|
|
72
|
+
def decode(self, token: str | None) -> dict[str, Any] | None:
|
|
73
|
+
"""Verify *token* and return the decoded payload, or ``None``."""
|
|
74
|
+
if not token:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return self._serializer.loads(token, max_age=self._session_ttl)
|
|
78
|
+
except (BadSignature, SignatureExpired, ValueError):
|
|
79
|
+
return None
|