supython 0.5.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 (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """supython: a lightweight Postgres-first BaaS framework for Python."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("supython")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,3 @@
1
+ from .api import router
2
+
3
+ __all__ = ["router"]
@@ -0,0 +1,24 @@
1
+ from fastapi import APIRouter
2
+
3
+ from .auth import router as auth_router
4
+ from .auth_templates import router as auth_templates_router
5
+ from .auth_users import router as auth_users_router
6
+ from .db import router as db_router
7
+ from .functions import router as functions_router
8
+ from .jobs import router as jobs_router
9
+ from .ops import router as ops_router
10
+ from .realtime import router as realtime_router
11
+ from .storage import router as storage_router
12
+ from .system import router as system_router
13
+
14
+ router = APIRouter()
15
+ router.include_router(auth_router)
16
+ router.include_router(auth_templates_router)
17
+ router.include_router(auth_users_router)
18
+ router.include_router(system_router)
19
+ router.include_router(db_router)
20
+ router.include_router(storage_router)
21
+ router.include_router(functions_router)
22
+ router.include_router(jobs_router)
23
+ router.include_router(realtime_router)
24
+ router.include_router(ops_router)
@@ -0,0 +1,118 @@
1
+ import ipaddress
2
+ from typing import Annotated
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
6
+
7
+ from ... import db
8
+ from .. import audit
9
+ from .. import session as admin_session
10
+ from ..errors import AdminError, to_http
11
+ from ..schemas import LoginRequest, SessionResponse
12
+ from . import service_auth
13
+
14
+ router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth"])
15
+
16
+
17
+ def _client_ip(request: Request) -> str | None:
18
+ if request.client is None:
19
+ return None
20
+ try:
21
+ ipaddress.ip_address(request.client.host)
22
+ except ValueError:
23
+ return None
24
+ return request.client.host
25
+
26
+
27
+ @router.post("/login", response_model=SessionResponse)
28
+ async def login(
29
+ payload: LoginRequest, request: Request, response: Response
30
+ ) -> SessionResponse:
31
+ ip = _client_ip(request)
32
+ ua = request.headers.get("user-agent")
33
+ try:
34
+ async with db.as_service_role() as conn:
35
+ admin_id, email = await service_auth.authenticate(
36
+ conn, payload.email, payload.password
37
+ )
38
+ token, expires = await admin_session.issue(
39
+ conn, admin_id=admin_id, ip=ip, ua=ua
40
+ )
41
+ await service_auth.touch_last_login(conn, admin_id)
42
+ await audit.write(
43
+ conn,
44
+ admin_id=admin_id,
45
+ action="admin.login",
46
+ target=payload.email,
47
+ payload={"email": payload.email},
48
+ ip=ip,
49
+ ua=ua,
50
+ )
51
+ except AdminError as exc:
52
+ async with db.as_service_role() as audit_conn:
53
+ await audit.write(
54
+ audit_conn,
55
+ admin_id=None,
56
+ action="admin.login.failed",
57
+ target=payload.email,
58
+ payload={"email": payload.email, "error_code": exc.code},
59
+ ip=ip,
60
+ ua=ua,
61
+ )
62
+ raise to_http(exc) from exc
63
+ response.set_cookie(
64
+ admin_session.SESSION_COOKIE,
65
+ token,
66
+ max_age=int(admin_session.SESSION_TTL.total_seconds()),
67
+ httponly=True,
68
+ secure=True,
69
+ samesite="strict",
70
+ path=admin_session.SESSION_PATH,
71
+ )
72
+ return SessionResponse(admin_id=admin_id, email=email, expires_at=expires)
73
+
74
+
75
+ @router.post("/logout", status_code=204)
76
+ async def logout(
77
+ request: Request,
78
+ response: Response,
79
+ cookie: Annotated[str | None, Cookie(alias=admin_session.SESSION_COOKIE)] = None,
80
+ ) -> None:
81
+ if cookie:
82
+ ip = _client_ip(request)
83
+ ua = request.headers.get("user-agent")
84
+ async with db.as_service_role() as conn:
85
+ resolved = await admin_session.resolve(conn, cookie)
86
+ if resolved is not None:
87
+ admin_id, _expires = resolved
88
+ await audit.write(
89
+ conn,
90
+ admin_id=admin_id,
91
+ action="admin.logout",
92
+ target=None,
93
+ payload={},
94
+ ip=ip,
95
+ ua=ua,
96
+ )
97
+ await admin_session.revoke(conn, cookie)
98
+ response.delete_cookie(admin_session.SESSION_COOKIE, path=admin_session.SESSION_PATH)
99
+
100
+
101
+ @router.get("/session", response_model=SessionResponse)
102
+ async def session(
103
+ request: Request,
104
+ cookie: Annotated[str | None, Cookie(alias=admin_session.SESSION_COOKIE)] = None,
105
+ ) -> SessionResponse:
106
+ if not cookie:
107
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin session required")
108
+ async with db.as_service_role() as conn:
109
+ resolved = await admin_session.resolve(conn, cookie)
110
+ if resolved is None:
111
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired session")
112
+ admin_id, expires = resolved
113
+ info = await service_auth.fetch_admin(conn, admin_id)
114
+ if info is None:
115
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin user not found")
116
+ email, _last_login = info
117
+ request.state.admin_id = admin_id
118
+ return SessionResponse(admin_id=admin_id, email=email, expires_at=expires)
@@ -0,0 +1,67 @@
1
+ from typing import Annotated
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter, Depends, Request
5
+
6
+ from ... import db
7
+ from .. import audit
8
+ from ..deps import require_admin
9
+ from ..errors import AdminError, to_http
10
+ from ..schemas import EmailTemplate, EmailTemplateUpdate
11
+ from . import service_auth_templates
12
+
13
+ router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth.templates"])
14
+
15
+
16
+ @router.get("/templates", response_model=list[EmailTemplate])
17
+ async def list_templates(
18
+ _: Annotated[UUID, Depends(require_admin)],
19
+ ) -> list[EmailTemplate]:
20
+ try:
21
+ async with db.as_service_role() as conn:
22
+ return await service_auth_templates.list_templates(conn)
23
+ except AdminError as exc:
24
+ raise to_http(exc) from exc
25
+
26
+
27
+ @router.get("/templates/{name}", response_model=EmailTemplate)
28
+ async def get_template(
29
+ name: str,
30
+ _: Annotated[UUID, Depends(require_admin)],
31
+ ) -> EmailTemplate:
32
+ try:
33
+ async with db.as_service_role() as conn:
34
+ return await service_auth_templates.get_template(conn, name)
35
+ except AdminError as exc:
36
+ raise to_http(exc) from exc
37
+
38
+
39
+ @router.patch("/templates/{name}", response_model=EmailTemplate)
40
+ async def update_template(
41
+ name: str,
42
+ payload: EmailTemplateUpdate,
43
+ request: Request,
44
+ admin_id: Annotated[UUID, Depends(require_admin)],
45
+ ) -> EmailTemplate:
46
+ try:
47
+ async with db.as_service_role() as conn:
48
+ result = await service_auth_templates.update_template(
49
+ conn,
50
+ name,
51
+ payload.subject,
52
+ payload.text_body,
53
+ )
54
+ except AdminError as exc:
55
+ raise to_http(exc) from exc
56
+
57
+ async with db.as_service_role() as conn:
58
+ await audit.write(
59
+ conn,
60
+ admin_id=admin_id,
61
+ action="auth.template.update",
62
+ target=name,
63
+ payload=payload.model_dump(exclude_none=True),
64
+ ip=request.client.host if request.client else None,
65
+ ua=request.headers.get("user-agent"),
66
+ )
67
+ return result
@@ -0,0 +1,225 @@
1
+ import ipaddress
2
+ from typing import Annotated
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Body, Depends, Request
6
+
7
+ from ... import db
8
+ from .. import audit
9
+ from ..deps import require_admin
10
+ from ..errors import AdminError, to_http
11
+ from ..schemas import (
12
+ AdminAuditPage,
13
+ AdminUserDetail,
14
+ AdminUsersPage,
15
+ BanRequest,
16
+ RefreshTokensPage,
17
+ )
18
+ from . import service_auth_users
19
+
20
+ router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth.users"])
21
+
22
+
23
+ def _client_ip(request: Request) -> str | None:
24
+ if request.client is None:
25
+ return None
26
+ try:
27
+ ipaddress.ip_address(request.client.host)
28
+ except ValueError:
29
+ return None
30
+ return request.client.host
31
+
32
+
33
+ @router.get("/users", response_model=AdminUsersPage)
34
+ async def list_users(
35
+ _: Annotated[UUID, Depends(require_admin)],
36
+ search: str | None = None,
37
+ confirmed: bool | None = None,
38
+ banned: bool | None = None,
39
+ limit: int = 50,
40
+ offset: int = 0,
41
+ ) -> AdminUsersPage:
42
+ try:
43
+ async with db.as_service_role() as conn:
44
+ return await service_auth_users.search_users(
45
+ conn, search, confirmed, banned, limit, offset
46
+ )
47
+ except AdminError as exc:
48
+ raise to_http(exc) from exc
49
+
50
+
51
+ @router.get("/users/{user_id}", response_model=AdminUserDetail)
52
+ async def get_user(
53
+ user_id: UUID,
54
+ _: Annotated[UUID, Depends(require_admin)],
55
+ ) -> AdminUserDetail:
56
+ try:
57
+ async with db.as_service_role() as conn:
58
+ return await service_auth_users.get_user_detail(conn, user_id)
59
+ except AdminError as exc:
60
+ raise to_http(exc) from exc
61
+
62
+
63
+ @router.post("/users/{user_id}/ban")
64
+ async def ban_user(
65
+ user_id: UUID,
66
+ request: Request,
67
+ admin_id: Annotated[UUID, Depends(require_admin)],
68
+ payload: Annotated[BanRequest | None, Body()] = None,
69
+ ) -> dict[str, str]:
70
+ ip = _client_ip(request)
71
+ ua = request.headers.get("user-agent")
72
+ duration = payload.duration_seconds if payload else None
73
+ try:
74
+ async with db.as_service_role() as conn:
75
+ banned_until = await service_auth_users.ban_user(conn, user_id, duration)
76
+ await service_auth_users.write_user_audit(
77
+ conn,
78
+ user_id=user_id,
79
+ event="user.banned",
80
+ payload={"banned_until": banned_until.isoformat()},
81
+ ip=ip,
82
+ ua=ua,
83
+ )
84
+ await audit.write(
85
+ conn,
86
+ admin_id=admin_id,
87
+ action="auth.user.ban",
88
+ target=str(user_id),
89
+ payload={"banned_until": banned_until.isoformat()},
90
+ ip=ip,
91
+ ua=ua,
92
+ )
93
+ except AdminError as exc:
94
+ raise to_http(exc) from exc
95
+ return {"banned_until": banned_until.isoformat()}
96
+
97
+
98
+ @router.post("/users/{user_id}/unban", status_code=204)
99
+ async def unban_user(
100
+ user_id: UUID,
101
+ request: Request,
102
+ admin_id: Annotated[UUID, Depends(require_admin)],
103
+ ) -> None:
104
+ ip = _client_ip(request)
105
+ ua = request.headers.get("user-agent")
106
+ try:
107
+ async with db.as_service_role() as conn:
108
+ await service_auth_users.unban_user(conn, user_id)
109
+ await service_auth_users.write_user_audit(
110
+ conn,
111
+ user_id=user_id,
112
+ event="user.unbanned",
113
+ payload={},
114
+ ip=ip,
115
+ ua=ua,
116
+ )
117
+ await audit.write(
118
+ conn,
119
+ admin_id=admin_id,
120
+ action="auth.user.unban",
121
+ target=str(user_id),
122
+ payload={},
123
+ ip=ip,
124
+ ua=ua,
125
+ )
126
+ except AdminError as exc:
127
+ raise to_http(exc) from exc
128
+
129
+
130
+ @router.post("/users/{user_id}/force-logout")
131
+ async def force_logout(
132
+ user_id: UUID,
133
+ request: Request,
134
+ admin_id: Annotated[UUID, Depends(require_admin)],
135
+ ) -> dict[str, int]:
136
+ ip = _client_ip(request)
137
+ ua = request.headers.get("user-agent")
138
+ try:
139
+ async with db.as_service_role() as conn:
140
+ revoked = await service_auth_users.force_logout(conn, user_id)
141
+ await service_auth_users.write_user_audit(
142
+ conn,
143
+ user_id=user_id,
144
+ event="user.force_logout",
145
+ payload={"revoked": revoked},
146
+ ip=ip,
147
+ ua=ua,
148
+ )
149
+ await audit.write(
150
+ conn,
151
+ admin_id=admin_id,
152
+ action="auth.user.force_logout",
153
+ target=str(user_id),
154
+ payload={"revoked": revoked},
155
+ ip=ip,
156
+ ua=ua,
157
+ )
158
+ except AdminError as exc:
159
+ raise to_http(exc) from exc
160
+ return {"revoked": revoked}
161
+
162
+
163
+ @router.get("/refresh-tokens", response_model=RefreshTokensPage)
164
+ async def list_refresh_tokens(
165
+ _: Annotated[UUID, Depends(require_admin)],
166
+ user_id: UUID | None = None,
167
+ limit: int = 50,
168
+ offset: int = 0,
169
+ ) -> RefreshTokensPage:
170
+ try:
171
+ async with db.as_service_role() as conn:
172
+ return await service_auth_users.list_refresh_tokens(conn, user_id, limit, offset)
173
+ except AdminError as exc:
174
+ raise to_http(exc) from exc
175
+
176
+
177
+ @router.delete("/refresh-tokens/{token_id}", status_code=204)
178
+ async def revoke_refresh_token(
179
+ token_id: int,
180
+ request: Request,
181
+ admin_id: Annotated[UUID, Depends(require_admin)],
182
+ ) -> None:
183
+ ip = _client_ip(request)
184
+ ua = request.headers.get("user-agent")
185
+ try:
186
+ async with db.as_service_role() as conn:
187
+ user_id = await service_auth_users.revoke_refresh_token(conn, token_id)
188
+ await service_auth_users.write_user_audit(
189
+ conn,
190
+ user_id=user_id,
191
+ event="refresh_token.revoked",
192
+ payload={"token_id": token_id},
193
+ ip=ip,
194
+ ua=ua,
195
+ )
196
+ await audit.write(
197
+ conn,
198
+ admin_id=admin_id,
199
+ action="auth.refresh_token.revoke",
200
+ target=str(token_id),
201
+ payload={"token_id": token_id, "user_id": str(user_id)},
202
+ ip=ip,
203
+ ua=ua,
204
+ )
205
+ except AdminError as exc:
206
+ raise to_http(exc) from exc
207
+
208
+
209
+ @router.get("/audit", response_model=AdminAuditPage)
210
+ async def list_audit(
211
+ _: Annotated[UUID, Depends(require_admin)],
212
+ event: str | None = None,
213
+ ip: str | None = None,
214
+ from_date: str | None = None,
215
+ to_date: str | None = None,
216
+ limit: int = 50,
217
+ offset: int = 0,
218
+ ) -> AdminAuditPage:
219
+ try:
220
+ async with db.as_service_role() as conn:
221
+ return await service_auth_users.list_audit_log(
222
+ conn, event, ip, from_date, to_date, limit, offset
223
+ )
224
+ except AdminError as exc:
225
+ raise to_http(exc) from exc
@@ -0,0 +1,174 @@
1
+ import ipaddress
2
+ from typing import Annotated, Literal
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Body, Depends, Request
6
+
7
+ from ... import db
8
+ from .. import audit
9
+ from ..deps import require_admin
10
+ from ..errors import AdminError, to_http
11
+ from ..schemas import (
12
+ DryRunRequest,
13
+ DryRunResponse,
14
+ MigrationRecord,
15
+ RlsPolicy,
16
+ RowsPage,
17
+ SchemaInfo,
18
+ SqlExecRequest,
19
+ SqlExecResponse,
20
+ TableInfo,
21
+ )
22
+ from . import service_db
23
+
24
+ router = APIRouter(prefix="/admin/api/v1/db", tags=["admin.db"])
25
+
26
+
27
+ def _client_ip(request: Request) -> str | None:
28
+ if request.client is None:
29
+ return None
30
+ try:
31
+ ipaddress.ip_address(request.client.host)
32
+ except ValueError:
33
+ return None
34
+ return request.client.host
35
+
36
+
37
+ @router.get("/schemas", response_model=list[SchemaInfo])
38
+ async def list_schemas(
39
+ _: Annotated[UUID, Depends(require_admin)],
40
+ ) -> list[SchemaInfo]:
41
+ async with db.as_service_role() as conn:
42
+ return await service_db.list_schemas(conn)
43
+
44
+
45
+ @router.get("/tables/{schema}", response_model=list[TableInfo])
46
+ async def list_tables(
47
+ schema: str,
48
+ _: Annotated[UUID, Depends(require_admin)],
49
+ ) -> list[TableInfo]:
50
+ try:
51
+ async with db.as_service_role() as conn:
52
+ return await service_db.list_tables(conn, schema)
53
+ except AdminError as exc:
54
+ raise to_http(exc) from exc
55
+
56
+
57
+ @router.get("/tables/{schema}/{table}/rows", response_model=RowsPage)
58
+ async def read_rows(
59
+ schema: str,
60
+ table: str,
61
+ _: Annotated[UUID, Depends(require_admin)],
62
+ limit: int = 50,
63
+ offset: int = 0,
64
+ order: str | None = None,
65
+ role: Literal["service_role", "authenticated", "anon"] = "service_role",
66
+ impersonate_sub: UUID | None = None,
67
+ ) -> RowsPage:
68
+ try:
69
+ if role == "service_role":
70
+ async with db.as_service_role() as conn:
71
+ return await service_db.read_rows(conn, schema, table, limit, offset, order)
72
+ claims = service_db.preview_claims(role, impersonate_sub)
73
+ async with db.as_role(role, claims) as conn:
74
+ return await service_db.read_rows(conn, schema, table, limit, offset, order)
75
+ except AdminError as exc:
76
+ raise to_http(exc) from exc
77
+
78
+
79
+ @router.post("/sql/execute", response_model=SqlExecResponse)
80
+ async def run_sql(
81
+ request: Request,
82
+ admin_id: Annotated[UUID, Depends(require_admin)],
83
+ payload: Annotated[SqlExecRequest, Body()],
84
+ ) -> SqlExecResponse:
85
+ ip = _client_ip(request)
86
+ ua = request.headers.get("user-agent")
87
+ try:
88
+ async with db.as_service_role() as conn:
89
+ try:
90
+ result = await service_db.execute_sql(conn, payload)
91
+ except AdminError as exc:
92
+ # Audit the failed attempt on a fresh connection so the poisoned
93
+ # transaction is not reused.
94
+ async with db.as_service_role() as audit_conn:
95
+ await audit.write(
96
+ audit_conn,
97
+ admin_id=admin_id,
98
+ action="sql.execute.failed",
99
+ target=None,
100
+ payload={
101
+ "statement": payload.statement[:2048],
102
+ "error_code": exc.code,
103
+ },
104
+ ip=ip,
105
+ ua=ua,
106
+ )
107
+ raise
108
+ await audit.write(
109
+ conn,
110
+ admin_id=admin_id,
111
+ action="sql.execute",
112
+ target=None,
113
+ payload={
114
+ "statement": payload.statement[:2048],
115
+ "row_count": result.row_count,
116
+ },
117
+ ip=ip,
118
+ ua=ua,
119
+ )
120
+ return result
121
+ except AdminError as exc:
122
+ raise to_http(exc) from exc
123
+
124
+
125
+ @router.get("/rls/{schema}/{table}", response_model=list[RlsPolicy])
126
+ async def list_policies(
127
+ schema: str,
128
+ table: str,
129
+ _: Annotated[UUID, Depends(require_admin)],
130
+ ) -> list[RlsPolicy]:
131
+ try:
132
+ async with db.as_service_role() as conn:
133
+ return await service_db.list_policies(conn, schema, table)
134
+ except AdminError as exc:
135
+ raise to_http(exc) from exc
136
+
137
+
138
+ @router.post("/rls/dry-run", response_model=DryRunResponse)
139
+ async def dry_run_policy(
140
+ request: Request,
141
+ admin_id: Annotated[UUID, Depends(require_admin)],
142
+ payload: Annotated[DryRunRequest, Body()],
143
+ ) -> DryRunResponse:
144
+ ip = _client_ip(request)
145
+ ua = request.headers.get("user-agent")
146
+ try:
147
+ async with db.as_service_role() as conn:
148
+ result = await service_db.dry_run_policy(conn, payload.ddl, payload.sample_query)
149
+ await audit.write(
150
+ conn,
151
+ admin_id=admin_id,
152
+ action="rls.dry_run",
153
+ target=None,
154
+ payload={
155
+ "ddl": payload.ddl[:2048],
156
+ "sample_query": payload.sample_query[:2048],
157
+ },
158
+ ip=ip,
159
+ ua=ua,
160
+ )
161
+ return result
162
+ except AdminError as exc:
163
+ raise to_http(exc) from exc
164
+
165
+
166
+ @router.get("/migrations", response_model=list[MigrationRecord])
167
+ async def list_migrations(
168
+ _: Annotated[UUID, Depends(require_admin)],
169
+ ) -> list[MigrationRecord]:
170
+ try:
171
+ async with db.as_service_role() as conn:
172
+ return await service_db.list_migrations(conn)
173
+ except AdminError as exc:
174
+ raise to_http(exc) from exc