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
@@ -1,4 +1,4 @@
1
- from typing import Annotated, Any, Optional, Sequence, Type, TypeVar, cast
1
+ from typing import Annotated, Any, Callable, Optional, Sequence, Type, TypeVar, cast
2
2
 
3
3
  from fastapi import APIRouter, Body, Depends, HTTPException
4
4
  from pydantic import BaseModel
@@ -15,7 +15,9 @@ from svc_infra.api.fastapi.db.http import (
15
15
  )
16
16
  from svc_infra.api.fastapi.dual.public import public_router
17
17
  from svc_infra.db.sql.service import SqlService
18
+ from svc_infra.db.sql.tenant import TenantSqlService
18
19
 
20
+ from ...tenancy.context import TenantId
19
21
  from .session import SqlSessionDep
20
22
 
21
23
  CreateModel = TypeVar("CreateModel", bound=BaseModel)
@@ -44,6 +46,18 @@ def make_crud_router_plus_sql(
44
46
  redirect_slashes=False,
45
47
  )
46
48
 
49
+ def _coerce_id(v: Any) -> Any:
50
+ """Best-effort coercion of path ids: cast digit-only strings to int.
51
+
52
+ Keeps original type otherwise to avoid breaking non-integer IDs.
53
+ """
54
+ if isinstance(v, str) and v.isdigit():
55
+ try:
56
+ return int(v)
57
+ except Exception:
58
+ return v
59
+ return v
60
+
47
61
  def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
48
62
  if not order_spec:
49
63
  return []
@@ -59,14 +73,14 @@ def make_crud_router_plus_sql(
59
73
  # -------- LIST --------
60
74
  @router.get(
61
75
  "",
62
- response_model=cast(Any, Page[read_schema]),
76
+ response_model=Page[read_schema], # type: ignore[valid-type]
63
77
  description=f"List items of type {model.__name__}",
64
78
  )
65
79
  async def list_items(
66
80
  lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
67
81
  op: Annotated[OrderParams, Depends(dep_order)],
68
82
  sp: Annotated[SearchParams, Depends(dep_search)],
69
- session: SqlSessionDep, # type: ignore[name-defined]
83
+ session: SqlSessionDep,
70
84
  ):
71
85
  order_spec = op.order_by or default_ordering
72
86
  order_fields = _parse_ordering_to_fields(order_spec)
@@ -79,24 +93,31 @@ def make_crud_router_plus_sql(
79
93
  if f.strip()
80
94
  ]
81
95
  items = await service.search(
82
- session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
96
+ session,
97
+ q=sp.q,
98
+ fields=fields,
99
+ limit=lp.limit,
100
+ offset=lp.offset,
101
+ order_by=order_by,
83
102
  )
84
103
  total = await service.count_filtered(session, q=sp.q, fields=fields)
85
104
  else:
86
- items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
105
+ items = await service.list(
106
+ session, limit=lp.limit, offset=lp.offset, order_by=order_by
107
+ )
87
108
  total = await service.count(session)
88
- return Page[read_schema].from_items(
109
+ return Page[Any].from_items(
89
110
  total=total, items=items, limit=lp.limit, offset=lp.offset
90
111
  )
91
112
 
92
113
  # -------- GET by id --------
93
114
  @router.get(
94
115
  "/{item_id}",
95
- response_model=cast(Any, read_schema),
116
+ response_model=read_schema,
96
117
  description=f"Get item of type {model.__name__}",
97
118
  )
98
- async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
99
- row = await service.get(session, item_id)
119
+ async def get_item(item_id: Any, session: SqlSessionDep):
120
+ row = await service.get(session, _coerce_id(item_id))
100
121
  if not row:
101
122
  raise HTTPException(404, "Not found")
102
123
  return row
@@ -104,40 +125,207 @@ def make_crud_router_plus_sql(
104
125
  # -------- CREATE --------
105
126
  @router.post(
106
127
  "",
107
- response_model=cast(Any, read_schema),
128
+ response_model=read_schema,
108
129
  status_code=201,
109
130
  description=f"Create item of type {model.__name__}",
110
131
  )
111
132
  async def create_item(
112
- session: SqlSessionDep, # type: ignore[name-defined]
113
- payload: create_schema = Body(...),
133
+ session: SqlSessionDep,
134
+ payload: create_schema = Body(...), # type: ignore[valid-type]
114
135
  ):
115
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
136
+ if isinstance(payload, BaseModel):
137
+ data = cast(BaseModel, payload).model_dump(exclude_unset=True)
138
+ elif isinstance(payload, dict):
139
+ data = payload
140
+ else:
141
+ raise HTTPException(422, "invalid_payload")
116
142
  return await service.create(session, data)
117
143
 
118
144
  # -------- UPDATE --------
119
145
  @router.patch(
120
146
  "/{item_id}",
121
- response_model=cast(Any, read_schema),
147
+ response_model=read_schema,
122
148
  description=f"Update item of type {model.__name__}",
123
149
  )
124
150
  async def update_item(
125
151
  item_id: Any,
126
- session: SqlSessionDep, # type: ignore[name-defined]
127
- payload: update_schema = Body(...),
152
+ session: SqlSessionDep,
153
+ payload: update_schema = Body(...), # type: ignore[valid-type]
128
154
  ):
129
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
130
- row = await service.update(session, item_id, data)
155
+ if isinstance(payload, BaseModel):
156
+ data = cast(BaseModel, payload).model_dump(exclude_unset=True)
157
+ elif isinstance(payload, dict):
158
+ data = payload
159
+ else:
160
+ raise HTTPException(422, "invalid_payload")
161
+ row = await service.update(session, _coerce_id(item_id), data)
131
162
  if not row:
132
163
  raise HTTPException(404, "Not found")
133
164
  return row
134
165
 
135
166
  # -------- DELETE --------
136
167
  @router.delete(
137
- "/{item_id}", status_code=204, description=f"Delete item of type {model.__name__}"
168
+ "/{item_id}",
169
+ status_code=204,
170
+ description=f"Delete item of type {model.__name__}",
171
+ )
172
+ async def delete_item(item_id: Any, session: SqlSessionDep):
173
+ ok = await service.delete(session, _coerce_id(item_id))
174
+ if not ok:
175
+ raise HTTPException(404, "Not found")
176
+ return
177
+
178
+ return router
179
+
180
+
181
+ def make_tenant_crud_router_plus_sql(
182
+ *,
183
+ model: type[Any],
184
+ service_factory: Callable[
185
+ [], Any
186
+ ], # factory that returns a SqlService (will be wrapped)
187
+ read_schema: Type[ReadModel],
188
+ create_schema: Type[CreateModel],
189
+ update_schema: Type[UpdateModel],
190
+ prefix: str,
191
+ tenant_field: str = "tenant_id",
192
+ tags: list[str] | None = None,
193
+ search_fields: Optional[Sequence[str]] = None,
194
+ default_ordering: Optional[str] = None,
195
+ allowed_order_fields: Optional[list[str]] = None,
196
+ mount_under_db_prefix: bool = True,
197
+ ) -> APIRouter:
198
+ """Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
199
+ router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
200
+ router = public_router(
201
+ prefix=router_prefix,
202
+ tags=tags or [prefix.strip("/")],
203
+ redirect_slashes=False,
138
204
  )
139
- async def delete_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
140
- ok = await service.delete(session, item_id)
205
+
206
+ # Evaluate the base service once to preserve in-memory state across requests in tests/local.
207
+ # Consumers may pass either an instance or a zero-arg factory function.
208
+ try:
209
+ _base_instance = (
210
+ service_factory() if callable(service_factory) else service_factory
211
+ )
212
+ except TypeError:
213
+ # If the callable requires args, assume it's already an instance
214
+ _base_instance = service_factory
215
+
216
+ def _coerce_id(v: Any) -> Any:
217
+ """Best-effort coercion of path ids: cast digit-only strings to int.
218
+ Keeps original type otherwise.
219
+ """
220
+ if isinstance(v, str) and v.isdigit():
221
+ try:
222
+ return int(v)
223
+ except Exception:
224
+ return v
225
+ return v
226
+
227
+ def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
228
+ if not order_spec:
229
+ return []
230
+ pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
231
+ fields: list[str] = []
232
+ for p in pieces:
233
+ name = p[1:] if p.startswith("-") else p
234
+ if allowed_order_fields and name not in (allowed_order_fields or []):
235
+ continue
236
+ fields.append(p)
237
+ return fields
238
+
239
+ # create per-request service with tenant scoping
240
+ async def _svc(session: SqlSessionDep, tenant_id: TenantId):
241
+ repo_or_service = getattr(_base_instance, "repo", _base_instance)
242
+ svc: Any = TenantSqlService(
243
+ repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field
244
+ )
245
+ return svc
246
+
247
+ @router.get("", response_model=Page[read_schema]) # type: ignore[valid-type]
248
+ async def list_items(
249
+ lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
250
+ op: Annotated[OrderParams, Depends(dep_order)],
251
+ sp: Annotated[SearchParams, Depends(dep_search)],
252
+ session: SqlSessionDep,
253
+ tenant_id: TenantId,
254
+ ):
255
+ svc = await _svc(session, tenant_id)
256
+ order_spec = op.order_by or default_ordering
257
+ order_fields = _parse_ordering_to_fields(order_spec)
258
+ order_by = build_order_by(model, order_fields)
259
+ if sp.q:
260
+ fields = [
261
+ f.strip()
262
+ for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
263
+ if f.strip()
264
+ ]
265
+ items = await svc.search(
266
+ session,
267
+ q=sp.q,
268
+ fields=fields,
269
+ limit=lp.limit,
270
+ offset=lp.offset,
271
+ order_by=order_by,
272
+ )
273
+ total = await svc.count_filtered(session, q=sp.q, fields=fields)
274
+ else:
275
+ items = await svc.list(
276
+ session, limit=lp.limit, offset=lp.offset, order_by=order_by
277
+ )
278
+ total = await svc.count(session)
279
+ return Page[Any].from_items(
280
+ total=total, items=items, limit=lp.limit, offset=lp.offset
281
+ )
282
+
283
+ @router.get("/{item_id}", response_model=read_schema)
284
+ async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
285
+ svc = await _svc(session, tenant_id)
286
+ obj = await svc.get(session, item_id)
287
+ if not obj:
288
+ raise HTTPException(404, "not_found")
289
+ return obj
290
+
291
+ @router.post("", response_model=read_schema, status_code=201)
292
+ async def create_item(
293
+ session: SqlSessionDep,
294
+ tenant_id: TenantId,
295
+ payload: create_schema = Body(...), # type: ignore[valid-type]
296
+ ):
297
+ svc = await _svc(session, tenant_id)
298
+ if isinstance(payload, BaseModel):
299
+ data = cast(BaseModel, payload).model_dump(exclude_unset=True)
300
+ elif isinstance(payload, dict):
301
+ data = payload
302
+ else:
303
+ raise HTTPException(422, "invalid_payload")
304
+ return await svc.create(session, data)
305
+
306
+ @router.patch("/{item_id}", response_model=read_schema)
307
+ async def update_item(
308
+ item_id: Any,
309
+ session: SqlSessionDep,
310
+ tenant_id: TenantId,
311
+ payload: update_schema = Body(...), # type: ignore[valid-type]
312
+ ):
313
+ svc = await _svc(session, tenant_id)
314
+ if isinstance(payload, BaseModel):
315
+ data = cast(BaseModel, payload).model_dump(exclude_unset=True)
316
+ elif isinstance(payload, dict):
317
+ data = payload
318
+ else:
319
+ raise HTTPException(422, "invalid_payload")
320
+ updated = await svc.update(session, item_id, data)
321
+ if not updated:
322
+ raise HTTPException(404, "not_found")
323
+ return updated
324
+
325
+ @router.delete("/{item_id}", status_code=204)
326
+ async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId):
327
+ svc = await _svc(session, tenant_id)
328
+ ok = await svc.delete(session, _coerce_id(item_id))
141
329
  if not ok:
142
330
  raise HTTPException(404, "Not found")
143
331
  return
@@ -145,4 +333,4 @@ def make_crud_router_plus_sql(
145
333
  return router
146
334
 
147
335
 
148
- __all__ = ["make_crud_router_plus_sql"]
336
+ __all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
@@ -14,7 +14,9 @@ def _make_db_health_router(
14
14
  include_in_schema: bool = False,
15
15
  ) -> APIRouter:
16
16
  """Internal factory for the DB health router."""
17
- router = public_router(prefix=prefix, tags=["health"], include_in_schema=include_in_schema)
17
+ router = public_router(
18
+ prefix=prefix, tags=["health"], include_in_schema=include_in_schema
19
+ )
18
20
 
19
21
  @router.get("", status_code=status.HTTP_200_OK)
20
22
  async def db_health(session: SqlSessionDep) -> Response:
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import os
4
5
  from typing import Annotated, AsyncIterator, Tuple
5
6
 
6
7
  from fastapi import Depends
8
+ from sqlalchemy import text
7
9
  from sqlalchemy.ext.asyncio import (
8
10
  AsyncEngine,
9
11
  AsyncSession,
@@ -53,6 +55,22 @@ async def get_session() -> AsyncIterator[AsyncSession]:
53
55
  if _SessionLocal is None:
54
56
  raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
55
57
  async with _SessionLocal() as session:
58
+ # Optional: set a per-transaction statement timeout for Postgres if configured
59
+ raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
60
+ if raw_ms:
61
+ try:
62
+ ms = int(raw_ms)
63
+ if ms > 0:
64
+ try:
65
+ # SET LOCAL applies for the duration of the current transaction only
66
+ await session.execute(
67
+ text("SET LOCAL statement_timeout = :ms"), {"ms": ms}
68
+ )
69
+ except Exception:
70
+ # Non-PG dialects (e.g., SQLite) will error; ignore silently
71
+ pass
72
+ except ValueError:
73
+ pass
56
74
  try:
57
75
  yield session
58
76
  await session.commit()
@@ -5,13 +5,18 @@ from uuid import UUID
5
5
 
6
6
  from fastapi import Depends
7
7
  from fastapi_users import FastAPIUsers
8
- from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
8
+ from fastapi_users.authentication import (
9
+ AuthenticationBackend,
10
+ BearerTransport,
11
+ JWTStrategy,
12
+ )
9
13
  from fastapi_users.manager import BaseUserManager, UUIDIDMixin
10
14
 
11
15
  from svc_infra.api.fastapi.auth.settings import get_auth_settings
12
16
  from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
13
17
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
14
- from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
18
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, require_secret
19
+ from svc_infra.security.jwt_rotation import RotatingJWTStrategy
15
20
 
16
21
  from ...auth.security import auth_login_path
17
22
  from ...auth.sender import get_sender
@@ -46,7 +51,9 @@ def get_fastapi_users(
46
51
 
47
52
  async def on_after_register(self, user: Any, request=None):
48
53
  st = get_auth_settings()
49
- if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(st.auto_verify_in_dev):
54
+ if CURRENT_ENVIRONMENT in (DEV_ENV, LOCAL_ENV) and bool(
55
+ st.auto_verify_in_dev
56
+ ):
50
57
  await self.user_db.update(user, {"is_verified": True})
51
58
  return
52
59
  await self.request_verify(user, request)
@@ -90,11 +97,28 @@ def get_fastapi_users(
90
97
  if jwt_block and getattr(jwt_block, "secret", None):
91
98
  secret = jwt_block.secret.get_secret_value()
92
99
  else:
93
- secret = "svc-dev-secret-change-me"
100
+ secret = require_secret(
101
+ None,
102
+ "JWT_SECRET (via auth settings jwt.secret)",
103
+ dev_default="dev-only-jwt-secret-not-for-production",
104
+ )
94
105
  lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
95
106
  if not isinstance(lifetime, int) or lifetime <= 0:
96
107
  lifetime = 3600
97
- return JWTStrategy(secret=secret, lifetime_seconds=lifetime)
108
+ old = []
109
+ if jwt_block and getattr(jwt_block, "old_secrets", None):
110
+ old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
111
+ audience = ["fastapi-users:auth"]
112
+ if old:
113
+ return RotatingJWTStrategy(
114
+ secret=secret,
115
+ lifetime_seconds=lifetime,
116
+ old_secrets=old,
117
+ token_audience=audience,
118
+ )
119
+ return JWTStrategy(
120
+ secret=secret, lifetime_seconds=lifetime, token_audience=audience
121
+ )
98
122
 
99
123
  bearer_transport = BearerTransport(tokenUrl=auth_login_path)
100
124
  auth_backend = AuthenticationBackend(
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from typing import Callable, Optional
6
+
7
+ from fastapi import HTTPException
8
+ from starlette.requests import Request
9
+
10
+ from svc_infra.api.fastapi.middleware.ratelimit_store import (
11
+ InMemoryRateLimitStore,
12
+ RateLimitStore,
13
+ )
14
+ from svc_infra.obs.metrics import emit_rate_limited
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ try:
19
+ from svc_infra.api.fastapi.tenancy.context import (
20
+ resolve_tenant_id as _resolve_tenant_id,
21
+ )
22
+ except Exception: # pragma: no cover - minimal builds
23
+ _resolve_tenant_id = None # type: ignore[assignment]
24
+
25
+
26
+ class RateLimiter:
27
+ def __init__(
28
+ self,
29
+ *,
30
+ limit: int,
31
+ window: int = 60,
32
+ key_fn: Callable = lambda r: "global",
33
+ limit_resolver: Optional[
34
+ Callable[[Request, Optional[str]], Optional[int]]
35
+ ] = None,
36
+ scope_by_tenant: bool = False,
37
+ store: RateLimitStore | None = None,
38
+ ):
39
+ self.limit = limit
40
+ self.window = window
41
+ self.key_fn = key_fn
42
+ self._limit_resolver = limit_resolver
43
+ self.scope_by_tenant = scope_by_tenant
44
+ self.store = store or InMemoryRateLimitStore(limit=limit)
45
+
46
+ async def __call__(self, request: Request):
47
+ # Try resolving tenant when asked
48
+ tenant_id = None
49
+ if self.scope_by_tenant or self._limit_resolver:
50
+ try:
51
+ if _resolve_tenant_id is not None:
52
+ tenant_id = await _resolve_tenant_id(request)
53
+ except Exception:
54
+ tenant_id = None
55
+
56
+ key = self.key_fn(request)
57
+ if self.scope_by_tenant and tenant_id:
58
+ key = f"{key}:tenant:{tenant_id}"
59
+
60
+ eff_limit = self.limit
61
+ if self._limit_resolver:
62
+ try:
63
+ v = self._limit_resolver(request, tenant_id)
64
+ eff_limit = int(v) if v is not None else self.limit
65
+ except Exception:
66
+ eff_limit = self.limit
67
+
68
+ count, store_limit, reset = self.store.incr(str(key), self.window)
69
+ if count > eff_limit:
70
+ retry = max(0, reset - int(time.time()))
71
+ try:
72
+ emit_rate_limited(str(key), eff_limit, retry)
73
+ except Exception as e:
74
+ logger.warning("Failed to emit rate limit metric: %s", e)
75
+ raise HTTPException(
76
+ status_code=429,
77
+ detail="Rate limit exceeded",
78
+ headers={"Retry-After": str(retry)},
79
+ )
80
+
81
+
82
+ __all__ = ["RateLimiter"]
83
+
84
+
85
+ def rate_limiter(
86
+ *,
87
+ limit: int,
88
+ window: int = 60,
89
+ key_fn: Callable = lambda r: "global",
90
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
91
+ scope_by_tenant: bool = False,
92
+ store: RateLimitStore | None = None,
93
+ ):
94
+ store_ = store or InMemoryRateLimitStore(limit=limit)
95
+
96
+ async def dep(request: Request):
97
+ tenant_id = None
98
+ if scope_by_tenant or limit_resolver:
99
+ try:
100
+ if _resolve_tenant_id is not None:
101
+ tenant_id = await _resolve_tenant_id(request)
102
+ except Exception:
103
+ tenant_id = None
104
+
105
+ key = key_fn(request)
106
+ if scope_by_tenant and tenant_id:
107
+ key = f"{key}:tenant:{tenant_id}"
108
+
109
+ eff_limit = limit
110
+ if limit_resolver:
111
+ try:
112
+ v = limit_resolver(request, tenant_id)
113
+ eff_limit = int(v) if v is not None else limit
114
+ except Exception:
115
+ eff_limit = limit
116
+
117
+ count, _store_limit, reset = store_.incr(str(key), window)
118
+ if count > eff_limit:
119
+ retry = max(0, reset - int(time.time()))
120
+ try:
121
+ emit_rate_limited(str(key), eff_limit, retry)
122
+ except Exception as e:
123
+ logger.warning("Failed to emit rate limit metric: %s", e)
124
+ raise HTTPException(
125
+ status_code=429,
126
+ detail="Rate limit exceeded",
127
+ headers={"Retry-After": str(retry)},
128
+ )
129
+
130
+ return dep