svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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 (140) hide show
  1. svc_infra/api/fastapi/admin/__init__.py +3 -0
  2. svc_infra/api/fastapi/admin/add.py +231 -0
  3. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  4. svc_infra/api/fastapi/auth/add.py +0 -4
  5. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  6. svc_infra/api/fastapi/billing/router.py +64 -0
  7. svc_infra/api/fastapi/billing/setup.py +19 -0
  8. svc_infra/api/fastapi/cache/add.py +9 -5
  9. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  10. svc_infra/api/fastapi/db/sql/add.py +40 -18
  11. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  12. svc_infra/api/fastapi/db/sql/session.py +16 -0
  13. svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
  14. svc_infra/api/fastapi/docs/add.py +160 -0
  15. svc_infra/api/fastapi/docs/landing.py +1 -1
  16. svc_infra/api/fastapi/docs/scoped.py +41 -6
  17. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  18. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  19. svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
  20. svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
  21. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  22. svc_infra/api/fastapi/openapi/mutators.py +114 -0
  23. svc_infra/api/fastapi/ops/add.py +73 -0
  24. svc_infra/api/fastapi/pagination.py +3 -1
  25. svc_infra/api/fastapi/routers/ping.py +1 -0
  26. svc_infra/api/fastapi/setup.py +21 -13
  27. svc_infra/api/fastapi/tenancy/add.py +19 -0
  28. svc_infra/api/fastapi/tenancy/context.py +112 -0
  29. svc_infra/api/fastapi/versioned.py +101 -0
  30. svc_infra/app/README.md +5 -5
  31. svc_infra/billing/__init__.py +23 -0
  32. svc_infra/billing/async_service.py +147 -0
  33. svc_infra/billing/jobs.py +230 -0
  34. svc_infra/billing/models.py +131 -0
  35. svc_infra/billing/quotas.py +101 -0
  36. svc_infra/billing/schemas.py +33 -0
  37. svc_infra/billing/service.py +115 -0
  38. svc_infra/bundled_docs/README.md +5 -0
  39. svc_infra/bundled_docs/__init__.py +1 -0
  40. svc_infra/bundled_docs/getting-started.md +6 -0
  41. svc_infra/cache/__init__.py +4 -0
  42. svc_infra/cache/add.py +158 -0
  43. svc_infra/cache/backend.py +5 -2
  44. svc_infra/cache/decorators.py +19 -1
  45. svc_infra/cache/keys.py +24 -4
  46. svc_infra/cli/__init__.py +28 -8
  47. svc_infra/cli/cmds/__init__.py +8 -0
  48. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  49. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  50. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  51. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  52. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  53. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  54. svc_infra/cli/cmds/dx/__init__.py +12 -0
  55. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  56. svc_infra/cli/cmds/help.py +4 -0
  57. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  58. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  59. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  60. svc_infra/data/add.py +61 -0
  61. svc_infra/data/backup.py +53 -0
  62. svc_infra/data/erasure.py +45 -0
  63. svc_infra/data/fixtures.py +40 -0
  64. svc_infra/data/retention.py +55 -0
  65. svc_infra/db/nosql/mongo/README.md +13 -13
  66. svc_infra/db/sql/repository.py +51 -11
  67. svc_infra/db/sql/resource.py +5 -0
  68. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  69. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  70. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  71. svc_infra/db/sql/tenant.py +79 -0
  72. svc_infra/db/sql/utils.py +18 -4
  73. svc_infra/docs/acceptance-matrix.md +88 -0
  74. svc_infra/docs/acceptance.md +44 -0
  75. svc_infra/docs/admin.md +425 -0
  76. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  77. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  78. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  79. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  80. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  81. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  82. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  83. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  84. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  85. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  86. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  87. svc_infra/docs/api.md +186 -0
  88. svc_infra/docs/auth.md +11 -0
  89. svc_infra/docs/billing.md +190 -0
  90. svc_infra/docs/cache.md +76 -0
  91. svc_infra/docs/cli.md +74 -0
  92. svc_infra/docs/contributing.md +34 -0
  93. svc_infra/docs/data-lifecycle.md +52 -0
  94. svc_infra/docs/database.md +14 -0
  95. svc_infra/docs/docs-and-sdks.md +62 -0
  96. svc_infra/docs/environment.md +114 -0
  97. svc_infra/docs/getting-started.md +63 -0
  98. svc_infra/docs/idempotency.md +111 -0
  99. svc_infra/docs/jobs.md +67 -0
  100. svc_infra/docs/observability.md +16 -0
  101. svc_infra/docs/ops.md +37 -0
  102. svc_infra/docs/rate-limiting.md +125 -0
  103. svc_infra/docs/repo-review.md +48 -0
  104. svc_infra/docs/security.md +176 -0
  105. svc_infra/docs/storage.md +982 -0
  106. svc_infra/docs/tenancy.md +35 -0
  107. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  108. svc_infra/docs/versioned-integrations.md +146 -0
  109. svc_infra/docs/webhooks.md +112 -0
  110. svc_infra/dx/add.py +63 -0
  111. svc_infra/dx/changelog.py +74 -0
  112. svc_infra/dx/checks.py +67 -0
  113. svc_infra/http/__init__.py +13 -0
  114. svc_infra/http/client.py +72 -0
  115. svc_infra/jobs/builtins/webhook_delivery.py +14 -2
  116. svc_infra/jobs/queue.py +9 -1
  117. svc_infra/jobs/runner.py +75 -0
  118. svc_infra/jobs/worker.py +17 -1
  119. svc_infra/mcp/svc_infra_mcp.py +85 -28
  120. svc_infra/obs/add.py +54 -7
  121. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  122. svc_infra/security/headers.py +15 -2
  123. svc_infra/security/hibp.py +6 -2
  124. svc_infra/security/models.py +27 -7
  125. svc_infra/security/oauth_models.py +59 -0
  126. svc_infra/security/permissions.py +1 -0
  127. svc_infra/storage/__init__.py +93 -0
  128. svc_infra/storage/add.py +250 -0
  129. svc_infra/storage/backends/__init__.py +11 -0
  130. svc_infra/storage/backends/local.py +331 -0
  131. svc_infra/storage/backends/memory.py +214 -0
  132. svc_infra/storage/backends/s3.py +329 -0
  133. svc_infra/storage/base.py +239 -0
  134. svc_infra/storage/easy.py +182 -0
  135. svc_infra/storage/settings.py +192 -0
  136. svc_infra/webhooks/service.py +10 -2
  137. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
  138. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
  139. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  140. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -29,17 +29,17 @@ We provide four CLI commands. You can register them on your Typer app or invoke
29
29
 
30
30
  ### Commands
31
31
 
32
- - `mongo-scaffold` — create both document **and** CRUD schemas
33
- - `mongo-scaffold-documents` — create only the **document** model (Pydantic)
34
- - `mongo-scaffold-schemas` — create only the **CRUD schemas**
35
- - `mongo-scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
32
+ - `mongo scaffold` — create both document **and** CRUD schemas
33
+ - `mongo scaffold-documents` — create only the **document** model (Pydantic)
34
+ - `mongo scaffold-schemas` — create only the **CRUD schemas**
35
+ - `mongo scaffold-resources` — create a starter `resources.py` with a `RESOURCES` list
36
36
 
37
37
  ### Typical usage
38
38
 
39
39
  #### A) Scaffold documents + schemas together
40
40
 
41
41
  ```bash
42
- yourapp mongo-scaffold \
42
+ yourapp mongo scaffold \
43
43
  --entity-name Product \
44
44
  --documents-dir ./src/your_app/products \
45
45
  --schemas-dir ./src/your_app/products \
@@ -57,7 +57,7 @@ src/your_app/products/schemas.py # ProductRead/ProductCreate/ProductUpdate
57
57
  B) Documents only
58
58
 
59
59
  ```bash
60
- yourapp mongo-scaffold-documents \
60
+ yourapp mongo scaffold-documents \
61
61
  --dest-dir ./src/your_app/products \
62
62
  --entity-name Product \
63
63
  --documents-filename product_doc.py
@@ -66,7 +66,7 @@ yourapp mongo-scaffold-documents \
66
66
  C) Schemas only
67
67
 
68
68
  ```bash
69
- yourapp mongo-scaffold-schemas \
69
+ yourapp mongo scaffold-schemas \
70
70
  --dest-dir ./src/your_app/products \
71
71
  --entity-name Product \
72
72
  --schemas-filename product_schemas.py
@@ -75,7 +75,7 @@ yourapp mongo-scaffold-schemas \
75
75
  D) Starter resources.py
76
76
 
77
77
  ```bash
78
- yourapp mongo-scaffold-resources \
78
+ yourapp mongo scaffold-resources \
79
79
  --dest-dir ./src/your_app/mongo \
80
80
  --filename resources.py \
81
81
  --overwrite
@@ -131,7 +131,7 @@ There are two flavors:
131
131
  A) Async, minimal (connect, create collections, apply indexes)
132
132
 
133
133
  ```bash
134
- yourapp mongo-prepare \
134
+ yourapp mongo prepare \
135
135
  --resources your_app.mongo.resources:RESOURCES \
136
136
  --mongo-url "$MONGO_URL" \
137
137
  --mongo-db "$MONGO_DB"
@@ -140,7 +140,7 @@ yourapp mongo-prepare \
140
140
  B) Synchronous wrapper (end-to-end convenience)
141
141
 
142
142
  ```bash
143
- yourapp mongo-setup-and-prepare \
143
+ yourapp mongo setup-and-prepare \
144
144
  --resources your_app.mongo.resources:RESOURCES \
145
145
  --mongo-url "$MONGO_URL" \
146
146
  --mongo-db "$MONGO_DB"
@@ -149,7 +149,7 @@ yourapp mongo-setup-and-prepare \
149
149
  You can also ping connectivity:
150
150
 
151
151
  ```bash
152
- yourapp mongo-ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
152
+ yourapp mongo ping --mongo-url "$MONGO_URL" --mongo-db "$MONGO_DB"
153
153
  ```
154
154
 
155
155
  Behind the scenes, preparation also locks a service ID to a DB name to prevent accidental cross-DB usage. You can pass --allow-rebind if you intentionally move environments.
@@ -430,9 +430,9 @@ NoSqlResource(
430
430
  • If using explicit schemas with PyObjectId, make sure model_config.json_encoders includes {PyObjectId: str}.
431
431
  • When using auto-schemas, we expose ObjectId-like fields as str so no custom encoder is needed.
432
432
  • Connected to wrong DB name
433
- • The system locks a service_id to the DB name once prepared. If you change DBs, run mongo-prepare with --allow-rebind.
433
+ • The system locks a service_id to the DB name once prepared. If you change DBs, run `mongo prepare` with --allow-rebind.
434
434
  • Indexes not created
435
- • Double-check RESOURCES[indexes]. Run mongo-prepare again and inspect the output dictionary of created indexes.
435
+ • Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
436
436
 
437
437
 
438
438
 
@@ -56,20 +56,31 @@ class SqlRepository:
56
56
  limit: int,
57
57
  offset: int,
58
58
  order_by: Optional[Sequence[Any]] = None,
59
+ where: Optional[Sequence[Any]] = None,
59
60
  ) -> Sequence[Any]:
60
- stmt = self._base_select().limit(limit).offset(offset)
61
+ stmt = self._base_select()
62
+ if where:
63
+ stmt = stmt.where(and_(*where))
64
+ stmt = stmt.limit(limit).offset(offset)
61
65
  if order_by:
62
66
  stmt = stmt.order_by(*order_by)
63
67
  rows = (await session.execute(stmt)).scalars().all()
64
68
  return rows
65
69
 
66
- async def count(self, session: AsyncSession) -> int:
67
- stmt = select(func.count()).select_from(self._base_select().subquery())
70
+ async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
71
+ base = self._base_select()
72
+ if where:
73
+ base = base.where(and_(*where))
74
+ stmt = select(func.count()).select_from(base.subquery())
68
75
  return (await session.execute(stmt)).scalar_one()
69
76
 
70
- async def get(self, session: AsyncSession, id_value: Any) -> Any | None:
77
+ async def get(
78
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
79
+ ) -> Any | None:
71
80
  # honors soft-delete if configured
72
81
  stmt = self._base_select().where(self._id_column() == id_value)
82
+ if where:
83
+ stmt = stmt.where(and_(*where))
73
84
  return (await session.execute(stmt)).scalars().first()
74
85
 
75
86
  async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
@@ -78,12 +89,18 @@ class SqlRepository:
78
89
  obj = self.model(**filtered)
79
90
  session.add(obj)
80
91
  await session.flush()
92
+ await session.refresh(obj)
81
93
  return obj
82
94
 
83
95
  async def update(
84
- self, session: AsyncSession, id_value: Any, data: dict[str, Any]
96
+ self,
97
+ session: AsyncSession,
98
+ id_value: Any,
99
+ data: dict[str, Any],
100
+ *,
101
+ where: Optional[Sequence[Any]] = None,
85
102
  ) -> Any | None:
86
- obj = await self.get(session, id_value)
103
+ obj = await self.get(session, id_value, where=where)
87
104
  if not obj:
88
105
  return None
89
106
  valid = self._model_columns()
@@ -91,17 +108,28 @@ class SqlRepository:
91
108
  if k in valid and k not in self.immutable_fields:
92
109
  setattr(obj, k, v)
93
110
  await session.flush()
111
+ await session.refresh(obj)
94
112
  return obj
95
113
 
96
- async def delete(self, session: AsyncSession, id_value: Any) -> bool:
97
- obj = await session.get(self.model, id_value)
114
+ async def delete(
115
+ self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
116
+ ) -> bool:
117
+ # Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
118
+ if not where:
119
+ obj = await session.get(self.model, id_value)
120
+ else:
121
+ # Respect soft-delete and optional tenant/extra filters by selecting through base select
122
+ stmt = self._base_select().where(self._id_column() == id_value)
123
+ stmt = stmt.where(and_(*where))
124
+ obj = (await session.execute(stmt)).scalars().first()
98
125
  if not obj:
99
126
  return False
100
127
  if self.soft_delete:
101
128
  # Prefer timestamp, also optionally set flag to False
102
- if hasattr(self.model, self.soft_delete_field):
129
+ # Check attributes on the instance to support test doubles without class-level fields
130
+ if hasattr(obj, self.soft_delete_field):
103
131
  setattr(obj, self.soft_delete_field, func.now())
104
- if self.soft_delete_flag_field and hasattr(self.model, self.soft_delete_flag_field):
132
+ if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
105
133
  setattr(obj, self.soft_delete_flag_field, False)
106
134
  await session.flush()
107
135
  return True
@@ -118,6 +146,7 @@ class SqlRepository:
118
146
  limit: int,
119
147
  offset: int,
120
148
  order_by: Optional[Sequence[Any]] = None,
149
+ where: Optional[Sequence[Any]] = None,
121
150
  ) -> Sequence[Any]:
122
151
  ilike = f"%{q}%"
123
152
  conditions = []
@@ -130,6 +159,8 @@ class SqlRepository:
130
159
  # skip columns that cannot be used in ilike even with cast
131
160
  continue
132
161
  stmt = self._base_select()
162
+ if where:
163
+ stmt = stmt.where(and_(*where))
133
164
  if conditions:
134
165
  stmt = stmt.where(or_(*conditions))
135
166
  stmt = stmt.limit(limit).offset(offset)
@@ -137,7 +168,14 @@ class SqlRepository:
137
168
  stmt = stmt.order_by(*order_by)
138
169
  return (await session.execute(stmt)).scalars().all()
139
170
 
140
- async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
171
+ async def count_filtered(
172
+ self,
173
+ session: AsyncSession,
174
+ *,
175
+ q: str,
176
+ fields: Sequence[str],
177
+ where: Optional[Sequence[Any]] = None,
178
+ ) -> int:
141
179
  ilike = f"%{q}%"
142
180
  conditions = []
143
181
  for f in fields:
@@ -148,6 +186,8 @@ class SqlRepository:
148
186
  except Exception:
149
187
  continue
150
188
  stmt = self._base_select()
189
+ if where:
190
+ stmt = stmt.where(and_(*where))
151
191
  if conditions:
152
192
  stmt = stmt.where(or_(*conditions))
153
193
  # SELECT COUNT(*) FROM (<stmt>) as t
@@ -34,3 +34,8 @@ class SqlResource:
34
34
 
35
35
  # Only a type reference; no runtime dependency on FastAPI layer
36
36
  service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
37
+
38
+ # Tenancy
39
+ tenant_field: Optional[str] = (
40
+ None # when set, CRUD router will require TenantId and scope by field
41
+ )
@@ -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
 
@@ -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,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from .service import SqlService
8
+
9
+
10
+ class TenantSqlService(SqlService):
11
+ """
12
+ SQL service wrapper that automatically scopes operations to a tenant.
13
+
14
+ - Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
15
+ - On create, if the model has the tenant field and it's not set in data, injects tenant_id.
16
+ """
17
+
18
+ def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
19
+ super().__init__(repo)
20
+ self.tenant_id = tenant_id
21
+ self.tenant_field = tenant_field
22
+
23
+ def _where(self) -> Sequence[Any]:
24
+ model = self.repo.model
25
+ col = getattr(model, self.tenant_field, None)
26
+ if col is None:
27
+ return []
28
+ return [col == self.tenant_id]
29
+
30
+ async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
31
+ return await self.repo.list(
32
+ session, limit=limit, offset=offset, order_by=order_by, where=self._where()
33
+ )
34
+
35
+ async def count(self, session: AsyncSession) -> int:
36
+ return await self.repo.count(session, where=self._where())
37
+
38
+ async def get(self, session: AsyncSession, id_value: Any):
39
+ return await self.repo.get(session, id_value, where=self._where())
40
+
41
+ async def create(self, session: AsyncSession, data: dict[str, Any]):
42
+ data = await self.pre_create(data)
43
+ # inject tenant_id if model supports it and value missing
44
+ if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
45
+ data[self.tenant_field] = self.tenant_id
46
+ return await self.repo.create(session, data)
47
+
48
+ async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
49
+ data = await self.pre_update(data)
50
+ return await self.repo.update(session, id_value, data, where=self._where())
51
+
52
+ async def delete(self, session: AsyncSession, id_value: Any) -> bool:
53
+ return await self.repo.delete(session, id_value, where=self._where())
54
+
55
+ async def search(
56
+ self,
57
+ session: AsyncSession,
58
+ *,
59
+ q: str,
60
+ fields: Sequence[str],
61
+ limit: int,
62
+ offset: int,
63
+ order_by=None,
64
+ ):
65
+ return await self.repo.search(
66
+ session,
67
+ q=q,
68
+ fields=fields,
69
+ limit=limit,
70
+ offset=offset,
71
+ order_by=order_by,
72
+ where=self._where(),
73
+ )
74
+
75
+ async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
76
+ return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
77
+
78
+
79
+ __all__ = ["TenantSqlService"]
svc_infra/db/sql/utils.py CHANGED
@@ -196,10 +196,17 @@ def _ensure_timeout_default(u: URL) -> URL:
196
196
  """
197
197
  Ensure a conservative connection timeout is present for libpq-based drivers.
198
198
  For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
199
+ For asyncpg, timeout is set via connect_args (not query string).
199
200
  """
200
201
  backend = (u.get_backend_name() or "").lower()
201
202
  if backend not in ("postgresql", "postgres"):
202
203
  return u
204
+
205
+ # asyncpg doesn't support connect_timeout in query string - use connect_args instead
206
+ dn = (u.drivername or "").lower()
207
+ if "+asyncpg" in dn:
208
+ return u
209
+
203
210
  if "connect_timeout" in u.query:
204
211
  return u
205
212
  # Default 10s unless overridden
@@ -337,9 +344,8 @@ def _ensure_ssl_default(u: URL) -> URL:
337
344
  mode = (mode_env or "").strip()
338
345
 
339
346
  if "+asyncpg" in driver:
340
- # asyncpg: use 'ssl=true' (SQLAlchemy forwards to asyncpg)
341
- if "ssl" not in u.query:
342
- return u.set(query={**u.query, "ssl": "true"})
347
+ # asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
348
+ # Do not add ssl parameter to URL query for asyncpg
343
349
  return u
344
350
  else:
345
351
  # libpq-based drivers: use sslmode (default 'require' for hosted PG)
@@ -382,10 +388,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
382
388
  "Async driver URL provided but SQLAlchemy async extras are not available."
383
389
  )
384
390
 
385
- # asyncpg: honor connection timeout
391
+ # asyncpg: honor connection timeout only (NOT connect_timeout)
386
392
  if "+asyncpg" in (u.drivername or ""):
387
393
  connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
388
394
 
395
+ # asyncpg doesn't accept sslmode or ssl=true in query params
396
+ # Remove these and set ssl='require' in connect_args
397
+ if "ssl" in u.query or "sslmode" in u.query:
398
+ new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
399
+ u = u.set(query=new_query)
400
+ # Set ssl in connect_args - 'require' is safest for hosted databases
401
+ connect_args["ssl"] = "require"
402
+
389
403
  # NEW: aiomysql SSL default
390
404
  if "+aiomysql" in (u.drivername or "") and not any(
391
405
  k in u.query for k in ("ssl", "ssl_ca", "sslmode")