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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- 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__)
|
supython/admin/audit.py
ADDED
|
@@ -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
|
supython/admin/errors.py
ADDED
|
@@ -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
|
+
)
|