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,44 @@
1
+ """Admin JSON API — provides REST endpoints for external frontend apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from fastapi_admin_kit.api.auth import router as auth_router
10
+ from fastapi_admin_kit.api.crud import build_api_router
11
+ from fastapi_admin_kit.api.roles import router as roles_router
12
+
13
+
14
+ class AdminAPIRouter:
15
+ """Builds and mounts the admin JSON API router.
16
+
17
+ Shares the same RBAC system as the HTML admin, providing
18
+ token-based authentication for external frontend apps.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ registry: Any = None,
24
+ prefix: str = "/api",
25
+ ):
26
+ self.registry = registry
27
+ self.prefix = prefix
28
+
29
+ def build_router(self) -> APIRouter:
30
+ """Build the complete API router with auth and CRUD endpoints."""
31
+ router = APIRouter(prefix=self.prefix)
32
+
33
+ # Auth routes (token obtain, refresh, logout, me)
34
+ router.include_router(auth_router)
35
+
36
+ # Role management routes (superuser only)
37
+ router.include_router(roles_router)
38
+
39
+ # CRUD routes for all registered models
40
+ if self.registry is not None:
41
+ crud_router = build_api_router(self.registry)
42
+ router.include_router(crud_router)
43
+
44
+ return router
@@ -0,0 +1,342 @@
1
+ """Token-based authentication for the Admin JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import uuid
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import Any
9
+
10
+ import jwt
11
+ from fastapi import APIRouter, HTTPException, Request
12
+
13
+ from fastapi_admin_kit.api.schemas import (
14
+ RefreshRequest,
15
+ RefreshResponse,
16
+ TokenRequest,
17
+ TokenResponse,
18
+ )
19
+ from fastapi_admin_kit.auth.ratelimit import RateLimiter, check_rate_limit
20
+ from fastapi_admin_kit.db import get_db_session
21
+
22
+ router = APIRouter(prefix="/auth", tags=["api-auth"])
23
+
24
+ _api_rate_limiter = RateLimiter(max_attempts=10, window_seconds=900)
25
+
26
+
27
+ def _get_secret_key(request: Request) -> str:
28
+ """Get the JWT signing key from the unified signing-key source.
29
+
30
+ Reads ``app.state.admin_secret_key`` (the same key the signed-cookie
31
+ sessions and CSRF use). Fails closed with 500 if unset or too short —
32
+ JWT signing must never silently fall back to a known constant.
33
+ """
34
+ secret_key = getattr(request.app.state, "admin_secret_key", "")
35
+ if not secret_key:
36
+ raise HTTPException(
37
+ status_code=500,
38
+ detail="JWT signing key not configured — admin secret_key not set.",
39
+ )
40
+ if len(secret_key) < 32:
41
+ raise HTTPException(
42
+ status_code=500,
43
+ detail="JWT signing key too short — must be at least 32 characters.",
44
+ )
45
+ return secret_key
46
+
47
+
48
+ def _get_token_ttl(request: Request) -> int:
49
+ """Get access token TTL in seconds from admin config."""
50
+ config = getattr(request.app.state, "admin_config", {})
51
+ return config.get("session_ttl", 1800)
52
+
53
+
54
+ def _get_refresh_ttl() -> int:
55
+ """Refresh token TTL: 7 days."""
56
+ return 7 * 24 * 3600
57
+
58
+
59
+ async def _build_user_permissions(
60
+ user: Any, db_session: Any
61
+ ) -> dict[str, list[str]]:
62
+ """Build permissions dict from user's roles and direct overrides."""
63
+ permissions: dict[str, list[str]] = {}
64
+ if getattr(user, "is_superuser", False):
65
+ return permissions
66
+
67
+ from sqlalchemy import select
68
+
69
+ from fastapi_admin_kit.auth.models import AdminPermission, AdminUserPermission
70
+
71
+ # Collect permissions from all assigned roles (OR merge)
72
+ role_ids = getattr(user, "role_ids", [])
73
+ if role_ids:
74
+ result = await db_session.execute(
75
+ select(AdminPermission).where(AdminPermission.role_id.in_(role_ids))
76
+ )
77
+ for perm in result.scalars():
78
+ actions = []
79
+ if perm.can_view:
80
+ actions.append("view")
81
+ if perm.can_create:
82
+ actions.append("create")
83
+ if perm.can_edit:
84
+ actions.append("edit")
85
+ if perm.can_delete:
86
+ actions.append("delete")
87
+ if actions:
88
+ permissions[perm.table_name] = actions
89
+
90
+ # Merge direct user permission overrides (OR on top)
91
+ user_id = getattr(user, "id", None)
92
+ if user_id is not None:
93
+ result = await db_session.execute(
94
+ select(AdminUserPermission).where(
95
+ AdminUserPermission.user_id == user_id
96
+ )
97
+ )
98
+ for perm in result.scalars():
99
+ actions = []
100
+ if perm.can_view:
101
+ actions.append("view")
102
+ if perm.can_create:
103
+ actions.append("create")
104
+ if perm.can_edit:
105
+ actions.append("edit")
106
+ if perm.can_delete:
107
+ actions.append("delete")
108
+ if actions:
109
+ existing = permissions.get(perm.table_name, [])
110
+ permissions[perm.table_name] = list(
111
+ set(existing) | set(actions)
112
+ )
113
+
114
+ return permissions
115
+
116
+
117
+ def create_access_token(
118
+ user: Any,
119
+ secret_key: str,
120
+ permissions: dict[str, list[str]] | None = None,
121
+ expires_delta: timedelta | None = None,
122
+ ) -> str:
123
+ """Create a JWT access token with embedded roles and permissions."""
124
+ now = datetime.now(UTC)
125
+ expire = now + (expires_delta or timedelta(minutes=30))
126
+ jti = str(uuid.uuid4())
127
+
128
+ role_names = []
129
+ roles = getattr(user, "roles", [])
130
+ for r in roles:
131
+ role_name = getattr(r, "name", None)
132
+ if role_name:
133
+ role_names.append(role_name)
134
+
135
+ payload = {
136
+ "sub": str(user.id),
137
+ "roles": role_names,
138
+ "permissions": permissions or {},
139
+ "is_superuser": getattr(user, "is_superuser", False),
140
+ "email": getattr(user, "email", ""),
141
+ "full_name": getattr(user, "full_name", ""),
142
+ "exp": expire,
143
+ "iat": now,
144
+ "jti": jti,
145
+ }
146
+ return jwt.encode(payload, secret_key, algorithm="HS256")
147
+
148
+
149
+ def decode_access_token(token: str, secret_key: str) -> dict[str, Any] | None:
150
+ """Decode and validate a JWT access token. Returns payload or None."""
151
+ try:
152
+ return jwt.decode(token, secret_key, algorithms=["HS256"])
153
+ except jwt.PyJWTError:
154
+ return None
155
+
156
+
157
+ def _hash_token(token: str) -> str:
158
+ """SHA256 hash of a token for storage."""
159
+ return hashlib.sha256(token.encode()).hexdigest()
160
+
161
+
162
+ @router.post("/token", response_model=TokenResponse)
163
+ async def obtain_token(
164
+ request: Request,
165
+ body: TokenRequest,
166
+ ) -> TokenResponse:
167
+ """POST /api/auth/token — obtain JWT access + refresh tokens."""
168
+ check_rate_limit(_api_rate_limiter, body.email)
169
+
170
+ auth_backend = getattr(request.app.state, "admin_auth_backend", None)
171
+ if auth_backend is None:
172
+ raise HTTPException(
173
+ status_code=500, detail="Auth backend not configured."
174
+ )
175
+
176
+ db_session = get_db_session(request)
177
+ if db_session is None:
178
+ raise HTTPException(
179
+ status_code=500, detail="Database session not available."
180
+ )
181
+
182
+ user = await auth_backend.authenticate(
183
+ body.email, body.password, db_session
184
+ )
185
+ if user is None:
186
+ _api_rate_limiter.record_attempt(body.email)
187
+ raise HTTPException(status_code=401, detail="Invalid credentials.")
188
+
189
+ _api_rate_limiter.reset(body.email)
190
+
191
+ secret_key = _get_secret_key(request)
192
+ ttl = _get_token_ttl(request)
193
+ permissions = await _build_user_permissions(user, db_session)
194
+ access_token = create_access_token(
195
+ user, secret_key, permissions, expires_delta=timedelta(seconds=ttl)
196
+ )
197
+
198
+ # Create refresh token
199
+ from fastapi_admin_kit.auth.models import AdminRefreshToken
200
+
201
+ refresh_jti = str(uuid.uuid4())
202
+ refresh_hash = _hash_token(refresh_jti)
203
+ refresh_expires = datetime.now(UTC) + timedelta(seconds=_get_refresh_ttl())
204
+
205
+ refresh_record = AdminRefreshToken(
206
+ user_id=user.id,
207
+ token_hash=refresh_hash,
208
+ expires_at=refresh_expires,
209
+ )
210
+ db_session.add(refresh_record)
211
+ await db_session.flush()
212
+
213
+ return TokenResponse(
214
+ access_token=access_token,
215
+ refresh_token=refresh_jti,
216
+ expires_in=ttl,
217
+ )
218
+
219
+
220
+ @router.post("/refresh", response_model=RefreshResponse)
221
+ async def refresh_token(
222
+ request: Request,
223
+ body: RefreshRequest,
224
+ ) -> RefreshResponse:
225
+ """POST /api/auth/refresh — exchange refresh token for new access token."""
226
+ db_session = get_db_session(request)
227
+ if db_session is None:
228
+ raise HTTPException(
229
+ status_code=500, detail="Database session not available."
230
+ )
231
+
232
+ from sqlalchemy import select
233
+
234
+ from fastapi_admin_kit.auth.models import AdminRefreshToken, AdminUser
235
+
236
+ refresh_hash = _hash_token(body.refresh_token)
237
+ result = await db_session.execute(
238
+ select(AdminRefreshToken).where(
239
+ AdminRefreshToken.token_hash == refresh_hash,
240
+ AdminRefreshToken.revoked_at.is_(None),
241
+ )
242
+ )
243
+ refresh_record = result.scalar_one_or_none()
244
+
245
+ if refresh_record is None:
246
+ raise HTTPException(status_code=401, detail="Invalid refresh token.")
247
+
248
+ if refresh_record.expires_at < datetime.now(UTC):
249
+ raise HTTPException(status_code=401, detail="Refresh token expired.")
250
+
251
+ # Load user
252
+ user_result = await db_session.execute(
253
+ select(AdminUser).where(
254
+ AdminUser.id == refresh_record.user_id,
255
+ AdminUser.is_active,
256
+ )
257
+ )
258
+ user = user_result.scalar_one_or_none()
259
+ if user is None:
260
+ raise HTTPException(
261
+ status_code=401, detail="User not found or inactive."
262
+ )
263
+
264
+ # Rotate refresh token
265
+ refresh_record.revoked_at = datetime.now(UTC)
266
+
267
+ secret_key = _get_secret_key(request)
268
+ ttl = _get_token_ttl(request)
269
+ permissions = await _build_user_permissions(user, db_session)
270
+ new_access_token = create_access_token(
271
+ user, secret_key, permissions, expires_delta=timedelta(seconds=ttl)
272
+ )
273
+
274
+ new_refresh_jti = str(uuid.uuid4())
275
+ new_refresh_hash = _hash_token(new_refresh_jti)
276
+ new_refresh_record = AdminRefreshToken(
277
+ user_id=user.id,
278
+ token_hash=new_refresh_hash,
279
+ expires_at=datetime.now(UTC) + timedelta(seconds=_get_refresh_ttl()),
280
+ )
281
+ db_session.add(new_refresh_record)
282
+ await db_session.flush()
283
+
284
+ return RefreshResponse(
285
+ access_token=new_access_token,
286
+ refresh_token=new_refresh_jti,
287
+ expires_in=ttl,
288
+ )
289
+
290
+
291
+ @router.post("/logout")
292
+ async def api_logout(
293
+ request: Request,
294
+ body: RefreshRequest | None = None,
295
+ ) -> dict[str, str]:
296
+ """POST /api/auth/logout — revoke refresh token."""
297
+ if body and body.refresh_token:
298
+ db_session = get_db_session(request)
299
+ if db_session:
300
+ from sqlalchemy import select
301
+
302
+ from fastapi_admin_kit.auth.models import AdminRefreshToken
303
+
304
+ refresh_hash = _hash_token(body.refresh_token)
305
+ result = await db_session.execute(
306
+ select(AdminRefreshToken).where(
307
+ AdminRefreshToken.token_hash == refresh_hash,
308
+ AdminRefreshToken.revoked_at.is_(None),
309
+ )
310
+ )
311
+ refresh_record = result.scalar_one_or_none()
312
+ if refresh_record:
313
+ refresh_record.revoked_at = datetime.now(UTC)
314
+ await db_session.flush()
315
+
316
+ return {"detail": "Logged out successfully."}
317
+
318
+
319
+ @router.get("/me")
320
+ async def get_current_user_info(
321
+ request: Request,
322
+ ) -> dict[str, Any]:
323
+ """GET /api/auth/me — return current user info from JWT (no DB hit)."""
324
+ auth_header = request.headers.get("Authorization", "")
325
+ if not auth_header.startswith("Bearer "):
326
+ raise HTTPException(
327
+ status_code=401, detail="Missing or invalid Authorization header."
328
+ )
329
+
330
+ token = auth_header[7:]
331
+ secret_key = _get_secret_key(request)
332
+ payload = decode_access_token(token, secret_key)
333
+ if payload is None:
334
+ raise HTTPException(status_code=401, detail="Invalid or expired token.")
335
+
336
+ return {
337
+ "user_id": payload.get("sub"),
338
+ "email": payload.get("email"),
339
+ "full_name": payload.get("full_name"),
340
+ "roles": payload.get("roles", []),
341
+ "is_superuser": payload.get("is_superuser", False),
342
+ }
@@ -0,0 +1,128 @@
1
+ """JSON CRUD handlers for the Admin API.
2
+
3
+ Uses view classes' api_response() to eliminate duplicate logic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, HTTPException, Query, Request
11
+ from fastapi.responses import JSONResponse
12
+
13
+ from fastapi_admin_kit.api.schema_generator import get_or_build_schemas
14
+ from fastapi_admin_kit.views.class_views import (
15
+ CreateView,
16
+ DeleteView,
17
+ EditView,
18
+ ListView,
19
+ _resolve_view_class,
20
+ )
21
+
22
+
23
+ async def _get_current_user(request: Request) -> dict[str, Any]:
24
+ """Extract and validate the current user from a Bearer token."""
25
+ from fastapi_admin_kit.api.deps import get_api_current_user
26
+
27
+ return await get_api_current_user(request)
28
+
29
+
30
+ async def _check_permission(
31
+ request: Request, user: dict[str, Any], table_name: str, action: str
32
+ ) -> None:
33
+ """Check if user has permission from JWT payload."""
34
+ if user.get("is_superuser"):
35
+ return
36
+
37
+ permissions = user.get("permissions", {})
38
+ table_perms = permissions.get(table_name, [])
39
+ if action not in table_perms:
40
+ raise HTTPException(
41
+ status_code=403,
42
+ detail=f"You do not have permission to {action} {table_name}.",
43
+ )
44
+
45
+
46
+ def build_api_router(registry: Any) -> APIRouter:
47
+ """Build the CRUD API router for all registered models."""
48
+ router = APIRouter(tags=["api-crud"])
49
+
50
+ for registered in registry.all():
51
+ _register_model_routes(router, registered)
52
+
53
+ return router
54
+
55
+
56
+ def _register_model_routes(router: APIRouter, registered: Any) -> None:
57
+ """Register CRUD routes for a single model using view classes."""
58
+ table_name = registered.table_name
59
+ prefix = f"/{table_name}"
60
+
61
+ # DIP: resolve view classes from ModelAdmin config
62
+ admin = registered.admin
63
+ list_v = _resolve_view_class(admin, "list_view_class", ListView)(registered)
64
+ create_v = _resolve_view_class(admin, "create_view_class", CreateView)(
65
+ registered
66
+ )
67
+ edit_v = _resolve_view_class(admin, "edit_view_class", EditView)(registered)
68
+ delete_v = _resolve_view_class(admin, "delete_view_class", DeleteView)(
69
+ registered
70
+ )
71
+
72
+ # Generate dynamic schemas for OpenAPI docs
73
+ schemas = get_or_build_schemas(registered)
74
+ response_schema = schemas["response"]
75
+ list_response_schema = schemas["list_response"]
76
+
77
+ @router.get(prefix, response_model=list_response_schema)
78
+ async def list_items(
79
+ request: Request,
80
+ page: int = Query(1, ge=1),
81
+ per_page: int = Query(25, ge=1, le=100),
82
+ q: str = Query(""),
83
+ order: str = Query(""),
84
+ after: str | None = Query(None),
85
+ before: str | None = Query(None),
86
+ ):
87
+ user = await _get_current_user(request)
88
+ await _check_permission(request, user, table_name, "view")
89
+ result = await list_v.api_response(
90
+ request, page=page, per_page=per_page, q=q, order=order,
91
+ after=after, before=before,
92
+ )
93
+ if isinstance(result, JSONResponse):
94
+ return result
95
+ return result
96
+
97
+ @router.post(prefix, response_model=response_schema, status_code=201)
98
+ async def create_item(request: Request):
99
+ user = await _get_current_user(request)
100
+ await _check_permission(request, user, table_name, "create")
101
+ result = await create_v.api_response(request)
102
+ if isinstance(result, JSONResponse):
103
+ return result
104
+ return result
105
+
106
+ @router.get(f"{prefix}/{{item_id}}", response_model=response_schema)
107
+ async def retrieve_item(request: Request, item_id: str):
108
+ user = await _get_current_user(request)
109
+ await _check_permission(request, user, table_name, "view")
110
+ result = await edit_v.api_response(request, item_id=item_id)
111
+ if isinstance(result, JSONResponse):
112
+ return result
113
+ return result
114
+
115
+ @router.put(f"{prefix}/{{item_id}}", response_model=response_schema)
116
+ async def update_item(request: Request, item_id: str):
117
+ user = await _get_current_user(request)
118
+ await _check_permission(request, user, table_name, "edit")
119
+ result = await edit_v.api_response(request, item_id=item_id)
120
+ if isinstance(result, JSONResponse):
121
+ return result
122
+ return result
123
+
124
+ @router.delete(f"{prefix}/{{item_id}}", status_code=204)
125
+ async def delete_item(request: Request, item_id: str):
126
+ user = await _get_current_user(request)
127
+ await _check_permission(request, user, table_name, "delete")
128
+ return await delete_v.api_response(request, item_id=item_id)
@@ -0,0 +1,79 @@
1
+ """API dependencies — JWT-based permission checking without DB hits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import HTTPException, Request
8
+
9
+ from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token
10
+
11
+
12
+ async def get_api_current_user(request: Request) -> dict[str, Any]:
13
+ """Decode JWT and return user payload (no DB hit).
14
+
15
+ Raises 401 if token is missing or invalid.
16
+ """
17
+ auth_header = request.headers.get("Authorization", "")
18
+ if not auth_header.startswith("Bearer "):
19
+ raise HTTPException(
20
+ status_code=401, detail="Missing or invalid Authorization header."
21
+ )
22
+
23
+ token = auth_header[7:]
24
+ secret_key = _get_secret_key(request)
25
+ payload = decode_access_token(token, secret_key)
26
+ if payload is None:
27
+ raise HTTPException(status_code=401, detail="Invalid or expired token.")
28
+ return payload
29
+
30
+
31
+ def require_api_permission(table_name: str, action: str):
32
+ """Return a dependency that checks JWT-embedded permissions.
33
+
34
+ Usage::
35
+
36
+ @router.get("/")
37
+ async def list_view(user=Depends(require_api_permission("products", "view"))):
38
+ ...
39
+ """
40
+
41
+ async def _check(
42
+ user: dict[str, Any] = None,
43
+ request: Request = None,
44
+ ) -> dict[str, Any]:
45
+ if user is None:
46
+ user = await get_api_current_user(request)
47
+
48
+ if user.get("is_superuser"):
49
+ return user
50
+
51
+ permissions = user.get("permissions", {})
52
+ table_perms = permissions.get(table_name, [])
53
+ if action not in table_perms:
54
+ raise HTTPException(
55
+ status_code=403,
56
+ detail=f"You do not have permission to {action} {table_name}.",
57
+ )
58
+ return user
59
+
60
+ return _check
61
+
62
+
63
+ def require_api_superuser():
64
+ """Return a dependency that enforces superuser access from JWT."""
65
+
66
+ async def _check(
67
+ user: dict[str, Any] = None,
68
+ request: Request = None,
69
+ ) -> dict[str, Any]:
70
+ if user is None:
71
+ user = await get_api_current_user(request)
72
+
73
+ if not user.get("is_superuser"):
74
+ raise HTTPException(
75
+ status_code=403, detail="Superuser access required."
76
+ )
77
+ return user
78
+
79
+ return _check