supython 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/secretset.py ADDED
@@ -0,0 +1,347 @@
1
+ """Symmetric secret rotation manifest.
2
+
3
+ Multi-secret lifecycle for symmetric secrets (storage signed URLs, OAuth state).
4
+ Mirrors the JWT keyset pattern: ``secrets.json`` manifest + ``secrets/*.secret``
5
+ files. The manifest tracks metadata (kid, status, created_at, retired_at);
6
+ secret values live in individual files with ``0o600`` permissions.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ from .settings import get_settings
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _DEFAULT_SECRETS_DIR = Path("./.supython/secrets")
24
+
25
+ SecretName = Literal["storage_signed_url", "oauth_state"]
26
+ SecretStatus = Literal["active", "verifying", "retired"]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SecretEntry:
31
+ kid: str
32
+ name: SecretName
33
+ secret_path: Path
34
+ created_at: datetime
35
+ retired_at: datetime | None
36
+ status: SecretStatus
37
+
38
+
39
+ def _now() -> datetime:
40
+ return datetime.now(tz=timezone.utc)
41
+
42
+
43
+ def secrets_dir() -> Path:
44
+ s = get_settings()
45
+ return s.secrets_dir if s.secrets_dir is not None else _DEFAULT_SECRETS_DIR
46
+
47
+
48
+ def manifest_path() -> Path:
49
+ return get_settings().secrets_manifest_path
50
+
51
+
52
+ def has_manifest() -> bool:
53
+ return manifest_path().exists()
54
+
55
+
56
+ def _to_iso(dt: datetime) -> str:
57
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
58
+
59
+
60
+ def _from_iso(value: str | None) -> datetime | None:
61
+ if value is None:
62
+ return None
63
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
64
+
65
+
66
+ def _entry_to_dict(entry: SecretEntry) -> dict[str, Any]:
67
+ return {
68
+ "kid": entry.kid,
69
+ "status": entry.status,
70
+ "created_at": _to_iso(entry.created_at),
71
+ "retired_at": _to_iso(entry.retired_at) if entry.retired_at else None,
72
+ }
73
+
74
+
75
+ def _entry_from_dict(data: dict[str, Any], name: SecretName, sdir: Path) -> SecretEntry:
76
+ return SecretEntry(
77
+ kid=data["kid"],
78
+ name=name,
79
+ secret_path=sdir / f"{name}.{data['kid']}.secret",
80
+ created_at=_from_iso(data["created_at"]) or _now(),
81
+ retired_at=_from_iso(data.get("retired_at")),
82
+ status=data.get("status", "verifying"),
83
+ )
84
+
85
+
86
+ def load_manifest() -> dict[str, Any] | None:
87
+ path = manifest_path()
88
+ if not path.exists():
89
+ return None
90
+ with path.open("r") as f:
91
+ return json.load(f)
92
+
93
+
94
+ def write_manifest(manifest: dict[str, Any]) -> None:
95
+ path = manifest_path()
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
98
+ tmp_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n")
99
+ os.replace(tmp_path, path)
100
+
101
+
102
+ def list_secrets(name: SecretName) -> list[SecretEntry]:
103
+ manifest = load_manifest()
104
+ if manifest is None:
105
+ return []
106
+ section = manifest.get(name)
107
+ if section is None:
108
+ return []
109
+ sdir = secrets_dir()
110
+ return [_entry_from_dict(d, name, sdir) for d in section.get("keys", [])]
111
+
112
+
113
+ def active_secret(name: SecretName) -> SecretEntry | None:
114
+ manifest = load_manifest()
115
+ if manifest is None:
116
+ return None
117
+ section = manifest.get(name)
118
+ if section is None:
119
+ return None
120
+ active_kid = section.get("active")
121
+ if active_kid is None:
122
+ return None
123
+ for entry in list_secrets(name):
124
+ if entry.kid == active_kid:
125
+ return entry
126
+ return None
127
+
128
+
129
+ def get_entry(name: SecretName, kid: str) -> SecretEntry | None:
130
+ for entry in list_secrets(name):
131
+ if entry.kid == kid:
132
+ return entry
133
+ return None
134
+
135
+
136
+ def import_legacy_single_secret(name: SecretName) -> SecretEntry | None:
137
+ """Seed the manifest from a legacy env var when the manifest is absent.
138
+
139
+ Returns ``None`` if the manifest already exists or if the legacy env var
140
+ is empty / shorter than 32 characters.
141
+ """
142
+ if has_manifest():
143
+ return None
144
+ s = get_settings()
145
+ if name == "storage_signed_url":
146
+ legacy = s.storage_signed_url_secret
147
+ elif name == "oauth_state":
148
+ legacy = s.oauth_state_secret
149
+ else:
150
+ return None
151
+ if not legacy or len(legacy) < 32:
152
+ return None
153
+
154
+ sdir = secrets_dir()
155
+ sdir.mkdir(parents=True, exist_ok=True)
156
+ kid = "v1"
157
+ secret_path = sdir / f"{name}.{kid}.secret"
158
+ secret_path.write_text(legacy)
159
+ if os.name == "posix":
160
+ secret_path.chmod(0o600)
161
+
162
+ entry = SecretEntry(
163
+ kid=kid,
164
+ name=name,
165
+ secret_path=secret_path,
166
+ created_at=_now(),
167
+ retired_at=None,
168
+ status="active",
169
+ )
170
+ manifest = load_manifest() or {}
171
+ manifest[name] = {
172
+ "active": kid,
173
+ "keys": [_entry_to_dict(entry)],
174
+ }
175
+ write_manifest(manifest)
176
+ return entry
177
+
178
+
179
+ def rotate(name: SecretName) -> SecretEntry:
180
+ """Generate a new secret, write file, append to manifest as ``verifying``.
181
+
182
+ On first call (no manifest), imports the legacy single secret before
183
+ appending the new one.
184
+ """
185
+ if not has_manifest():
186
+ import_legacy_single_secret(name)
187
+
188
+ sdir = secrets_dir()
189
+ sdir.mkdir(parents=True, exist_ok=True)
190
+
191
+ kid = secrets.token_urlsafe(8)
192
+ secret_path = sdir / f"{name}.{kid}.secret"
193
+ secret_value = secrets.token_urlsafe(48)
194
+ secret_path.write_text(secret_value)
195
+ if os.name == "posix":
196
+ secret_path.chmod(0o600)
197
+
198
+ entry = SecretEntry(
199
+ kid=kid,
200
+ name=name,
201
+ secret_path=secret_path,
202
+ created_at=_now(),
203
+ retired_at=None,
204
+ status="verifying",
205
+ )
206
+
207
+ manifest = load_manifest() or {}
208
+ section = manifest.get(name)
209
+ if section is None:
210
+ section = {"active": None, "keys": []}
211
+ manifest[name] = section
212
+
213
+ if not any(k["kid"] == kid for k in section["keys"]):
214
+ section["keys"].append(_entry_to_dict(entry))
215
+
216
+ if section.get("active") is None:
217
+ section["active"] = kid
218
+ for k in section["keys"]:
219
+ if k["kid"] == kid:
220
+ k["status"] = "active"
221
+ k["retired_at"] = None
222
+
223
+ write_manifest(manifest)
224
+ return entry
225
+
226
+
227
+ def activate(name: SecretName, kid: str) -> None:
228
+ """Promote ``kid`` to ``active``; previous active becomes ``retired``."""
229
+ manifest = load_manifest()
230
+ if manifest is None:
231
+ raise FileNotFoundError(f"secret manifest not found: {manifest_path()}")
232
+ section = manifest.get(name)
233
+ if section is None:
234
+ raise KeyError(f"secret name not in manifest: {name!r}")
235
+ target = next((k for k in section["keys"] if k["kid"] == kid), None)
236
+ if target is None:
237
+ raise KeyError(f"kid not in {name} secret set: {kid!r}")
238
+ previous = section.get("active")
239
+ now_iso = _to_iso(_now())
240
+ for k in section["keys"]:
241
+ if k["kid"] == previous and previous != kid:
242
+ k["status"] = "retired"
243
+ k["retired_at"] = now_iso
244
+ target["status"] = "active"
245
+ target["retired_at"] = None
246
+ section["active"] = kid
247
+ write_manifest(manifest)
248
+
249
+
250
+ def prune(name: SecretName, *, force_all: bool = False) -> list[str]:
251
+ """Drop retired secrets past grace; delete ``.secret`` files; return removed kids."""
252
+ manifest = load_manifest()
253
+ if manifest is None:
254
+ return []
255
+ section = manifest.get(name)
256
+ if section is None:
257
+ return []
258
+ grace = get_settings().secret_rotation_grace_seconds
259
+ now = _now()
260
+ sdir = secrets_dir()
261
+ removed: list[str] = []
262
+ surviving: list[dict[str, Any]] = []
263
+ for k in section["keys"]:
264
+ if k.get("status") != "retired":
265
+ surviving.append(k)
266
+ continue
267
+ retired_at = _from_iso(k.get("retired_at"))
268
+ elapsed = (now - retired_at).total_seconds() if retired_at else 0.0
269
+ if force_all or (retired_at is not None and elapsed >= grace):
270
+ secret_path = sdir / f"{name}.{k['kid']}.secret"
271
+ try:
272
+ secret_path.unlink()
273
+ except FileNotFoundError:
274
+ pass
275
+ removed.append(k["kid"])
276
+ else:
277
+ surviving.append(k)
278
+ section["keys"] = surviving
279
+ if section.get("active") in removed:
280
+ section["active"] = None
281
+ write_manifest(manifest)
282
+ return removed
283
+
284
+
285
+ def read_secret_value(entry: SecretEntry) -> str:
286
+ return entry.secret_path.read_text().strip()
287
+
288
+
289
+ @lru_cache(maxsize=None)
290
+ def load_signing_secret(name: SecretName) -> str | None:
291
+ """Return the active secret value, or ``None`` when callers should fall back.
292
+
293
+ Returns ``None`` when the manifest is absent **or** has no section for
294
+ ``name`` so callers can use the legacy env-var path per-``SecretName``.
295
+ This lets one secret family migrate to the manifest while another keeps
296
+ running on its env var (the §18 "each secret family still rotates
297
+ independently" decision).
298
+
299
+ Raises ``RuntimeError`` only when the section exists but its ``active``
300
+ pointer is absent or dangles to a kid that is not in the section's
301
+ ``keys`` list — that state is operator error, surfaced by ``supython
302
+ doctor``.
303
+ """
304
+ manifest = load_manifest()
305
+ if manifest is None:
306
+ return None
307
+ section = manifest.get(name)
308
+ if section is None:
309
+ return None
310
+ active_kid = section.get("active")
311
+ if active_kid is None:
312
+ raise RuntimeError(f"no active secret for {name!r}")
313
+ entry = get_entry(name, active_kid)
314
+ if entry is None:
315
+ raise RuntimeError(
316
+ f"active kid {active_kid!r} for {name!r} not present in manifest"
317
+ )
318
+ return read_secret_value(entry)
319
+
320
+
321
+ @lru_cache(maxsize=None)
322
+ def load_verification_secrets(name: SecretName) -> list[tuple[str, str | None]]:
323
+ """Return ``(secret_value, kid)`` for active + retired-within-grace.
324
+
325
+ Returns ``[]`` when the manifest is absent.
326
+ """
327
+ manifest = load_manifest()
328
+ if manifest is None:
329
+ return []
330
+ grace = get_settings().secret_rotation_grace_seconds
331
+ now = _now()
332
+ result: list[tuple[str, str | None]] = []
333
+ for entry in list_secrets(name):
334
+ if entry.status == "active":
335
+ result.append((read_secret_value(entry), entry.kid))
336
+ elif entry.status == "retired":
337
+ retired_at = entry.retired_at
338
+ elapsed = (now - retired_at).total_seconds() if retired_at else 0.0
339
+ if retired_at is not None and elapsed < grace:
340
+ result.append((read_secret_value(entry), entry.kid))
341
+ return result
342
+
343
+
344
+ def clear_cache() -> None:
345
+ """Clear cached loaders; call after manifest mutations."""
346
+ load_signing_secret.cache_clear()
347
+ load_verification_secrets.cache_clear()
@@ -0,0 +1,78 @@
1
+ import logging
2
+ from collections.abc import Awaitable, Callable
3
+ from typing import Any
4
+
5
+ from .settings import Settings, get_settings
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def _hsts_effective(settings: Settings) -> bool:
11
+ # Explicit override wins; otherwise auto-on for https site_url, off for http.
12
+ if settings.security_hsts_enabled is not None:
13
+ return settings.security_hsts_enabled
14
+ return settings.site_url.lower().startswith("https://")
15
+
16
+
17
+ def _build_always_headers(settings: Settings) -> list[tuple[bytes, bytes]]:
18
+ headers: list[tuple[bytes, bytes]] = [
19
+ (b"x-content-type-options", b"nosniff"),
20
+ (b"x-frame-options", settings.security_frame_options.encode("ascii")),
21
+ (b"referrer-policy", settings.security_referrer_policy.encode("ascii")),
22
+ ]
23
+ if _hsts_effective(settings):
24
+ parts = [f"max-age={settings.security_hsts_max_age}"]
25
+ if settings.security_hsts_include_subdomains:
26
+ parts.append("includeSubDomains")
27
+ if settings.security_hsts_preload:
28
+ parts.append("preload")
29
+ headers.append(
30
+ (b"strict-transport-security", "; ".join(parts).encode("ascii"))
31
+ )
32
+ return headers
33
+
34
+
35
+ class SecurityHeadersMiddleware:
36
+ def __init__(self, app: Any, settings: Settings | None = None) -> None:
37
+ self.app = app
38
+ self._settings = settings or get_settings()
39
+ self._always = _build_always_headers(self._settings)
40
+ self._csp = (
41
+ self._settings.security_csp.encode("ascii")
42
+ if self._settings.security_csp
43
+ else b""
44
+ )
45
+ self._csp_exempt = tuple(
46
+ p.strip()
47
+ for p in self._settings.security_csp_exempt_paths.split(",")
48
+ if p.strip()
49
+ )
50
+
51
+ async def __call__(
52
+ self,
53
+ scope: dict[str, Any],
54
+ receive: Callable[[], Awaitable[dict[str, Any]]],
55
+ send: Callable[[dict[str, Any]], Awaitable[None]],
56
+ ) -> None:
57
+ if scope["type"] != "http" or not self._settings.security_headers_enabled:
58
+ await self.app(scope, receive, send)
59
+ return
60
+
61
+ path = scope.get("path", "")
62
+ apply_csp = bool(self._csp) and not any(
63
+ path == p or path.startswith(p + "/") for p in self._csp_exempt
64
+ )
65
+
66
+ async def _send(message: dict[str, Any]) -> None:
67
+ if message["type"] == "http.response.start":
68
+ existing: list[tuple[bytes, bytes]] = list(message.get("headers", []))
69
+ present = {name.lower() for name, _ in existing}
70
+ for name, value in self._always:
71
+ if name not in present:
72
+ existing.append((name, value))
73
+ if apply_csp and b"content-security-policy" not in present:
74
+ existing.append((b"content-security-policy", self._csp))
75
+ message["headers"] = existing
76
+ await send(message)
77
+
78
+ await self.app(scope, receive, _send)
supython/settings.py ADDED
@@ -0,0 +1,198 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(
11
+ env_file=".env",
12
+ env_file_encoding="utf-8",
13
+ extra="ignore",
14
+ case_sensitive=False,
15
+ )
16
+
17
+ database_url: str = "postgresql://supython:supython@localhost:54322/supython"
18
+ db_statement_timeout_ms: int = 30000
19
+ db_pool_min_size: int = 1
20
+ db_pool_max_size: int = 10
21
+ db_allowed_roles: frozenset[str] = frozenset[str]({"anon", "authenticated"})
22
+ jwt_alg: Literal["RS256", "ES256"] = "RS256"
23
+ jwt_aud: str = "authenticated"
24
+
25
+ jwt_private_key: str | None = None
26
+ jwt_private_key_path: Path | None = None
27
+ jwt_kid: str | None = None
28
+ jwt_jwks_path: Path = Path("./.supython/jwks.json")
29
+ jwt_keys_dir: Path | None = None
30
+ jwt_keyset_manifest_path: Path = Path("./.supython/keyset.json")
31
+ jwt_rotation_grace_seconds: int = 3600
32
+
33
+ secrets_dir: Path | None = None
34
+ secrets_manifest_path: Path = Path("./.supython/secrets.json")
35
+ secret_rotation_grace_seconds: int = 3600
36
+
37
+ @field_validator(
38
+ "jwt_private_key",
39
+ "jwt_kid",
40
+ "secrets_dir",
41
+ "storage_signed_url_secret",
42
+ "oauth_state_secret",
43
+ mode="before",
44
+ )
45
+ @classmethod
46
+ def _empty_str_to_none(cls, v: object) -> object:
47
+ if isinstance(v, str) and not v.strip():
48
+ return None
49
+ return v
50
+
51
+ @field_validator("jwt_private_key_path", "jwt_keys_dir", mode="before")
52
+ @classmethod
53
+ def _empty_path_to_none(cls, v: object) -> object:
54
+ if isinstance(v, str) and not v.strip():
55
+ return None
56
+ return v
57
+
58
+ access_token_ttl: int = 3600
59
+ refresh_token_ttl: int = 60 * 60 * 24 * 30
60
+
61
+ recover_token_ttl: int = 3600
62
+ magic_link_token_ttl: int = 15 * 60
63
+ otp_token_ttl: int = 10 * 60
64
+ auth_rate_limit_enabled: bool = True
65
+ auth_rate_limit_window_seconds: int = 60
66
+ auth_rate_limit_token_per_window: int = 10
67
+ auth_rate_limit_signup_per_window: int = 5
68
+ auth_rate_limit_recover_per_window: int = 3
69
+ auth_rate_limit_otp_per_window: int = 5
70
+ auth_rate_limit_magiclink_per_window: int = 5
71
+
72
+ authenticator_password: str = "authenticator"
73
+
74
+ email_backend: Literal["console", "smtp"] = "console"
75
+ email_from: str = "supython@localhost"
76
+ smtp_host: str = "localhost"
77
+ smtp_port: int = 587
78
+ smtp_username: str = ""
79
+ smtp_password: str = ""
80
+ smtp_starttls: bool = True
81
+
82
+ google_client_id: str = ""
83
+ google_client_secret: str = ""
84
+ github_client_id: str = ""
85
+ github_client_secret: str = ""
86
+ oauth_state_secret: str | None = Field(default=None, min_length=32)
87
+ oauth_state_max_age: int = 600
88
+
89
+ # PostgREST
90
+ postgrest_url: str = "http://localhost:54321"
91
+ site_url: str = "http://localhost:8000"
92
+
93
+ # CORS: comma-separated allowed browser origins. Empty = no wildcard.
94
+ # Example: CORS_ORIGINS=https://app.example.com,http://localhost:5173
95
+ cors_origins: str = ""
96
+
97
+ # Storage (v0.3)
98
+ storage_backend: Literal["local", "s3"] = "local"
99
+ storage_local_root: str = "./storage"
100
+
101
+ storage_s3_endpoint: str | None = None
102
+ storage_s3_region: str = "us-east-1"
103
+ storage_s3_bucket: str = ""
104
+ storage_s3_access_key_id: str = ""
105
+ storage_s3_secret_access_key: str = ""
106
+
107
+ storage_signed_url_secret: str | None = Field(default=None, min_length=32)
108
+ storage_signed_url_default_ttl: int = 3600
109
+ storage_max_upload_bytes: int = 50 * 1024 * 1024
110
+
111
+ # Security headers
112
+ security_headers_enabled: bool = True
113
+
114
+ # HSTS: None = auto (on iff site_url starts with https://). True/False
115
+ # explicitly forces. Dev defaults are safe because site_url defaults to
116
+ # http://localhost:8000, so HSTS is off out of the box.
117
+ security_hsts_enabled: bool | None = None
118
+ security_hsts_max_age: int = 31536000
119
+ security_hsts_include_subdomains: bool = True
120
+ security_hsts_preload: bool = False
121
+
122
+ security_frame_options: str = "DENY"
123
+ security_referrer_policy: str = "strict-origin-when-cross-origin"
124
+ security_csp: str = (
125
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
126
+ )
127
+ # Comma-separated path prefixes that skip CSP. Default exempts FastAPI's
128
+ # auto docs (inline scripts + jsdelivr CDN bundle) and the bundled admin
129
+ # SPA (Vue + Naive UI inject inline styles, Monaco uses blob: workers) —
130
+ # both would be broken by the strict `default-src 'none'` policy.
131
+ security_csp_exempt_paths: str = "/docs,/redoc,/openapi.json,/admin"
132
+
133
+ # Input size guards (v0.7 — Security round 2)
134
+ # Global cap on request body size for non-streaming write routes. Sized
135
+ # generously for JSON/form payloads so it never trips legitimate auth or
136
+ # control-plane traffic, while still rejecting "1 GB password" abuse
137
+ # before it reaches argon2.
138
+ security_max_body_bytes: int = 1 * 1024 * 1024
139
+ # Path prefixes whose bodies are governed by their own per-feature caps
140
+ # (storage_max_upload_bytes, functions_max_body_bytes) rather than the
141
+ # global cap. Keeps streaming uploads working without bloating the
142
+ # global default.
143
+ security_body_limit_exempt_paths: str = "/storage/v1/object,/functions"
144
+
145
+ # Functions (v0.3)
146
+ functions_dir: str = "./functions"
147
+ functions_hot_reload: bool = True
148
+ functions_max_body_bytes: int = 5 * 1024 * 1024
149
+ functions_max_handler_seconds: float = 30.0
150
+
151
+ # Realtime (v0.4)
152
+ realtime_enabled: bool = True
153
+ realtime_notify_channel: str = "realtime:changes"
154
+ realtime_max_connections: int = 1000
155
+ realtime_max_subs_per_conn: int = 100
156
+ # Server-side timeout: close socket with 1001 if no heartbeat arrives within this window.
157
+ # Client SDK sends heartbeats every 25 s; default gives 5 s of grace.
158
+ realtime_heartbeat_timeout_seconds: int = 30
159
+ realtime_broker_queue_size: int = 1000
160
+ realtime_rls_check_timeout_s: float = 1.0
161
+ realtime_broadcast_self_default: bool = False
162
+
163
+ # Jobs (v0.5)
164
+ jobs_enabled: bool = True
165
+ jobs_backend: Literal["pg"] = "pg"
166
+ jobs_cron_backend: Literal["pg_cron", "inproc", "off"] = "pg_cron"
167
+ jobs_queue_default: str = "default"
168
+ jobs_poll_interval_s: float = 1.0
169
+ jobs_concurrency: int = 5
170
+ jobs_default_max_attempts: int = 3
171
+ jobs_backoff_base_s: float = 5.0
172
+ jobs_backoff_max_s: float = 300.0
173
+ jobs_visibility_timeout_s: float = 300.0
174
+ jobs_visibility_reclaim_batch: int = 10
175
+ jobs_drain_timeout_s: float = 30.0
176
+ jobs_dev_inprocess: bool = False
177
+ arq_redis_url: str = "redis://localhost:6379"
178
+ dramatiq_broker_url: str = "redis://localhost:6379"
179
+
180
+ # Backups (v1.1.4)
181
+ backups_dir: str = "./backups"
182
+ backup_timeout_s: int = 1800
183
+ # "host" — invoke pg_dump from the worker's PATH (production: bundle it
184
+ # in the worker image; bare-metal: install postgresql-client).
185
+ # "docker" — exec pg_dump inside the running postgres container (dev with
186
+ # docker-compose; uses the postgres image's bundled binary so
187
+ # the version always matches the server and no host install
188
+ # is needed).
189
+ backup_via: Literal["host", "docker"] = "docker"
190
+ backup_docker_container: str = "supython-db"
191
+
192
+ log_level: str = "INFO"
193
+ log_json: bool = True
194
+
195
+
196
+ @lru_cache
197
+ def get_settings() -> Settings:
198
+ return Settings()
@@ -0,0 +1,5 @@
1
+ """Storage module: buckets, objects, backends, signed URLs."""
2
+
3
+ from .router import router
4
+
5
+ __all__ = ["router"]