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
@@ -129,62 +129,13 @@ for _ix in make_unique_sql_indexes(
129
129
  # Registered with Table metadata (alembic/autogenerate will pick them up)
130
130
  pass
131
131
 
132
- # ------------------------------ Model: ProviderAccount -------------------------
133
-
134
- class ProviderAccount(ModelBase):
135
- """
136
- Links a local user to an external identity provider account.
137
-
138
- - (provider, provider_account_id) is unique
139
- - Optionally stores tokens for later API calls (refresh_token encrypted at rest)
140
- """
141
- __tablename__ = "provider_accounts"
142
-
143
- id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
144
-
145
- user_id: Mapped[uuid.UUID] = mapped_column(
146
- GUID(), ForeignKey("${auth_table_name}.id", ondelete="CASCADE"), nullable=False
147
- )
148
- user: Mapped["${AuthEntity}"] = relationship(
149
- back_populates="provider_accounts",
150
- lazy="selectin",
151
- )
152
-
153
- provider: Mapped[str] = mapped_column(String(50), nullable=False) # "google"|"github"|"linkedin"|"microsoft"|...
154
- provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False) # sub/oid (OIDC) or id (github/linkedin)
155
-
156
- # Optional token material
157
- access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
158
-
159
- # Store encrypted refresh_token in the same column name for DB compatibility.
160
- _refresh_token: Mapped[Optional[str]] = mapped_column("refresh_token", Text, nullable=True)
161
-
162
- @property
163
- def refresh_token(self) -> Optional[str]:
164
- return _decrypt(self._refresh_token)
165
-
166
- @refresh_token.setter
167
- def refresh_token(self, value: Optional[str]) -> None:
168
- self._refresh_token = _encrypt(value)
169
-
170
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
171
- raw_claims: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSON), nullable=True)
172
-
173
- created_at = mapped_column(
174
- DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
175
- )
176
- updated_at = mapped_column(
177
- DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"),
178
- onupdate=text("CURRENT_TIMESTAMP"), nullable=False
179
- )
180
-
181
- __table_args__ = (
182
- UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
183
- Index("ix_provider_accounts_user_id", "user_id"),
184
- )
185
-
186
- def __repr__(self) -> str:
187
- return f"<ProviderAccount provider={self.provider!r} provider_account_id={self.provider_account_id!r} user_id={self.user_id}>"
132
+ # NOTE: ProviderAccount model is imported from svc_infra.security.oauth_models
133
+ # It's an opt-in OAuth model that links users to providers (Google, GitHub, etc.)
134
+ # The relationship 'provider_accounts' is defined above in the ${AuthEntity} class.
135
+ # To enable OAuth in your project:
136
+ # 1. Set ALEMBIC_ENABLE_OAUTH=true in your .env
137
+ # 2. Pass provider_account_model=ProviderAccount to add_auth_users()
138
+ # 3. Import: from svc_infra.security.oauth_models import ProviderAccount
188
139
 
189
140
  # --- Auth service factory ------------------------------------------------------
190
141
 
@@ -18,7 +18,7 @@ class Timestamped(BaseModel):
18
18
 
19
19
  class ProviderAccountBase(BaseModel):
20
20
  model_config = ConfigDict(from_attributes=True)
21
- provider: str = Field(..., examples=["google", "github", "linkedin", "microsoft"])
21
+ provider: str = Field(..., json_schema_extra={"examples": ["google", "github", "linkedin", "microsoft"]})
22
22
  provider_account_id: str
23
23
 
24
24
  class ProviderAccountRead(ProviderAccountBase, Timestamped):
@@ -6,13 +6,10 @@ from typing import List, Tuple
6
6
  import sys, pathlib, importlib, pkgutil, traceback
7
7
 
8
8
  from alembic import context
9
+ from sqlalchemy import MetaData
9
10
  from sqlalchemy.engine import make_url, URL
10
- from sqlalchemy.ext.asyncio import create_async_engine
11
11
 
12
- from svc_infra.db.sql.utils import (
13
- get_database_url_from_env,
14
- _ensure_ssl_default_async as _ensure_ssl_default,
15
- )
12
+ from svc_infra.db.sql.utils import get_database_url_from_env
16
13
 
17
14
  try:
18
15
  from svc_infra.db.sql.types import GUID as _GUID # type: ignore
@@ -105,7 +102,6 @@ def _coerce_to_async(u: URL) -> URL:
105
102
 
106
103
  u = make_url(effective_url)
107
104
  u = _coerce_to_async(u)
108
- u = _ensure_ssl_default(u)
109
105
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
110
106
 
111
107
  # feature flags
@@ -131,15 +127,16 @@ def _collect_metadata() -> list[object]:
131
127
 
132
128
  def _maybe_add(obj: object) -> None:
133
129
  md = getattr(obj, "metadata", None) or obj
134
- if hasattr(md, "tables") and getattr(md, "tables"):
130
+ # Strict check: must be actual MetaData instance
131
+ if isinstance(md, MetaData) and md.tables:
135
132
  found.append(md)
136
133
 
137
134
  def _scan_module_objects(mod: object) -> None:
138
135
  try:
139
136
  for val in vars(mod).values():
140
- md = getattr(val, "metadata", None) or None
141
- if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
142
- found.append(md)
137
+ # Strict check: must be actual MetaData instance
138
+ if isinstance(val, MetaData) and val.tables:
139
+ found.append(val)
143
140
  except Exception:
144
141
  pass
145
142
 
@@ -177,8 +174,16 @@ def _collect_metadata() -> list[object]:
177
174
  if name not in pkgs:
178
175
  pkgs.append(name)
179
176
 
177
+ # Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
180
178
  if "models" not in pkgs:
181
- pkgs.append("models")
179
+ try:
180
+ spec = getattr(importlib, "util", None)
181
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
182
+ if spec.find_spec("models") is not None:
183
+ pkgs.append("models")
184
+ except Exception:
185
+ # Best-effort; if discovery fails, skip adding bare 'models'
186
+ pass
182
187
 
183
188
  def _import_and_collect(modname: str):
184
189
  try:
@@ -221,6 +226,21 @@ def _collect_metadata() -> list[object]:
221
226
  except Exception:
222
227
  _note("ModelBase import", False, traceback.format_exc())
223
228
 
229
+ # Core security models (AuthSession, RefreshToken, etc.)
230
+ try:
231
+ import svc_infra.security.models # noqa: F401
232
+ _note("svc_infra.security.models", True, None)
233
+ except Exception:
234
+ _note("svc_infra.security.models", False, traceback.format_exc())
235
+
236
+ # OAuth models (opt-in via environment variable)
237
+ if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
238
+ try:
239
+ import svc_infra.security.oauth_models # noqa: F401
240
+ _note("svc_infra.security.oauth_models", True, None)
241
+ except Exception:
242
+ _note("svc_infra.security.oauth_models", False, traceback.format_exc())
243
+
224
244
  try:
225
245
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
226
246
  try_autobind_apikey_model(require_env=False)
@@ -352,7 +372,9 @@ def _do_run_migrations(connection):
352
372
 
353
373
  async def run_migrations_online() -> None:
354
374
  url = config.get_main_option("sqlalchemy.url")
355
- engine = create_async_engine(url)
375
+ # Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
376
+ from svc_infra.db.sql.utils import build_engine
377
+ engine = build_engine(url)
356
378
  async with engine.connect() as connection:
357
379
  await connection.run_sync(_do_run_migrations)
358
380
  await engine.dispose()
@@ -6,11 +6,10 @@ from typing import List, Tuple
6
6
  import sys, pathlib, importlib, pkgutil, traceback
7
7
 
8
8
  from alembic import context
9
+ from sqlalchemy import MetaData
9
10
  from sqlalchemy.engine import make_url, URL
10
11
 
11
12
  from svc_infra.db.sql.utils import (
12
- _coerce_sync_driver,
13
- _ensure_ssl_default,
14
13
  get_database_url_from_env,
15
14
  build_engine,
16
15
  )
@@ -103,7 +102,6 @@ if not effective_url:
103
102
 
104
103
  u = make_url(effective_url)
105
104
  u = _coerce_sync_driver(u)
106
- u = _ensure_ssl_default(u)
107
105
  config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
108
106
 
109
107
 
@@ -142,14 +140,16 @@ def _collect_metadata() -> list[object]:
142
140
 
143
141
  def _maybe_add(obj: object) -> None:
144
142
  md = getattr(obj, "metadata", None) or obj
145
- if hasattr(md, "tables") and getattr(md, "tables"):
143
+ # Strict check: must be actual MetaData instance
144
+ if isinstance(md, MetaData) and md.tables:
146
145
  found.append(md)
147
146
 
148
147
  def _scan_module_objects(mod: object) -> None:
149
148
  try:
150
149
  for val in vars(mod).values():
151
150
  md = getattr(val, "metadata", None) or None
152
- if md is not None and hasattr(md, "tables") and getattr(md, "tables"):
151
+ # Only add if it's a SQLAlchemy MetaData object (has tables dict, not a callable/generator)
152
+ if md is not None and hasattr(md, "tables") and isinstance(getattr(md, "tables", None), dict):
153
153
  found.append(md)
154
154
  except Exception:
155
155
  pass
@@ -191,9 +191,16 @@ def _collect_metadata() -> list[object]:
191
191
  if name not in pkgs:
192
192
  pkgs.append(name)
193
193
 
194
- # Always also try a bare 'models'
194
+ # Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
195
195
  if "models" not in pkgs:
196
- pkgs.append("models")
196
+ try:
197
+ spec = getattr(importlib, "util", None)
198
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
199
+ if spec.find_spec("models") is not None:
200
+ pkgs.append("models")
201
+ except Exception:
202
+ # If discovery fails, skip adding bare 'models'
203
+ pass
197
204
 
198
205
  def _import_and_collect(modname: str):
199
206
  try:
@@ -239,6 +246,21 @@ def _collect_metadata() -> list[object]:
239
246
  except Exception:
240
247
  _note("ModelBase import", False, traceback.format_exc())
241
248
 
249
+ # Core security models (AuthSession, RefreshToken, etc.)
250
+ try:
251
+ import svc_infra.security.models # noqa: F401
252
+ _note("svc_infra.security.models", True, None)
253
+ except Exception:
254
+ _note("svc_infra.security.models", False, traceback.format_exc())
255
+
256
+ # OAuth models (opt-in via environment variable)
257
+ if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
258
+ try:
259
+ import svc_infra.security.oauth_models # noqa: F401
260
+ _note("svc_infra.security.oauth_models", True, None)
261
+ except Exception:
262
+ _note("svc_infra.security.oauth_models", False, traceback.format_exc())
263
+
242
264
  # Optional: autobind API key model
243
265
  try:
244
266
  from svc_infra.db.sql.apikey import try_autobind_apikey_model
@@ -0,0 +1,88 @@
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(
31
+ self, session: AsyncSession, *, limit: int, offset: int, order_by=None
32
+ ):
33
+ return await self.repo.list(
34
+ session, limit=limit, offset=offset, order_by=order_by, where=self._where()
35
+ )
36
+
37
+ async def count(self, session: AsyncSession) -> int:
38
+ return await self.repo.count(session, where=self._where())
39
+
40
+ async def get(self, session: AsyncSession, id_value: Any):
41
+ return await self.repo.get(session, id_value, where=self._where())
42
+
43
+ async def create(self, session: AsyncSession, data: dict[str, Any]):
44
+ data = await self.pre_create(data)
45
+ # inject tenant_id if model supports it and value missing
46
+ if (
47
+ self.tenant_field in self.repo._model_columns()
48
+ and self.tenant_field not in data
49
+ ):
50
+ data[self.tenant_field] = self.tenant_id
51
+ return await self.repo.create(session, data)
52
+
53
+ async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
54
+ data = await self.pre_update(data)
55
+ return await self.repo.update(session, id_value, data, where=self._where())
56
+
57
+ async def delete(self, session: AsyncSession, id_value: Any) -> bool:
58
+ return await self.repo.delete(session, id_value, where=self._where())
59
+
60
+ async def search(
61
+ self,
62
+ session: AsyncSession,
63
+ *,
64
+ q: str,
65
+ fields: Sequence[str],
66
+ limit: int,
67
+ offset: int,
68
+ order_by=None,
69
+ ):
70
+ return await self.repo.search(
71
+ session,
72
+ q=q,
73
+ fields=fields,
74
+ limit=limit,
75
+ offset=offset,
76
+ order_by=order_by,
77
+ where=self._where(),
78
+ )
79
+
80
+ async def count_filtered(
81
+ self, session: AsyncSession, *, q: str, fields: Sequence[str]
82
+ ) -> int:
83
+ return await self.repo.count_filtered(
84
+ session, q=q, fields=fields, where=self._where()
85
+ )
86
+
87
+
88
+ __all__ = ["TenantSqlService"]
@@ -73,7 +73,9 @@ def dedupe_sql_service(
73
73
 
74
74
  return clauses
75
75
 
76
- async def _precheck(session, data: Dict[str, Any], *, exclude_id: Any | None) -> None:
76
+ async def _precheck(
77
+ session, data: Dict[str, Any], *, exclude_id: Any | None
78
+ ) -> None:
77
79
  # Check CI specs first to catch the broadest conflicts, then CS.
78
80
  for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
79
81
  for spec in spec_list:
@@ -97,7 +99,9 @@ def dedupe_sql_service(
97
99
  return await self.repo.create(session, data)
98
100
  except IntegrityError as e:
99
101
  # Race fallback: let DB constraint be the last line of defense.
100
- raise HTTPException(status_code=409, detail="Record already exists.") from e
102
+ raise HTTPException(
103
+ status_code=409, detail="Record already exists."
104
+ ) from e
101
105
 
102
106
  async def update(self, session, id_value, data):
103
107
  data = await self.pre_update(data)
@@ -105,6 +109,8 @@ def dedupe_sql_service(
105
109
  try:
106
110
  return await self.repo.update(session, id_value, data)
107
111
  except IntegrityError as e:
108
- raise HTTPException(status_code=409, detail="Record already exists.") from e
112
+ raise HTTPException(
113
+ status_code=409, detail="Record already exists."
114
+ ) from e
109
115
 
110
116
  return _Svc(repo, pre_create=pre_create, pre_update=pre_update)