svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -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 -0
- 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 +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -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/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- 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 +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- 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/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 +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -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/__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 +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- 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/versioned-integrations.md +146 -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 +255 -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.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -2,15 +2,13 @@ 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
|
|
10
10
|
|
|
11
11
|
from svc_infra.db.sql.utils import (
|
|
12
|
-
_coerce_sync_driver,
|
|
13
|
-
_ensure_ssl_default,
|
|
14
12
|
get_database_url_from_env,
|
|
15
13
|
build_engine,
|
|
16
14
|
)
|
|
@@ -20,6 +18,7 @@ try:
|
|
|
20
18
|
except Exception:
|
|
21
19
|
_GUID = None
|
|
22
20
|
|
|
21
|
+
|
|
23
22
|
def _render_item(type_, obj, autogen_context):
|
|
24
23
|
if type_ == "type" and (
|
|
25
24
|
(_GUID is not None and isinstance(obj, _GUID))
|
|
@@ -29,38 +28,49 @@ def _render_item(type_, obj, autogen_context):
|
|
|
29
28
|
return "GUID()"
|
|
30
29
|
return False
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
|
|
32
|
+
# ---- Logging ----
|
|
33
33
|
config = context.config
|
|
34
34
|
if config.config_file_name is not None:
|
|
35
|
-
import logging.config
|
|
36
|
-
|
|
35
|
+
import logging.config as _lc
|
|
36
|
+
_lc.fileConfig(config.config_file_name)
|
|
37
|
+
|
|
37
38
|
logger = logging.getLogger(__name__)
|
|
39
|
+
logger.setLevel(logging.INFO)
|
|
40
|
+
|
|
38
41
|
|
|
39
|
-
#
|
|
42
|
+
# ---- sys.path bootstrap (append; do NOT shadow site-packages) ----
|
|
40
43
|
prepend = config.get_main_option("prepend_sys_path") or ""
|
|
44
|
+
script_loc = config.get_main_option("script_location") or os.path.dirname(__file__)
|
|
45
|
+
migrations_dir = pathlib.Path(script_loc).resolve()
|
|
46
|
+
project_root = migrations_dir.parent
|
|
47
|
+
|
|
48
|
+
def _ensure_on_syspath_end(p: pathlib.Path) -> None:
|
|
49
|
+
s = str(p)
|
|
50
|
+
if s and s not in sys.path:
|
|
51
|
+
sys.path.append(s) # append instead of insert(0) to avoid shadowing installed packages
|
|
52
|
+
|
|
41
53
|
if prepend:
|
|
42
|
-
|
|
43
|
-
sys.path.insert(0, prepend)
|
|
54
|
+
_ensure_on_syspath_end(pathlib.Path(prepend))
|
|
44
55
|
src_path = pathlib.Path(prepend) / "src"
|
|
45
56
|
if src_path.exists():
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
_ensure_on_syspath_end(src_path)
|
|
58
|
+
|
|
59
|
+
_ensure_on_syspath_end(project_root)
|
|
60
|
+
if (project_root / "src").exists():
|
|
61
|
+
_ensure_on_syspath_end(project_root / "src")
|
|
62
|
+
|
|
49
63
|
|
|
50
|
-
#
|
|
64
|
+
# ---- x-args ----
|
|
51
65
|
def _x_args_dict() -> dict:
|
|
52
66
|
try:
|
|
53
|
-
# Alembic >= 1.8 uses as_dictionary kw only
|
|
54
67
|
return context.get_x_argument(as_dictionary=True) # type: ignore[arg-type]
|
|
55
68
|
except TypeError:
|
|
56
69
|
try:
|
|
57
|
-
# Some versions just return a list even if you pass kw
|
|
58
70
|
xs = context.get_x_argument()
|
|
59
71
|
except TypeError:
|
|
60
|
-
# Very old signatures
|
|
61
72
|
xs = []
|
|
62
|
-
|
|
63
|
-
out = {}
|
|
73
|
+
out: dict = {}
|
|
64
74
|
for item in xs:
|
|
65
75
|
if "=" in item:
|
|
66
76
|
k, v = item.split("=", 1)
|
|
@@ -69,7 +79,8 @@ def _x_args_dict() -> dict:
|
|
|
69
79
|
out[item] = ""
|
|
70
80
|
return out
|
|
71
81
|
|
|
72
|
-
|
|
82
|
+
|
|
83
|
+
# ---- DB URL resolution ----
|
|
73
84
|
_x = _x_args_dict()
|
|
74
85
|
cli_dburl = _x.get("dburl", "").strip()
|
|
75
86
|
env_dburl = os.getenv("SQL_URL", "").strip()
|
|
@@ -90,103 +101,215 @@ if not effective_url:
|
|
|
90
101
|
|
|
91
102
|
u = make_url(effective_url)
|
|
92
103
|
u = _coerce_sync_driver(u)
|
|
93
|
-
u = _ensure_ssl_default(u)
|
|
94
104
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
95
105
|
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
|
|
107
|
+
# ---- feature flags / constants ----
|
|
108
|
+
WANT_PAYMENTS = os.getenv("APF_ENABLE_PAYMENTS", "").lower() in {"1", "true", "yes"}
|
|
109
|
+
FORCE_PAYMENTS = os.getenv("ALEMBIC_FORCE_PAYMENTS", "").lower() in {"1", "true", "yes"}
|
|
110
|
+
PAYMENT_TABLES = {"pay_customers", "pay_intents", "pay_events", "ledger_entries"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---- metadata discovery (prefer ModelBase; scan ALL attrs) ----
|
|
114
|
+
# IMPORTANT: do NOT seed with payments package unless enabled/forced.
|
|
115
|
+
DISCOVER_PACKAGES: List[str] = [] # seed empty; merged with FS + env
|
|
98
116
|
ENV_DISCOVER = os.getenv("ALEMBIC_DISCOVER_PACKAGES")
|
|
99
117
|
if ENV_DISCOVER:
|
|
100
|
-
DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(
|
|
118
|
+
DISCOVER_PACKAGES = [s.strip() for s in ENV_DISCOVER.split(",") if s.strip()]
|
|
101
119
|
|
|
102
120
|
def _collect_metadata() -> list[object]:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
"""
|
|
122
|
+
Strategy:
|
|
123
|
+
1) (If WANT_PAYMENTS or FORCE_PAYMENTS) force-import payments module and log result.
|
|
124
|
+
2) Import packages + ALEMBIC_DISCOVER_PACKAGES (+ payments only when enabled).
|
|
125
|
+
3) ALSO import top-level packages under project root and src/.
|
|
126
|
+
4) Import common model-bearing submodules.
|
|
127
|
+
5) Prefer ModelBase.metadata after imports.
|
|
128
|
+
6) Collect ANY object whose `.metadata` has tables (scan ALL attrs).
|
|
129
|
+
7) De-dup and keep only those with at least one table.
|
|
130
|
+
"""
|
|
131
|
+
tried: list[Tuple[str, str]] = []
|
|
132
|
+
errors: list[Tuple[str, str]] = []
|
|
112
133
|
found: list[object] = []
|
|
113
134
|
|
|
135
|
+
def _note(name: str, ok: bool, err: str | None = None):
|
|
136
|
+
tried.append((name, "ok" if ok else "err"))
|
|
137
|
+
if not ok and err:
|
|
138
|
+
errors.append((name, err))
|
|
139
|
+
|
|
114
140
|
def _maybe_add(obj: object) -> None:
|
|
115
141
|
md = getattr(obj, "metadata", None) or obj
|
|
116
|
-
if hasattr(md, "tables") and
|
|
142
|
+
if hasattr(md, "tables") and getattr(md, "tables"):
|
|
117
143
|
found.append(md)
|
|
118
144
|
|
|
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)
|
|
145
|
+
def _scan_module_objects(mod: object) -> None:
|
|
146
|
+
try:
|
|
147
|
+
for val in vars(mod).values():
|
|
148
|
+
md = getattr(val, "metadata", None) or None
|
|
149
|
+
if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
|
|
150
|
+
found.append(md)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
131
153
|
|
|
132
|
-
|
|
154
|
+
# (1) Force-load payments when enabled/forced and log explicit outcome
|
|
155
|
+
if WANT_PAYMENTS or FORCE_PAYMENTS:
|
|
133
156
|
try:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
importlib.import_module("svc_infra.apf_payments.models")
|
|
158
|
+
context.config.print_stdout("[alembic env] payments module import: ok (svc_infra.apf_payments.models)")
|
|
159
|
+
except Exception:
|
|
160
|
+
context.config.print_stdout("[alembic env] payments module import: ERR (svc_infra.apf_payments.models)")
|
|
161
|
+
context.config.print_stdout(traceback.format_exc())
|
|
162
|
+
|
|
163
|
+
# (2) seed list
|
|
164
|
+
pkgs: list[str] = []
|
|
165
|
+
# add payments package ONLY when enabled/forced
|
|
166
|
+
if WANT_PAYMENTS or FORCE_PAYMENTS:
|
|
167
|
+
pkgs.append("svc_infra.apf_payments.models")
|
|
168
|
+
|
|
169
|
+
for p in list(DISCOVER_PACKAGES or []):
|
|
170
|
+
if p and p not in pkgs:
|
|
171
|
+
pkgs.append(p)
|
|
172
|
+
|
|
173
|
+
env_pkgs = os.getenv("ALEMBIC_DISCOVER_PACKAGES", "")
|
|
174
|
+
if env_pkgs:
|
|
175
|
+
for p in (x.strip() for x in env_pkgs.split(",") if x.strip()):
|
|
176
|
+
if p not in pkgs:
|
|
177
|
+
pkgs.append(p)
|
|
178
|
+
|
|
179
|
+
# (3) filesystem discovery (root + src) – appended, not shadowing site-packages
|
|
180
|
+
fs_roots: list[pathlib.Path] = []
|
|
181
|
+
for candidate in {project_root, project_root / "src"}:
|
|
182
|
+
if candidate.exists():
|
|
183
|
+
fs_roots.append(candidate)
|
|
184
|
+
for root in fs_roots:
|
|
185
|
+
for p in root.iterdir():
|
|
186
|
+
if p.is_dir() and (p / "__init__.py").exists():
|
|
187
|
+
name = p.name
|
|
188
|
+
if name not in pkgs:
|
|
189
|
+
pkgs.append(name)
|
|
138
190
|
|
|
191
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
192
|
+
if "models" not in pkgs:
|
|
193
|
+
try:
|
|
194
|
+
spec = getattr(importlib, "util", None)
|
|
195
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
196
|
+
if spec.find_spec("models") is not None:
|
|
197
|
+
pkgs.append("models")
|
|
198
|
+
except Exception:
|
|
199
|
+
# If discovery fails, skip adding bare 'models'
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
def _import_and_collect(modname: str):
|
|
203
|
+
try:
|
|
204
|
+
mod = importlib.import_module(modname)
|
|
205
|
+
_note(modname, True, None)
|
|
206
|
+
except Exception:
|
|
207
|
+
_note(modname, False, traceback.format_exc())
|
|
208
|
+
return None
|
|
139
209
|
for attr in ("metadata", "MetaData", "Base", "base"):
|
|
140
|
-
obj = getattr(
|
|
210
|
+
obj = getattr(mod, attr, None)
|
|
141
211
|
if obj is not None:
|
|
142
212
|
_maybe_add(obj)
|
|
213
|
+
_scan_module_objects(mod)
|
|
214
|
+
return mod
|
|
215
|
+
|
|
216
|
+
for pkg_name in pkgs:
|
|
217
|
+
pkg = _import_and_collect(pkg_name)
|
|
218
|
+
if pkg is None:
|
|
219
|
+
continue
|
|
143
220
|
|
|
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
|
|
221
|
+
for subname in ("models", "db", "orm", "entities"):
|
|
222
|
+
_import_and_collect(f"{pkg_name}.{subname}")
|
|
153
223
|
|
|
154
224
|
mod_path = getattr(pkg, "__path__", None)
|
|
155
225
|
if not mod_path:
|
|
156
226
|
continue
|
|
157
227
|
for _, name, ispkg in pkgutil.walk_packages(mod_path, prefix=pkg_name + "."):
|
|
158
|
-
if ispkg
|
|
228
|
+
if ispkg:
|
|
159
229
|
continue
|
|
160
|
-
|
|
161
|
-
mod = importlib.import_module(name)
|
|
162
|
-
except Exception:
|
|
230
|
+
if not any(x in name for x in (".models", ".db", ".orm", ".entities")):
|
|
163
231
|
continue
|
|
164
|
-
|
|
165
|
-
obj = getattr(mod, attr, None)
|
|
166
|
-
if obj is not None:
|
|
167
|
-
_maybe_add(obj)
|
|
232
|
+
_import_and_collect(name)
|
|
168
233
|
|
|
169
|
-
#
|
|
234
|
+
# Prefer ModelBase after all imports
|
|
235
|
+
try:
|
|
236
|
+
from svc_infra.db.sql.base import ModelBase # type: ignore
|
|
237
|
+
mb_md = getattr(ModelBase, "metadata", None)
|
|
238
|
+
if mb_md is not None and getattr(mb_md, "tables", {}):
|
|
239
|
+
found.append(mb_md)
|
|
240
|
+
_note("ModelBase.metadata", True, None)
|
|
241
|
+
else:
|
|
242
|
+
_note("ModelBase.metadata(empty)", True, None)
|
|
243
|
+
except Exception:
|
|
244
|
+
_note("ModelBase import", False, traceback.format_exc())
|
|
245
|
+
|
|
246
|
+
# Optional: autobind API key model
|
|
170
247
|
try:
|
|
171
248
|
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
249
|
try_autobind_apikey_model(require_env=False)
|
|
174
|
-
|
|
175
|
-
|
|
250
|
+
_note("svc_infra.db.sql.apikey.try_autobind_apikey_model", True, None)
|
|
251
|
+
except Exception:
|
|
252
|
+
_note("svc_infra.db.sql.apikey.try_autobind_apikey_model", False, traceback.format_exc())
|
|
176
253
|
|
|
177
|
-
|
|
254
|
+
# De-dup MetaData objects
|
|
255
|
+
uniq: list[object] = []
|
|
256
|
+
seen: set[int] = set()
|
|
178
257
|
for md in found:
|
|
258
|
+
try:
|
|
259
|
+
if not getattr(md, "tables", {}):
|
|
260
|
+
continue
|
|
261
|
+
except Exception:
|
|
262
|
+
continue
|
|
179
263
|
if id(md) not in seen:
|
|
180
264
|
seen.add(id(md))
|
|
181
265
|
uniq.append(md)
|
|
266
|
+
|
|
267
|
+
total_tables = 0
|
|
268
|
+
try:
|
|
269
|
+
total_tables = sum(len(getattr(md, "tables", {})) for md in uniq)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
context.config.print_stdout(
|
|
274
|
+
f"[alembic env] discovered {len(uniq)} metadata objects with {total_tables} tables total"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if WANT_PAYMENTS and not FORCE_PAYMENTS:
|
|
278
|
+
saw_pay = any(any(tn in PAYMENT_TABLES for tn in md.tables.keys()) for md in uniq) if uniq else False
|
|
279
|
+
if not saw_pay:
|
|
280
|
+
context.config.print_stdout(
|
|
281
|
+
"[alembic env] WARNING: APF_ENABLE_PAYMENTS is set but no payments tables were discovered. "
|
|
282
|
+
"If you still see this, a local package named 'svc_infra' may be shadowing the installed one."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# If nothing, dump import attempts / first 10 tracebacks
|
|
286
|
+
if total_tables == 0:
|
|
287
|
+
context.config.print_stdout("[alembic env] import attempts (ok/err):")
|
|
288
|
+
for name, status in tried:
|
|
289
|
+
context.config.print_stdout(f" - {status:3s} {name}")
|
|
290
|
+
for name, tb in errors[:10]:
|
|
291
|
+
context.config.print_stdout(f" --- import error: {name} ---")
|
|
292
|
+
context.config.print_stdout(tb)
|
|
293
|
+
|
|
182
294
|
return uniq
|
|
183
295
|
|
|
296
|
+
|
|
184
297
|
target_metadata = _collect_metadata()
|
|
185
298
|
|
|
299
|
+
|
|
186
300
|
def _want_include_schemas() -> bool:
|
|
187
|
-
# allow override: alembic -x include_schemas=1
|
|
188
301
|
val = _x.get("include_schemas", "") or os.getenv("ALEMBIC_INCLUDE_SCHEMAS", "")
|
|
189
|
-
|
|
302
|
+
if str(val).strip() in {"1", "true", "True", "yes"}:
|
|
303
|
+
return True
|
|
304
|
+
try:
|
|
305
|
+
for md in (target_metadata or []):
|
|
306
|
+
for t in getattr(md, "tables", {}).values():
|
|
307
|
+
if getattr(t, "schema", None):
|
|
308
|
+
return True
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
return False
|
|
312
|
+
|
|
190
313
|
|
|
191
314
|
def _system_schemas_for(url: str) -> set[str]:
|
|
192
315
|
try:
|
|
@@ -203,21 +326,48 @@ def _system_schemas_for(url: str) -> set[str]:
|
|
|
203
326
|
return {"INFORMATION_SCHEMA"}
|
|
204
327
|
return set()
|
|
205
328
|
|
|
329
|
+
|
|
206
330
|
def _include_object_factory(url: str):
|
|
207
331
|
sys_schemas = _system_schemas_for(url)
|
|
332
|
+
skip_drops = os.getenv("ALEMBIC_SKIP_DROPS", "").lower() in {"1", "true", "yes"}
|
|
333
|
+
want_payments = WANT_PAYMENTS or FORCE_PAYMENTS
|
|
208
334
|
|
|
209
335
|
def _include_object(obj, name, type_, reflected, compare_to):
|
|
210
|
-
#
|
|
336
|
+
# filter system schemas
|
|
211
337
|
schema = getattr(obj, "schema", None)
|
|
212
338
|
if schema and str(schema) in sys_schemas:
|
|
213
339
|
return False
|
|
214
|
-
|
|
215
|
-
|
|
340
|
+
|
|
341
|
+
# Always keep Alembic version table
|
|
342
|
+
version_table = (
|
|
343
|
+
context.get_x_argument(as_dictionary=True).get("version_table")
|
|
344
|
+
if hasattr(context, "get_x_argument")
|
|
345
|
+
else None
|
|
346
|
+
) or os.getenv("ALEMBIC_VERSION_TABLE", "alembic_version")
|
|
347
|
+
if type_ == "table" and name == version_table:
|
|
216
348
|
return True
|
|
349
|
+
|
|
350
|
+
# Guard: don't drop tables that exist in DB but aren't in metadata
|
|
351
|
+
if skip_drops and type_ == "table" and reflected and compare_to is None:
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
# Payments gating: when disabled, exclude payments tables and their indexes
|
|
355
|
+
if not want_payments:
|
|
356
|
+
if type_ == "table" and name in PAYMENT_TABLES:
|
|
357
|
+
return False
|
|
358
|
+
if type_ == "index":
|
|
359
|
+
try:
|
|
360
|
+
parent = getattr(obj, "table", None)
|
|
361
|
+
if parent is not None and getattr(parent, "name", None) in PAYMENT_TABLES:
|
|
362
|
+
return False
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
|
|
217
366
|
return True
|
|
218
367
|
|
|
219
368
|
return _include_object
|
|
220
369
|
|
|
370
|
+
|
|
221
371
|
def run_migrations_offline() -> None:
|
|
222
372
|
url = config.get_main_option("sqlalchemy.url")
|
|
223
373
|
context.configure(
|
|
@@ -236,6 +386,7 @@ def run_migrations_offline() -> None:
|
|
|
236
386
|
with context.begin_transaction():
|
|
237
387
|
context.run_migrations()
|
|
238
388
|
|
|
389
|
+
|
|
239
390
|
def run_migrations_online() -> None:
|
|
240
391
|
url = config.get_main_option("sqlalchemy.url")
|
|
241
392
|
engine = build_engine(url, echo=False)
|
|
@@ -255,6 +406,7 @@ def run_migrations_online() -> None:
|
|
|
255
406
|
context.run_migrations()
|
|
256
407
|
engine.dispose()
|
|
257
408
|
|
|
409
|
+
|
|
258
410
|
if context.is_offline_mode():
|
|
259
411
|
run_migrations_offline()
|
|
260
412
|
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
|