svc-infra 0.1.484__py3-none-any.whl → 0.1.640__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/__init__.py +0 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +75 -0
- svc_infra/api/fastapi/auth/add.py +246 -81
- svc_infra/api/fastapi/auth/gaurd.py +69 -8
- svc_infra/api/fastapi/auth/mfa/router.py +18 -10
- svc_infra/api/fastapi/auth/mfa/security.py +7 -8
- svc_infra/api/fastapi/auth/routers/account.py +9 -13
- svc_infra/api/fastapi/auth/routers/apikey_router.py +12 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +83 -41
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +4 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/db/sql/add.py +32 -13
- svc_infra/api/fastapi/db/sql/crud_router.py +178 -16
- svc_infra/api/fastapi/db/sql/session.py +18 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +219 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +68 -13
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/debug.py +20 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +814 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/__init__.py +0 -0
- svc_infra/api/fastapi/paths/auth.py +19 -0
- svc_infra/api/fastapi/paths/generic.py +2 -0
- svc_infra/api/fastapi/paths/prefix.py +2 -0
- svc_infra/api/fastapi/paths/user.py +10 -0
- svc_infra/api/fastapi/routers/ping.py +4 -2
- svc_infra/api/fastapi/setup.py +71 -20
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/README.md +1 -1
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +195 -69
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -76
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +245 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.640.dist-info/METADATA +154 -0
- svc_infra-0.1.640.dist-info/RECORD +350 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.484.dist-info/METADATA +0 -78
- svc_infra-0.1.484.dist-info/RECORD +0 -206
- {svc_infra-0.1.484.dist-info → svc_infra-0.1.640.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.484.dist-info → svc_infra-0.1.640.dist-info}/entry_points.txt +0 -0
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import logging
|
|
5
|
-
from typing import List
|
|
6
|
-
import sys, pathlib, importlib
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
import sys, pathlib, importlib, pkgutil, traceback
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
9
|
from sqlalchemy.engine import make_url, URL
|
|
@@ -20,6 +20,7 @@ try:
|
|
|
20
20
|
except Exception:
|
|
21
21
|
_GUID = None
|
|
22
22
|
|
|
23
|
+
|
|
23
24
|
def _render_item(type_, obj, autogen_context):
|
|
24
25
|
if type_ == "type" and (
|
|
25
26
|
(_GUID is not None and isinstance(obj, _GUID))
|
|
@@ -29,38 +30,49 @@ def _render_item(type_, obj, autogen_context):
|
|
|
29
30
|
return "GUID()"
|
|
30
31
|
return False
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
|
|
34
|
+
# ---- Logging ----
|
|
33
35
|
config = context.config
|
|
34
36
|
if config.config_file_name is not None:
|
|
35
|
-
import logging.config
|
|
36
|
-
|
|
37
|
+
import logging.config as _lc
|
|
38
|
+
_lc.fileConfig(config.config_file_name)
|
|
39
|
+
|
|
37
40
|
logger = logging.getLogger(__name__)
|
|
41
|
+
logger.setLevel(logging.INFO)
|
|
42
|
+
|
|
38
43
|
|
|
39
|
-
#
|
|
44
|
+
# ---- sys.path bootstrap (append; do NOT shadow site-packages) ----
|
|
40
45
|
prepend = config.get_main_option("prepend_sys_path") or ""
|
|
46
|
+
script_loc = config.get_main_option("script_location") or os.path.dirname(__file__)
|
|
47
|
+
migrations_dir = pathlib.Path(script_loc).resolve()
|
|
48
|
+
project_root = migrations_dir.parent
|
|
49
|
+
|
|
50
|
+
def _ensure_on_syspath_end(p: pathlib.Path) -> None:
|
|
51
|
+
s = str(p)
|
|
52
|
+
if s and s not in sys.path:
|
|
53
|
+
sys.path.append(s) # append instead of insert(0) to avoid shadowing installed packages
|
|
54
|
+
|
|
41
55
|
if prepend:
|
|
42
|
-
|
|
43
|
-
sys.path.insert(0, prepend)
|
|
56
|
+
_ensure_on_syspath_end(pathlib.Path(prepend))
|
|
44
57
|
src_path = pathlib.Path(prepend) / "src"
|
|
45
58
|
if src_path.exists():
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
_ensure_on_syspath_end(src_path)
|
|
60
|
+
|
|
61
|
+
_ensure_on_syspath_end(project_root)
|
|
62
|
+
if (project_root / "src").exists():
|
|
63
|
+
_ensure_on_syspath_end(project_root / "src")
|
|
64
|
+
|
|
49
65
|
|
|
50
|
-
#
|
|
66
|
+
# ---- x-args ----
|
|
51
67
|
def _x_args_dict() -> dict:
|
|
52
68
|
try:
|
|
53
|
-
# Alembic >= 1.8 uses as_dictionary kw only
|
|
54
69
|
return context.get_x_argument(as_dictionary=True) # type: ignore[arg-type]
|
|
55
70
|
except TypeError:
|
|
56
71
|
try:
|
|
57
|
-
# Some versions just return a list even if you pass kw
|
|
58
72
|
xs = context.get_x_argument()
|
|
59
73
|
except TypeError:
|
|
60
|
-
# Very old signatures
|
|
61
74
|
xs = []
|
|
62
|
-
|
|
63
|
-
out = {}
|
|
75
|
+
out: dict = {}
|
|
64
76
|
for item in xs:
|
|
65
77
|
if "=" in item:
|
|
66
78
|
k, v = item.split("=", 1)
|
|
@@ -69,7 +81,8 @@ def _x_args_dict() -> dict:
|
|
|
69
81
|
out[item] = ""
|
|
70
82
|
return out
|
|
71
83
|
|
|
72
|
-
|
|
84
|
+
|
|
85
|
+
# ---- DB URL resolution ----
|
|
73
86
|
_x = _x_args_dict()
|
|
74
87
|
cli_dburl = _x.get("dburl", "").strip()
|
|
75
88
|
env_dburl = os.getenv("SQL_URL", "").strip()
|
|
@@ -93,100 +106,213 @@ u = _coerce_sync_driver(u)
|
|
|
93
106
|
u = _ensure_ssl_default(u)
|
|
94
107
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
|
|
110
|
+
# ---- feature flags / constants ----
|
|
111
|
+
WANT_PAYMENTS = os.getenv("APF_ENABLE_PAYMENTS", "").lower() in {"1", "true", "yes"}
|
|
112
|
+
FORCE_PAYMENTS = os.getenv("ALEMBIC_FORCE_PAYMENTS", "").lower() in {"1", "true", "yes"}
|
|
113
|
+
PAYMENT_TABLES = {"pay_customers", "pay_intents", "pay_events", "ledger_entries"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---- metadata discovery (prefer ModelBase; scan ALL attrs) ----
|
|
117
|
+
# IMPORTANT: do NOT seed with payments package unless enabled/forced.
|
|
118
|
+
DISCOVER_PACKAGES: List[str] = [] # seed empty; merged with FS + env
|
|
98
119
|
ENV_DISCOVER = os.getenv("ALEMBIC_DISCOVER_PACKAGES")
|
|
99
120
|
if ENV_DISCOVER:
|
|
100
|
-
DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(
|
|
121
|
+
DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(",") if s.strip()]
|
|
101
122
|
|
|
102
123
|
def _collect_metadata() -> list[object]:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
"""
|
|
125
|
+
Strategy:
|
|
126
|
+
1) (If WANT_PAYMENTS or FORCE_PAYMENTS) force-import payments module and log result.
|
|
127
|
+
2) Import packages + ALEMBIC_DISCOVER_PACKAGES (+ payments only when enabled).
|
|
128
|
+
3) ALSO import top-level packages under project root and src/.
|
|
129
|
+
4) Import common model-bearing submodules.
|
|
130
|
+
5) Prefer ModelBase.metadata after imports.
|
|
131
|
+
6) Collect ANY object whose `.metadata` has tables (scan ALL attrs).
|
|
132
|
+
7) De-dup and keep only those with at least one table.
|
|
133
|
+
"""
|
|
134
|
+
tried: list[Tuple[str, str]] = []
|
|
135
|
+
errors: list[Tuple[str, str]] = []
|
|
112
136
|
found: list[object] = []
|
|
113
137
|
|
|
138
|
+
def _note(name: str, ok: bool, err: str | None = None):
|
|
139
|
+
tried.append((name, "ok" if ok else "err"))
|
|
140
|
+
if not ok and err:
|
|
141
|
+
errors.append((name, err))
|
|
142
|
+
|
|
114
143
|
def _maybe_add(obj: object) -> None:
|
|
115
144
|
md = getattr(obj, "metadata", None) or obj
|
|
116
|
-
if hasattr(md, "tables") and
|
|
145
|
+
if hasattr(md, "tables") and getattr(md, "tables"):
|
|
117
146
|
found.append(md)
|
|
118
147
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
continue
|
|
128
|
-
for p in root.iterdir():
|
|
129
|
-
if p.is_dir() and (p / "__init__.py").exists():
|
|
130
|
-
pkgs.append(p.name)
|
|
148
|
+
def _scan_module_objects(mod: object) -> None:
|
|
149
|
+
try:
|
|
150
|
+
for val in vars(mod).values():
|
|
151
|
+
md = getattr(val, "metadata", None) or None
|
|
152
|
+
if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
|
|
153
|
+
found.append(md)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
131
156
|
|
|
132
|
-
|
|
157
|
+
# (1) Force-load payments when enabled/forced and log explicit outcome
|
|
158
|
+
if WANT_PAYMENTS or FORCE_PAYMENTS:
|
|
133
159
|
try:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
importlib.import_module("svc_infra.apf_payments.models")
|
|
161
|
+
context.config.print_stdout("[alembic env] payments module import: ok (svc_infra.apf_payments.models)")
|
|
162
|
+
except Exception:
|
|
163
|
+
context.config.print_stdout("[alembic env] payments module import: ERR (svc_infra.apf_payments.models)")
|
|
164
|
+
context.config.print_stdout(traceback.format_exc())
|
|
165
|
+
|
|
166
|
+
# (2) seed list
|
|
167
|
+
pkgs: list[str] = []
|
|
168
|
+
# add payments package ONLY when enabled/forced
|
|
169
|
+
if WANT_PAYMENTS or FORCE_PAYMENTS:
|
|
170
|
+
pkgs.append("svc_infra.apf_payments.models")
|
|
171
|
+
|
|
172
|
+
for p in list(DISCOVER_PACKAGES or []):
|
|
173
|
+
if p and p not in pkgs:
|
|
174
|
+
pkgs.append(p)
|
|
175
|
+
|
|
176
|
+
env_pkgs = os.getenv("ALEMBIC_DISCOVER_PACKAGES", "")
|
|
177
|
+
if env_pkgs:
|
|
178
|
+
for p in (x.strip() for x in env_pkgs.split(",") if x.strip()):
|
|
179
|
+
if p not in pkgs:
|
|
180
|
+
pkgs.append(p)
|
|
181
|
+
|
|
182
|
+
# (3) filesystem discovery (root + src) – appended, not shadowing site-packages
|
|
183
|
+
fs_roots: list[pathlib.Path] = []
|
|
184
|
+
for candidate in {project_root, project_root / "src"}:
|
|
185
|
+
if candidate.exists():
|
|
186
|
+
fs_roots.append(candidate)
|
|
187
|
+
for root in fs_roots:
|
|
188
|
+
for p in root.iterdir():
|
|
189
|
+
if p.is_dir() and (p / "__init__.py").exists():
|
|
190
|
+
name = p.name
|
|
191
|
+
if name not in pkgs:
|
|
192
|
+
pkgs.append(name)
|
|
138
193
|
|
|
194
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
195
|
+
if "models" not in pkgs:
|
|
196
|
+
try:
|
|
197
|
+
spec = getattr(importlib, "util", None)
|
|
198
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
199
|
+
if spec.find_spec("models") is not None:
|
|
200
|
+
pkgs.append("models")
|
|
201
|
+
except Exception:
|
|
202
|
+
# If discovery fails, skip adding bare 'models'
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
def _import_and_collect(modname: str):
|
|
206
|
+
try:
|
|
207
|
+
mod = importlib.import_module(modname)
|
|
208
|
+
_note(modname, True, None)
|
|
209
|
+
except Exception:
|
|
210
|
+
_note(modname, False, traceback.format_exc())
|
|
211
|
+
return None
|
|
139
212
|
for attr in ("metadata", "MetaData", "Base", "base"):
|
|
140
|
-
obj = getattr(
|
|
213
|
+
obj = getattr(mod, attr, None)
|
|
141
214
|
if obj is not None:
|
|
142
215
|
_maybe_add(obj)
|
|
216
|
+
_scan_module_objects(mod)
|
|
217
|
+
return mod
|
|
218
|
+
|
|
219
|
+
for pkg_name in pkgs:
|
|
220
|
+
pkg = _import_and_collect(pkg_name)
|
|
221
|
+
if pkg is None:
|
|
222
|
+
continue
|
|
143
223
|
|
|
144
|
-
for subname in ("models",):
|
|
145
|
-
|
|
146
|
-
sub = importlib.import_module(f"{pkg_name}.{subname}")
|
|
147
|
-
for attr in ("metadata", "MetaData", "Base", "base"):
|
|
148
|
-
obj = getattr(sub, attr, None)
|
|
149
|
-
if obj is not None:
|
|
150
|
-
_maybe_add(obj)
|
|
151
|
-
except Exception:
|
|
152
|
-
pass
|
|
224
|
+
for subname in ("models", "db", "orm", "entities"):
|
|
225
|
+
_import_and_collect(f"{pkg_name}.{subname}")
|
|
153
226
|
|
|
154
227
|
mod_path = getattr(pkg, "__path__", None)
|
|
155
228
|
if not mod_path:
|
|
156
229
|
continue
|
|
157
230
|
for _, name, ispkg in pkgutil.walk_packages(mod_path, prefix=pkg_name + "."):
|
|
158
|
-
if ispkg
|
|
231
|
+
if ispkg:
|
|
159
232
|
continue
|
|
160
|
-
|
|
161
|
-
mod = importlib.import_module(name)
|
|
162
|
-
except Exception:
|
|
233
|
+
if not any(x in name for x in (".models", ".db", ".orm", ".entities")):
|
|
163
234
|
continue
|
|
164
|
-
|
|
165
|
-
obj = getattr(mod, attr, None)
|
|
166
|
-
if obj is not None:
|
|
167
|
-
_maybe_add(obj)
|
|
235
|
+
_import_and_collect(name)
|
|
168
236
|
|
|
169
|
-
#
|
|
237
|
+
# Prefer ModelBase after all imports
|
|
238
|
+
try:
|
|
239
|
+
from svc_infra.db.sql.base import ModelBase # type: ignore
|
|
240
|
+
mb_md = getattr(ModelBase, "metadata", None)
|
|
241
|
+
if mb_md is not None and getattr(mb_md, "tables", {}):
|
|
242
|
+
found.append(mb_md)
|
|
243
|
+
_note("ModelBase.metadata", True, None)
|
|
244
|
+
else:
|
|
245
|
+
_note("ModelBase.metadata(empty)", True, None)
|
|
246
|
+
except Exception:
|
|
247
|
+
_note("ModelBase import", False, traceback.format_exc())
|
|
248
|
+
|
|
249
|
+
# Optional: autobind API key model
|
|
170
250
|
try:
|
|
171
251
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
172
|
-
# If you prefer gating on env, pass require_env=True and set AUTH_ENABLE_API_KEYS=1
|
|
173
252
|
try_autobind_apikey_model(require_env=False)
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
_note("svc_infra.db.sql.apikey.try_autobind_apikey_model", True, None)
|
|
254
|
+
except Exception:
|
|
255
|
+
_note("svc_infra.db.sql.apikey.try_autobind_apikey_model", False, traceback.format_exc())
|
|
176
256
|
|
|
177
|
-
|
|
257
|
+
# De-dup MetaData objects
|
|
258
|
+
uniq: list[object] = []
|
|
259
|
+
seen: set[int] = set()
|
|
178
260
|
for md in found:
|
|
261
|
+
try:
|
|
262
|
+
if not getattr(md, "tables", {}):
|
|
263
|
+
continue
|
|
264
|
+
except Exception:
|
|
265
|
+
continue
|
|
179
266
|
if id(md) not in seen:
|
|
180
267
|
seen.add(id(md))
|
|
181
268
|
uniq.append(md)
|
|
269
|
+
|
|
270
|
+
total_tables = 0
|
|
271
|
+
try:
|
|
272
|
+
total_tables = sum(len(getattr(md, "tables", {})) for md in uniq)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
context.config.print_stdout(
|
|
277
|
+
f"[alembic env] discovered {len(uniq)} metadata objects with {total_tables} tables total"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if WANT_PAYMENTS and not FORCE_PAYMENTS:
|
|
281
|
+
saw_pay = any(any(tn in PAYMENT_TABLES for tn in md.tables.keys()) for md in uniq) if uniq else False
|
|
282
|
+
if not saw_pay:
|
|
283
|
+
context.config.print_stdout(
|
|
284
|
+
"[alembic env] WARNING: APF_ENABLE_PAYMENTS is set but no payments tables were discovered. "
|
|
285
|
+
"If you still see this, a local package named 'svc_infra' may be shadowing the installed one."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# If nothing, dump import attempts / first 10 tracebacks
|
|
289
|
+
if total_tables == 0:
|
|
290
|
+
context.config.print_stdout("[alembic env] import attempts (ok/err):")
|
|
291
|
+
for name, status in tried:
|
|
292
|
+
context.config.print_stdout(f" - {status:3s} {name}")
|
|
293
|
+
for name, tb in errors[:10]:
|
|
294
|
+
context.config.print_stdout(f" --- import error: {name} ---")
|
|
295
|
+
context.config.print_stdout(tb)
|
|
296
|
+
|
|
182
297
|
return uniq
|
|
183
298
|
|
|
299
|
+
|
|
184
300
|
target_metadata = _collect_metadata()
|
|
185
301
|
|
|
302
|
+
|
|
186
303
|
def _want_include_schemas() -> bool:
|
|
187
|
-
# allow override: alembic -x include_schemas=1
|
|
188
304
|
val = _x.get("include_schemas", "") or os.getenv("ALEMBIC_INCLUDE_SCHEMAS", "")
|
|
189
|
-
|
|
305
|
+
if str(val).strip() in {"1", "true", "True", "yes"}:
|
|
306
|
+
return True
|
|
307
|
+
try:
|
|
308
|
+
for md in (target_metadata or []):
|
|
309
|
+
for t in getattr(md, "tables", {}).values():
|
|
310
|
+
if getattr(t, "schema", None):
|
|
311
|
+
return True
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
return False
|
|
315
|
+
|
|
190
316
|
|
|
191
317
|
def _system_schemas_for(url: str) -> set[str]:
|
|
192
318
|
try:
|
|
@@ -203,21 +329,48 @@ def _system_schemas_for(url: str) -> set[str]:
|
|
|
203
329
|
return {"INFORMATION_SCHEMA"}
|
|
204
330
|
return set()
|
|
205
331
|
|
|
332
|
+
|
|
206
333
|
def _include_object_factory(url: str):
|
|
207
334
|
sys_schemas = _system_schemas_for(url)
|
|
335
|
+
skip_drops = os.getenv("ALEMBIC_SKIP_DROPS", "").lower() in {"1", "true", "yes"}
|
|
336
|
+
want_payments = WANT_PAYMENTS or FORCE_PAYMENTS
|
|
208
337
|
|
|
209
338
|
def _include_object(obj, name, type_, reflected, compare_to):
|
|
210
|
-
#
|
|
339
|
+
# filter system schemas
|
|
211
340
|
schema = getattr(obj, "schema", None)
|
|
212
341
|
if schema and str(schema) in sys_schemas:
|
|
213
342
|
return False
|
|
214
|
-
|
|
215
|
-
|
|
343
|
+
|
|
344
|
+
# Always keep Alembic version table
|
|
345
|
+
version_table = (
|
|
346
|
+
context.get_x_argument(as_dictionary=True).get("version_table")
|
|
347
|
+
if hasattr(context, "get_x_argument")
|
|
348
|
+
else None
|
|
349
|
+
) or os.getenv("ALEMBIC_VERSION_TABLE", "alembic_version")
|
|
350
|
+
if type_ == "table" and name == version_table:
|
|
216
351
|
return True
|
|
352
|
+
|
|
353
|
+
# Guard: don't drop tables that exist in DB but aren't in metadata
|
|
354
|
+
if skip_drops and type_ == "table" and reflected and compare_to is None:
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
# Payments gating: when disabled, exclude payments tables and their indexes
|
|
358
|
+
if not want_payments:
|
|
359
|
+
if type_ == "table" and name in PAYMENT_TABLES:
|
|
360
|
+
return False
|
|
361
|
+
if type_ == "index":
|
|
362
|
+
try:
|
|
363
|
+
parent = getattr(obj, "table", None)
|
|
364
|
+
if parent is not None and getattr(parent, "name", None) in PAYMENT_TABLES:
|
|
365
|
+
return False
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
|
|
217
369
|
return True
|
|
218
370
|
|
|
219
371
|
return _include_object
|
|
220
372
|
|
|
373
|
+
|
|
221
374
|
def run_migrations_offline() -> None:
|
|
222
375
|
url = config.get_main_option("sqlalchemy.url")
|
|
223
376
|
context.configure(
|
|
@@ -236,6 +389,7 @@ def run_migrations_offline() -> None:
|
|
|
236
389
|
with context.begin_transaction():
|
|
237
390
|
context.run_migrations()
|
|
238
391
|
|
|
392
|
+
|
|
239
393
|
def run_migrations_online() -> None:
|
|
240
394
|
url = config.get_main_option("sqlalchemy.url")
|
|
241
395
|
engine = build_engine(url, echo=False)
|
|
@@ -255,6 +409,7 @@ def run_migrations_online() -> None:
|
|
|
255
409
|
context.run_migrations()
|
|
256
410
|
engine.dispose()
|
|
257
411
|
|
|
412
|
+
|
|
258
413
|
if context.is_offline_mode():
|
|
259
414
|
run_migrations_offline()
|
|
260
415
|
else:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .service import SqlService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantSqlService(SqlService):
|
|
11
|
+
"""
|
|
12
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
13
|
+
|
|
14
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
15
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
19
|
+
super().__init__(repo)
|
|
20
|
+
self.tenant_id = tenant_id
|
|
21
|
+
self.tenant_field = tenant_field
|
|
22
|
+
|
|
23
|
+
def _where(self) -> Sequence[Any]:
|
|
24
|
+
model = self.repo.model
|
|
25
|
+
col = getattr(model, self.tenant_field, None)
|
|
26
|
+
if col is None:
|
|
27
|
+
return []
|
|
28
|
+
return [col == self.tenant_id]
|
|
29
|
+
|
|
30
|
+
async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
|
|
31
|
+
return await self.repo.list(
|
|
32
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def count(self, session: AsyncSession) -> int:
|
|
36
|
+
return await self.repo.count(session, where=self._where())
|
|
37
|
+
|
|
38
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
39
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
40
|
+
|
|
41
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
42
|
+
data = await self.pre_create(data)
|
|
43
|
+
# inject tenant_id if model supports it and value missing
|
|
44
|
+
if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
|
|
45
|
+
data[self.tenant_field] = self.tenant_id
|
|
46
|
+
return await self.repo.create(session, data)
|
|
47
|
+
|
|
48
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
49
|
+
data = await self.pre_update(data)
|
|
50
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
51
|
+
|
|
52
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
53
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
54
|
+
|
|
55
|
+
async def search(
|
|
56
|
+
self,
|
|
57
|
+
session: AsyncSession,
|
|
58
|
+
*,
|
|
59
|
+
q: str,
|
|
60
|
+
fields: Sequence[str],
|
|
61
|
+
limit: int,
|
|
62
|
+
offset: int,
|
|
63
|
+
order_by=None,
|
|
64
|
+
):
|
|
65
|
+
return await self.repo.search(
|
|
66
|
+
session,
|
|
67
|
+
q=q,
|
|
68
|
+
fields=fields,
|
|
69
|
+
limit=limit,
|
|
70
|
+
offset=offset,
|
|
71
|
+
order_by=order_by,
|
|
72
|
+
where=self._where(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
|
|
76
|
+
return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["TenantSqlService"]
|
svc_infra/db/sql/utils.py
CHANGED
|
@@ -196,10 +196,17 @@ def _ensure_timeout_default(u: URL) -> URL:
|
|
|
196
196
|
"""
|
|
197
197
|
Ensure a conservative connection timeout is present for libpq-based drivers.
|
|
198
198
|
For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
|
|
199
|
+
For asyncpg, timeout is set via connect_args (not query string).
|
|
199
200
|
"""
|
|
200
201
|
backend = (u.get_backend_name() or "").lower()
|
|
201
202
|
if backend not in ("postgresql", "postgres"):
|
|
202
203
|
return u
|
|
204
|
+
|
|
205
|
+
# asyncpg doesn't support connect_timeout in query string - use connect_args instead
|
|
206
|
+
dn = (u.drivername or "").lower()
|
|
207
|
+
if "+asyncpg" in dn:
|
|
208
|
+
return u
|
|
209
|
+
|
|
203
210
|
if "connect_timeout" in u.query:
|
|
204
211
|
return u
|
|
205
212
|
# Default 10s unless overridden
|
|
@@ -337,9 +344,8 @@ def _ensure_ssl_default(u: URL) -> URL:
|
|
|
337
344
|
mode = (mode_env or "").strip()
|
|
338
345
|
|
|
339
346
|
if "+asyncpg" in driver:
|
|
340
|
-
# asyncpg:
|
|
341
|
-
|
|
342
|
-
return u.set(query={**u.query, "ssl": "true"})
|
|
347
|
+
# asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
|
|
348
|
+
# Do not add ssl parameter to URL query for asyncpg
|
|
343
349
|
return u
|
|
344
350
|
else:
|
|
345
351
|
# libpq-based drivers: use sslmode (default 'require' for hosted PG)
|
|
@@ -382,10 +388,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
382
388
|
"Async driver URL provided but SQLAlchemy async extras are not available."
|
|
383
389
|
)
|
|
384
390
|
|
|
385
|
-
# asyncpg: honor connection timeout
|
|
391
|
+
# asyncpg: honor connection timeout only (NOT connect_timeout)
|
|
386
392
|
if "+asyncpg" in (u.drivername or ""):
|
|
387
393
|
connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
|
|
388
394
|
|
|
395
|
+
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
396
|
+
# Remove these and set ssl='require' in connect_args
|
|
397
|
+
if "ssl" in u.query or "sslmode" in u.query:
|
|
398
|
+
new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
|
|
399
|
+
u = u.set(query=new_query)
|
|
400
|
+
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
401
|
+
connect_args["ssl"] = "require"
|
|
402
|
+
|
|
389
403
|
# NEW: aiomysql SSL default
|
|
390
404
|
if "+aiomysql" in (u.drivername or "") and not any(
|
|
391
405
|
k in u.query for k in ("ssl", "ssl_ca", "sslmode")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Integer
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Versioned:
|
|
8
|
+
"""Mixin for optimistic locking with integer version.
|
|
9
|
+
|
|
10
|
+
- Initialize version=1 on insert (via default=1)
|
|
11
|
+
- Bump version in app code before commit to detect mismatches.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Acceptance Matrix (A-IDs)
|
|
2
|
+
|
|
3
|
+
This document maps Acceptance scenarios (A-IDs) to endpoints, CLIs, fixtures, and seed data. Use it to drive the CI promotion gate and local `make accept` runs.
|
|
4
|
+
|
|
5
|
+
## A0. Harness
|
|
6
|
+
- Stack: docker-compose.test.yml (api, db, redis)
|
|
7
|
+
- Makefile targets: accept, compose_up, wait, seed, down
|
|
8
|
+
- Tests bootstrap: tests/acceptance/conftest.py (BASE_URL), _auth.py, _seed.py, _http.py
|
|
9
|
+
|
|
10
|
+
## A1. Security & Auth
|
|
11
|
+
- A1-01 Register → Verify → Login → /auth/me
|
|
12
|
+
- Endpoints: POST /auth/register, POST /auth/verify, POST /auth/login, GET /auth/me
|
|
13
|
+
- Fixtures: admin, user
|
|
14
|
+
- A1-02 Password policy & breach check
|
|
15
|
+
- Endpoints: POST /auth/register
|
|
16
|
+
- A1-03 Lockout escalation and cooldown
|
|
17
|
+
- Endpoints: POST /auth/login
|
|
18
|
+
- A1-04 RBAC/ABAC enforced
|
|
19
|
+
- Endpoints: GET /admin/*, resource GET with owner guard
|
|
20
|
+
- A1-05 Session list & revoke
|
|
21
|
+
- Endpoints: GET/DELETE /auth/sessions
|
|
22
|
+
- A1-06 API keys lifecycle
|
|
23
|
+
- Endpoints: POST/GET/DELETE /auth/api-keys, usage via Authorization header
|
|
24
|
+
- A1-07 MFA lifecycle
|
|
25
|
+
- Endpoints: /auth/mfa/*
|
|
26
|
+
|
|
27
|
+
## A2. Rate Limiting
|
|
28
|
+
- A2-01 Global limit → 429 with Retry-After
|
|
29
|
+
- A2-02 Per-route & tenant override honored
|
|
30
|
+
- A2-03 Window reset
|
|
31
|
+
|
|
32
|
+
## A3. Idempotency & Concurrency
|
|
33
|
+
- A3-01 Same Idempotency-Key → identical 2xx
|
|
34
|
+
- A3-02 Conflicting payload + same key → 409
|
|
35
|
+
- A3-03 Optimistic lock mismatch → 409; success increments version
|
|
36
|
+
|
|
37
|
+
## A4. Jobs & Scheduling
|
|
38
|
+
- A4-01 Custom job consumed
|
|
39
|
+
- A4-02 Backoff & DLQ
|
|
40
|
+
- A4-03 Cron tick observed
|
|
41
|
+
|
|
42
|
+
## A5. Webhooks
|
|
43
|
+
- A5-01 Producer → delivery (HMAC verified)
|
|
44
|
+
- A5-02 Retry stops on success
|
|
45
|
+
- A5-03 Secret rotation window accepts old+new
|
|
46
|
+
|
|
47
|
+
## A6. Tenancy
|
|
48
|
+
- A6-01 tenant_id injected on create; list scoped
|
|
49
|
+
- A6-02 Cross-tenant → 404/403
|
|
50
|
+
- A6-03 Per-tenant quotas enforced
|
|
51
|
+
|
|
52
|
+
## A7. Data Lifecycle
|
|
53
|
+
- A7-01 Soft delete hides; undelete restores
|
|
54
|
+
- A7-02 GDPR erasure steps with audit
|
|
55
|
+
- A7-03 Retention purge soft→hard
|
|
56
|
+
- A7-04 Backup verification healthy
|
|
57
|
+
|
|
58
|
+
## A8. SLOs & Ops
|
|
59
|
+
- A8-01 Metrics http_server_* and db_pool_* present
|
|
60
|
+
- A8-02 Maintenance mode 503; circuit breaker trips/recover
|
|
61
|
+
- A8-03 Liveness/readiness under DB up/down
|
|
62
|
+
|
|
63
|
+
## A9. OpenAPI & Error Contracts
|
|
64
|
+
- A9-01 /openapi.json valid; examples present
|
|
65
|
+
- A9-02 Problem+JSON conforms
|
|
66
|
+
- A9-03 Spectral + API Doctor pass
|
|
67
|
+
|
|
68
|
+
## A10. CLI & DX
|
|
69
|
+
- A10-01 DB migrate/rollback/seed
|
|
70
|
+
- A10-02 Jobs runner consumes a sample job
|
|
71
|
+
- A10-03 SDK smoke-import and /ping
|