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
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,244 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, NoDecode, 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: Annotated[frozenset[str], NoDecode] = frozenset[str](
22
+ {"anon", "authenticated"}
23
+ )
24
+ jwt_alg: Literal["RS256", "ES256"] = "RS256"
25
+ jwt_aud: str = "authenticated"
26
+
27
+ jwt_private_key: str | None = None
28
+ jwt_private_key_path: Path | None = None
29
+ jwt_kid: str | None = None
30
+ jwt_jwks_path: Path = Path("./.supython/jwks.json")
31
+ jwt_keys_dir: Path | None = None
32
+ jwt_keyset_manifest_path: Path = Path("./.supython/keyset.json")
33
+ jwt_rotation_grace_seconds: int = 3600
34
+
35
+ secrets_dir: Path | None = None
36
+ secrets_manifest_path: Path = Path("./.supython/secrets.json")
37
+ secret_rotation_grace_seconds: int = 3600
38
+
39
+ @field_validator(
40
+ "jwt_private_key",
41
+ "jwt_kid",
42
+ "secrets_dir",
43
+ "storage_signed_url_secret",
44
+ "oauth_state_secret",
45
+ "storage_s3_endpoint",
46
+ "settings_module",
47
+ mode="before",
48
+ )
49
+ @classmethod
50
+ def _empty_str_to_none(cls, v: object) -> object:
51
+ if isinstance(v, str) and not v.strip():
52
+ return None
53
+ return v
54
+
55
+ @field_validator("db_allowed_roles", mode="before")
56
+ @classmethod
57
+ def _split_db_allowed_roles(cls, v: object) -> object:
58
+ if isinstance(v, str):
59
+ return frozenset(part.strip() for part in v.split(",") if part.strip())
60
+ return v
61
+
62
+ @field_validator("security_hsts_enabled", mode="before")
63
+ @classmethod
64
+ def _empty_bool_to_none(cls, v: object) -> object:
65
+ if isinstance(v, str) and not v.strip():
66
+ return None
67
+ return v
68
+
69
+ @field_validator(
70
+ "jwt_private_key_path", "jwt_keys_dir", "backup_docker_container", mode="before"
71
+ )
72
+ @classmethod
73
+ def _empty_path_to_none(cls, v: object) -> object:
74
+ if isinstance(v, str) and not v.strip():
75
+ return None
76
+ return v
77
+
78
+ access_token_ttl: int = 3600
79
+ refresh_token_ttl: int = 60 * 60 * 24 * 30
80
+
81
+ recover_token_ttl: int = 3600
82
+ magic_link_token_ttl: int = 15 * 60
83
+ otp_token_ttl: int = 10 * 60
84
+ auth_rate_limit_enabled: bool = True
85
+ auth_rate_limit_window_seconds: int = 60
86
+ auth_rate_limit_token_per_window: int = 10
87
+ auth_rate_limit_signup_per_window: int = 5
88
+ auth_rate_limit_recover_per_window: int = 3
89
+ auth_rate_limit_otp_per_window: int = 5
90
+ auth_rate_limit_magiclink_per_window: int = 5
91
+
92
+ authenticator_password: str = "authenticator"
93
+
94
+ email_backend: Literal["console", "smtp"] = "console"
95
+ email_from: str = "supython@localhost"
96
+ smtp_host: str = "localhost"
97
+ smtp_port: int = 587
98
+ smtp_username: str = ""
99
+ smtp_password: str = ""
100
+ smtp_starttls: bool = True
101
+
102
+ google_client_id: str = ""
103
+ google_client_secret: str = ""
104
+ github_client_id: str = ""
105
+ github_client_secret: str = ""
106
+ oauth_state_secret: str | None = Field(default=None, min_length=32)
107
+ oauth_state_max_age: int = 600
108
+
109
+ # PostgREST
110
+ postgrest_url: str = "http://localhost:54321"
111
+ site_url: str = "http://localhost:8000"
112
+
113
+ # CORS: comma-separated allowed browser origins. Empty = no wildcard.
114
+ # Example: CORS_ORIGINS=https://app.example.com,http://localhost:5173
115
+ cors_origins: str = ""
116
+
117
+ # Storage (v0.3)
118
+ storage_backend: Literal["local", "s3"] = "local"
119
+ storage_local_root: str = "./storage"
120
+
121
+ storage_s3_endpoint: str | None = None
122
+ storage_s3_region: str = "us-east-1"
123
+ storage_s3_bucket: str = ""
124
+ storage_s3_access_key_id: str = ""
125
+ storage_s3_secret_access_key: str = ""
126
+
127
+ storage_signed_url_secret: str | None = Field(default=None, min_length=32)
128
+ storage_signed_url_default_ttl: int = 3600
129
+ storage_max_upload_bytes: int = 50 * 1024 * 1024
130
+
131
+ # Security headers
132
+ security_headers_enabled: bool = True
133
+
134
+ # HSTS: None = auto (on iff site_url starts with https://). True/False
135
+ # explicitly forces. Dev defaults are safe because site_url defaults to
136
+ # http://localhost:8000, so HSTS is off out of the box.
137
+ security_hsts_enabled: bool | None = None
138
+ security_hsts_max_age: int = 31536000
139
+ security_hsts_include_subdomains: bool = True
140
+ security_hsts_preload: bool = False
141
+
142
+ security_frame_options: str = "DENY"
143
+ security_referrer_policy: str = "strict-origin-when-cross-origin"
144
+ security_csp: str = (
145
+ "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
146
+ )
147
+ # Comma-separated path prefixes that skip CSP. Default exempts FastAPI's
148
+ # auto docs (inline scripts + jsdelivr CDN bundle) and the bundled admin
149
+ # SPA (Vue + Naive UI inject inline styles, Monaco uses blob: workers) —
150
+ # both would be broken by the strict `default-src 'none'` policy.
151
+ security_csp_exempt_paths: str = "/docs,/redoc,/openapi.json,/admin"
152
+
153
+ # Input size guards (v0.7 — Security round 2)
154
+ # Global cap on request body size for non-streaming write routes. Sized
155
+ # generously for JSON/form payloads so it never trips legitimate auth or
156
+ # control-plane traffic, while still rejecting "1 GB password" abuse
157
+ # before it reaches argon2.
158
+ security_max_body_bytes: int = 1 * 1024 * 1024
159
+ # Path prefixes whose bodies are governed by their own per-feature caps
160
+ # (storage_max_upload_bytes, functions_max_body_bytes) rather than the
161
+ # global cap. Keeps streaming uploads working without bloating the
162
+ # global default.
163
+ security_body_limit_exempt_paths: str = "/storage/v1/object,/functions"
164
+
165
+ # Functions (v0.3)
166
+ functions_dir: str = "./functions"
167
+ functions_hot_reload: bool = True
168
+ functions_max_body_bytes: int = 5 * 1024 * 1024
169
+ functions_max_handler_seconds: float = 30.0
170
+
171
+ # Realtime (v0.4)
172
+ realtime_enabled: bool = True
173
+ realtime_notify_channel: str = "realtime:changes"
174
+ realtime_max_connections: int = 1000
175
+ realtime_max_subs_per_conn: int = 100
176
+ # Server-side timeout: close socket with 1001 if no heartbeat arrives within this window.
177
+ # Client SDK sends heartbeats every 25 s; default gives 5 s of grace.
178
+ realtime_heartbeat_timeout_seconds: int = 30
179
+ realtime_broker_queue_size: int = 1000
180
+ realtime_rls_check_timeout_s: float = 1.0
181
+ realtime_broadcast_self_default: bool = False
182
+
183
+ # Jobs (v0.5)
184
+ jobs_enabled: bool = True
185
+ jobs_backend: Literal["pg"] = "pg"
186
+ jobs_cron_backend: Literal["pg_cron", "inproc", "off"] = "pg_cron"
187
+ jobs_queue_default: str = "default"
188
+ jobs_poll_interval_s: float = 1.0
189
+ jobs_concurrency: int = 5
190
+ jobs_default_max_attempts: int = 3
191
+ jobs_backoff_base_s: float = 5.0
192
+ jobs_backoff_max_s: float = 300.0
193
+ jobs_visibility_timeout_s: float = 300.0
194
+ jobs_visibility_reclaim_batch: int = 10
195
+ jobs_drain_timeout_s: float = 30.0
196
+ jobs_dev_inprocess: bool = False
197
+ arq_redis_url: str = "redis://localhost:6379"
198
+ dramatiq_broker_url: str = "redis://localhost:6379"
199
+
200
+ # Backups (v1.1.4)
201
+ backups_dir: str = "./backups"
202
+ backup_timeout_s: int = 1800
203
+ # "host" — invoke pg_dump from the worker's PATH (production: bundle it
204
+ # in the worker image; bare-metal: install postgresql-client).
205
+ # "docker" — exec pg_dump inside the running postgres container (dev with
206
+ # docker-compose; uses the postgres image's bundled binary so
207
+ # the version always matches the server and no host install
208
+ # is needed).
209
+ backup_via: Literal["host", "docker"] = "docker"
210
+ # No default: scaffolded projects pre-fill this in .env (`<project>-db`),
211
+ # and `_build_args` raises a clear error when docker mode is selected
212
+ # without a container name configured.
213
+ backup_docker_container: str | None = None
214
+
215
+ log_level: str = "INFO"
216
+ log_json: bool = True
217
+
218
+ # Comma-separated dotted module paths to import at boot, e.g.
219
+ # `myapp.jobs,myapp.hooks`. Imports happen before FastAPI app
220
+ # construction (so @job / @cron / @on decorators register) and again
221
+ # before the job worker starts. Env var: `EXTENSIONS=…`.
222
+ extensions: Annotated[list[str], NoDecode] = []
223
+
224
+ @field_validator("extensions", mode="before")
225
+ @classmethod
226
+ def _split_extensions(cls, v: object) -> object:
227
+ if isinstance(v, str):
228
+ return [part.strip() for part in v.split(",") if part.strip()]
229
+ return v
230
+
231
+ # Django-style Python config module declaring EXTENSIONS, EXTRA_ROUTERS,
232
+ # EXTRA_MIDDLEWARE. Loaded before FastAPI app construction. The scaffold's
233
+ # manage.py shim sets this for you. Env var: `SUPYTHON_SETTINGS_MODULE=…`.
234
+ # Prefix differs from `EXTENSIONS=` deliberately: parallels Django's
235
+ # `DJANGO_SETTINGS_MODULE` and avoids collision with unrelated tooling.
236
+ settings_module: str | None = Field(
237
+ default=None,
238
+ validation_alias="SUPYTHON_SETTINGS_MODULE",
239
+ )
240
+
241
+
242
+ @lru_cache
243
+ def get_settings() -> Settings:
244
+ return Settings()