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,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
|