svc-infra 0.1.600__py3-none-any.whl → 0.1.640__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 (118) 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/billing/router.py +64 -0
  4. svc_infra/api/fastapi/billing/setup.py +19 -0
  5. svc_infra/api/fastapi/db/sql/add.py +32 -13
  6. svc_infra/api/fastapi/db/sql/crud_router.py +178 -16
  7. svc_infra/api/fastapi/db/sql/session.py +16 -0
  8. svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
  9. svc_infra/api/fastapi/docs/add.py +160 -0
  10. svc_infra/api/fastapi/docs/landing.py +1 -1
  11. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  12. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  13. svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
  14. svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
  15. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  16. svc_infra/api/fastapi/openapi/mutators.py +114 -0
  17. svc_infra/api/fastapi/ops/add.py +73 -0
  18. svc_infra/api/fastapi/pagination.py +3 -1
  19. svc_infra/api/fastapi/routers/ping.py +1 -0
  20. svc_infra/api/fastapi/setup.py +11 -1
  21. svc_infra/api/fastapi/tenancy/add.py +19 -0
  22. svc_infra/api/fastapi/tenancy/context.py +112 -0
  23. svc_infra/app/README.md +5 -5
  24. svc_infra/billing/__init__.py +23 -0
  25. svc_infra/billing/async_service.py +147 -0
  26. svc_infra/billing/jobs.py +230 -0
  27. svc_infra/billing/models.py +131 -0
  28. svc_infra/billing/quotas.py +101 -0
  29. svc_infra/billing/schemas.py +33 -0
  30. svc_infra/billing/service.py +115 -0
  31. svc_infra/bundled_docs/README.md +5 -0
  32. svc_infra/bundled_docs/__init__.py +1 -0
  33. svc_infra/bundled_docs/getting-started.md +6 -0
  34. svc_infra/cache/__init__.py +4 -0
  35. svc_infra/cache/add.py +158 -0
  36. svc_infra/cache/backend.py +5 -2
  37. svc_infra/cache/decorators.py +19 -1
  38. svc_infra/cache/keys.py +24 -4
  39. svc_infra/cli/__init__.py +28 -8
  40. svc_infra/cli/cmds/__init__.py +8 -0
  41. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  42. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  43. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  44. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  45. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  46. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  47. svc_infra/cli/cmds/dx/__init__.py +12 -0
  48. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  49. svc_infra/cli/cmds/help.py +4 -0
  50. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  51. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  52. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  53. svc_infra/data/add.py +61 -0
  54. svc_infra/data/backup.py +53 -0
  55. svc_infra/data/erasure.py +45 -0
  56. svc_infra/data/fixtures.py +40 -0
  57. svc_infra/data/retention.py +55 -0
  58. svc_infra/db/nosql/mongo/README.md +13 -13
  59. svc_infra/db/sql/repository.py +51 -11
  60. svc_infra/db/sql/resource.py +5 -0
  61. svc_infra/db/sql/templates/setup/env_async.py.tmpl +9 -1
  62. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -2
  63. svc_infra/db/sql/tenant.py +79 -0
  64. svc_infra/db/sql/utils.py +18 -4
  65. svc_infra/docs/acceptance-matrix.md +71 -0
  66. svc_infra/docs/acceptance.md +44 -0
  67. svc_infra/docs/admin.md +425 -0
  68. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  69. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  70. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  71. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  72. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  73. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  74. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  75. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  76. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  77. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  78. svc_infra/docs/api.md +59 -0
  79. svc_infra/docs/auth.md +11 -0
  80. svc_infra/docs/billing.md +190 -0
  81. svc_infra/docs/cache.md +76 -0
  82. svc_infra/docs/cli.md +74 -0
  83. svc_infra/docs/contributing.md +34 -0
  84. svc_infra/docs/data-lifecycle.md +52 -0
  85. svc_infra/docs/database.md +14 -0
  86. svc_infra/docs/docs-and-sdks.md +62 -0
  87. svc_infra/docs/environment.md +114 -0
  88. svc_infra/docs/getting-started.md +63 -0
  89. svc_infra/docs/idempotency.md +111 -0
  90. svc_infra/docs/jobs.md +67 -0
  91. svc_infra/docs/observability.md +16 -0
  92. svc_infra/docs/ops.md +37 -0
  93. svc_infra/docs/rate-limiting.md +125 -0
  94. svc_infra/docs/repo-review.md +48 -0
  95. svc_infra/docs/security.md +176 -0
  96. svc_infra/docs/tenancy.md +35 -0
  97. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  98. svc_infra/docs/webhooks.md +112 -0
  99. svc_infra/dx/add.py +63 -0
  100. svc_infra/dx/changelog.py +74 -0
  101. svc_infra/dx/checks.py +67 -0
  102. svc_infra/http/__init__.py +13 -0
  103. svc_infra/http/client.py +72 -0
  104. svc_infra/jobs/builtins/webhook_delivery.py +14 -2
  105. svc_infra/jobs/queue.py +9 -1
  106. svc_infra/jobs/runner.py +75 -0
  107. svc_infra/jobs/worker.py +17 -1
  108. svc_infra/mcp/svc_infra_mcp.py +85 -28
  109. svc_infra/obs/add.py +54 -7
  110. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  111. svc_infra/security/headers.py +15 -2
  112. svc_infra/security/hibp.py +6 -2
  113. svc_infra/security/permissions.py +1 -0
  114. svc_infra/webhooks/service.py +10 -2
  115. {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/METADATA +40 -14
  116. {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/RECORD +118 -44
  117. {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/WHEEL +0 -0
  118. {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Iterable, Optional, Protocol, Sequence
6
+
7
+
8
+ class SqlSession(Protocol): # minimal protocol for tests/integration
9
+ async def execute(self, stmt: Any) -> Any:
10
+ pass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RetentionPolicy:
15
+ name: str
16
+ model: Any # SQLAlchemy model or test double exposing columns
17
+ older_than_days: int
18
+ soft_delete_field: Optional[str] = "deleted_at"
19
+ extra_where: Optional[Sequence[Any]] = None
20
+ hard_delete: bool = False
21
+
22
+
23
+ async def purge_policy(session: SqlSession, policy: RetentionPolicy) -> int:
24
+ """Execute a single retention purge according to policy.
25
+
26
+ If hard_delete is False and soft_delete_field exists on model, set timestamp; else DELETE.
27
+ Returns number of affected rows (best-effort; test doubles may return an int directly).
28
+ """
29
+ cutoff = datetime.now(timezone.utc) - timedelta(days=policy.older_than_days)
30
+ m = policy.model
31
+ where = list(policy.extra_where or [])
32
+ created_col = getattr(m, "created_at", None)
33
+ if created_col is not None and hasattr(created_col, "__le__"):
34
+ where.append(created_col <= cutoff) # type: ignore[operator]
35
+
36
+ # Soft-delete path when available and requested
37
+ if not policy.hard_delete and policy.soft_delete_field and hasattr(m, policy.soft_delete_field):
38
+ stmt = m.update().where(*where).values({policy.soft_delete_field: cutoff}) # type: ignore[attr-defined]
39
+ res = await session.execute(stmt)
40
+ return getattr(res, "rowcount", 0)
41
+
42
+ # Hard delete fallback
43
+ stmt = m.delete().where(*where) # type: ignore[attr-defined]
44
+ res = await session.execute(stmt)
45
+ return getattr(res, "rowcount", 0)
46
+
47
+
48
+ async def run_retention_purge(session: SqlSession, policies: Iterable[RetentionPolicy]) -> int:
49
+ total = 0
50
+ for p in policies:
51
+ total += await purge_policy(session, p)
52
+ return total
53
+
54
+
55
+ __all__ = ["RetentionPolicy", "purge_policy", "run_retention_purge"]
@@ -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
+ )
@@ -177,8 +177,16 @@ def _collect_metadata() -> list[object]:
177
177
  if name not in pkgs:
178
178
  pkgs.append(name)
179
179
 
180
+ # Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
180
181
  if "models" not in pkgs:
181
- pkgs.append("models")
182
+ try:
183
+ spec = getattr(importlib, "util", None)
184
+ if spec is not None and getattr(spec, "find_spec", None) is not None:
185
+ if spec.find_spec("models") is not None:
186
+ pkgs.append("models")
187
+ except Exception:
188
+ # Best-effort; if discovery fails, skip adding bare 'models'
189
+ pass
182
190
 
183
191
  def _import_and_collect(modname: str):
184
192
  try:
@@ -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:
@@ -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")
@@ -0,0 +1,71 @@
1
+ # Acceptance Matrix (A-IDs)
2
+
3
+ This document maps Acceptance scenarios (A-IDs) to endpoints, CLIs, fixtures, and seed data. Use it to drive the CI promotion gate and local `make accept` runs.
4
+
5
+ ## A0. Harness
6
+ - Stack: docker-compose.test.yml (api, db, redis)
7
+ - Makefile targets: accept, compose_up, wait, seed, down
8
+ - Tests bootstrap: tests/acceptance/conftest.py (BASE_URL), _auth.py, _seed.py, _http.py
9
+
10
+ ## A1. Security & Auth
11
+ - A1-01 Register → Verify → Login → /auth/me
12
+ - Endpoints: POST /auth/register, POST /auth/verify, POST /auth/login, GET /auth/me
13
+ - Fixtures: admin, user
14
+ - A1-02 Password policy & breach check
15
+ - Endpoints: POST /auth/register
16
+ - A1-03 Lockout escalation and cooldown
17
+ - Endpoints: POST /auth/login
18
+ - A1-04 RBAC/ABAC enforced
19
+ - Endpoints: GET /admin/*, resource GET with owner guard
20
+ - A1-05 Session list & revoke
21
+ - Endpoints: GET/DELETE /auth/sessions
22
+ - A1-06 API keys lifecycle
23
+ - Endpoints: POST/GET/DELETE /auth/api-keys, usage via Authorization header
24
+ - A1-07 MFA lifecycle
25
+ - Endpoints: /auth/mfa/*
26
+
27
+ ## A2. Rate Limiting
28
+ - A2-01 Global limit → 429 with Retry-After
29
+ - A2-02 Per-route & tenant override honored
30
+ - A2-03 Window reset
31
+
32
+ ## A3. Idempotency & Concurrency
33
+ - A3-01 Same Idempotency-Key → identical 2xx
34
+ - A3-02 Conflicting payload + same key → 409
35
+ - A3-03 Optimistic lock mismatch → 409; success increments version
36
+
37
+ ## A4. Jobs & Scheduling
38
+ - A4-01 Custom job consumed
39
+ - A4-02 Backoff & DLQ
40
+ - A4-03 Cron tick observed
41
+
42
+ ## A5. Webhooks
43
+ - A5-01 Producer → delivery (HMAC verified)
44
+ - A5-02 Retry stops on success
45
+ - A5-03 Secret rotation window accepts old+new
46
+
47
+ ## A6. Tenancy
48
+ - A6-01 tenant_id injected on create; list scoped
49
+ - A6-02 Cross-tenant → 404/403
50
+ - A6-03 Per-tenant quotas enforced
51
+
52
+ ## A7. Data Lifecycle
53
+ - A7-01 Soft delete hides; undelete restores
54
+ - A7-02 GDPR erasure steps with audit
55
+ - A7-03 Retention purge soft→hard
56
+ - A7-04 Backup verification healthy
57
+
58
+ ## A8. SLOs & Ops
59
+ - A8-01 Metrics http_server_* and db_pool_* present
60
+ - A8-02 Maintenance mode 503; circuit breaker trips/recover
61
+ - A8-03 Liveness/readiness under DB up/down
62
+
63
+ ## A9. OpenAPI & Error Contracts
64
+ - A9-01 /openapi.json valid; examples present
65
+ - A9-02 Problem+JSON conforms
66
+ - A9-03 Spectral + API Doctor pass
67
+
68
+ ## A10. CLI & DX
69
+ - A10-01 DB migrate/rollback/seed
70
+ - A10-02 Jobs runner consumes a sample job
71
+ - A10-03 SDK smoke-import and /ping
@@ -0,0 +1,44 @@
1
+ # Pre-Deploy Acceptance (Promotion Gate)
2
+
3
+ This guide describes the acceptance harness that runs post-build against an ephemeral stack. Artifacts are promoted only if acceptance checks pass.
4
+
5
+ ## Stack
6
+ - docker-compose.test.yml: api (uvicorn serving tests.acceptance.app), optional db/redis (via profiles), and a tester container to run pytest inside
7
+ - Makefile targets: accept, compose_up, wait, seed, down
8
+ - Health probes: /healthz (liveness), /readyz (readiness), /startupz (startup)
9
+
10
+ ## Workflow
11
+ 1. Build image
12
+ 2. docker compose up -d (test stack)
13
+ 3. CLI DB checks & seed: run `sql setup-and-migrate`, `sql current`, `sql downgrade -1`, `sql upgrade head` against an ephemeral SQLite DB, then call `sql seed tests.acceptance._seed:acceptance_seed` (no-op by default)
14
+ 4. Run pytest inside tester: docker compose run --rm tester (Makefile wires this)
15
+ 5. OpenAPI lint & API Doctor
16
+ 6. Teardown
17
+
18
+ ## Supply-chain & Matrix (v1 scope)
19
+ - SBOM: generate and upload as artifact; image scan (Trivy/Grype) with severity gate.
20
+ - Provenance: sign/attest images (cosign/SLSA) on best-effort basis.
21
+ - Backend matrix: run acceptance against two stacks via COMPOSE_PROFILES:
22
+ 1) in-memory stores (default), 2) Redis + Postgres (COMPOSE_PROFILES=pg-redis).
23
+
24
+ ## Additional Acceptance Checks (fast wins)
25
+ - Headers/CORS: assert HSTS, X-Content-Type-Options, Referrer-Policy, X-Frame-Options/SameSite; OPTIONS preflight behavior.
26
+ - Resilience: restart DB/Redis during request; expect breaker trip and recovery.
27
+ - DR drill: restore a tiny SQL dump then run smoke.
28
+ - OpenAPI invariants: no orphan routes; servers block correctness for versions; 100% examples for public JSON; stable operationIds; reject /auth/{id} path via lint rule.
29
+ - CLI contracts: `svc-infra --help` and key subcommands exit 0 and print expected flags.
30
+
31
+ ## Local usage
32
+ - make accept (runs the full flow locally)
33
+ - make down (tears down the stack)
34
+ - To run tests manually: docker compose run --rm tester
35
+ - To target a different backend: COMPOSE_PROFILES=pg-redis make accept
36
+
37
+ ## Files
38
+ - tests/acceptance/conftest.py: BASE_URL, httpx client, fixtures
39
+ - tests/acceptance/_auth.py: login/register helpers
40
+ - tests/acceptance/_seed.py: seed users/tenants/api keys
41
+ - tests/acceptance/_http.py: HTTP helpers
42
+
43
+ ## Scenarios
44
+ See docs/acceptance-matrix.md for A-IDs and mapping to endpoints.