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.
- supython/__init__.py +8 -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 +149 -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/body_size.py +184 -0
- supython/cli.py +1653 -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/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 +118 -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 +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -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 +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -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.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import asyncpg
|
|
7
|
+
|
|
8
|
+
from ..errors import AdminError
|
|
9
|
+
from ..schemas import (
|
|
10
|
+
AdminAuditPage,
|
|
11
|
+
AdminUser,
|
|
12
|
+
AdminUserDetail,
|
|
13
|
+
AdminUsersPage,
|
|
14
|
+
AuditEvent,
|
|
15
|
+
Identity,
|
|
16
|
+
RefreshToken,
|
|
17
|
+
RefreshTokensPage,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
DEFAULT_BAN_SECONDS = 24 * 60 * 60
|
|
21
|
+
RECENT_AUDIT_LIMIT = 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _row_to_admin_user(row: asyncpg.Record) -> AdminUser:
|
|
25
|
+
return AdminUser(
|
|
26
|
+
id=row["id"],
|
|
27
|
+
email=row["email"],
|
|
28
|
+
created_at=row["created_at"],
|
|
29
|
+
last_sign_in_at=row["last_sign_in_at"],
|
|
30
|
+
banned_until=row["banned_until"],
|
|
31
|
+
email_confirmed_at=row["email_confirmed_at"],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _row_to_identity(row: asyncpg.Record) -> Identity:
|
|
36
|
+
raw = row["identity_data"]
|
|
37
|
+
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
38
|
+
return Identity(
|
|
39
|
+
id=row["id"],
|
|
40
|
+
user_id=row["user_id"],
|
|
41
|
+
provider=row["provider"],
|
|
42
|
+
provider_user_id=row["provider_user_id"],
|
|
43
|
+
identity_data=data,
|
|
44
|
+
created_at=row["created_at"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _row_to_audit(row: asyncpg.Record) -> AuditEvent:
|
|
49
|
+
raw = row["payload"]
|
|
50
|
+
payload = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
51
|
+
return AuditEvent(
|
|
52
|
+
id=row["id"],
|
|
53
|
+
user_id=row["user_id"],
|
|
54
|
+
event=row["event"],
|
|
55
|
+
ip=str(row["ip"]) if row["ip"] is not None else None,
|
|
56
|
+
ua=row["ua"],
|
|
57
|
+
payload=payload,
|
|
58
|
+
created_at=row["created_at"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def search_users(
|
|
63
|
+
conn: asyncpg.Connection,
|
|
64
|
+
search: str | None,
|
|
65
|
+
confirmed: bool | None,
|
|
66
|
+
banned: bool | None,
|
|
67
|
+
limit: int,
|
|
68
|
+
offset: int,
|
|
69
|
+
) -> AdminUsersPage:
|
|
70
|
+
if limit <= 0 or limit > 200:
|
|
71
|
+
raise AdminError("invalid_limit", "limit must be between 1 and 200", 422)
|
|
72
|
+
if offset < 0:
|
|
73
|
+
raise AdminError("invalid_offset", "offset must be >= 0", 422)
|
|
74
|
+
pattern = f"%{search}%" if search else None
|
|
75
|
+
where_sql = """
|
|
76
|
+
($1::text is null or email ilike $1)
|
|
77
|
+
and ($2::bool is null or (email_confirmed_at is not null) = $2)
|
|
78
|
+
and ($3::bool is null or (banned_until is not null and banned_until > now()) = $3)
|
|
79
|
+
"""
|
|
80
|
+
rows = await conn.fetch(
|
|
81
|
+
f"""
|
|
82
|
+
select id, email, created_at, last_sign_in_at, banned_until, email_confirmed_at
|
|
83
|
+
from auth.users
|
|
84
|
+
where {where_sql}
|
|
85
|
+
order by created_at desc
|
|
86
|
+
limit $4 offset $5
|
|
87
|
+
""",
|
|
88
|
+
pattern,
|
|
89
|
+
confirmed,
|
|
90
|
+
banned,
|
|
91
|
+
limit,
|
|
92
|
+
offset,
|
|
93
|
+
)
|
|
94
|
+
total = await conn.fetchval(
|
|
95
|
+
f"""
|
|
96
|
+
select count(*)
|
|
97
|
+
from auth.users
|
|
98
|
+
where {where_sql}
|
|
99
|
+
""",
|
|
100
|
+
pattern,
|
|
101
|
+
confirmed,
|
|
102
|
+
banned,
|
|
103
|
+
)
|
|
104
|
+
return AdminUsersPage(
|
|
105
|
+
rows=[_row_to_admin_user(r) for r in rows],
|
|
106
|
+
total=total or 0,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def get_user_detail(conn: asyncpg.Connection, user_id: UUID) -> AdminUserDetail:
|
|
111
|
+
row = await conn.fetchrow(
|
|
112
|
+
"""
|
|
113
|
+
select id, email, created_at, last_sign_in_at, banned_until, email_confirmed_at
|
|
114
|
+
from auth.users
|
|
115
|
+
where id = $1
|
|
116
|
+
""",
|
|
117
|
+
user_id,
|
|
118
|
+
)
|
|
119
|
+
if row is None:
|
|
120
|
+
raise AdminError("not_found", "user not found", 404)
|
|
121
|
+
identities = await conn.fetch(
|
|
122
|
+
"""
|
|
123
|
+
select id, user_id, provider, provider_user_id, identity_data, created_at
|
|
124
|
+
from auth.identities
|
|
125
|
+
where user_id = $1
|
|
126
|
+
order by created_at desc
|
|
127
|
+
""",
|
|
128
|
+
user_id,
|
|
129
|
+
)
|
|
130
|
+
audit = await conn.fetch(
|
|
131
|
+
"""
|
|
132
|
+
select id, user_id, event, ip, ua, payload, created_at
|
|
133
|
+
from auth.audit_log
|
|
134
|
+
where user_id = $1
|
|
135
|
+
order by created_at desc
|
|
136
|
+
limit $2
|
|
137
|
+
""",
|
|
138
|
+
user_id,
|
|
139
|
+
RECENT_AUDIT_LIMIT,
|
|
140
|
+
)
|
|
141
|
+
return AdminUserDetail(
|
|
142
|
+
user=_row_to_admin_user(row),
|
|
143
|
+
identities=[_row_to_identity(r) for r in identities],
|
|
144
|
+
recent_audit=[_row_to_audit(r) for r in audit],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def ban_user(
|
|
149
|
+
conn: asyncpg.Connection, user_id: UUID, duration_seconds: int | None
|
|
150
|
+
) -> datetime:
|
|
151
|
+
seconds = duration_seconds if duration_seconds is not None else DEFAULT_BAN_SECONDS
|
|
152
|
+
if seconds <= 0:
|
|
153
|
+
raise AdminError("invalid_duration", "duration_seconds must be > 0", 422)
|
|
154
|
+
banned_until = datetime.now(tz=timezone.utc) + timedelta(seconds=seconds)
|
|
155
|
+
updated = await conn.fetchval(
|
|
156
|
+
"""
|
|
157
|
+
update auth.users
|
|
158
|
+
set banned_until = $2, updated_at = now()
|
|
159
|
+
where id = $1
|
|
160
|
+
returning banned_until
|
|
161
|
+
""",
|
|
162
|
+
user_id,
|
|
163
|
+
banned_until,
|
|
164
|
+
)
|
|
165
|
+
if updated is None:
|
|
166
|
+
raise AdminError("not_found", "user not found", 404)
|
|
167
|
+
return updated
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def unban_user(conn: asyncpg.Connection, user_id: UUID) -> None:
|
|
171
|
+
updated = await conn.fetchval(
|
|
172
|
+
"""
|
|
173
|
+
update auth.users
|
|
174
|
+
set banned_until = null, updated_at = now()
|
|
175
|
+
where id = $1
|
|
176
|
+
returning id
|
|
177
|
+
""",
|
|
178
|
+
user_id,
|
|
179
|
+
)
|
|
180
|
+
if updated is None:
|
|
181
|
+
raise AdminError("not_found", "user not found", 404)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def force_logout(conn: asyncpg.Connection, user_id: UUID) -> int:
|
|
185
|
+
exists = await conn.fetchval(
|
|
186
|
+
"select 1 from auth.users where id = $1",
|
|
187
|
+
user_id,
|
|
188
|
+
)
|
|
189
|
+
if exists is None:
|
|
190
|
+
raise AdminError("not_found", "user not found", 404)
|
|
191
|
+
revoked = await conn.fetch(
|
|
192
|
+
"""
|
|
193
|
+
update auth.refresh_tokens
|
|
194
|
+
set revoked = true
|
|
195
|
+
where user_id = $1 and revoked = false
|
|
196
|
+
returning id
|
|
197
|
+
""",
|
|
198
|
+
user_id,
|
|
199
|
+
)
|
|
200
|
+
return len(revoked)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def write_user_audit(
|
|
204
|
+
conn: asyncpg.Connection,
|
|
205
|
+
*,
|
|
206
|
+
user_id: UUID,
|
|
207
|
+
event: str,
|
|
208
|
+
payload: dict[str, Any],
|
|
209
|
+
ip: str | None,
|
|
210
|
+
ua: str | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
await conn.execute(
|
|
213
|
+
"""
|
|
214
|
+
insert into auth.audit_log (user_id, event, ip, ua, payload)
|
|
215
|
+
values ($1, $2, $3::inet, $4, $5::jsonb)
|
|
216
|
+
""",
|
|
217
|
+
user_id,
|
|
218
|
+
event,
|
|
219
|
+
ip,
|
|
220
|
+
ua,
|
|
221
|
+
json.dumps(payload),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _row_to_refresh_token(row: asyncpg.Record) -> RefreshToken:
|
|
226
|
+
return RefreshToken(
|
|
227
|
+
id=row["id"],
|
|
228
|
+
user_id=row["user_id"],
|
|
229
|
+
token=row["token"],
|
|
230
|
+
parent=row["parent"],
|
|
231
|
+
revoked=row["revoked"],
|
|
232
|
+
created_at=row["created_at"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def list_refresh_tokens(
|
|
237
|
+
conn: asyncpg.Connection,
|
|
238
|
+
user_id: UUID | None,
|
|
239
|
+
limit: int,
|
|
240
|
+
offset: int,
|
|
241
|
+
) -> RefreshTokensPage:
|
|
242
|
+
if limit <= 0 or limit > 200:
|
|
243
|
+
raise AdminError("invalid_limit", "limit must be between 1 and 200", 422)
|
|
244
|
+
if offset < 0:
|
|
245
|
+
raise AdminError("invalid_offset", "offset must be >= 0", 422)
|
|
246
|
+
|
|
247
|
+
rows = await conn.fetch(
|
|
248
|
+
"""
|
|
249
|
+
select id, user_id, token, parent, revoked, created_at
|
|
250
|
+
from auth.refresh_tokens
|
|
251
|
+
where ($1::uuid is null or user_id = $1)
|
|
252
|
+
order by created_at desc
|
|
253
|
+
limit $2 offset $3
|
|
254
|
+
""",
|
|
255
|
+
user_id,
|
|
256
|
+
limit,
|
|
257
|
+
offset,
|
|
258
|
+
)
|
|
259
|
+
total = await conn.fetchval(
|
|
260
|
+
"""
|
|
261
|
+
select count(*)
|
|
262
|
+
from auth.refresh_tokens
|
|
263
|
+
where ($1::uuid is null or user_id = $1)
|
|
264
|
+
""",
|
|
265
|
+
user_id,
|
|
266
|
+
)
|
|
267
|
+
return RefreshTokensPage(
|
|
268
|
+
rows=[_row_to_refresh_token(r) for r in rows],
|
|
269
|
+
total=total or 0,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def revoke_refresh_token(conn: asyncpg.Connection, token_id: int) -> UUID:
|
|
274
|
+
"""Revoke a single refresh token. Returns the owning user_id for audit."""
|
|
275
|
+
row = await conn.fetchrow(
|
|
276
|
+
"""
|
|
277
|
+
update auth.refresh_tokens
|
|
278
|
+
set revoked = true
|
|
279
|
+
where id = $1
|
|
280
|
+
returning id, user_id
|
|
281
|
+
""",
|
|
282
|
+
token_id,
|
|
283
|
+
)
|
|
284
|
+
if row is None:
|
|
285
|
+
raise AdminError("not_found", "refresh token not found", 404)
|
|
286
|
+
return row["user_id"]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def list_audit_log(
|
|
290
|
+
conn: asyncpg.Connection,
|
|
291
|
+
event: str | None,
|
|
292
|
+
ip: str | None,
|
|
293
|
+
from_date: str | None,
|
|
294
|
+
to_date: str | None,
|
|
295
|
+
limit: int,
|
|
296
|
+
offset: int,
|
|
297
|
+
) -> AdminAuditPage:
|
|
298
|
+
if limit <= 0 or limit > 200:
|
|
299
|
+
raise AdminError("invalid_limit", "limit must be between 1 and 200", 422)
|
|
300
|
+
if offset < 0:
|
|
301
|
+
raise AdminError("invalid_offset", "offset must be >= 0", 422)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
from_date_d = datetime.strptime(from_date, "%Y-%m-%d").date() if from_date else None
|
|
305
|
+
to_date_d = datetime.strptime(to_date, "%Y-%m-%d").date() if to_date else None
|
|
306
|
+
except ValueError as exc:
|
|
307
|
+
raise AdminError("invalid_date", "dates must be YYYY-MM-DD", 422) from exc
|
|
308
|
+
|
|
309
|
+
ip_pattern = f"%{ip}%" if ip else None
|
|
310
|
+
|
|
311
|
+
rows = await conn.fetch(
|
|
312
|
+
"""
|
|
313
|
+
select id, user_id, event, ip, ua, payload, created_at
|
|
314
|
+
from auth.audit_log
|
|
315
|
+
where ($1::text is null or event = $1)
|
|
316
|
+
and ($2::text is null or ip::text ilike $2)
|
|
317
|
+
and ($3::date is null or created_at::date >= $3)
|
|
318
|
+
and ($4::date is null or created_at::date <= $4)
|
|
319
|
+
order by created_at desc
|
|
320
|
+
limit $5 offset $6
|
|
321
|
+
""",
|
|
322
|
+
event,
|
|
323
|
+
ip_pattern,
|
|
324
|
+
from_date_d,
|
|
325
|
+
to_date_d,
|
|
326
|
+
limit,
|
|
327
|
+
offset,
|
|
328
|
+
)
|
|
329
|
+
total = await conn.fetchval(
|
|
330
|
+
"""
|
|
331
|
+
select count(*)
|
|
332
|
+
from auth.audit_log
|
|
333
|
+
where ($1::text is null or event = $1)
|
|
334
|
+
and ($2::text is null or ip::text ilike $2)
|
|
335
|
+
and ($3::date is null or created_at::date >= $3)
|
|
336
|
+
and ($4::date is null or created_at::date <= $4)
|
|
337
|
+
""",
|
|
338
|
+
event,
|
|
339
|
+
ip_pattern,
|
|
340
|
+
from_date_d,
|
|
341
|
+
to_date_d,
|
|
342
|
+
)
|
|
343
|
+
return AdminAuditPage(
|
|
344
|
+
rows=[_row_to_audit(r) for r in rows],
|
|
345
|
+
total=total or 0,
|
|
346
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
from uuid import UUID, uuid4
|
|
4
|
+
|
|
5
|
+
import asyncpg
|
|
6
|
+
|
|
7
|
+
from ..errors import AdminError
|
|
8
|
+
from ..schemas import (
|
|
9
|
+
DryRunResponse,
|
|
10
|
+
MigrationRecord,
|
|
11
|
+
RlsPolicy,
|
|
12
|
+
RowsPage,
|
|
13
|
+
SchemaInfo,
|
|
14
|
+
SqlExecRequest,
|
|
15
|
+
SqlExecResponse,
|
|
16
|
+
TableInfo,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
_DDL_ALLOWED = re.compile(r"^\s*(create|drop|alter)\s+policy\b", re.IGNORECASE | re.DOTALL)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_value(v: Any) -> Any:
|
|
24
|
+
"""Ensure values are JSON-serializable (bytes → hex, else identity)."""
|
|
25
|
+
if isinstance(v, bytes):
|
|
26
|
+
return r"\x" + v.hex()
|
|
27
|
+
return v
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_ident(kind: str, value: str) -> None:
|
|
31
|
+
if not _IDENT_RE.match(value):
|
|
32
|
+
raise AdminError(f"invalid_{kind}", f"invalid {kind} name: {value}", 400)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _qident(schema: str, table: str) -> str:
|
|
36
|
+
_check_ident("schema", schema)
|
|
37
|
+
_check_ident("table", table)
|
|
38
|
+
return f'"{schema}"."{table}"'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def list_schemas(conn: asyncpg.Connection) -> list[SchemaInfo]:
|
|
42
|
+
rows = await conn.fetch(
|
|
43
|
+
"""
|
|
44
|
+
select n.nspname as name,
|
|
45
|
+
pg_catalog.pg_get_userbyid(n.nspowner) as owner,
|
|
46
|
+
n.nspname not in ('pg_catalog', 'information_schema') as is_user
|
|
47
|
+
from pg_catalog.pg_namespace n
|
|
48
|
+
where n.nspname not like 'pg_%'
|
|
49
|
+
order by n.nspname
|
|
50
|
+
""",
|
|
51
|
+
)
|
|
52
|
+
return [SchemaInfo(**dict(r)) for r in rows]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def list_tables(conn: asyncpg.Connection, schema: str) -> list[TableInfo]:
|
|
56
|
+
_check_ident("schema", schema)
|
|
57
|
+
rows = await conn.fetch(
|
|
58
|
+
"""
|
|
59
|
+
select c.relname as name,
|
|
60
|
+
c.relrowsecurity as rls_enabled
|
|
61
|
+
from pg_catalog.pg_class c
|
|
62
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
63
|
+
where n.nspname = $1
|
|
64
|
+
and c.relkind = 'r'
|
|
65
|
+
order by c.relname
|
|
66
|
+
""",
|
|
67
|
+
schema,
|
|
68
|
+
)
|
|
69
|
+
return [TableInfo(**dict(r)) for r in rows]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def read_rows(
|
|
73
|
+
conn: asyncpg.Connection,
|
|
74
|
+
schema: str,
|
|
75
|
+
table: str,
|
|
76
|
+
limit: int,
|
|
77
|
+
offset: int,
|
|
78
|
+
order: str | None,
|
|
79
|
+
) -> RowsPage:
|
|
80
|
+
qident = _qident(schema, table)
|
|
81
|
+
order_sql = ""
|
|
82
|
+
if order:
|
|
83
|
+
if not all(c.isalnum() or c in "_," for c in order):
|
|
84
|
+
raise AdminError("invalid_order", f"invalid order clause: {order}", 400)
|
|
85
|
+
order_sql = f" order by {order}"
|
|
86
|
+
try:
|
|
87
|
+
rows = await conn.fetch(
|
|
88
|
+
f"select * from {qident}{order_sql} limit $1 offset $2",
|
|
89
|
+
limit,
|
|
90
|
+
offset,
|
|
91
|
+
)
|
|
92
|
+
total = await conn.fetchval(f"select count(*) from {qident}")
|
|
93
|
+
except asyncpg.UndefinedTableError as exc:
|
|
94
|
+
raise AdminError("not_found", str(exc), 404) from exc
|
|
95
|
+
except asyncpg.PostgresError as exc:
|
|
96
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
97
|
+
cols = list(rows[0].keys()) if rows else []
|
|
98
|
+
return RowsPage(
|
|
99
|
+
columns=cols,
|
|
100
|
+
rows=[[_safe_value(r[c]) for c in cols] for r in rows],
|
|
101
|
+
total=total or 0,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def preview_claims(role: Literal["authenticated", "anon"], sub: UUID | None) -> dict[str, Any]:
|
|
106
|
+
if role == "authenticated" and sub is None:
|
|
107
|
+
raise AdminError(
|
|
108
|
+
"preview_sub_required",
|
|
109
|
+
"authenticated preview needs impersonate_sub",
|
|
110
|
+
422,
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
"role": role,
|
|
114
|
+
"sub": str(sub) if sub else str(uuid4()),
|
|
115
|
+
"aud": "authenticated" if role == "authenticated" else "anon",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def execute_sql(conn: asyncpg.Connection, payload: SqlExecRequest) -> SqlExecResponse:
|
|
120
|
+
async with conn.transaction():
|
|
121
|
+
if payload.read_only:
|
|
122
|
+
await conn.execute("set local transaction read only")
|
|
123
|
+
try:
|
|
124
|
+
rows = await conn.fetch(payload.statement)
|
|
125
|
+
except asyncpg.PostgresError as exc:
|
|
126
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
127
|
+
cols = list(rows[0].keys()) if rows else []
|
|
128
|
+
return SqlExecResponse(
|
|
129
|
+
columns=cols,
|
|
130
|
+
rows=[[_safe_value(r[c]) for c in cols] for r in rows],
|
|
131
|
+
row_count=len(rows),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def list_policies(conn: asyncpg.Connection, schema: str, table: str) -> list[RlsPolicy]:
|
|
136
|
+
_qident(schema, table)
|
|
137
|
+
try:
|
|
138
|
+
rows = await conn.fetch(
|
|
139
|
+
"""
|
|
140
|
+
select schemaname, tablename, policyname, permissive,
|
|
141
|
+
roles, cmd, qual, with_check
|
|
142
|
+
from pg_policies
|
|
143
|
+
where schemaname = $1 and tablename = $2
|
|
144
|
+
order by policyname
|
|
145
|
+
""",
|
|
146
|
+
schema,
|
|
147
|
+
table,
|
|
148
|
+
)
|
|
149
|
+
except asyncpg.PostgresError as exc:
|
|
150
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
151
|
+
return [RlsPolicy(**dict(r)) for r in rows]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def dry_run_policy(conn: asyncpg.Connection, ddl: str, sample_query: str) -> DryRunResponse:
|
|
155
|
+
if not _DDL_ALLOWED.match(ddl):
|
|
156
|
+
raise AdminError(
|
|
157
|
+
"invalid_ddl",
|
|
158
|
+
"dry-run only accepts (create|drop|alter) policy statements",
|
|
159
|
+
422,
|
|
160
|
+
)
|
|
161
|
+
if len(ddl) > 8192:
|
|
162
|
+
raise AdminError("invalid_ddl", "ddl too long", 422)
|
|
163
|
+
|
|
164
|
+
class _Rollback(Exception):
|
|
165
|
+
def __init__(self, rows: list[asyncpg.Record]) -> None:
|
|
166
|
+
self.rows = rows
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
async with conn.transaction():
|
|
170
|
+
await conn.execute(ddl)
|
|
171
|
+
rows = await conn.fetch(sample_query)
|
|
172
|
+
raise _Rollback(rows)
|
|
173
|
+
except _Rollback as rb:
|
|
174
|
+
cols = list(rb.rows[0].keys()) if rb.rows else []
|
|
175
|
+
return DryRunResponse(
|
|
176
|
+
columns=cols,
|
|
177
|
+
rows=[[_safe_value(r[c]) for c in cols] for r in rb.rows],
|
|
178
|
+
)
|
|
179
|
+
except asyncpg.PostgresError as exc:
|
|
180
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def list_migrations(conn: asyncpg.Connection) -> list[MigrationRecord]:
|
|
184
|
+
out: list[MigrationRecord] = []
|
|
185
|
+
try:
|
|
186
|
+
supython_rows = await conn.fetch(
|
|
187
|
+
"""
|
|
188
|
+
select name as version, applied_at
|
|
189
|
+
from supython.migrations
|
|
190
|
+
order by name
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
193
|
+
except asyncpg.PostgresError as exc:
|
|
194
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
195
|
+
out.extend(
|
|
196
|
+
MigrationRecord(version=r["version"], applied_at=r["applied_at"], source="supython")
|
|
197
|
+
for r in supython_rows
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
has_dbmate = await conn.fetchval(
|
|
202
|
+
"select to_regclass('public.schema_migrations') is not null"
|
|
203
|
+
)
|
|
204
|
+
if has_dbmate:
|
|
205
|
+
dbmate_rows = await conn.fetch(
|
|
206
|
+
"select version from public.schema_migrations order by version"
|
|
207
|
+
)
|
|
208
|
+
out.extend(
|
|
209
|
+
MigrationRecord(version=r["version"], applied_at=None, source="dbmate")
|
|
210
|
+
for r in dbmate_rows
|
|
211
|
+
)
|
|
212
|
+
except asyncpg.PostgresError as exc:
|
|
213
|
+
raise AdminError("sql_error", str(exc), 400) from exc
|
|
214
|
+
return out
|