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.
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {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
|
|
33
|
-
- `mongo
|
|
34
|
-
- `mongo
|
|
35
|
-
- `mongo
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
435
|
+
• Double-check RESOURCES[indexes]. Run `mongo prepare` again and inspect the output dictionary of created indexes.
|
|
436
436
|
|
|
437
437
|
⸻
|
|
438
438
|
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
97
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -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
|
-
#
|
|
133
|
-
|
|
134
|
-
class
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
if
|
|
142
|
-
found.append(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
194
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
195
195
|
if "models" not in pkgs:
|
|
196
|
-
|
|
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:
|
|
341
|
-
|
|
342
|
-
return u.set(query={**u.query, "ssl": "true"})
|
|
347
|
+
# asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
|
|
348
|
+
# Do not add ssl parameter to URL query for asyncpg
|
|
343
349
|
return u
|
|
344
350
|
else:
|
|
345
351
|
# libpq-based drivers: use sslmode (default 'require' for hosted PG)
|
|
@@ -382,10 +388,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
382
388
|
"Async driver URL provided but SQLAlchemy async extras are not available."
|
|
383
389
|
)
|
|
384
390
|
|
|
385
|
-
# asyncpg: honor connection timeout
|
|
391
|
+
# asyncpg: honor connection timeout only (NOT connect_timeout)
|
|
386
392
|
if "+asyncpg" in (u.drivername or ""):
|
|
387
393
|
connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
|
|
388
394
|
|
|
395
|
+
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
396
|
+
# Remove these and set ssl='require' in connect_args
|
|
397
|
+
if "ssl" in u.query or "sslmode" in u.query:
|
|
398
|
+
new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
|
|
399
|
+
u = u.set(query=new_query)
|
|
400
|
+
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
401
|
+
connect_args["ssl"] = "require"
|
|
402
|
+
|
|
389
403
|
# NEW: aiomysql SSL default
|
|
390
404
|
if "+aiomysql" in (u.drivername or "") and not any(
|
|
391
405
|
k in u.query for k in ("ssl", "ssl_ca", "sslmode")
|