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/keyset.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""JWT key rotation manifest.
|
|
2
|
+
|
|
3
|
+
Multi-kid keyset lifecycle: tracks per-kid PEM files in a directory
|
|
4
|
+
(``settings.jwt_keys_dir``, default ``./.supython/keys/``) governed by a
|
|
5
|
+
JSON manifest (``settings.jwt_keyset_manifest_path``, default
|
|
6
|
+
``./.supython/keyset.json``). The manifest is the source of truth for
|
|
7
|
+
the active signing kid; ``settings.jwt_kid`` (the ``JWT_KID`` env var)
|
|
8
|
+
remains as an explicit override for read-only-FS deployments.
|
|
9
|
+
|
|
10
|
+
When the manifest is absent, ``jwks.load_signing_key`` /
|
|
11
|
+
``load_verification_keyset`` continue to behave as in Phase 5 (single
|
|
12
|
+
key from ``JWT_PRIVATE_KEY_PATH`` / ``JWT_PRIVATE_KEY``). The first
|
|
13
|
+
``supython keygen rotate`` invocation calls
|
|
14
|
+
``import_legacy_single_key`` to seed the manifest from the legacy
|
|
15
|
+
single-key environment.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
from cryptography.hazmat.primitives import serialization
|
|
27
|
+
|
|
28
|
+
from . import jwks
|
|
29
|
+
from .settings import get_settings
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_DEFAULT_KEYS_DIR = Path("./.supython/keys")
|
|
34
|
+
|
|
35
|
+
KeyStatus = Literal["active", "verifying", "retired"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class KeyEntry:
|
|
40
|
+
kid: str
|
|
41
|
+
alg: str
|
|
42
|
+
pem_path: Path
|
|
43
|
+
created_at: datetime
|
|
44
|
+
retired_at: datetime | None
|
|
45
|
+
status: KeyStatus
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _now() -> datetime:
|
|
49
|
+
return datetime.now(tz=timezone.utc)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def keys_dir() -> Path:
|
|
53
|
+
s = get_settings()
|
|
54
|
+
return s.jwt_keys_dir if s.jwt_keys_dir is not None else _DEFAULT_KEYS_DIR
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def manifest_path() -> Path:
|
|
58
|
+
return get_settings().jwt_keyset_manifest_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_manifest() -> bool:
|
|
62
|
+
return manifest_path().exists()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _to_iso(dt: datetime) -> str:
|
|
66
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _from_iso(value: str | None) -> datetime | None:
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _entry_to_dict(entry: KeyEntry) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"kid": entry.kid,
|
|
78
|
+
"alg": entry.alg,
|
|
79
|
+
"created_at": _to_iso(entry.created_at),
|
|
80
|
+
"retired_at": _to_iso(entry.retired_at) if entry.retired_at else None,
|
|
81
|
+
"status": entry.status,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _entry_from_dict(data: dict[str, Any], kdir: Path) -> KeyEntry:
|
|
86
|
+
return KeyEntry(
|
|
87
|
+
kid=data["kid"],
|
|
88
|
+
alg=data["alg"],
|
|
89
|
+
pem_path=kdir / f"{data['kid']}.pem",
|
|
90
|
+
created_at=_from_iso(data["created_at"]) or _now(),
|
|
91
|
+
retired_at=_from_iso(data.get("retired_at")),
|
|
92
|
+
status=data.get("status", "verifying"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_manifest() -> dict[str, Any] | None:
|
|
97
|
+
path = manifest_path()
|
|
98
|
+
if not path.exists():
|
|
99
|
+
return None
|
|
100
|
+
with path.open("r") as f:
|
|
101
|
+
return json.load(f)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_manifest(manifest: dict[str, Any]) -> None:
|
|
105
|
+
path = manifest_path()
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
108
|
+
tmp_path.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n")
|
|
109
|
+
os.replace(tmp_path, path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_keys() -> list[KeyEntry]:
|
|
113
|
+
manifest = load_manifest()
|
|
114
|
+
if manifest is None:
|
|
115
|
+
return []
|
|
116
|
+
kdir = keys_dir()
|
|
117
|
+
return [_entry_from_dict(d, kdir) for d in manifest.get("keys", [])]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_entry(kid: str) -> KeyEntry | None:
|
|
121
|
+
for entry in list_keys():
|
|
122
|
+
if entry.kid == kid:
|
|
123
|
+
return entry
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def active_kid() -> str | None:
|
|
128
|
+
"""Return the kid the auth process should sign with.
|
|
129
|
+
|
|
130
|
+
``JWT_KID`` env var wins over the manifest so a deployment with a
|
|
131
|
+
read-only filesystem can pin a kid without touching the manifest.
|
|
132
|
+
"""
|
|
133
|
+
s = get_settings()
|
|
134
|
+
if s.jwt_kid is not None:
|
|
135
|
+
return s.jwt_kid
|
|
136
|
+
manifest = load_manifest()
|
|
137
|
+
if manifest is None:
|
|
138
|
+
return None
|
|
139
|
+
return manifest.get("active")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def add_key(
|
|
143
|
+
alg: Literal["RS256", "ES256"] = "RS256",
|
|
144
|
+
*,
|
|
145
|
+
status: KeyStatus = "verifying",
|
|
146
|
+
activate_immediately: bool = False,
|
|
147
|
+
) -> KeyEntry:
|
|
148
|
+
"""Generate a new keypair, persist its PEM, and append it to the manifest.
|
|
149
|
+
|
|
150
|
+
When the manifest has no active kid yet, the new key is promoted to
|
|
151
|
+
``active`` regardless of ``status`` so the keyset is always usable.
|
|
152
|
+
"""
|
|
153
|
+
kdir = keys_dir()
|
|
154
|
+
kdir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
key = jwks.generate_private_key(alg)
|
|
157
|
+
signer = jwks.signing_key_from_private_key(key, alg)
|
|
158
|
+
pem_path = kdir / f"{signer.kid}.pem"
|
|
159
|
+
if not pem_path.exists():
|
|
160
|
+
jwks.write_private_key_pem(pem_path, jwks.private_key_to_pem(key), force=False)
|
|
161
|
+
|
|
162
|
+
manifest = load_manifest() or {"active": None, "keys": []}
|
|
163
|
+
|
|
164
|
+
if not any(e["kid"] == signer.kid for e in manifest["keys"]):
|
|
165
|
+
entry = KeyEntry(
|
|
166
|
+
kid=signer.kid,
|
|
167
|
+
alg=alg,
|
|
168
|
+
pem_path=pem_path,
|
|
169
|
+
created_at=_now(),
|
|
170
|
+
retired_at=None,
|
|
171
|
+
status=status,
|
|
172
|
+
)
|
|
173
|
+
manifest["keys"].append(_entry_to_dict(entry))
|
|
174
|
+
|
|
175
|
+
if activate_immediately or manifest.get("active") is None:
|
|
176
|
+
manifest["active"] = signer.kid
|
|
177
|
+
for e in manifest["keys"]:
|
|
178
|
+
if e["kid"] == signer.kid:
|
|
179
|
+
e["status"] = "active"
|
|
180
|
+
e["retired_at"] = None
|
|
181
|
+
|
|
182
|
+
write_manifest(manifest)
|
|
183
|
+
record = next(e for e in manifest["keys"] if e["kid"] == signer.kid)
|
|
184
|
+
return _entry_from_dict(record, kdir)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def activate(kid: str) -> None:
|
|
188
|
+
"""Flip the active signing kid; previously-active kid becomes ``retired``."""
|
|
189
|
+
manifest = load_manifest()
|
|
190
|
+
if manifest is None:
|
|
191
|
+
raise FileNotFoundError(f"keyset manifest not found: {manifest_path()}")
|
|
192
|
+
target = next((e for e in manifest["keys"] if e["kid"] == kid), None)
|
|
193
|
+
if target is None:
|
|
194
|
+
raise KeyError(f"kid not in keyset: {kid!r}")
|
|
195
|
+
previous = manifest.get("active")
|
|
196
|
+
now_iso = _to_iso(_now())
|
|
197
|
+
for e in manifest["keys"]:
|
|
198
|
+
if e["kid"] == previous and previous != kid:
|
|
199
|
+
e["status"] = "retired"
|
|
200
|
+
e["retired_at"] = now_iso
|
|
201
|
+
target["status"] = "active"
|
|
202
|
+
target["retired_at"] = None
|
|
203
|
+
manifest["active"] = kid
|
|
204
|
+
write_manifest(manifest)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def prune(*, force_all: bool = False) -> list[str]:
|
|
208
|
+
"""Drop retired kids whose grace window has elapsed; return removed kids."""
|
|
209
|
+
manifest = load_manifest()
|
|
210
|
+
if manifest is None:
|
|
211
|
+
return []
|
|
212
|
+
grace = get_settings().jwt_rotation_grace_seconds
|
|
213
|
+
now = _now()
|
|
214
|
+
kdir = keys_dir()
|
|
215
|
+
removed: list[str] = []
|
|
216
|
+
surviving: list[dict[str, Any]] = []
|
|
217
|
+
for e in manifest["keys"]:
|
|
218
|
+
if e.get("status") != "retired":
|
|
219
|
+
surviving.append(e)
|
|
220
|
+
continue
|
|
221
|
+
retired_at = _from_iso(e.get("retired_at"))
|
|
222
|
+
elapsed = (now - retired_at).total_seconds() if retired_at else 0.0
|
|
223
|
+
if force_all or (retired_at is not None and elapsed >= grace):
|
|
224
|
+
pem_path = kdir / f"{e['kid']}.pem"
|
|
225
|
+
try:
|
|
226
|
+
pem_path.unlink()
|
|
227
|
+
except FileNotFoundError:
|
|
228
|
+
pass
|
|
229
|
+
removed.append(e["kid"])
|
|
230
|
+
else:
|
|
231
|
+
surviving.append(e)
|
|
232
|
+
manifest["keys"] = surviving
|
|
233
|
+
if manifest.get("active") in removed:
|
|
234
|
+
manifest["active"] = None
|
|
235
|
+
write_manifest(manifest)
|
|
236
|
+
return removed
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def import_legacy_single_key() -> KeyEntry | None:
|
|
240
|
+
"""Seed the manifest from ``JWT_PRIVATE_KEY_PATH`` / ``JWT_PRIVATE_KEY``.
|
|
241
|
+
|
|
242
|
+
Returns ``None`` if the manifest already exists or if the legacy
|
|
243
|
+
single-key environment is empty. Idempotent: re-running on a
|
|
244
|
+
non-empty manifest is a no-op.
|
|
245
|
+
"""
|
|
246
|
+
if has_manifest():
|
|
247
|
+
return None
|
|
248
|
+
s = get_settings()
|
|
249
|
+
if s.jwt_private_key is None and s.jwt_private_key_path is None:
|
|
250
|
+
return None
|
|
251
|
+
if s.jwt_private_key is not None:
|
|
252
|
+
pem_bytes = s.jwt_private_key.encode()
|
|
253
|
+
elif s.jwt_private_key_path is not None and s.jwt_private_key_path.exists():
|
|
254
|
+
pem_bytes = s.jwt_private_key_path.read_bytes()
|
|
255
|
+
else:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
key = serialization.load_pem_private_key(pem_bytes, password=None)
|
|
259
|
+
signer = jwks.signing_key_from_private_key(key, s.jwt_alg, s.jwt_kid)
|
|
260
|
+
|
|
261
|
+
kdir = keys_dir()
|
|
262
|
+
kdir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
pem_path = kdir / f"{signer.kid}.pem"
|
|
264
|
+
if not pem_path.exists():
|
|
265
|
+
jwks.write_private_key_pem(pem_path, jwks.private_key_to_pem(key), force=False)
|
|
266
|
+
|
|
267
|
+
entry = KeyEntry(
|
|
268
|
+
kid=signer.kid,
|
|
269
|
+
alg=s.jwt_alg,
|
|
270
|
+
pem_path=pem_path,
|
|
271
|
+
created_at=_now(),
|
|
272
|
+
retired_at=None,
|
|
273
|
+
status="active",
|
|
274
|
+
)
|
|
275
|
+
write_manifest({
|
|
276
|
+
"active": signer.kid,
|
|
277
|
+
"keys": [_entry_to_dict(entry)],
|
|
278
|
+
})
|
|
279
|
+
return entry
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_request_id() -> str | None:
|
|
17
|
+
return request_id.get(None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JsonFormatter(logging.Formatter):
|
|
21
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
22
|
+
entry: dict[str, Any] = {
|
|
23
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
24
|
+
"level": record.levelname,
|
|
25
|
+
"logger": record.name,
|
|
26
|
+
"message": record.getMessage(),
|
|
27
|
+
}
|
|
28
|
+
rid = get_request_id()
|
|
29
|
+
if rid is not None:
|
|
30
|
+
entry["request_id"] = rid
|
|
31
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
32
|
+
entry["exc_info"] = "".join(traceback.format_exception(*record.exc_info))
|
|
33
|
+
if hasattr(record, "extra_fields"):
|
|
34
|
+
entry.update(record.extra_fields)
|
|
35
|
+
return json.dumps(entry, default=str)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PLAIN_FORMAT = "%(levelname)s %(name)s %(message)s"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BoundedLogRingHandler(logging.Handler):
|
|
42
|
+
"""In-memory ring buffer of structured log records.
|
|
43
|
+
|
|
44
|
+
Each entry is a dict with 'timestamp', 'level', 'logger', 'message',
|
|
45
|
+
and optionally 'request_id' and 'exc_info'. The buffer is bounded so
|
|
46
|
+
it never grows without limit.
|
|
47
|
+
|
|
48
|
+
Access the buffer via the module-level ``log_ring`` tuple:
|
|
49
|
+
|
|
50
|
+
from supython.logging_config import log_ring
|
|
51
|
+
entries = log_ring.get() # list[dict[str, Any]]
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, capacity: int = 5000) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self._buffer: deque[dict[str, Any]] = deque(maxlen=capacity)
|
|
57
|
+
|
|
58
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
59
|
+
entry: dict[str, Any] = {
|
|
60
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
61
|
+
"level": record.levelname,
|
|
62
|
+
"logger": record.name,
|
|
63
|
+
"message": record.getMessage(),
|
|
64
|
+
}
|
|
65
|
+
rid = get_request_id()
|
|
66
|
+
if rid is not None:
|
|
67
|
+
entry["request_id"] = rid
|
|
68
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
69
|
+
entry["exc_info"] = "".join(traceback.format_exception(*record.exc_info))
|
|
70
|
+
if hasattr(record, "extra_fields"):
|
|
71
|
+
entry.update(record.extra_fields) # type: ignore[attr-defined]
|
|
72
|
+
self._buffer.append(entry)
|
|
73
|
+
|
|
74
|
+
def get(self) -> list[dict[str, Any]]:
|
|
75
|
+
return list(self._buffer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Module-level ring buffer instance — populated by configure_logging() below.
|
|
79
|
+
_log_ring_handler: BoundedLogRingHandler | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_log_ring() -> list[dict[str, Any]]:
|
|
83
|
+
"""Return a snapshot of the in-memory log ring buffer.
|
|
84
|
+
|
|
85
|
+
Returns an empty list before ``configure_logging()`` has been called.
|
|
86
|
+
"""
|
|
87
|
+
if _log_ring_handler is None:
|
|
88
|
+
return []
|
|
89
|
+
return _log_ring_handler.get()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def configure_logging(level: str = "INFO", *, json_format: bool = True) -> None:
|
|
93
|
+
global _log_ring_handler
|
|
94
|
+
|
|
95
|
+
root = logging.getLogger()
|
|
96
|
+
numeric = getattr(logging, level.upper(), logging.INFO)
|
|
97
|
+
root.setLevel(numeric)
|
|
98
|
+
|
|
99
|
+
# Stdout handler
|
|
100
|
+
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
101
|
+
if json_format:
|
|
102
|
+
stdout_handler.setFormatter(JsonFormatter())
|
|
103
|
+
else:
|
|
104
|
+
stdout_handler.setFormatter(logging.Formatter(_PLAIN_FORMAT))
|
|
105
|
+
|
|
106
|
+
# Ring-buffer handler (always structured, regardless of json_format)
|
|
107
|
+
ring_handler = BoundedLogRingHandler(capacity=5000)
|
|
108
|
+
ring_handler.setLevel(numeric)
|
|
109
|
+
|
|
110
|
+
root.handlers = [
|
|
111
|
+
h
|
|
112
|
+
for h in root.handlers
|
|
113
|
+
if not isinstance(h, (logging.StreamHandler, BoundedLogRingHandler))
|
|
114
|
+
]
|
|
115
|
+
root.addHandler(stdout_handler)
|
|
116
|
+
root.addHandler(ring_handler)
|
|
117
|
+
_log_ring_handler = ring_handler
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RequestIdMiddleware:
|
|
121
|
+
def __init__(self, app: Any) -> None:
|
|
122
|
+
self.app = app
|
|
123
|
+
|
|
124
|
+
async def __call__(
|
|
125
|
+
self,
|
|
126
|
+
scope: dict[str, Any],
|
|
127
|
+
receive: Callable[[], Awaitable[dict[str, Any]]],
|
|
128
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
129
|
+
) -> None:
|
|
130
|
+
if scope["type"] not in ("http", "websocket"):
|
|
131
|
+
await self.app(scope, receive, send)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
req_id: str | None = None
|
|
135
|
+
for name, value in scope.get("headers", []):
|
|
136
|
+
if name == b"x-request-id":
|
|
137
|
+
req_id = value.decode("ascii", errors="replace")
|
|
138
|
+
break
|
|
139
|
+
if not req_id:
|
|
140
|
+
req_id = uuid.uuid4().hex
|
|
141
|
+
|
|
142
|
+
token = request_id.set(req_id)
|
|
143
|
+
try:
|
|
144
|
+
await self.app(scope, receive, send)
|
|
145
|
+
finally:
|
|
146
|
+
request_id.reset(token)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_REQUEST_LOG_MAX_BODY_BYTES = 10 * 1024
|
|
150
|
+
_AUTH_HEADER = b"authorization"
|
|
151
|
+
_REDACTED = "***REDACTED***"
|
|
152
|
+
|
|
153
|
+
_access_logger = logging.getLogger("supython.access")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RequestLoggingMiddleware:
|
|
157
|
+
def __init__(self, app: Any) -> None:
|
|
158
|
+
self.app = app
|
|
159
|
+
|
|
160
|
+
async def __call__(
|
|
161
|
+
self,
|
|
162
|
+
scope: dict[str, Any],
|
|
163
|
+
receive: Callable[[], Awaitable[dict[str, Any]]],
|
|
164
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
165
|
+
) -> None:
|
|
166
|
+
if scope["type"] != "http":
|
|
167
|
+
await self.app(scope, receive, send)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
start = time.monotonic()
|
|
171
|
+
method = scope.get("method", "")
|
|
172
|
+
path = scope.get("path", "")
|
|
173
|
+
|
|
174
|
+
user_agent: str | None = None
|
|
175
|
+
for name, value in scope.get("headers", []):
|
|
176
|
+
if name == b"user-agent":
|
|
177
|
+
user_agent = value.decode("utf-8", errors="replace")
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
rid = get_request_id()
|
|
181
|
+
|
|
182
|
+
body_chunks: list[bytes] = []
|
|
183
|
+
body_size = 0
|
|
184
|
+
body_truncated = False
|
|
185
|
+
|
|
186
|
+
async def _drain_body() -> None:
|
|
187
|
+
nonlocal body_size, body_truncated
|
|
188
|
+
while True:
|
|
189
|
+
msg = await receive()
|
|
190
|
+
if msg["type"] != "http.request":
|
|
191
|
+
continue
|
|
192
|
+
chunk = msg.get("body", b"")
|
|
193
|
+
more = msg.get("more_body", False)
|
|
194
|
+
if chunk and body_size < _REQUEST_LOG_MAX_BODY_BYTES:
|
|
195
|
+
space = _REQUEST_LOG_MAX_BODY_BYTES - body_size
|
|
196
|
+
if len(chunk) > space:
|
|
197
|
+
body_chunks.append(chunk[:space])
|
|
198
|
+
body_size += space
|
|
199
|
+
body_truncated = True
|
|
200
|
+
else:
|
|
201
|
+
body_chunks.append(chunk)
|
|
202
|
+
body_size += len(chunk)
|
|
203
|
+
if not more:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
await _drain_body()
|
|
207
|
+
|
|
208
|
+
full_body = b"".join(body_chunks)
|
|
209
|
+
body_consumed = False
|
|
210
|
+
|
|
211
|
+
async def _receive() -> dict[str, Any]:
|
|
212
|
+
nonlocal body_consumed
|
|
213
|
+
if not body_consumed:
|
|
214
|
+
body_consumed = True
|
|
215
|
+
return {
|
|
216
|
+
"type": "http.request",
|
|
217
|
+
"body": full_body,
|
|
218
|
+
"more_body": False,
|
|
219
|
+
}
|
|
220
|
+
# Body already delivered. Forward subsequent calls to the real
|
|
221
|
+
# receive() so callers awaiting http.disconnect (e.g. Starlette's
|
|
222
|
+
# StreamingResponse.listen_for_disconnect) actually block on the
|
|
223
|
+
# event loop instead of busy-looping on synchronous returns,
|
|
224
|
+
# which would starve the streaming task and hang the request.
|
|
225
|
+
return await receive()
|
|
226
|
+
|
|
227
|
+
status_code: int = 0
|
|
228
|
+
response_size: int = 0
|
|
229
|
+
|
|
230
|
+
async def _send(message: dict[str, Any]) -> None:
|
|
231
|
+
nonlocal status_code, response_size
|
|
232
|
+
if message["type"] == "http.response.start":
|
|
233
|
+
status_code = message.get("status", 0)
|
|
234
|
+
elif message["type"] == "http.response.body":
|
|
235
|
+
response_size += len(message.get("body", b""))
|
|
236
|
+
await send(message)
|
|
237
|
+
|
|
238
|
+
exc_info: tuple[type, BaseException, Any] | None = None
|
|
239
|
+
try:
|
|
240
|
+
await self.app(scope, _receive, _send)
|
|
241
|
+
except Exception:
|
|
242
|
+
exc_info = sys.exc_info()
|
|
243
|
+
raise
|
|
244
|
+
finally:
|
|
245
|
+
duration_ms = round((time.monotonic() - start) * 1000, 2)
|
|
246
|
+
|
|
247
|
+
redacted_headers: list[list[str]] = []
|
|
248
|
+
for name, value in scope.get("headers", []):
|
|
249
|
+
if name == _AUTH_HEADER:
|
|
250
|
+
redacted_headers.append([name.decode("ascii", errors="replace"), _REDACTED])
|
|
251
|
+
else:
|
|
252
|
+
redacted_headers.append(
|
|
253
|
+
[
|
|
254
|
+
name.decode("ascii", errors="replace"),
|
|
255
|
+
value.decode("utf-8", errors="replace"),
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
fields: dict[str, Any] = {
|
|
260
|
+
"duration_ms": duration_ms,
|
|
261
|
+
"status": status_code,
|
|
262
|
+
"method": method,
|
|
263
|
+
"path": path,
|
|
264
|
+
"request_id": rid,
|
|
265
|
+
"user_agent": user_agent,
|
|
266
|
+
"response_size": response_size,
|
|
267
|
+
"headers": redacted_headers,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
is_server_error = 500 <= status_code < 600 or exc_info is not None
|
|
271
|
+
|
|
272
|
+
if is_server_error:
|
|
273
|
+
fields["request_body"] = full_body.decode("utf-8", errors="replace")
|
|
274
|
+
if body_truncated:
|
|
275
|
+
fields["body_truncated"] = True
|
|
276
|
+
if exc_info is not None and exc_info[1] is not None:
|
|
277
|
+
fields["traceback"] = "".join(traceback.format_exception(*exc_info))
|
|
278
|
+
|
|
279
|
+
level = logging.ERROR if is_server_error else logging.INFO
|
|
280
|
+
record = logging.LogRecord(
|
|
281
|
+
name="supython.access",
|
|
282
|
+
level=level,
|
|
283
|
+
pathname=__file__,
|
|
284
|
+
lineno=0,
|
|
285
|
+
msg="request completed",
|
|
286
|
+
args=(),
|
|
287
|
+
exc_info=None,
|
|
288
|
+
)
|
|
289
|
+
record.extra_fields = fields # type: ignore[attr-defined]
|
|
290
|
+
|
|
291
|
+
_access_logger.handle(record)
|
supython/mail.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared email dispatcher.
|
|
2
|
+
|
|
3
|
+
Routes email through the jobs queue when jobs are enabled and a caller
|
|
4
|
+
connection is available, otherwise falls back to synchronous send.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncpg
|
|
8
|
+
|
|
9
|
+
from .mailer import EmailMessage, get_mailer
|
|
10
|
+
from .settings import get_settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def dispatch(
|
|
14
|
+
conn: asyncpg.Connection | None,
|
|
15
|
+
msg: EmailMessage,
|
|
16
|
+
*,
|
|
17
|
+
job_name: str = "send_email",
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Send an email, via the jobs queue when enabled and a conn is available.
|
|
20
|
+
|
|
21
|
+
Falls back to synchronous ``mailer.send()`` otherwise.
|
|
22
|
+
"""
|
|
23
|
+
s = get_settings()
|
|
24
|
+
if s.jobs_enabled and conn is not None:
|
|
25
|
+
from .jobs import service as _jobs_svc
|
|
26
|
+
|
|
27
|
+
await _jobs_svc.enqueue(
|
|
28
|
+
conn,
|
|
29
|
+
name=job_name,
|
|
30
|
+
payload=msg.model_dump(),
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
await get_mailer().send(msg)
|
supython/mailer.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from email.message import EmailMessage as MimeMessage
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
import aiosmtplib
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from .settings import Settings, get_settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmailMessage(BaseModel):
|
|
14
|
+
to: list[str] = Field(min_length=1)
|
|
15
|
+
subject: str
|
|
16
|
+
text: str
|
|
17
|
+
html: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmailBackend(Protocol):
|
|
21
|
+
async def send(self, msg: EmailMessage) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConsoleBackend:
|
|
25
|
+
async def send(self, msg: EmailMessage) -> None:
|
|
26
|
+
logger.info(
|
|
27
|
+
"email (console): to=%s subject=%s text=%r html=%r",
|
|
28
|
+
msg.to,
|
|
29
|
+
msg.subject,
|
|
30
|
+
msg.text,
|
|
31
|
+
msg.html,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SmtpBackend:
|
|
36
|
+
def __init__(self, settings: Settings) -> None:
|
|
37
|
+
self._settings = settings
|
|
38
|
+
|
|
39
|
+
async def send(self, msg: EmailMessage) -> None:
|
|
40
|
+
mime = MimeMessage()
|
|
41
|
+
mime["From"] = self._settings.email_from
|
|
42
|
+
mime["To"] = ", ".join(msg.to)
|
|
43
|
+
mime["Subject"] = msg.subject
|
|
44
|
+
mime.set_content(msg.text)
|
|
45
|
+
if msg.html is not None:
|
|
46
|
+
mime.add_alternative(msg.html, subtype="html")
|
|
47
|
+
|
|
48
|
+
username = self._settings.smtp_username or None
|
|
49
|
+
password = self._settings.smtp_password or None
|
|
50
|
+
|
|
51
|
+
await aiosmtplib.send(
|
|
52
|
+
mime,
|
|
53
|
+
hostname=self._settings.smtp_host,
|
|
54
|
+
port=self._settings.smtp_port,
|
|
55
|
+
username=username,
|
|
56
|
+
password=password,
|
|
57
|
+
start_tls=self._settings.smtp_starttls,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_mailer() -> EmailBackend:
|
|
62
|
+
settings = get_settings()
|
|
63
|
+
if settings.email_backend == "smtp":
|
|
64
|
+
return SmtpBackend(settings)
|
|
65
|
+
return ConsoleBackend()
|