svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Iterable, List, Optional, Union
3
+ from collections.abc import Iterable
4
+ from typing import Any
4
5
 
5
- import jwt as pyjwt
6
+ import jwt
6
7
  from fastapi_users.authentication.strategy.jwt import JWTStrategy
8
+ from fastapi_users.jwt import decode_jwt
7
9
 
8
10
 
9
11
  class RotatingJWTStrategy(JWTStrategy):
@@ -18,36 +20,83 @@ class RotatingJWTStrategy(JWTStrategy):
18
20
  *,
19
21
  secret: str,
20
22
  lifetime_seconds: int,
21
- old_secrets: Optional[Iterable[str]] = None,
22
- token_audience: Optional[Union[str, List[str]]] = None,
23
+ old_secrets: Iterable[str] | None = None,
24
+ token_audience: str | list[str] | None = None,
23
25
  ):
24
- super().__init__(
25
- secret=secret, lifetime_seconds=lifetime_seconds, token_audience=token_audience
26
- )
27
- self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
28
-
29
- async def read_token(self, token: str, audience: Optional[str] = None): # type: ignore[override]
30
- # Try with current strategy's configured secret first
31
- eff_aud = audience or self.token_audience
32
- try:
33
- return await super().read_token(token, audience=eff_aud)
34
- except Exception:
35
- pass
36
- # Try older secrets
37
- for s in self._verify_secrets[1:]:
26
+ # Normalize token_audience to list as required by parent JWTStrategy
27
+ aud_list: list[str] = (
28
+ [token_audience]
29
+ if isinstance(token_audience, str)
30
+ else list(token_audience)
31
+ if token_audience
32
+ else []
33
+ ) or ["fastapi-users:auth"]
34
+ super().__init__(secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list)
35
+ self._verify_secrets: list[str] = [secret, *list(old_secrets or [])]
36
+ self._lifetime_seconds = lifetime_seconds
37
+
38
+ async def read_token(
39
+ self,
40
+ token: str | None,
41
+ user_manager: Any = None,
42
+ *,
43
+ audience: str | list[str] | None = None,
44
+ ) -> Any:
45
+ """Read/verify a token against the active + rotated secrets.
46
+
47
+ Compatibility:
48
+ - fastapi-users signature: (token, user_manager) -> user | None
49
+ - legacy/test helper usage: (token, *, audience=...) -> claims | None
50
+ """
51
+
52
+ if token is None:
53
+ return None
54
+
55
+ if user_manager is None:
56
+ aud_list: list[str]
57
+ if audience is None:
58
+ aud_list = self.token_audience
59
+ elif isinstance(audience, str):
60
+ aud_list = [audience]
61
+ else:
62
+ aud_list = audience
38
63
  try:
39
- data = pyjwt.decode(
40
- token,
41
- s,
42
- algorithms=["HS256"],
43
- audience=eff_aud,
44
- )
45
- if data is not None:
46
- return data
47
- except Exception:
64
+ return decode_jwt(token, self.decode_key, aud_list, algorithms=[self.algorithm])
65
+ except jwt.PyJWTError:
48
66
  pass
49
- # If none of the secrets validated the token, raise a generic error
50
- raise ValueError("Invalid token for all configured secrets")
67
+
68
+ for secret in self._verify_secrets[1:]:
69
+ candidate: JWTStrategy[Any, Any] = JWTStrategy(
70
+ secret=secret,
71
+ lifetime_seconds=self._lifetime_seconds,
72
+ token_audience=self.token_audience,
73
+ )
74
+ try:
75
+ return decode_jwt(
76
+ token,
77
+ candidate.decode_key,
78
+ aud_list,
79
+ algorithms=[candidate.algorithm],
80
+ )
81
+ except jwt.PyJWTError:
82
+ continue
83
+ raise ValueError("Invalid token for all configured secrets")
84
+
85
+ user = await super().read_token(token, user_manager)
86
+ if user is not None:
87
+ return user
88
+
89
+ for secret in self._verify_secrets[1:]:
90
+ candidate = JWTStrategy(
91
+ secret=secret,
92
+ lifetime_seconds=self._lifetime_seconds,
93
+ token_audience=self.token_audience,
94
+ )
95
+ user = await candidate.read_token(token, user_manager)
96
+ if user is not None:
97
+ return user
98
+
99
+ return None
51
100
 
52
101
 
53
102
  __all__ = ["RotatingJWTStrategy"]
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import uuid
4
+ from collections.abc import Sequence
4
5
  from dataclasses import dataclass
5
- from datetime import datetime, timedelta, timezone
6
- from typing import Any, Optional, Sequence
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
7
8
 
8
9
  try:
9
- from sqlalchemy import select
10
+ from sqlalchemy import or_, select
10
11
  from sqlalchemy.ext.asyncio import AsyncSession
11
12
  except Exception: # pragma: no cover - optional import for type hints
12
- AsyncSession = Any # type: ignore[misc]
13
- select = None # type: ignore
13
+ AsyncSession = Any # type: ignore[misc,assignment]
14
+ select = None # type: ignore[assignment]
15
+ or_ = None # type: ignore[assignment]
14
16
 
15
17
  from svc_infra.security.models import FailedAuthAttempt
16
18
 
@@ -26,7 +28,7 @@ class LockoutConfig:
26
28
  @dataclass
27
29
  class LockoutStatus:
28
30
  locked: bool
29
- next_allowed_at: Optional[datetime]
31
+ next_allowed_at: datetime | None
30
32
  failure_count: int
31
33
 
32
34
 
@@ -34,9 +36,9 @@ class LockoutStatus:
34
36
 
35
37
 
36
38
  def compute_lockout(
37
- fail_count: int, *, cfg: LockoutConfig, now: Optional[datetime] = None
39
+ fail_count: int, *, cfg: LockoutConfig, now: datetime | None = None
38
40
  ) -> LockoutStatus:
39
- now = now or datetime.now(timezone.utc)
41
+ now = now or datetime.now(UTC)
40
42
  if fail_count < cfg.threshold:
41
43
  return LockoutStatus(False, None, fail_count)
42
44
  # cooldown factor exponent = fail_count - threshold
@@ -53,8 +55,8 @@ def compute_lockout(
53
55
  async def record_attempt(
54
56
  session: AsyncSession,
55
57
  *,
56
- user_id: Optional[uuid.UUID],
57
- ip_hash: Optional[str],
58
+ user_id: uuid.UUID | None,
59
+ ip_hash: str | None,
58
60
  success: bool,
59
61
  ) -> None:
60
62
  attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
@@ -65,22 +67,27 @@ async def record_attempt(
65
67
  async def get_lockout_status(
66
68
  session: AsyncSession,
67
69
  *,
68
- user_id: Optional[uuid.UUID],
69
- ip_hash: Optional[str],
70
- cfg: Optional[LockoutConfig] = None,
70
+ user_id: uuid.UUID | None,
71
+ ip_hash: str | None,
72
+ cfg: LockoutConfig | None = None,
71
73
  ) -> LockoutStatus:
72
74
  cfg = cfg or LockoutConfig()
73
- now = datetime.now(timezone.utc)
75
+ now = datetime.now(UTC)
74
76
  window_start = now - timedelta(minutes=cfg.window_minutes)
75
77
 
76
78
  q = select(FailedAuthAttempt).where(
77
79
  FailedAuthAttempt.ts >= window_start,
78
80
  FailedAuthAttempt.success == False, # noqa: E712
79
81
  )
82
+ # Use OR logic: lock out if EITHER user_id OR ip_hash has too many failures
83
+ # This prevents attackers from rotating IPs to bypass lockout
84
+ filters = []
80
85
  if user_id:
81
- q = q.where(FailedAuthAttempt.user_id == user_id)
86
+ filters.append(FailedAuthAttempt.user_id == user_id)
82
87
  if ip_hash:
83
- q = q.where(FailedAuthAttempt.ip_hash == ip_hash)
88
+ filters.append(FailedAuthAttempt.ip_hash == ip_hash)
89
+ if filters:
90
+ q = q.where(or_(*filters))
84
91
 
85
92
  rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
86
93
  fail_count = len(rows)
@@ -3,10 +3,19 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import json
5
5
  import uuid
6
- from datetime import datetime, timedelta, timezone
7
- from typing import Optional
8
-
9
- from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from sqlalchemy import (
9
+ JSON,
10
+ Boolean,
11
+ DateTime,
12
+ ForeignKey,
13
+ Index,
14
+ String,
15
+ Text,
16
+ UniqueConstraint,
17
+ text,
18
+ )
10
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
11
20
 
12
21
  from svc_infra.db.sql.base import ModelBase
@@ -22,19 +31,21 @@ class AuthSession(ModelBase):
22
31
  user_id: Mapped[uuid.UUID] = mapped_column(
23
32
  GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
24
33
  )
25
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
26
- user_agent: Mapped[Optional[str]] = mapped_column(String(512))
27
- ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
28
- last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
29
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
30
- revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
31
-
32
- refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
34
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
35
+ user_agent: Mapped[str | None] = mapped_column(String(512))
36
+ ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
37
+ last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
38
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
39
+ revoke_reason: Mapped[str | None] = mapped_column(Text)
40
+
41
+ refresh_tokens: Mapped[list[RefreshToken]] = relationship(
33
42
  back_populates="session", cascade="all, delete-orphan", lazy="selectin"
34
43
  )
35
44
 
36
45
  created_at = mapped_column(
37
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
46
+ DateTime(timezone=True),
47
+ server_default=text("CURRENT_TIMESTAMP"),
48
+ nullable=False,
38
49
  )
39
50
 
40
51
 
@@ -48,13 +59,15 @@ class RefreshToken(ModelBase):
48
59
  session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
49
60
 
50
61
  token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
51
- rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
52
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
53
- revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
54
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
62
+ rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
63
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
64
+ revoke_reason: Mapped[str | None] = mapped_column(Text)
65
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
55
66
 
56
67
  created_at = mapped_column(
57
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
68
+ DateTime(timezone=True),
69
+ server_default=text("CURRENT_TIMESTAMP"),
70
+ nullable=False,
58
71
  )
59
72
 
60
73
  __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
@@ -66,19 +79,21 @@ class RefreshTokenRevocation(ModelBase):
66
79
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
67
80
  token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
68
81
  revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
69
- reason: Mapped[Optional[str]] = mapped_column(Text)
82
+ reason: Mapped[str | None] = mapped_column(Text)
70
83
 
71
84
 
72
85
  class FailedAuthAttempt(ModelBase):
73
86
  __tablename__ = "failed_auth_attempts"
74
87
 
75
88
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
76
- user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
89
+ user_id: Mapped[uuid.UUID | None] = mapped_column(
77
90
  GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
78
91
  )
79
- ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
92
+ ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
80
93
  ts: Mapped[datetime] = mapped_column(
81
- DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
94
+ DateTime(timezone=True),
95
+ nullable=False,
96
+ default=lambda: datetime.now(UTC),
82
97
  )
83
98
  success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
84
99
 
@@ -99,17 +114,17 @@ class AuditLog(ModelBase):
99
114
  ts: Mapped[datetime] = mapped_column(
100
115
  DateTime(timezone=True),
101
116
  nullable=False,
102
- default=lambda: datetime.now(timezone.utc),
117
+ default=lambda: datetime.now(UTC),
103
118
  index=True,
104
119
  )
105
- actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
120
+ actor_id: Mapped[uuid.UUID | None] = mapped_column(
106
121
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
107
122
  )
108
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
123
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
109
124
  event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
110
- resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
125
+ resource_ref: Mapped[str | None] = mapped_column(String(255), index=True)
111
126
  event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
112
- prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
127
+ prev_hash: Mapped[str | None] = mapped_column(String(64))
113
128
  hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
114
129
 
115
130
  __table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
@@ -123,10 +138,12 @@ class Organization(ModelBase):
123
138
 
124
139
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
125
140
  name: Mapped[str] = mapped_column(String(128), nullable=False)
126
- slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
127
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
141
+ slug: Mapped[str | None] = mapped_column(String(64), index=True)
142
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
128
143
  created_at = mapped_column(
129
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
144
+ DateTime(timezone=True),
145
+ server_default=text("CURRENT_TIMESTAMP"),
146
+ nullable=False,
130
147
  )
131
148
 
132
149
 
@@ -139,7 +156,9 @@ class Team(ModelBase):
139
156
  )
140
157
  name: Mapped[str] = mapped_column(String(128), nullable=False)
141
158
  created_at = mapped_column(
142
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
159
+ DateTime(timezone=True),
160
+ server_default=text("CURRENT_TIMESTAMP"),
161
+ nullable=False,
143
162
  )
144
163
 
145
164
 
@@ -155,9 +174,11 @@ class OrganizationMembership(ModelBase):
155
174
  )
156
175
  role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
157
176
  created_at = mapped_column(
158
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
177
+ DateTime(timezone=True),
178
+ server_default=text("CURRENT_TIMESTAMP"),
179
+ nullable=False,
159
180
  )
160
- deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
181
+ deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
161
182
 
162
183
  __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
163
184
 
@@ -172,17 +193,24 @@ class OrganizationInvitation(ModelBase):
172
193
  email: Mapped[str] = mapped_column(String(255), index=True)
173
194
  role: Mapped[str] = mapped_column(String(64), nullable=False)
174
195
  token_hash: Mapped[str] = mapped_column(String(64), index=True)
175
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
176
- created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
196
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
197
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
177
198
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
178
199
  )
179
200
  created_at = mapped_column(
180
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
201
+ DateTime(timezone=True),
202
+ server_default=text("CURRENT_TIMESTAMP"),
203
+ nullable=False,
181
204
  )
182
- last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
205
+ last_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
183
206
  resend_count: Mapped[int] = mapped_column(default=0)
184
- used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
185
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
207
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
208
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
209
+
210
+
211
+ # ------------------------ OAuth Provider Accounts -----------------------------
212
+ # MOVED to svc_infra.security.oauth_models for opt-in OAuth support
213
+ # Projects that enable OAuth should import ProviderAccount from there
186
214
 
187
215
 
188
216
  # ------------------------ Utilities -------------------------------------------
@@ -198,13 +226,13 @@ def hash_refresh_token(raw: str) -> str:
198
226
 
199
227
 
200
228
  def compute_audit_hash(
201
- prev_hash: Optional[str],
229
+ prev_hash: str | None,
202
230
  *,
203
231
  ts: datetime,
204
- actor_id: Optional[uuid.UUID],
205
- tenant_id: Optional[str],
232
+ actor_id: uuid.UUID | None,
233
+ tenant_id: str | None,
206
234
  event_type: str,
207
- resource_ref: Optional[str],
235
+ resource_ref: str | None,
208
236
  metadata: dict,
209
237
  ) -> str:
210
238
  """Compute SHA256 hash chaining previous hash + canonical event payload."""
@@ -227,7 +255,7 @@ def rotate_refresh_token(
227
255
  """Rotate: returns (new_raw, new_hash, expires_at)."""
228
256
  new_raw = generate_refresh_token()
229
257
  new_hash = hash_refresh_token(new_raw)
230
- expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
258
+ expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
231
259
  return new_raw, new_hash, expires_at
232
260
 
233
261
 
@@ -238,6 +266,11 @@ __all__ = [
238
266
  "FailedAuthAttempt",
239
267
  "RolePermission",
240
268
  "AuditLog",
269
+ "Organization",
270
+ "Team",
271
+ "OrganizationMembership",
272
+ "OrganizationInvitation",
273
+ # ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
241
274
  "generate_refresh_token",
242
275
  "hash_refresh_token",
243
276
  "compute_audit_hash",
@@ -0,0 +1,73 @@
1
+ """
2
+ OAuth provider account models (opt-in).
3
+
4
+ These models are only registered when a project explicitly enables OAuth.
5
+ Import this module only when enable_oauth=True is passed to add_auth_users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
+
14
+ from sqlalchemy import (
15
+ JSON,
16
+ DateTime,
17
+ ForeignKey,
18
+ Index,
19
+ String,
20
+ Text,
21
+ UniqueConstraint,
22
+ text,
23
+ )
24
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
25
+
26
+ from svc_infra.db.sql.base import ModelBase
27
+ from svc_infra.db.sql.types import GUID
28
+
29
+ if TYPE_CHECKING:
30
+ from typing import Any
31
+
32
+ # User model is application-specific; this is a forward reference for type hints
33
+ User = Any
34
+
35
+
36
+ class ProviderAccount(ModelBase):
37
+ """OAuth provider account linking (Google, GitHub, etc.)."""
38
+
39
+ __tablename__ = "provider_accounts"
40
+
41
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
42
+ user_id: Mapped[uuid.UUID] = mapped_column(
43
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
44
+ )
45
+ provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
46
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
47
+ access_token: Mapped[str | None] = mapped_column(Text)
48
+ refresh_token: Mapped[str | None] = mapped_column(Text)
49
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
50
+ raw_claims: Mapped[dict | None] = mapped_column(JSON)
51
+
52
+ # Bidirectional relationship to User model
53
+ user: Mapped[User] = relationship(back_populates="provider_accounts")
54
+
55
+ created_at = mapped_column(
56
+ DateTime(timezone=True),
57
+ server_default=text("CURRENT_TIMESTAMP"),
58
+ nullable=False,
59
+ )
60
+ updated_at = mapped_column(
61
+ DateTime(timezone=True),
62
+ server_default=text("CURRENT_TIMESTAMP"),
63
+ onupdate=lambda: datetime.now(UTC),
64
+ nullable=False,
65
+ )
66
+
67
+ __table_args__ = (
68
+ UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
69
+ Index("ix_provider_accounts_user_provider", "user_id", "provider"),
70
+ )
71
+
72
+
73
+ __all__ = ["ProviderAccount"]
@@ -2,15 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import uuid
5
- from datetime import datetime, timedelta, timezone
6
- from typing import Any, Optional
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any
7
7
 
8
8
  try:
9
9
  from sqlalchemy import select
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  except Exception: # pragma: no cover
12
- AsyncSession = object # type: ignore
13
- select = None # type: ignore
12
+ AsyncSession = object # type: ignore[misc,assignment]
13
+ select = None # type: ignore[assignment]
14
14
 
15
15
  from .models import OrganizationInvitation, OrganizationMembership
16
16
 
@@ -29,7 +29,7 @@ async def issue_invitation(
29
29
  org_id: uuid.UUID,
30
30
  email: str,
31
31
  role: str,
32
- created_by: Optional[uuid.UUID] = None,
32
+ created_by: uuid.UUID | None = None,
33
33
  ttl_hours: int = 72,
34
34
  ) -> tuple[str, OrganizationInvitation]:
35
35
  """Create a new invitation; revoke any existing active invites for the same email+org."""
@@ -50,7 +50,7 @@ async def issue_invitation(
50
50
  .scalars()
51
51
  .all()
52
52
  )
53
- now = datetime.now(timezone.utc)
53
+ now = datetime.now(UTC)
54
54
  for r in rows:
55
55
  r.revoked_at = now
56
56
  except Exception: # pragma: no cover
@@ -58,8 +58,8 @@ async def issue_invitation(
58
58
  else:
59
59
  # FakeDB path: revoke in-memory invites
60
60
  if hasattr(db, "added"):
61
- now = datetime.now(timezone.utc)
62
- for r in list(getattr(db, "added")):
61
+ now = datetime.now(UTC)
62
+ for r in list(db.added):
63
63
  if (
64
64
  isinstance(r, OrganizationInvitation)
65
65
  and r.org_id == org_id
@@ -75,9 +75,9 @@ async def issue_invitation(
75
75
  email=email.lower().strip(),
76
76
  role=role,
77
77
  token_hash=_hash_token(raw),
78
- expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
78
+ expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
79
79
  created_by=created_by,
80
- last_sent_at=datetime.now(timezone.utc),
80
+ last_sent_at=datetime.now(UTC),
81
81
  resend_count=0,
82
82
  )
83
83
  if hasattr(db, "add"):
@@ -90,7 +90,7 @@ async def issue_invitation(
90
90
  async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
91
91
  raw = _new_token()
92
92
  invitation.token_hash = _hash_token(raw)
93
- invitation.last_sent_at = datetime.now(timezone.utc)
93
+ invitation.last_sent_at = datetime.now(UTC)
94
94
  invitation.resend_count = (invitation.resend_count or 0) + 1
95
95
  if hasattr(db, "flush"):
96
96
  await db.flush()
@@ -103,7 +103,7 @@ async def accept_invitation(
103
103
  invitation: OrganizationInvitation,
104
104
  user_id: uuid.UUID,
105
105
  ) -> OrganizationMembership:
106
- now = datetime.now(timezone.utc)
106
+ now = datetime.now(UTC)
107
107
  if invitation.revoked_at or invitation.used_at:
108
108
  raise ValueError("invitation_unusable")
109
109
  if invitation.expires_at and invitation.expires_at < now:
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ from collections.abc import Callable, Iterable
4
5
  from dataclasses import dataclass
5
- from typing import Callable, Iterable, Optional
6
6
 
7
7
  COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
8
8
 
@@ -36,10 +36,10 @@ SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
36
36
  BreachedChecker = Callable[[str], bool]
37
37
 
38
38
 
39
- _breached_checker: Optional[BreachedChecker] = None
39
+ _breached_checker: BreachedChecker | None = None
40
40
 
41
41
 
42
- def configure_breached_checker(checker: Optional[BreachedChecker]) -> None:
42
+ def configure_breached_checker(checker: BreachedChecker | None) -> None:
43
43
  global _breached_checker
44
44
  _breached_checker = checker
45
45