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,240 @@
1
+ """CSRF protection — double-submit cookie pattern with HMAC signing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import os
8
+ import time
9
+
10
+ from fastapi import HTTPException, Request, Response
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.responses import JSONResponse, RedirectResponse
13
+
14
+ CSRF_COOKIE_NAME = "admin_csrf_token"
15
+ CSRF_FORM_FIELD = "csrf_token"
16
+ CSRF_HEADER = "X-CSRF-Token"
17
+ CSRF_TOKEN_MAX_AGE = 3600 # 1 hour
18
+
19
+
20
+ def _get_secret_key(request: Request) -> str | None:
21
+ """Resolve the CSRF signing key from app state.
22
+
23
+ Prefers an explicit ``app.state.admin_secret_key`` (the unified signing-key
24
+ source shared with sessions and JWT); falls back to the session backend's
25
+ public ``secret_key``. Returns ``None`` only when neither is configured.
26
+ """
27
+ explicit = getattr(request.app.state, "admin_secret_key", None)
28
+ if explicit:
29
+ return explicit
30
+ session_backend = getattr(request.app.state, "admin_session_backend", None)
31
+ if session_backend is None:
32
+ return None
33
+ return getattr(session_backend, "secret_key", None)
34
+
35
+
36
+ def generate_csrf_token(secret_key: str) -> str:
37
+ """Generate a signed CSRF token: ``timestamp.random_hex.signature``."""
38
+ random_bytes = os.urandom(16)
39
+ timestamp = str(int(time.time()))
40
+ payload = f"{timestamp}.{random_bytes.hex()}"
41
+ signature = hmac.new(
42
+ secret_key.encode(), payload.encode(), hashlib.sha256
43
+ ).hexdigest()[:32]
44
+ return f"{payload}.{signature}"
45
+
46
+
47
+ def _verify_csrf_token(secret_key: str, token: str) -> bool:
48
+ """Verify the HMAC signature and check token freshness."""
49
+ parts = token.rsplit(".", 2)
50
+ if len(parts) != 3:
51
+ return False
52
+ timestamp_str, random_hex, provided_sig = parts
53
+ payload = f"{timestamp_str}.{random_hex}"
54
+ expected_sig = hmac.new(
55
+ secret_key.encode(), payload.encode(), hashlib.sha256
56
+ ).hexdigest()[:32]
57
+ if not hmac.compare_digest(provided_sig, expected_sig):
58
+ return False
59
+ try:
60
+ token_time = int(timestamp_str)
61
+ except ValueError:
62
+ return False
63
+ if time.time() - token_time > CSRF_TOKEN_MAX_AGE:
64
+ return False
65
+ return True
66
+
67
+
68
+ def set_csrf_cookie(response: Response, secret_key: str) -> str:
69
+ """Generate and set the CSRF cookie on a response. Returns the token."""
70
+ token = generate_csrf_token(secret_key)
71
+ response.set_cookie(
72
+ key=CSRF_COOKIE_NAME,
73
+ value=token,
74
+ max_age=CSRF_TOKEN_MAX_AGE,
75
+ path="/",
76
+ secure=False,
77
+ httponly=False,
78
+ samesite="strict",
79
+ )
80
+ return token
81
+
82
+
83
+ def validate_csrf_token(
84
+ request: Request, csrf_token: str | None = None
85
+ ) -> None:
86
+ """Validate CSRF token from form body or header against the cookie.
87
+
88
+ Raises ``HTTPException(403)`` if the token is missing or invalid.
89
+ Fails *closed*: if no signing key is configured (the admin was not
90
+ set up properly), raises ``HTTPException(500)`` rather than silently
91
+ disabling CSRF protection — a missing key must never mean "protection off".
92
+ """
93
+ secret_key = _get_secret_key(request)
94
+ if secret_key is None:
95
+ raise HTTPException(
96
+ status_code=500,
97
+ detail="CSRF secret not configured — admin session backend not initialised.",
98
+ )
99
+
100
+ # Read token from argument, header, or request state (set by middleware)
101
+ form_token = csrf_token
102
+ if form_token is None:
103
+ form_token = request.headers.get(CSRF_HEADER)
104
+ if form_token is None:
105
+ form_token = getattr(request.state, "_csrf_token", None)
106
+ if not form_token:
107
+ raise HTTPException(
108
+ status_code=403,
109
+ detail="CSRF token missing. Please refresh the page and try again.",
110
+ )
111
+
112
+ # Read the cookie token
113
+ cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
114
+ if not cookie_token:
115
+ raise HTTPException(
116
+ status_code=403,
117
+ detail="CSRF session cookie missing. Please refresh the page and try again.",
118
+ )
119
+
120
+ # Verify both tokens have valid HMAC signatures
121
+ if not _verify_csrf_token(secret_key, form_token):
122
+ raise HTTPException(
123
+ status_code=403,
124
+ detail="Invalid CSRF token. Please refresh the page and try again.",
125
+ )
126
+ if not _verify_csrf_token(secret_key, cookie_token):
127
+ raise HTTPException(
128
+ status_code=403,
129
+ detail="Invalid CSRF cookie. Please refresh the page and try again.",
130
+ )
131
+ # Compare the inner payloads (timestamp + random)
132
+ form_parts = form_token.rsplit(".", 2)
133
+ cookie_parts = cookie_token.rsplit(".", 2)
134
+ if form_parts[0] != cookie_parts[0] or form_parts[1] != cookie_parts[1]:
135
+ raise HTTPException(
136
+ status_code=403,
137
+ detail="CSRF token mismatch. Please refresh the page and try again.",
138
+ )
139
+
140
+
141
+ async def require_csrf_token(request: Request) -> None:
142
+ """FastAPI dependency that enforces CSRF validation on state-changing requests.
143
+
144
+ Reads the CSRF token from ``X-CSRF-Token`` header or from the form body
145
+ (extracted by :class:`CSRFMiddleware` and stored in ``request.state``).
146
+ """
147
+ validate_csrf_token(request)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # CSRF Middleware — extracts form CSRF token + generates per-request token
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class CSRFMiddleware(BaseHTTPMiddleware):
156
+ """Middleware that:
157
+ 1. Generates a CSRF token per request and stores it in ``request.state.csrf_token``
158
+ for templates to render as a hidden field or meta tag.
159
+ 2. Sets the CSRF cookie on GET requests if not already present.
160
+ 3. Extracts the CSRF token from form bodies on POST/PUT/PATCH/DELETE requests
161
+ and stores it in ``request.state._csrf_token`` for the dependency to validate.
162
+ """
163
+
164
+ async def dispatch(self, request: Request, call_next) -> Response:
165
+ # Generate per-request CSRF token for templates
166
+ secret_key = _get_secret_key(request)
167
+ if secret_key is not None:
168
+ existing_cookie = request.cookies.get(CSRF_COOKIE_NAME)
169
+ if existing_cookie and _verify_csrf_token(secret_key, existing_cookie):
170
+ csrf_token = existing_cookie
171
+ else:
172
+ csrf_token = generate_csrf_token(secret_key)
173
+ request.state.csrf_token = csrf_token
174
+ else:
175
+ secret_key = ""
176
+ csrf_token = ""
177
+ request.state.csrf_token = ""
178
+
179
+ # On state-changing requests, extract CSRF token from form body
180
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
181
+ content_type = request.headers.get("content-type", "")
182
+ is_form = (
183
+ "application/x-www-form-urlencoded" in content_type
184
+ or "multipart/form-data" in content_type
185
+ )
186
+ if is_form:
187
+ try:
188
+ body = await request.body()
189
+ from urllib.parse import parse_qs
190
+
191
+ form_data = parse_qs(body.decode("utf-8", errors="replace"))
192
+ csrf_values = form_data.get(CSRF_FORM_FIELD)
193
+ if csrf_values:
194
+ request.state._csrf_token = csrf_values[0]
195
+ except Exception:
196
+ pass # Let the dependency handle missing token
197
+
198
+ response = await call_next(request)
199
+
200
+ # Set CSRF cookie on all responses so it's always present
201
+ if secret_key:
202
+ response.set_cookie(
203
+ key=CSRF_COOKIE_NAME,
204
+ value=csrf_token,
205
+ max_age=CSRF_TOKEN_MAX_AGE,
206
+ path="/",
207
+ secure=False,
208
+ httponly=False,
209
+ samesite="strict",
210
+ )
211
+
212
+ return response
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Auth Redirect Exception Handler — redirects HTML requests to login on 401
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ async def auth_redirect_handler(request: Request, exc: HTTPException) -> Response:
221
+ """Exception handler that catches 401 HTTPExceptions and redirects
222
+ HTML requests to the login page instead of returning a JSON error.
223
+ """
224
+ if exc.status_code == 401:
225
+ accept = request.headers.get("accept", "")
226
+ if "text/html" in accept:
227
+ login_url = "/admin/login"
228
+ current_path = request.url.path
229
+ if current_path != "/admin/login":
230
+ if request.url.query:
231
+ login_url += f"?next={current_path}%3F{request.url.query}"
232
+ else:
233
+ login_url += f"?next={current_path}"
234
+ return RedirectResponse(url=login_url, status_code=302)
235
+ from starlette.responses import JSONResponse
236
+ return JSONResponse(
237
+ status_code=401,
238
+ content={"detail": exc.detail or "Not authenticated"},
239
+ )
240
+ raise exc
@@ -0,0 +1,150 @@
1
+ """FastAPI dependencies — session, current user, permission checker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import Depends, HTTPException, Request
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from fastapi_admin_kit.auth.csrf import require_csrf_token # noqa: F401
11
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
12
+ from fastapi_admin_kit.auth.session import SignedCookieSessionBackend
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Session helpers
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def _get_session_backend(request: Request) -> SignedCookieSessionBackend:
20
+ """Resolve the session backend from app state (set during admin setup)."""
21
+ backend: SignedCookieSessionBackend | None = getattr(
22
+ request.app.state, "admin_session_backend", None
23
+ )
24
+ if backend is None:
25
+ raise HTTPException(
26
+ status_code=500,
27
+ detail="Admin session backend not initialised.",
28
+ )
29
+ return backend
30
+
31
+
32
+ async def _get_db_session(request: Request) -> AsyncSession:
33
+ """Yield the async SQLAlchemy session from app.state."""
34
+ from fastapi_admin_kit.db import get_db_session
35
+
36
+ session: AsyncSession = get_db_session(request)
37
+ yield session
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Per-request dependencies
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def get_session(request: Request) -> dict[str, Any] | None:
46
+ """Read and decode the admin session cookie from the current request."""
47
+ session_backend = _get_session_backend(request)
48
+ token = request.cookies.get(session_backend.cookie_name)
49
+ return session_backend.decode(token)
50
+
51
+
52
+ async def get_current_admin_user(
53
+ request: Request,
54
+ session_payload: dict[str, Any] | None = Depends(get_session),
55
+ ) -> AdminUserProtocol:
56
+ """Resolve the logged-in admin user from the session cookie.
57
+
58
+ Raises ``401`` if the session is missing, invalid, the user is
59
+ no longer active, or the password was changed after the session was issued.
60
+ """
61
+ if session_payload is None:
62
+ raise HTTPException(
63
+ status_code=401,
64
+ detail="Not authenticated. Please log in.",
65
+ )
66
+
67
+ user_id = session_payload.get("user_id")
68
+ if user_id is None:
69
+ raise HTTPException(status_code=401, detail="Invalid session payload.")
70
+
71
+ # request.state.admin_user is populated as a side effect.
72
+ from fastapi_admin_kit.auth.identity import resolve_user
73
+
74
+ user = await resolve_user(request, user_id)
75
+ if user is None:
76
+ raise HTTPException(
77
+ status_code=401, detail="User not found or inactive."
78
+ )
79
+
80
+ # Check session invalidation: reject if password changed after session iat
81
+ password_changed_at = getattr(user, "password_changed_at", None)
82
+ session_iat = session_payload.get("iat")
83
+ if password_changed_at is not None and session_iat is not None:
84
+ from datetime import UTC, datetime
85
+
86
+ if isinstance(password_changed_at, datetime):
87
+ if password_changed_at.tzinfo is None:
88
+ password_changed_at = password_changed_at.replace(tzinfo=UTC)
89
+ if isinstance(session_iat, (int, float)):
90
+ session_time = datetime.fromtimestamp(session_iat, tz=UTC)
91
+ if password_changed_at > session_time:
92
+ raise HTTPException(
93
+ status_code=401,
94
+ detail="Session invalidated. Password was changed. Please log in again.",
95
+ )
96
+
97
+ return user
98
+
99
+
100
+ async def get_permission_checker(
101
+ request: Request,
102
+ user: AdminUserProtocol = Depends(get_current_admin_user),
103
+ session: AsyncSession = Depends(_get_db_session),
104
+ ) -> Any:
105
+ """Build a ``PermissionChecker`` for the current user.
106
+
107
+ The concrete ``PermissionChecker`` class is imported here to avoid
108
+ circular imports.
109
+ """
110
+ from fastapi_admin_kit.auth.permissions import PermissionChecker
111
+
112
+ snapshot = getattr(request.state, "admin_user_snapshot", None)
113
+ return PermissionChecker(session=session, user=user, user_snapshot=snapshot)
114
+
115
+
116
+ def require_permission(table_name: str, action: str): # type: ignore[no-untyped-def]
117
+ """Return a FastAPI dependency that enforces a permission check.
118
+
119
+ Usage::
120
+
121
+ @router.get("/")
122
+ async def list_view(_=Depends(require_permission("products", "view"))):
123
+ ...
124
+ """
125
+
126
+ async def _check(
127
+ checker: Any = Depends(get_permission_checker),
128
+ ) -> None:
129
+ if not await checker.has_permission(table_name, action):
130
+ raise HTTPException(
131
+ status_code=403,
132
+ detail=f"You do not have permission to {action} {table_name}.",
133
+ )
134
+
135
+ return _check
136
+
137
+
138
+ async def require_superuser(
139
+ user: AdminUserProtocol = Depends(get_current_admin_user),
140
+ ) -> AdminUserProtocol:
141
+ """FastAPI dependency that enforces superuser access.
142
+
143
+ The single source of the "must be superuser" rule, shared by the roles
144
+ and audit views (previously copy-pasted in each).
145
+ """
146
+ if not getattr(user, "is_superuser", False):
147
+ raise HTTPException(
148
+ status_code=403, detail="Superuser access required."
149
+ )
150
+ return user
@@ -0,0 +1,181 @@
1
+ """Request-authentication — the single place that resolves "the current user".
2
+
3
+ The admin framework historically had *three* parallel implementations of
4
+ "decode a credential, then SELECT the active user it belongs to":
5
+
6
+ 1. :func:`fastapi_admin_kit.auth.dependencies.get_current_admin_user` (cookie)
7
+ 2. :func:`fastapi_admin_kit.views.factory._resolve_permission_checker` (cookie,
8
+ with a process-global cache on ``app.state`` that leaked identity across
9
+ requests)
10
+ 3. :func:`fastapi_admin_kit.api.crud._get_current_user` (bearer JWT, hardcoded to
11
+ the built-in ``AdminUser`` model and so bypassing the ``AuthBackend`` seam)
12
+
13
+ This module is the deep seam they all delegate to. The two credential
14
+ transports (signed cookie, bearer JWT) become thin internal adapters; the
15
+ "load the active user and remember it for this request" rule lives in
16
+ :func:`resolve_user` exactly once.
17
+
18
+ The only request-scoped side effect is writing ``request.state.admin_user``
19
+ once per request, which the audit middleware, sidebar, and templates read.
20
+ Nothing is ever cached on ``app.state``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from fastapi import Request
28
+
29
+ if TYPE_CHECKING:
30
+ from sqlalchemy.ext.asyncio import AsyncSession
31
+
32
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
33
+
34
+
35
+ def _get_session_backend(request: Request) -> Any:
36
+ """Return the configured session backend, or ``None`` if not initialised."""
37
+ return getattr(request.app.state, "admin_session_backend", None)
38
+
39
+
40
+ def _get_auth_backend(request: Request) -> Any:
41
+ """Return the configured auth backend, or ``None`` if not initialised."""
42
+ return getattr(request.app.state, "admin_auth_backend", None)
43
+
44
+
45
+ def _get_db_session(request: Request) -> AsyncSession | None:
46
+ """Return the per-request DB session, falling back to app.state."""
47
+ from fastapi_admin_kit.db import get_db_session
48
+
49
+ try:
50
+ return get_db_session(request)
51
+ except AttributeError:
52
+ return None
53
+
54
+
55
+ async def resolve_user(
56
+ request: Request, user_id: int | str | None
57
+ ) -> AdminUserProtocol | None:
58
+ """Resolve *user_id* to an active user and cache it on the request.
59
+
60
+ Idempotent for a given request: if ``request.state.admin_user`` is already
61
+ populated, it is returned without another DB hit. Returns ``None`` when the
62
+ user is absent or no longer active — callers decide whether that is an
63
+ error (views raise 401) or a soft miss (list/form views render unauthed).
64
+
65
+ Always honours the configured ``AuthBackend.get_user`` seam, so BYO user
66
+ models are supported on every transport (cookie *and* JWT), not just the
67
+ built-in one.
68
+ """
69
+ cached = getattr(request.state, "admin_user", None)
70
+ if cached is not None:
71
+ if not hasattr(request.state, "admin_user_snapshot"):
72
+ try:
73
+ role_ids = list(getattr(cached, "role_ids", []))
74
+ except Exception:
75
+ role_ids = []
76
+ request.state.admin_user_snapshot = {
77
+ "id": getattr(cached, "id", None),
78
+ "email": getattr(cached, "email", None),
79
+ "is_superuser": bool(getattr(cached, "is_superuser", False)),
80
+ "role_ids": role_ids,
81
+ }
82
+ return cached
83
+
84
+ if user_id is None:
85
+ return None
86
+
87
+ auth_backend = _get_auth_backend(request)
88
+ session = _get_db_session(request)
89
+ if auth_backend is None or session is None:
90
+ return None
91
+
92
+ user = await auth_backend.get_user(user_id, session)
93
+ if user is None or not getattr(user, "is_active", False):
94
+ return None
95
+
96
+ # Per-request memoisation. This is request-scoped state, never app-scoped:
97
+ # two concurrent requests on the same Admin instance resolve independently.
98
+ request.state.admin_user = user
99
+
100
+ # Snapshot scalar fields into a plain dict so sync code can read them
101
+ # without touching the ORM object (which may be expired after a rollback).
102
+ # Use getattr with [] default — role_ids may not be loaded yet for BYO models.
103
+ try:
104
+ role_ids = list(getattr(user, "role_ids", []))
105
+ except Exception:
106
+ role_ids = []
107
+ request.state.admin_user_snapshot = {
108
+ "id": getattr(user, "id", None),
109
+ "email": getattr(user, "email", None),
110
+ "is_superuser": bool(getattr(user, "is_superuser", False)),
111
+ "role_ids": role_ids,
112
+ }
113
+
114
+ # Inject user identity into the audit context so audit listeners can
115
+ # record who performed the action. The middleware already set IP and
116
+ # user-agent; this merges the user fields in.
117
+ from fastapi_admin_kit.audit.context import set_audit_context
118
+
119
+ set_audit_context({
120
+ "user_id": request.state.admin_user_snapshot["id"],
121
+ "user_email": request.state.admin_user_snapshot["email"],
122
+ })
123
+
124
+ return user
125
+
126
+
127
+ def _decode_cookie_payload(request: Request) -> dict[str, Any] | None:
128
+ """Read and decode the signed admin session cookie. Returns ``None`` if absent/invalid."""
129
+ backend = _get_session_backend(request)
130
+ if backend is None:
131
+ return None
132
+ token = request.cookies.get(
133
+ getattr(backend, "cookie_name", "admin_session")
134
+ )
135
+ return backend.decode(token) if token else None
136
+
137
+
138
+ async def get_current_user_from_cookie(
139
+ request: Request,
140
+ ) -> AdminUserProtocol | None:
141
+ """Resolve the current user from the signed ``admin_session`` cookie.
142
+
143
+ Returns ``None`` when there is no valid session. Does not raise — callers
144
+ (dependencies, view factories) decide whether a missing user is a 401.
145
+ """
146
+ payload = _decode_cookie_payload(request)
147
+ if payload is None:
148
+ return None
149
+ return await resolve_user(request, payload.get("user_id"))
150
+
151
+
152
+ async def get_current_user_from_bearer(
153
+ request: Request,
154
+ ) -> AdminUserProtocol | None:
155
+ """Resolve the current user from an ``Authorization: Bearer <jwt>`` header.
156
+
157
+ Returns ``None`` when the header is absent or the token is invalid/expired.
158
+ The DB lookup is delegated to :func:`resolve_user`, so the JWT API now
159
+ honours the ``AuthBackend.get_user`` seam just like the cookie path.
160
+ """
161
+ # Imported lazily to avoid a circular import at module load time.
162
+ from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token
163
+
164
+ auth_header = request.headers.get("Authorization", "")
165
+ if not auth_header.startswith("Bearer "):
166
+ return None
167
+
168
+ token = auth_header[len("Bearer ") :]
169
+ secret_key = _get_secret_key(request)
170
+ payload = decode_access_token(token, secret_key)
171
+ if payload is None:
172
+ return None
173
+
174
+ sub = payload.get("sub")
175
+ if sub is None:
176
+ return None
177
+ try:
178
+ user_id: int | str = int(sub) # type: ignore[assignment]
179
+ except (TypeError, ValueError):
180
+ return None
181
+ return await resolve_user(request, user_id)