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