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