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.
Files changed (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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,10 @@
1
+ """Auth router — mounts login/logout views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from fastapi_admin_kit.auth import views
8
+
9
+ router = APIRouter()
10
+ router.include_router(views.router)
@@ -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