supython 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 (200) hide show
  1. supython/__init__.py +24 -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 +162 -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/backups/__init__.py +24 -0
  100. supython/backups/_backup_job.py +170 -0
  101. supython/backups/schemas.py +18 -0
  102. supython/backups/service.py +217 -0
  103. supython/body_size.py +184 -0
  104. supython/cli.py +1663 -0
  105. supython/client/__init__.py +67 -0
  106. supython/client/_auth.py +249 -0
  107. supython/client/_client.py +145 -0
  108. supython/client/_config.py +92 -0
  109. supython/client/_functions.py +69 -0
  110. supython/client/_storage.py +255 -0
  111. supython/client/py.typed +0 -0
  112. supython/db.py +151 -0
  113. supython/db_admin.py +8 -0
  114. supython/extensions.py +36 -0
  115. supython/functions/__init__.py +19 -0
  116. supython/functions/context.py +262 -0
  117. supython/functions/loader.py +307 -0
  118. supython/functions/router.py +228 -0
  119. supython/functions/schemas.py +50 -0
  120. supython/gen/__init__.py +5 -0
  121. supython/gen/_introspect.py +137 -0
  122. supython/gen/types_py.py +270 -0
  123. supython/gen/types_ts.py +365 -0
  124. supython/health.py +229 -0
  125. supython/hooks.py +117 -0
  126. supython/jobs/__init__.py +31 -0
  127. supython/jobs/backends.py +97 -0
  128. supython/jobs/context.py +58 -0
  129. supython/jobs/cron.py +152 -0
  130. supython/jobs/cron_inproc.py +119 -0
  131. supython/jobs/decorators.py +76 -0
  132. supython/jobs/registry.py +79 -0
  133. supython/jobs/router.py +136 -0
  134. supython/jobs/schemas.py +92 -0
  135. supython/jobs/service.py +311 -0
  136. supython/jobs/worker.py +219 -0
  137. supython/jwks.py +257 -0
  138. supython/keyset.py +279 -0
  139. supython/logging_config.py +291 -0
  140. supython/mail.py +33 -0
  141. supython/mailer.py +65 -0
  142. supython/migrate.py +81 -0
  143. supython/migrations/0001_extensions_and_roles.sql +46 -0
  144. supython/migrations/0002_auth_schema.sql +66 -0
  145. supython/migrations/0003_demo_todos.sql +42 -0
  146. supython/migrations/0004_auth_v0_2.sql +47 -0
  147. supython/migrations/0005_storage_schema.sql +117 -0
  148. supython/migrations/0006_realtime_schema.sql +206 -0
  149. supython/migrations/0007_jobs_schema.sql +254 -0
  150. supython/migrations/0008_jobs_last_error.sql +56 -0
  151. supython/migrations/0009_auth_rate_limits.sql +33 -0
  152. supython/migrations/0010_worker_heartbeat.sql +14 -0
  153. supython/migrations/0011_admin_schema.sql +45 -0
  154. supython/migrations/0012_auth_banned_until.sql +10 -0
  155. supython/migrations/0013_email_templates.sql +19 -0
  156. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  157. supython/migrations/0015_backups_schema.sql +14 -0
  158. supython/passwords.py +15 -0
  159. supython/realtime/__init__.py +6 -0
  160. supython/realtime/broker.py +814 -0
  161. supython/realtime/protocol.py +234 -0
  162. supython/realtime/router.py +184 -0
  163. supython/realtime/schemas.py +207 -0
  164. supython/realtime/service.py +261 -0
  165. supython/realtime/topics.py +175 -0
  166. supython/realtime/websocket.py +586 -0
  167. supython/scaffold/__init__.py +5 -0
  168. supython/scaffold/init_project.py +144 -0
  169. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  170. supython/scaffold/templates/README.md.tmpl +22 -0
  171. supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
  172. supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
  173. supython/scaffold/templates/asgi.py.tmpl +14 -0
  174. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  175. supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
  176. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  177. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  178. supython/scaffold/templates/env.example.tmpl +168 -0
  179. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  180. supython/scaffold/templates/gitignore.tmpl +14 -0
  181. supython/scaffold/templates/manage.py.tmpl +11 -0
  182. supython/scaffold/templates/migrations/.gitkeep +0 -0
  183. supython/scaffold/templates/package_init.py.tmpl +1 -0
  184. supython/scaffold/templates/settings.py.tmpl +31 -0
  185. supython/secretset.py +347 -0
  186. supython/security_headers.py +78 -0
  187. supython/settings.py +244 -0
  188. supython/settings_module.py +117 -0
  189. supython/storage/__init__.py +5 -0
  190. supython/storage/backends.py +392 -0
  191. supython/storage/router.py +341 -0
  192. supython/storage/schemas.py +50 -0
  193. supython/storage/service.py +445 -0
  194. supython/storage/signing.py +119 -0
  195. supython/tokens.py +85 -0
  196. supython-0.1.0.dist-info/METADATA +756 -0
  197. supython-0.1.0.dist-info/RECORD +200 -0
  198. supython-0.1.0.dist-info/WHEEL +4 -0
  199. supython-0.1.0.dist-info/entry_points.txt +2 -0
  200. supython-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,213 @@
1
+ """Admin service layer for the ops backups + live log tail surface.
2
+
3
+ Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ from collections.abc import AsyncIterator
10
+ from datetime import UTC, datetime
11
+ from uuid import UUID
12
+
13
+ import asyncpg
14
+
15
+ from ...backups import service as backups_service
16
+ from ...logging_config import get_log_ring
17
+ from ..errors import AdminError
18
+ from ..schemas import AdminBackupRow, AdminBackupsPage, BackupDownloadResponse
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _row_to_admin_backup(row: asyncpg.Record) -> AdminBackupRow:
24
+ return AdminBackupRow(
25
+ id=row["id"],
26
+ kind=row["kind"],
27
+ status=row["status"],
28
+ size=row.get("size"),
29
+ file_path=row.get("file_path"),
30
+ error_message=row.get("error_message"),
31
+ started_at=row["started_at"],
32
+ finished_at=row.get("finished_at"),
33
+ created_at=row["created_at"],
34
+ )
35
+
36
+
37
+ async def list_backups(
38
+ conn: asyncpg.Connection,
39
+ *,
40
+ limit: int = 50,
41
+ offset: int = 0,
42
+ ) -> AdminBackupsPage:
43
+ rows = await conn.fetch(
44
+ """
45
+ select id, kind, status, size, file_path, error_message,
46
+ started_at, finished_at, created_at
47
+ from admin.backups
48
+ order by created_at desc
49
+ limit $1 offset $2
50
+ """,
51
+ limit,
52
+ offset,
53
+ )
54
+ total = await conn.fetchval("select count(*) from admin.backups")
55
+ return AdminBackupsPage(
56
+ rows=[_row_to_admin_backup(r) for r in rows],
57
+ total=total or 0,
58
+ )
59
+
60
+
61
+ async def start_backup(conn: asyncpg.Connection, *, kind: str) -> AdminBackupRow:
62
+ try:
63
+ record = await backups_service.start_backup(conn, kind=kind)
64
+ except backups_service.BackupError as exc:
65
+ raise AdminError(exc.code, exc.message, exc.status) from exc
66
+
67
+ # Re-fetch to get the row in admin shape
68
+ row = await conn.fetchrow(
69
+ """
70
+ select id, kind, status, size, file_path, error_message,
71
+ started_at, finished_at, created_at
72
+ from admin.backups
73
+ where id = $1
74
+ """,
75
+ record.id,
76
+ )
77
+ if row is None:
78
+ raise AdminError("backup_not_found", "backup not found after creation", 500)
79
+ return _row_to_admin_backup(row)
80
+
81
+
82
+ async def get_backup_download_response(
83
+ conn: asyncpg.Connection, backup_id: UUID
84
+ ) -> BackupDownloadResponse:
85
+ row = await conn.fetchrow(
86
+ """
87
+ select id, status, file_path
88
+ from admin.backups
89
+ where id = $1
90
+ """,
91
+ backup_id,
92
+ )
93
+ if row is None:
94
+ raise AdminError("backup_not_found", f"backup {backup_id} not found", 404)
95
+ if row["status"] != "completed":
96
+ raise AdminError(
97
+ "backup_not_ready",
98
+ f"backup status is {row['status']!r}, not 'completed'",
99
+ 409,
100
+ )
101
+ if not row["file_path"]:
102
+ raise AdminError(
103
+ "backup_no_file",
104
+ "backup has no file_path",
105
+ 500,
106
+ )
107
+
108
+ token = backups_service.generate_download_token(backup_id)
109
+ download_url = f"/admin/api/v1/ops/downloads/{token}"
110
+
111
+ return BackupDownloadResponse(
112
+ download_url=download_url,
113
+ expires_in=600,
114
+ backup_id=backup_id,
115
+ )
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Live log tail
120
+ # ---------------------------------------------------------------------------
121
+
122
+ _LOG_LEVEL_RANK: dict[str, int] = {
123
+ "DEBUG": 10,
124
+ "INFO": 20,
125
+ "WARNING": 30,
126
+ "ERROR": 40,
127
+ "CRITICAL": 50,
128
+ }
129
+
130
+
131
+ def _entry_matches(
132
+ entry: dict[str, object],
133
+ *,
134
+ level: str | None,
135
+ substring: str | None,
136
+ request_id: str | None,
137
+ ) -> bool:
138
+ if level is not None:
139
+ min_rank = _LOG_LEVEL_RANK.get(level.upper(), 0)
140
+ entry_rank = _LOG_LEVEL_RANK.get(str(entry.get("level", "")), 0)
141
+ if entry_rank < min_rank:
142
+ return False
143
+ if substring is not None:
144
+ msg = str(entry.get("message", ""))
145
+ if substring.lower() not in msg.lower():
146
+ return False
147
+ if request_id is not None:
148
+ entry_rid = str(entry.get("request_id", ""))
149
+ if request_id != entry_rid:
150
+ return False
151
+ return True
152
+
153
+
154
+ _LOG_TAIL_POLL_S = 0.5
155
+ _LOG_TAIL_KEEPALIVE_S = 15.0
156
+
157
+
158
+ async def tail_logs(
159
+ *,
160
+ level: str | None = None,
161
+ substring: str | None = None,
162
+ request_id: str | None = None,
163
+ ) -> AsyncIterator[str]:
164
+ """SSE event-stream generator that tails the in-memory log ring buffer.
165
+
166
+ On first iteration it emits a ``logs:snapshot`` event with all matching
167
+ entries. Subsequent iterations poll the ring buffer and emit
168
+ ``logs:append`` events for any new matching entries.
169
+
170
+ A keepalive ``:`` comment line is emitted every 15 s to prevent proxies
171
+ from closing the connection.
172
+ """
173
+ last_ts: str = ""
174
+ last_keepalive = datetime.now(tz=UTC)
175
+
176
+ while True:
177
+ all_entries = get_log_ring()
178
+
179
+ # Find the slice of entries that arrived after the last timestamp we saw.
180
+ # Walk from the right (newest) to find where to start.
181
+ if last_ts:
182
+ new_entries: list[dict[str, object]] = []
183
+ for entry in reversed(all_entries):
184
+ if str(entry.get("timestamp", "")) <= last_ts:
185
+ break
186
+ new_entries.append(entry)
187
+ new_entries.reverse()
188
+ else:
189
+ # First poll — always emit a snapshot so clients know the
190
+ # handshake is complete, even when filters exclude every entry.
191
+ matching = [
192
+ e
193
+ for e in all_entries
194
+ if _entry_matches(e, level=level, substring=substring, request_id=request_id)
195
+ ]
196
+ yield f"event: logs:snapshot\ndata: {json.dumps(matching)}\n\n"
197
+ last_ts = str(all_entries[-1].get("timestamp", "")) if all_entries else ""
198
+ continue
199
+ # Emit matching new entries as `logs:append` events.
200
+ for entry in new_entries:
201
+ if _entry_matches(entry, level=level, substring=substring, request_id=request_id):
202
+ yield f"event: logs:append\ndata: {json.dumps(entry)}\n\n"
203
+
204
+ if new_entries:
205
+ last_ts = str(new_entries[-1].get("timestamp", ""))
206
+
207
+ # Keepalive
208
+ now = datetime.now(tz=UTC)
209
+ if (now - last_keepalive).total_seconds() >= _LOG_TAIL_KEEPALIVE_S:
210
+ yield ": keepalive\n\n"
211
+ last_keepalive = now
212
+
213
+ await asyncio.sleep(_LOG_TAIL_POLL_S)
@@ -0,0 +1,30 @@
1
+ """Admin service layer for the realtime surface.
2
+
3
+ Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
4
+ """
5
+
6
+ import asyncpg
7
+
8
+ from ...realtime.schemas import EnabledTable
9
+
10
+
11
+ def _row_to_enabled_table(row: asyncpg.Record) -> EnabledTable:
12
+ return EnabledTable(
13
+ schema_name=row["schema_name"],
14
+ table_name=row["table_name"],
15
+ pk_columns=list(row["pk_columns"]),
16
+ owner_column=row["owner_column"],
17
+ created_at=row["created_at"],
18
+ )
19
+
20
+
21
+ async def list_enabled_tables(conn: asyncpg.Connection) -> list[EnabledTable]:
22
+ """Return every row from ``realtime.enabled_tables`` (service_role view)."""
23
+ rows = await conn.fetch(
24
+ """
25
+ select schema_name, table_name, pk_columns, owner_column, created_at
26
+ from realtime.enabled_tables
27
+ order by schema_name, table_name
28
+ """
29
+ )
30
+ return [_row_to_enabled_table(r) for r in rows]
@@ -0,0 +1,220 @@
1
+ from typing import Literal
2
+ from uuid import UUID
3
+
4
+ import asyncpg
5
+
6
+ from ... import db
7
+ from ...storage import service as storage_service
8
+ from ...storage.backends import BackendError, StorageBackend, make_object_key
9
+ from ..errors import AdminError
10
+ from ..schemas import (
11
+ AdminBucket,
12
+ AdminObject,
13
+ AdminObjectsPage,
14
+ AdminSignedUrlResponse,
15
+ )
16
+ from .service_db import preview_claims
17
+
18
+
19
+ def _row_to_bucket(row: asyncpg.Record) -> AdminBucket:
20
+ return AdminBucket(
21
+ id=row["id"],
22
+ name=row["name"],
23
+ owner=row["owner"],
24
+ public=row["public"],
25
+ object_count=row["object_count"],
26
+ total_size=row["total_size"],
27
+ file_size_limit=row["file_size_limit"],
28
+ allowed_mime_types=row["allowed_mime_types"],
29
+ created_at=row["created_at"],
30
+ updated_at=row["updated_at"],
31
+ )
32
+
33
+
34
+ def _row_to_object(row: asyncpg.Record) -> AdminObject:
35
+ return AdminObject(
36
+ id=row["id"],
37
+ bucket=row["bucket_name"],
38
+ name=row["name"],
39
+ owner=row["owner"],
40
+ size=row["size"],
41
+ mime_type=row["mime_type"],
42
+ etag=row["etag"],
43
+ created_at=row["created_at"],
44
+ updated_at=row["updated_at"],
45
+ )
46
+
47
+
48
+ async def list_buckets(conn: asyncpg.Connection) -> list[AdminBucket]:
49
+ rows = await conn.fetch(
50
+ """
51
+ select b.id, b.name, b.owner, b.public,
52
+ b.file_size_limit, b.allowed_mime_types,
53
+ b.created_at, b.updated_at,
54
+ coalesce(o.count, 0)::bigint as object_count,
55
+ coalesce(o.total_size, 0)::bigint as total_size
56
+ from storage.buckets b
57
+ left join (
58
+ select bucket_id,
59
+ count(*) as count,
60
+ sum(size) as total_size
61
+ from storage.objects
62
+ group by bucket_id
63
+ ) o on o.bucket_id = b.id
64
+ order by b.name
65
+ """
66
+ )
67
+ return [_row_to_bucket(r) for r in rows]
68
+
69
+
70
+ async def list_bucket_objects(
71
+ conn: asyncpg.Connection,
72
+ bucket: str,
73
+ prefix: str | None,
74
+ limit: int,
75
+ offset: int,
76
+ ) -> AdminObjectsPage:
77
+ if limit <= 0 or limit > 500:
78
+ raise AdminError("invalid_limit", "limit must be between 1 and 500", 422)
79
+ if offset < 0:
80
+ raise AdminError("invalid_offset", "offset must be >= 0", 422)
81
+ bucket_row = await conn.fetchrow(
82
+ "select id from storage.buckets where name = $1",
83
+ bucket,
84
+ )
85
+ if bucket_row is None:
86
+ raise AdminError("bucket_not_found", f"Bucket {bucket!r} not found", 404)
87
+ bucket_id = bucket_row["id"]
88
+ rows = await conn.fetch(
89
+ """
90
+ select o.id, o.bucket_id, o.name, o.owner, o.size, o.mime_type,
91
+ o.etag, o.created_at, o.updated_at,
92
+ $1::text as bucket_name
93
+ from storage.objects o
94
+ where o.bucket_id = $2
95
+ and ($3::text is null or o.name like $3 || '%')
96
+ order by o.name
97
+ limit $4 offset $5
98
+ """,
99
+ bucket,
100
+ bucket_id,
101
+ prefix,
102
+ limit,
103
+ offset,
104
+ )
105
+ total = await conn.fetchval(
106
+ """
107
+ select count(*)
108
+ from storage.objects
109
+ where bucket_id = $1
110
+ and ($2::text is null or name like $2 || '%')
111
+ """,
112
+ bucket_id,
113
+ prefix,
114
+ )
115
+ return AdminObjectsPage(
116
+ rows=[_row_to_object(r) for r in rows],
117
+ total=total or 0,
118
+ prefix=prefix,
119
+ )
120
+
121
+
122
+ async def _resolve_object(
123
+ conn: asyncpg.Connection, object_id: UUID
124
+ ) -> tuple[str, str]:
125
+ row = await conn.fetchrow(
126
+ """
127
+ select b.name as bucket_name, o.name
128
+ from storage.objects o
129
+ join storage.buckets b on b.id = o.bucket_id
130
+ where o.id = $1
131
+ """,
132
+ object_id,
133
+ )
134
+ if row is None:
135
+ raise AdminError("object_not_found", "Object not found", 404)
136
+ return row["bucket_name"], row["name"]
137
+
138
+
139
+ async def sign_object(
140
+ object_id: UUID,
141
+ expires_in: int | None,
142
+ role: Literal["service_role", "authenticated", "anon"],
143
+ impersonate_sub: UUID | None,
144
+ ) -> tuple[AdminSignedUrlResponse, str, str]:
145
+ """Sign a download URL for ``object_id``.
146
+
147
+ Resolution happens under ``service_role`` so the admin can always find the
148
+ object. The signing call itself runs under ``role`` so RLS gates whether
149
+ that role would have been able to issue the URL — the disclosed
150
+ ``signed_under_role`` tells the operator which surface they granted
151
+ access through.
152
+ """
153
+ async with db.as_service_role() as conn:
154
+ bucket_name, path = await _resolve_object(conn, object_id)
155
+
156
+ if role == "service_role":
157
+ async with db.as_service_role() as conn:
158
+ try:
159
+ signed = await storage_service.issue_signed_url(
160
+ conn,
161
+ bucket_name=bucket_name,
162
+ path=path,
163
+ expires_in=expires_in,
164
+ )
165
+ except storage_service.StorageError as exc:
166
+ raise AdminError(exc.code, exc.message, exc.status) from exc
167
+ else:
168
+ claims = preview_claims(role, impersonate_sub)
169
+ async with db.as_role(role, claims) as conn:
170
+ try:
171
+ signed = await storage_service.issue_signed_url(
172
+ conn,
173
+ bucket_name=bucket_name,
174
+ path=path,
175
+ expires_in=expires_in,
176
+ )
177
+ except storage_service.StorageError as exc:
178
+ raise AdminError(exc.code, exc.message, exc.status) from exc
179
+
180
+ response = AdminSignedUrlResponse(
181
+ signed_url=signed.signed_url,
182
+ token=signed.token,
183
+ expires_at=signed.expires_at,
184
+ expires_in=signed.expires_in,
185
+ signed_under_role=role,
186
+ )
187
+ return response, bucket_name, path
188
+
189
+
190
+ async def delete_object(
191
+ backend: StorageBackend, object_id: UUID
192
+ ) -> tuple[str, str]:
193
+ """Delete object metadata + bytes. Returns ``(bucket_name, path)`` for audit."""
194
+ async with db.as_service_role() as conn:
195
+ row = await conn.fetchrow(
196
+ """
197
+ select b.name as bucket_name, o.name
198
+ from storage.objects o
199
+ join storage.buckets b on b.id = o.bucket_id
200
+ where o.id = $1
201
+ """,
202
+ object_id,
203
+ )
204
+ if row is None:
205
+ raise AdminError("object_not_found", "Object not found", 404)
206
+ bucket_name = row["bucket_name"]
207
+ path = row["name"]
208
+ await conn.execute(
209
+ "delete from storage.objects where id = $1",
210
+ object_id,
211
+ )
212
+
213
+ try:
214
+ await backend.delete(make_object_key(bucket_name, path))
215
+ except BackendError:
216
+ # Orphaned bytes after a successful metadata delete are acceptable per
217
+ # the storage contract. Metadata is the authority.
218
+ pass
219
+
220
+ return bucket_name, path
@@ -0,0 +1,117 @@
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 ...storage.backends import get_backend
9
+ from .. import audit
10
+ from ..deps import require_admin
11
+ from ..errors import AdminError, to_http
12
+ from ..schemas import (
13
+ AdminBucket,
14
+ AdminObjectsPage,
15
+ AdminSignedUrlResponse,
16
+ AdminSignObjectRequest,
17
+ )
18
+ from . import service_storage
19
+
20
+ router = APIRouter(prefix="/admin/api/v1/storage", tags=["admin.storage"])
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("/buckets", response_model=list[AdminBucket])
34
+ async def list_buckets(
35
+ _: Annotated[UUID, Depends(require_admin)],
36
+ ) -> list[AdminBucket]:
37
+ async with db.as_service_role() as conn:
38
+ return await service_storage.list_buckets(conn)
39
+
40
+
41
+ @router.get("/buckets/{bucket}/objects", response_model=AdminObjectsPage)
42
+ async def list_bucket_objects(
43
+ bucket: str,
44
+ _: Annotated[UUID, Depends(require_admin)],
45
+ prefix: str | None = None,
46
+ limit: int = 100,
47
+ offset: int = 0,
48
+ ) -> AdminObjectsPage:
49
+ try:
50
+ async with db.as_service_role() as conn:
51
+ return await service_storage.list_bucket_objects(
52
+ conn, bucket, prefix, limit, offset
53
+ )
54
+ except AdminError as exc:
55
+ raise to_http(exc) from exc
56
+
57
+
58
+ @router.post("/objects/{object_id}/sign", response_model=AdminSignedUrlResponse)
59
+ async def sign_object(
60
+ object_id: UUID,
61
+ request: Request,
62
+ admin_id: Annotated[UUID, Depends(require_admin)],
63
+ payload: Annotated[AdminSignObjectRequest | None, Body()] = None,
64
+ ) -> AdminSignedUrlResponse:
65
+ body = payload or AdminSignObjectRequest()
66
+ ip = _client_ip(request)
67
+ ua = request.headers.get("user-agent")
68
+ try:
69
+ response, bucket_name, path = await service_storage.sign_object(
70
+ object_id,
71
+ body.expires_in,
72
+ body.role,
73
+ body.impersonate_sub,
74
+ )
75
+ async with db.as_service_role() as conn:
76
+ await audit.write(
77
+ conn,
78
+ admin_id=admin_id,
79
+ action="storage.object.sign",
80
+ target=str(object_id),
81
+ payload={
82
+ "bucket": bucket_name,
83
+ "path": path,
84
+ "expires_in": response.expires_in,
85
+ "signed_under_role": response.signed_under_role,
86
+ },
87
+ ip=ip,
88
+ ua=ua,
89
+ )
90
+ except AdminError as exc:
91
+ raise to_http(exc) from exc
92
+ return response
93
+
94
+
95
+ @router.delete("/objects/{object_id}", status_code=204)
96
+ async def delete_object(
97
+ object_id: UUID,
98
+ request: Request,
99
+ admin_id: Annotated[UUID, Depends(require_admin)],
100
+ ) -> None:
101
+ ip = _client_ip(request)
102
+ ua = request.headers.get("user-agent")
103
+ backend = get_backend()
104
+ try:
105
+ bucket_name, path = await service_storage.delete_object(backend, object_id)
106
+ async with db.as_service_role() as conn:
107
+ await audit.write(
108
+ conn,
109
+ admin_id=admin_id,
110
+ action="storage.object.delete",
111
+ target=str(object_id),
112
+ payload={"bucket": bucket_name, "path": path},
113
+ ip=ip,
114
+ ua=ua,
115
+ )
116
+ except AdminError as exc:
117
+ raise to_http(exc) from exc
@@ -0,0 +1,37 @@
1
+ from typing import Annotated
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter, Depends
5
+
6
+ from ... import db, jwks
7
+ from ...settings import get_settings
8
+ from ..deps import require_admin
9
+ from ..schemas import SystemStatus, SystemVersion
10
+
11
+ router = APIRouter(prefix="/admin/api/v1/system", tags=["admin.system"])
12
+
13
+
14
+ @router.get("/status", response_model=SystemStatus)
15
+ async def system_status(
16
+ _: Annotated[UUID, Depends(require_admin)],
17
+ ) -> SystemStatus:
18
+ settings = get_settings()
19
+ pool = db.get_pool()
20
+ jwks_doc = jwks.dump_jwks(jwks.load_verification_keyset())
21
+ kids = [k["kid"] for k in jwks_doc.get("keys", [])]
22
+ kid = kids[0] if kids else ""
23
+ return SystemStatus(
24
+ pool_size=pool.get_size(),
25
+ jwks_kid=kid,
26
+ broker=settings.realtime_enabled,
27
+ jobs=settings.jobs_enabled,
28
+ )
29
+
30
+
31
+ @router.get("/version", response_model=SystemVersion)
32
+ async def system_version(
33
+ _: Annotated[UUID, Depends(require_admin)],
34
+ ) -> SystemVersion:
35
+ from ... import __version__
36
+
37
+ return SystemVersion(version=__version__)
@@ -0,0 +1,29 @@
1
+ import json
2
+ from typing import Any
3
+ from uuid import UUID
4
+
5
+ import asyncpg
6
+
7
+
8
+ async def write(
9
+ conn: asyncpg.Connection,
10
+ *,
11
+ admin_id: UUID | None,
12
+ action: str,
13
+ target: str | None,
14
+ payload: dict[str, Any],
15
+ ip: str | None,
16
+ ua: str | None,
17
+ ) -> None:
18
+ await conn.execute(
19
+ """
20
+ insert into admin.admin_audit (admin_id, action, target, payload, ip, user_agent)
21
+ values ($1, $2, $3, $4::jsonb, $5, $6)
22
+ """,
23
+ admin_id,
24
+ action,
25
+ target,
26
+ json.dumps(payload),
27
+ ip,
28
+ ua,
29
+ )
supython/admin/deps.py ADDED
@@ -0,0 +1,22 @@
1
+ from typing import Annotated
2
+ from uuid import UUID
3
+
4
+ from fastapi import Cookie, HTTPException, Request, status
5
+
6
+ from .. import db
7
+ from . import session as admin_session
8
+
9
+
10
+ async def require_admin(
11
+ request: Request,
12
+ cookie: Annotated[str | None, Cookie(alias=admin_session.SESSION_COOKIE)] = None,
13
+ ) -> UUID:
14
+ if not cookie:
15
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin session required")
16
+ async with db.as_service_role() as conn:
17
+ resolved = await admin_session.resolve(conn, cookie)
18
+ if resolved is None:
19
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired session")
20
+ admin_id, _expires = resolved
21
+ request.state.admin_id = admin_id
22
+ return admin_id
@@ -0,0 +1,16 @@
1
+ from fastapi import HTTPException
2
+
3
+
4
+ class AdminError(Exception):
5
+ def __init__(self, code: str, message: str, status: int = 400):
6
+ self.code = code
7
+ self.message = message
8
+ self.status = status
9
+ super().__init__(message)
10
+
11
+
12
+ def to_http(exc: AdminError) -> HTTPException:
13
+ return HTTPException(
14
+ status_code=exc.status,
15
+ detail={"code": exc.code, "message": exc.message},
16
+ )