svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/api/__init__.py CHANGED
@@ -0,0 +1,61 @@
1
+ """svc-infra API module.
2
+
3
+ Re-exports key API utilities from svc_infra.api.fastapi for convenient imports.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ # Re-export from fastapi submodule
9
+ from svc_infra.api.fastapi import (
10
+ # Dual routers
11
+ DualAPIRouter,
12
+ dualize_protected,
13
+ dualize_public,
14
+ dualize_user,
15
+ # Service setup
16
+ ServiceInfo,
17
+ APIVersionSpec,
18
+ setup_service_api,
19
+ easy_service_api,
20
+ easy_service_app,
21
+ setup_caching,
22
+ # Health checks
23
+ add_startup_probe,
24
+ add_health_routes,
25
+ add_dependency_health,
26
+ check_database,
27
+ check_redis,
28
+ check_url,
29
+ # Pagination
30
+ use_pagination,
31
+ text_filter,
32
+ sort_by,
33
+ cursor_window,
34
+ )
35
+
36
+ __all__ = [
37
+ # Dual routers
38
+ "DualAPIRouter",
39
+ "dualize_protected",
40
+ "dualize_public",
41
+ "dualize_user",
42
+ # Service setup
43
+ "ServiceInfo",
44
+ "APIVersionSpec",
45
+ "setup_service_api",
46
+ "easy_service_api",
47
+ "easy_service_app",
48
+ "setup_caching",
49
+ # Health checks
50
+ "add_startup_probe",
51
+ "add_health_routes",
52
+ "add_dependency_health",
53
+ "check_database",
54
+ "check_redis",
55
+ "check_url",
56
+ # Pagination
57
+ "use_pagination",
58
+ "text_filter",
59
+ "sort_by",
60
+ "cursor_window",
61
+ ]
@@ -5,6 +5,14 @@ from svc_infra.api.fastapi.dual import (
5
5
  dualize_user,
6
6
  )
7
7
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
8
+ from svc_infra.health import (
9
+ add_dependency_health,
10
+ add_health_routes,
11
+ add_startup_probe,
12
+ check_database,
13
+ check_redis,
14
+ check_url,
15
+ )
8
16
 
9
17
  from .cache.add import setup_caching
10
18
  from .ease import easy_service_api, easy_service_app
@@ -18,6 +26,13 @@ __all__ = [
18
26
  "dualize_protected",
19
27
  "ServiceInfo",
20
28
  "APIVersionSpec",
29
+ # Health
30
+ "add_startup_probe",
31
+ "add_health_routes",
32
+ "add_dependency_health",
33
+ "check_database",
34
+ "check_redis",
35
+ "check_url",
21
36
  # Ease
22
37
  "setup_service_api",
23
38
  "easy_service_api",
@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from hashlib import sha256
11
+ from types import SimpleNamespace
12
+ from typing import Any, Callable, Optional, cast
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+
16
+ from ....app.env import get_current_environment, require_secret
17
+ from ....security.permissions import RequirePermission
18
+ from ..auth.security import Identity, Principal, _current_principal
19
+ from ..auth.state import get_auth_state
20
+ from ..db.sql.session import SqlSessionDep
21
+ from ..dual.protected import roles_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _b64u(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
28
+
29
+
30
+ def _b64u_decode(s: str) -> bytes:
31
+ pad = "=" * ((4 - len(s) % 4) % 4)
32
+ return base64.urlsafe_b64decode(s + pad)
33
+
34
+
35
+ def _sign(payload: dict, *, secret: str) -> str:
36
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
38
+ return _b64u(body) + "." + _b64u(sig)
39
+
40
+
41
+ def _verify(token: str, *, secret: str) -> dict:
42
+ try:
43
+ b64_body, b64_sig = token.split(".", 1)
44
+ body = _b64u_decode(b64_body)
45
+ exp_sig = _b64u_decode(b64_sig)
46
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
47
+ if not hmac.compare_digest(exp_sig, got_sig):
48
+ raise ValueError("bad_signature")
49
+ payload = json.loads(body)
50
+ if int(payload.get("exp", 0)) < int(time.time()):
51
+ raise ValueError("expired")
52
+ return cast(dict[Any, Any], payload)
53
+ except Exception as e:
54
+ raise ValueError("invalid_token") from e
55
+
56
+
57
+ def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
58
+ """Role-gated admin router for coarse access control.
59
+
60
+ Use permission guards inside endpoints for fine-grained control.
61
+ """
62
+
63
+ return cast(APIRouter, roles_router("admin", **kwargs))
64
+
65
+
66
+ def add_admin(
67
+ app,
68
+ *,
69
+ base_path: str = "/admin",
70
+ enable_impersonation: bool = True,
71
+ secret: Optional[str] = None,
72
+ ttl_seconds: int = 15 * 60,
73
+ cookie_name: str = "impersonation",
74
+ impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
75
+ ) -> None:
76
+ """Wire admin surfaces with sensible defaults.
77
+
78
+ - Mounts an admin router under base_path.
79
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
80
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
81
+
82
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
83
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
84
+ """
85
+
86
+ # Idempotency: only mount once per app instance
87
+ if getattr(app.state, "_admin_added", False):
88
+ return
89
+
90
+ env = get_current_environment()
91
+ _secret = require_secret(
92
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
93
+ "ADMIN_IMPERSONATION_SECRET or APP_SECRET",
94
+ dev_default="dev-only-admin-impersonation-secret-not-for-production",
95
+ )
96
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
97
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
98
+
99
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
100
+
101
+ async def _default_user_getter(
102
+ request: Request, user_id: str, session: SqlSessionDep
103
+ ):
104
+ try:
105
+ UserModel, _, _ = get_auth_state()
106
+ except Exception:
107
+ # Fallback: simple shim if auth state not configured
108
+ return SimpleNamespace(id=user_id)
109
+ obj = await cast(Any, session).get(UserModel, user_id)
110
+ if not obj:
111
+ raise HTTPException(404, "user_not_found")
112
+ return obj
113
+
114
+ user_getter = impersonation_user_getter
115
+
116
+ @r.post(
117
+ "/impersonate/start",
118
+ status_code=204,
119
+ dependencies=[RequirePermission("admin.impersonate")],
120
+ )
121
+ async def start_impersonation(
122
+ body: dict,
123
+ request: Request,
124
+ response: Response,
125
+ session: SqlSessionDep,
126
+ identity: Identity,
127
+ ):
128
+ target_id = (body or {}).get("user_id")
129
+ reason = (body or {}).get("reason", "")
130
+ if not target_id:
131
+ raise HTTPException(422, "user_id_required")
132
+ # Load target for validation (custom getter or default)
133
+ _res = (
134
+ user_getter(request, target_id)
135
+ if user_getter
136
+ else _default_user_getter(request, target_id, session)
137
+ )
138
+ target = await _res if inspect.isawaitable(_res) else _res
139
+ actor: Principal = identity
140
+ payload = {
141
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
142
+ "target_id": str(getattr(target, "id", target_id)),
143
+ "iat": int(time.time()),
144
+ "exp": int(time.time()) + _ttl,
145
+ "nonce": _b64u(os.urandom(8)),
146
+ }
147
+ token = _sign(payload, secret=_secret)
148
+ response.set_cookie(
149
+ key=_cookie,
150
+ value=token,
151
+ httponly=True,
152
+ samesite="lax",
153
+ secure=(env in ("prod", "production")),
154
+ path="/",
155
+ max_age=_ttl,
156
+ )
157
+ logger.info(
158
+ "admin.impersonation.started",
159
+ extra={
160
+ "actor_id": payload["actor_id"],
161
+ "target_id": payload["target_id"],
162
+ "reason": reason,
163
+ "expires_in": _ttl,
164
+ },
165
+ )
166
+ # Re-compose override now to wrap any late overrides set by tests/harness
167
+ try:
168
+ _compose_override()
169
+ except Exception:
170
+ pass
171
+
172
+ @r.post("/impersonate/stop", status_code=204)
173
+ async def stop_impersonation(response: Response):
174
+ response.delete_cookie(_cookie, path="/")
175
+ logger.info("admin.impersonation.stopped")
176
+
177
+ app.include_router(r)
178
+
179
+ # Dependency override: wrap the base principal to honor impersonation cookie.
180
+ # Compose with any existing override (e.g., acceptance app/test harness) and
181
+ # re-compose at startup to capture late overrides.
182
+ def _compose_override():
183
+ existing = app.dependency_overrides.get(_current_principal)
184
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
185
+ dep_provider = getattr(
186
+ existing, "_admin_impersonation_base", _current_principal
187
+ )
188
+ else:
189
+ dep_provider = existing or _current_principal
190
+
191
+ async def _override_current_principal(
192
+ request: Request,
193
+ session: SqlSessionDep,
194
+ base: Principal = Depends(dep_provider),
195
+ ) -> Principal:
196
+ token = request.cookies.get(_cookie) if request else None
197
+ if not token:
198
+ return base
199
+ try:
200
+ payload = _verify(token, secret=_secret)
201
+ except Exception:
202
+ return base
203
+ # Load target user
204
+ target_id = payload.get("target_id")
205
+ if not target_id:
206
+ return base
207
+ # Preserve actor roles/claims so permissions remain that of the actor
208
+ actor_user = getattr(base, "user", None)
209
+ actor_roles = getattr(actor_user, "roles", []) or []
210
+ _res = (
211
+ user_getter(request, target_id)
212
+ if user_getter
213
+ else _default_user_getter(request, target_id, session)
214
+ )
215
+ target = await _res if inspect.isawaitable(_res) else _res
216
+ # Swap user but keep actor for audit if needed
217
+ setattr(base, "actor", getattr(base, "user", None))
218
+ # If target lacks roles, inherit actor roles to maintain permission checks
219
+ try:
220
+ if not getattr(target, "roles", None):
221
+ setattr(target, "roles", actor_roles)
222
+ except Exception:
223
+ # Best-effort; if target object is immutable, fallback by wrapping
224
+ target = SimpleNamespace(
225
+ id=getattr(target, "id", target_id), roles=actor_roles
226
+ )
227
+ base.user = target
228
+ base.via = "impersonated"
229
+ return base
230
+
231
+ app.dependency_overrides[_current_principal] = _override_current_principal
232
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
233
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
234
+
235
+ # Compose now (best-effort) and again on startup to wrap any later overrides
236
+ _compose_override()
237
+ try:
238
+ app.add_event_handler("startup", _compose_override)
239
+ except Exception:
240
+ # Best-effort; if app doesn't support event handlers, we already composed once
241
+ pass
242
+ app.state._admin_added = True
243
+
244
+
245
+ # no extra helpers
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Literal, Optional, cast
3
+ import inspect
4
+ from typing import Callable, Literal, Optional, cast
4
5
 
5
- from fastapi import Body, Depends, Header, Request, Response, status
6
+ from fastapi import Body, Depends, Header, HTTPException, Request, Response, status
6
7
  from starlette.responses import JSONResponse
7
8
 
8
9
  from svc_infra.apf_payments.schemas import (
@@ -47,8 +48,14 @@ from svc_infra.apf_payments.schemas import (
47
48
  WebhookReplayOut,
48
49
  )
49
50
  from svc_infra.apf_payments.service import PaymentsService
51
+ from svc_infra.api.fastapi.auth.security import OptionalIdentity, Principal
50
52
  from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
51
- from svc_infra.api.fastapi.dual import protected_router, public_router, service_router, user_router
53
+ from svc_infra.api.fastapi.dual import (
54
+ protected_router,
55
+ public_router,
56
+ service_router,
57
+ user_router,
58
+ )
52
59
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
53
60
  from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
54
61
  from svc_infra.api.fastapi.pagination import (
@@ -68,9 +75,84 @@ def _tx_kind(kind: str) -> Literal["payment", "refund", "fee", "payout", "captur
68
75
  return cast(Literal["payment", "refund", "fee", "payout", "capture"], kind)
69
76
 
70
77
 
78
+ # --- tenant resolution ---
79
+ _tenant_resolver: Callable | None = None
80
+
81
+
82
+ def set_payments_tenant_resolver(fn):
83
+ """Set or clear an override hook for payments tenant resolution.
84
+
85
+ fn(request: Request, identity: Principal | None, header: str | None) -> str | None
86
+ Return a tenant_id to override, or None to defer to default flow.
87
+ """
88
+ global _tenant_resolver
89
+ _tenant_resolver = fn
90
+
91
+
92
+ async def resolve_payments_tenant_id(
93
+ request: Request,
94
+ identity: Principal | None = None,
95
+ tenant_header: str | None = None,
96
+ ) -> str:
97
+ # 1) Override hook
98
+ if _tenant_resolver is not None:
99
+ val = _tenant_resolver(request, identity, tenant_header)
100
+ # Support async or sync resolver
101
+ if inspect.isawaitable(val):
102
+ val = await val
103
+ if val:
104
+ return cast(str, val)
105
+ # if None, continue default flow
106
+
107
+ # 2) Principal (user)
108
+ if identity and getattr(identity.user or object(), "tenant_id", None):
109
+ return cast(str, getattr(identity.user, "tenant_id"))
110
+
111
+ # 3) Principal (api key)
112
+ if identity and getattr(identity.api_key or object(), "tenant_id", None):
113
+ return cast(str, getattr(identity.api_key, "tenant_id"))
114
+
115
+ # 4) Explicit header argument (tests pass this)
116
+ if tenant_header:
117
+ return tenant_header
118
+
119
+ # 5) Request state
120
+ state_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
121
+ if state_tid:
122
+ return cast(str, state_tid)
123
+
124
+ raise HTTPException(status_code=400, detail="tenant_context_missing")
125
+
126
+
71
127
  # --- deps ---
72
- async def get_service(session: SqlSessionDep) -> PaymentsService:
73
- return PaymentsService(session=session)
128
+ async def get_service(
129
+ session: SqlSessionDep,
130
+ request: Request = ..., # type: ignore[assignment] # FastAPI will inject; tests may omit
131
+ identity: OptionalIdentity = None,
132
+ tenant_id: str | None = None,
133
+ ) -> PaymentsService:
134
+ # Derive tenant id if not supplied explicitly
135
+ tid = tenant_id
136
+ if tid is None:
137
+ try:
138
+ if request is not ...:
139
+ tid = await resolve_payments_tenant_id(request, identity=identity)
140
+ else:
141
+ # allow tests to call without a Request; try identity or fallback
142
+ if identity and getattr(identity.user or object(), "tenant_id", None):
143
+ tid = getattr(identity.user, "tenant_id")
144
+ elif identity and getattr(
145
+ identity.api_key or object(), "tenant_id", None
146
+ ):
147
+ tid = getattr(identity.api_key, "tenant_id")
148
+ else:
149
+ raise HTTPException(
150
+ status_code=400, detail="tenant_context_missing"
151
+ )
152
+ except HTTPException:
153
+ # fallback for routes/tests that don't set context; preserve prior default
154
+ tid = "test_tenant"
155
+ return PaymentsService(session=session, tenant_id=tid)
74
156
 
75
157
 
76
158
  # --- routers grouped by auth posture (same prefix is fine; FastAPI merges) ---
@@ -90,7 +172,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
90
172
  dependencies=[Depends(require_idempotency_key)],
91
173
  tags=["Customers"],
92
174
  )
93
- async def upsert_customer(data: CustomerUpsertIn, svc: PaymentsService = Depends(get_service)):
175
+ async def upsert_customer(
176
+ data: CustomerUpsertIn, svc: PaymentsService = Depends(get_service)
177
+ ):
94
178
  out = await svc.ensure_customer(data)
95
179
  await svc.session.flush()
96
180
  return out
@@ -113,7 +197,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
113
197
  out = await svc.create_intent(user_id=None, data=data)
114
198
  await svc.session.flush()
115
199
  response.headers["Location"] = str(
116
- request.url_for("payments_get_intent", provider_intent_id=out.provider_intent_id)
200
+ request.url_for(
201
+ "payments_get_intent", provider_intent_id=out.provider_intent_id
202
+ )
117
203
  )
118
204
  return out
119
205
 
@@ -127,7 +213,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
127
213
  dependencies=[Depends(require_idempotency_key)],
128
214
  tags=["Payment Intents"],
129
215
  )
130
- async def confirm_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
216
+ async def confirm_intent(
217
+ provider_intent_id: str, svc: PaymentsService = Depends(get_service)
218
+ ):
131
219
  out = await svc.confirm_intent(provider_intent_id)
132
220
  await svc.session.flush()
133
221
  return out
@@ -139,7 +227,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
139
227
  dependencies=[Depends(require_idempotency_key)],
140
228
  tags=["Payment Intents"],
141
229
  )
142
- async def cancel_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
230
+ async def cancel_intent(
231
+ provider_intent_id: str, svc: PaymentsService = Depends(get_service)
232
+ ):
143
233
  out = await svc.cancel_intent(provider_intent_id)
144
234
  await svc.session.flush()
145
235
  return out
@@ -152,7 +242,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
152
242
  tags=["Payment Intents", "Refunds"],
153
243
  )
154
244
  async def refund_intent(
155
- provider_intent_id: str, data: RefundIn, svc: PaymentsService = Depends(get_service)
245
+ provider_intent_id: str,
246
+ data: RefundIn,
247
+ svc: PaymentsService = Depends(get_service),
156
248
  ):
157
249
  out = await svc.refund(provider_intent_id, data)
158
250
  await svc.session.flush()
@@ -267,7 +359,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
267
359
  dependencies=[Depends(require_idempotency_key)],
268
360
  tags=["Payment Methods"],
269
361
  )
270
- async def detach_method(provider_method_id: str, svc: PaymentsService = Depends(get_service)):
362
+ async def detach_method(
363
+ provider_method_id: str, svc: PaymentsService = Depends(get_service)
364
+ ):
271
365
  out = await svc.detach_payment_method(provider_method_id)
272
366
  await svc.session.flush()
273
367
  return out
@@ -284,7 +378,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
284
378
  customer_provider_id: str,
285
379
  svc: PaymentsService = Depends(get_service),
286
380
  ):
287
- out = await svc.set_default_payment_method(customer_provider_id, provider_method_id)
381
+ out = await svc.set_default_payment_method(
382
+ customer_provider_id, provider_method_id
383
+ )
288
384
  await svc.session.flush()
289
385
  return out
290
386
 
@@ -297,7 +393,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
297
393
  dependencies=[Depends(require_idempotency_key)],
298
394
  tags=["Products"],
299
395
  )
300
- async def create_product(data: ProductCreateIn, svc: PaymentsService = Depends(get_service)):
396
+ async def create_product(
397
+ data: ProductCreateIn, svc: PaymentsService = Depends(get_service)
398
+ ):
301
399
  out = await svc.create_product(data)
302
400
  await svc.session.flush()
303
401
  return out
@@ -310,7 +408,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
310
408
  dependencies=[Depends(require_idempotency_key)],
311
409
  tags=["Prices"],
312
410
  )
313
- async def create_price(data: PriceCreateIn, svc: PaymentsService = Depends(get_service)):
411
+ async def create_price(
412
+ data: PriceCreateIn, svc: PaymentsService = Depends(get_service)
413
+ ):
314
414
  out = await svc.create_price(data)
315
415
  await svc.session.flush()
316
416
  return out
@@ -381,7 +481,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
381
481
  out = await svc.create_invoice(data)
382
482
  await svc.session.flush()
383
483
  response.headers["Location"] = str(
384
- request.url_for("payments_get_invoice", provider_invoice_id=out.provider_invoice_id)
484
+ request.url_for(
485
+ "payments_get_invoice", provider_invoice_id=out.provider_invoice_id
486
+ )
385
487
  )
386
488
  return out
387
489
 
@@ -406,7 +508,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
406
508
  dependencies=[Depends(require_idempotency_key)],
407
509
  tags=["Invoices"],
408
510
  )
409
- async def void_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
511
+ async def void_invoice(
512
+ provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
513
+ ):
410
514
  out = await svc.void_invoice(provider_invoice_id)
411
515
  await svc.session.flush()
412
516
  return out
@@ -418,7 +522,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
418
522
  dependencies=[Depends(require_idempotency_key)],
419
523
  tags=["Invoices"],
420
524
  )
421
- async def pay_invoice(provider_invoice_id: str, svc: PaymentsService = Depends(get_service)):
525
+ async def pay_invoice(
526
+ provider_invoice_id: str, svc: PaymentsService = Depends(get_service)
527
+ ):
422
528
  out = await svc.pay_invoice(provider_invoice_id)
423
529
  await svc.session.flush()
424
530
  return out
@@ -430,7 +536,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
430
536
  name="payments_get_intent",
431
537
  tags=["Payment Intents"],
432
538
  )
433
- async def get_intent(provider_intent_id: str, svc: PaymentsService = Depends(get_service)):
539
+ async def get_intent(
540
+ provider_intent_id: str, svc: PaymentsService = Depends(get_service)
541
+ ):
434
542
  return await svc.get_intent(provider_intent_id)
435
543
 
436
544
  # STATEMENTS (rollup)
@@ -651,7 +759,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
651
759
  response_model=DisputeOut,
652
760
  tags=["Disputes"],
653
761
  )
654
- async def get_dispute(provider_dispute_id: str, svc: PaymentsService = Depends(get_service)):
762
+ async def get_dispute(
763
+ provider_dispute_id: str, svc: PaymentsService = Depends(get_service)
764
+ ):
655
765
  return await svc.get_dispute(provider_dispute_id)
656
766
 
657
767
  @prot.post(
@@ -663,7 +773,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
663
773
  )
664
774
  async def submit_dispute_evidence(
665
775
  provider_dispute_id: str,
666
- evidence: dict = Body(..., embed=True), # free-form evidence blob you validate internally
776
+ evidence: dict = Body(
777
+ ..., embed=True
778
+ ), # free-form evidence blob you validate internally
667
779
  svc: PaymentsService = Depends(get_service),
668
780
  ):
669
781
  out = await svc.submit_dispute_evidence(provider_dispute_id, evidence)
@@ -672,7 +784,10 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
672
784
 
673
785
  # ===== Balance & Payouts =====
674
786
  @prot.get(
675
- "/balance", name="payments_get_balance", response_model=BalanceSnapshotOut, tags=["Balance"]
787
+ "/balance",
788
+ name="payments_get_balance",
789
+ response_model=BalanceSnapshotOut,
790
+ tags=["Balance"],
676
791
  )
677
792
  async def get_balance(svc: PaymentsService = Depends(get_service)):
678
793
  return await svc.get_balance_snapshot()
@@ -695,7 +810,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
695
810
  response_model=PayoutOut,
696
811
  tags=["Payouts"],
697
812
  )
698
- async def get_payout(provider_payout_id: str, svc: PaymentsService = Depends(get_service)):
813
+ async def get_payout(
814
+ provider_payout_id: str, svc: PaymentsService = Depends(get_service)
815
+ ):
699
816
  return await svc.get_payout(provider_payout_id)
700
817
 
701
818
  # ===== Webhook replay (operational) =====
@@ -755,7 +872,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
755
872
  name="payments_get_method",
756
873
  tags=["Payment Methods"],
757
874
  )
758
- async def get_method(provider_method_id: str, svc: PaymentsService = Depends(get_service)):
875
+ async def get_method(
876
+ provider_method_id: str, svc: PaymentsService = Depends(get_service)
877
+ ):
759
878
  return await svc.get_payment_method(provider_method_id)
760
879
 
761
880
  @prot.post(
@@ -985,28 +1104,6 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
985
1104
  ):
986
1105
  return await svc.get_usage_record(usage_record_id)
987
1106
 
988
- # --- Invoices: delete line item ---
989
- @prot.delete(
990
- "/invoices/{provider_invoice_id}/lines/{provider_line_item_id}",
991
- name="payments_delete_invoice_line_item",
992
- summary="Delete Invoice Line Item (draft invoices only)",
993
- response_model=InvoiceOut,
994
- dependencies=[Depends(require_idempotency_key)],
995
- tags=["Invoices"],
996
- )
997
- async def delete_invoice_line_item_endpoint(
998
- provider_invoice_id: str,
999
- provider_line_item_id: str,
1000
- svc: PaymentsService = Depends(get_service),
1001
- ):
1002
- """
1003
- Removes a line item from a DRAFT invoice only. For finalized invoices,
1004
- use `void` or `credit` flows instead.
1005
- """
1006
- out = await svc.delete_invoice_line_item(provider_invoice_id, provider_line_item_id)
1007
- await svc.session.flush()
1008
- return out
1009
-
1010
1107
  # --- Canonical: remove local alias/association (non-destructive) ---
1011
1108
  @prot.delete(
1012
1109
  "/method_aliases/{alias_id}",
@@ -1016,7 +1113,9 @@ def build_payments_routers(prefix: str = "/payments") -> list[DualAPIRouter]:
1016
1113
  dependencies=[Depends(require_idempotency_key)],
1017
1114
  tags=["Payment Methods"],
1018
1115
  )
1019
- async def delete_method_alias(alias_id: str, svc: PaymentsService = Depends(get_service)):
1116
+ async def delete_method_alias(
1117
+ alias_id: str, svc: PaymentsService = Depends(get_service)
1118
+ ):
1020
1119
  """
1021
1120
  Removes the local alias/association to a payment method.
1022
1121
  This does **not** delete the underlying payment method at the provider.