svc-infra 0.1.589__py3-none-any.whl → 0.1.706__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, List, Optional, Union
4
+
5
+ import jwt
6
+ from fastapi_users.authentication.strategy.jwt import JWTStrategy
7
+ from fastapi_users.jwt import decode_jwt
8
+
9
+
10
+ class RotatingJWTStrategy(JWTStrategy):
11
+ """JWTStrategy that can verify tokens against multiple secrets.
12
+
13
+ Signing uses the primary secret (as in base class). Verification accepts any of
14
+ the provided secrets: [primary] + old_secrets.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ secret: str,
21
+ lifetime_seconds: int,
22
+ old_secrets: Optional[Iterable[str]] = None,
23
+ token_audience: Optional[Union[str, List[str]]] = None,
24
+ ):
25
+ # Normalize token_audience to list as required by parent JWTStrategy
26
+ aud_list: list[str] = (
27
+ [token_audience]
28
+ if isinstance(token_audience, str)
29
+ else list(token_audience)
30
+ if token_audience
31
+ else []
32
+ ) or ["fastapi-users:auth"]
33
+ super().__init__(
34
+ secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list
35
+ )
36
+ self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
37
+ self._lifetime_seconds = lifetime_seconds
38
+
39
+ async def read_token(
40
+ self,
41
+ token: str | None,
42
+ user_manager: Any = None,
43
+ *,
44
+ audience: str | list[str] | None = None,
45
+ ) -> Any:
46
+ """Read/verify a token against the active + rotated secrets.
47
+
48
+ Compatibility:
49
+ - fastapi-users signature: (token, user_manager) -> user | None
50
+ - legacy/test helper usage: (token, *, audience=...) -> claims | None
51
+ """
52
+
53
+ if token is None:
54
+ return None
55
+
56
+ if user_manager is None:
57
+ aud_list: list[str]
58
+ if audience is None:
59
+ aud_list = self.token_audience
60
+ elif isinstance(audience, str):
61
+ aud_list = [audience]
62
+ else:
63
+ aud_list = audience
64
+ try:
65
+ return decode_jwt(
66
+ token, self.decode_key, aud_list, algorithms=[self.algorithm]
67
+ )
68
+ except jwt.PyJWTError:
69
+ pass
70
+
71
+ for secret in self._verify_secrets[1:]:
72
+ candidate: JWTStrategy[Any, Any] = JWTStrategy(
73
+ secret=secret,
74
+ lifetime_seconds=self._lifetime_seconds,
75
+ token_audience=self.token_audience,
76
+ )
77
+ try:
78
+ return decode_jwt(
79
+ token,
80
+ candidate.decode_key,
81
+ aud_list,
82
+ algorithms=[candidate.algorithm],
83
+ )
84
+ except jwt.PyJWTError:
85
+ continue
86
+ raise ValueError("Invalid token for all configured secrets")
87
+
88
+ user = await super().read_token(token, user_manager)
89
+ if user is not None:
90
+ return user
91
+
92
+ for secret in self._verify_secrets[1:]:
93
+ candidate = JWTStrategy(
94
+ secret=secret,
95
+ lifetime_seconds=self._lifetime_seconds,
96
+ token_audience=self.token_audience,
97
+ )
98
+ user = await candidate.read_token(token, user_manager)
99
+ if user is not None:
100
+ return user
101
+
102
+ return None
103
+
104
+
105
+ __all__ = ["RotatingJWTStrategy"]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional, Sequence
7
+
8
+ try:
9
+ from sqlalchemy import or_, select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ except Exception: # pragma: no cover - optional import for type hints
12
+ AsyncSession = Any # type: ignore[misc,assignment]
13
+ select = None # type: ignore[assignment]
14
+ or_ = None # type: ignore[assignment]
15
+
16
+ from svc_infra.security.models import FailedAuthAttempt
17
+
18
+
19
+ @dataclass
20
+ class LockoutConfig:
21
+ threshold: int = 5 # failures before cooldown starts
22
+ window_minutes: int = 15 # look-back window for counting failures
23
+ base_cooldown_seconds: int = 30 # initial cooldown once threshold reached
24
+ max_cooldown_seconds: int = 3600 # cap exponential growth at 1 hour
25
+
26
+
27
+ @dataclass
28
+ class LockoutStatus:
29
+ locked: bool
30
+ next_allowed_at: Optional[datetime]
31
+ failure_count: int
32
+
33
+
34
+ # ---------------- Pure calculation -----------------
35
+
36
+
37
+ def compute_lockout(
38
+ fail_count: int, *, cfg: LockoutConfig, now: Optional[datetime] = None
39
+ ) -> LockoutStatus:
40
+ now = now or datetime.now(timezone.utc)
41
+ if fail_count < cfg.threshold:
42
+ return LockoutStatus(False, None, fail_count)
43
+ # cooldown factor exponent = fail_count - threshold
44
+ exponent = fail_count - cfg.threshold
45
+ cooldown = cfg.base_cooldown_seconds * (2**exponent)
46
+ if cooldown > cfg.max_cooldown_seconds:
47
+ cooldown = cfg.max_cooldown_seconds
48
+ return LockoutStatus(True, now + timedelta(seconds=cooldown), fail_count)
49
+
50
+
51
+ # ---------------- Persistence helpers (async) ---------------
52
+
53
+
54
+ async def record_attempt(
55
+ session: AsyncSession,
56
+ *,
57
+ user_id: Optional[uuid.UUID],
58
+ ip_hash: Optional[str],
59
+ success: bool,
60
+ ) -> None:
61
+ attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
62
+ session.add(attempt)
63
+ await session.flush()
64
+
65
+
66
+ async def get_lockout_status(
67
+ session: AsyncSession,
68
+ *,
69
+ user_id: Optional[uuid.UUID],
70
+ ip_hash: Optional[str],
71
+ cfg: Optional[LockoutConfig] = None,
72
+ ) -> LockoutStatus:
73
+ cfg = cfg or LockoutConfig()
74
+ now = datetime.now(timezone.utc)
75
+ window_start = now - timedelta(minutes=cfg.window_minutes)
76
+
77
+ q = select(FailedAuthAttempt).where(
78
+ FailedAuthAttempt.ts >= window_start,
79
+ FailedAuthAttempt.success == False, # noqa: E712
80
+ )
81
+ # Use OR logic: lock out if EITHER user_id OR ip_hash has too many failures
82
+ # This prevents attackers from rotating IPs to bypass lockout
83
+ filters = []
84
+ if user_id:
85
+ filters.append(FailedAuthAttempt.user_id == user_id)
86
+ if ip_hash:
87
+ filters.append(FailedAuthAttempt.ip_hash == ip_hash)
88
+ if filters:
89
+ q = q.where(or_(*filters))
90
+
91
+ rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
92
+ fail_count = len(rows)
93
+ return compute_lockout(fail_count, cfg=cfg, now=now)
94
+
95
+
96
+ __all__ = [
97
+ "LockoutConfig",
98
+ "LockoutStatus",
99
+ "compute_lockout",
100
+ "record_attempt",
101
+ "get_lockout_status",
102
+ ]
@@ -0,0 +1,287 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional
8
+
9
+ from sqlalchemy import (
10
+ JSON,
11
+ Boolean,
12
+ DateTime,
13
+ ForeignKey,
14
+ Index,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ text,
19
+ )
20
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
21
+
22
+ from svc_infra.db.sql.base import ModelBase
23
+ from svc_infra.db.sql.types import GUID
24
+
25
+ # ----------------------------- Models -----------------------------------------
26
+
27
+
28
+ class AuthSession(ModelBase):
29
+ __tablename__ = "auth_sessions"
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
32
+ user_id: Mapped[uuid.UUID] = mapped_column(
33
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
34
+ )
35
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
36
+ user_agent: Mapped[Optional[str]] = mapped_column(String(512))
37
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
38
+ last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
39
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
40
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
41
+
42
+ refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
43
+ back_populates="session", cascade="all, delete-orphan", lazy="selectin"
44
+ )
45
+
46
+ created_at = mapped_column(
47
+ DateTime(timezone=True),
48
+ server_default=text("CURRENT_TIMESTAMP"),
49
+ nullable=False,
50
+ )
51
+
52
+
53
+ class RefreshToken(ModelBase):
54
+ __tablename__ = "refresh_tokens"
55
+
56
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
57
+ session_id: Mapped[uuid.UUID] = mapped_column(
58
+ GUID(), ForeignKey("auth_sessions.id", ondelete="CASCADE"), index=True
59
+ )
60
+ session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
61
+
62
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
63
+ rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
64
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
65
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
66
+ expires_at: Mapped[Optional[datetime]] = mapped_column(
67
+ DateTime(timezone=True), index=True
68
+ )
69
+
70
+ created_at = mapped_column(
71
+ DateTime(timezone=True),
72
+ server_default=text("CURRENT_TIMESTAMP"),
73
+ nullable=False,
74
+ )
75
+
76
+ __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
77
+
78
+
79
+ class RefreshTokenRevocation(ModelBase):
80
+ __tablename__ = "refresh_token_revocations"
81
+
82
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
83
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
84
+ revoked_at: Mapped[datetime] = mapped_column(
85
+ DateTime(timezone=True), nullable=False
86
+ )
87
+ reason: Mapped[Optional[str]] = mapped_column(Text)
88
+
89
+
90
+ class FailedAuthAttempt(ModelBase):
91
+ __tablename__ = "failed_auth_attempts"
92
+
93
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
94
+ user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
95
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
96
+ )
97
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
98
+ ts: Mapped[datetime] = mapped_column(
99
+ DateTime(timezone=True),
100
+ nullable=False,
101
+ default=lambda: datetime.now(timezone.utc),
102
+ )
103
+ success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
104
+
105
+ __table_args__ = (Index("ix_failed_attempt_user_time", "user_id", "ts"),)
106
+
107
+
108
+ class RolePermission(ModelBase):
109
+ __tablename__ = "role_permissions"
110
+
111
+ role: Mapped[str] = mapped_column(String(64), primary_key=True)
112
+ permission: Mapped[str] = mapped_column(String(128), primary_key=True)
113
+
114
+
115
+ class AuditLog(ModelBase):
116
+ __tablename__ = "audit_logs"
117
+
118
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
119
+ ts: Mapped[datetime] = mapped_column(
120
+ DateTime(timezone=True),
121
+ nullable=False,
122
+ default=lambda: datetime.now(timezone.utc),
123
+ index=True,
124
+ )
125
+ actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
126
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
127
+ )
128
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
129
+ event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
130
+ resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
131
+ event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
132
+ prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
133
+ hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
134
+
135
+ __table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
136
+
137
+
138
+ # ------------------------ Org / Teams ----------------------------------------
139
+
140
+
141
+ class Organization(ModelBase):
142
+ __tablename__ = "organizations"
143
+
144
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
145
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
146
+ slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
147
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
148
+ created_at = mapped_column(
149
+ DateTime(timezone=True),
150
+ server_default=text("CURRENT_TIMESTAMP"),
151
+ nullable=False,
152
+ )
153
+
154
+
155
+ class Team(ModelBase):
156
+ __tablename__ = "teams"
157
+
158
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
159
+ org_id: Mapped[uuid.UUID] = mapped_column(
160
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
161
+ )
162
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
163
+ created_at = mapped_column(
164
+ DateTime(timezone=True),
165
+ server_default=text("CURRENT_TIMESTAMP"),
166
+ nullable=False,
167
+ )
168
+
169
+
170
+ class OrganizationMembership(ModelBase):
171
+ __tablename__ = "organization_memberships"
172
+
173
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
174
+ org_id: Mapped[uuid.UUID] = mapped_column(
175
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
176
+ )
177
+ user_id: Mapped[uuid.UUID] = mapped_column(
178
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
179
+ )
180
+ role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
181
+ created_at = mapped_column(
182
+ DateTime(timezone=True),
183
+ server_default=text("CURRENT_TIMESTAMP"),
184
+ nullable=False,
185
+ )
186
+ deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
187
+
188
+ __table_args__ = (
189
+ UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),
190
+ )
191
+
192
+
193
+ class OrganizationInvitation(ModelBase):
194
+ __tablename__ = "organization_invitations"
195
+
196
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
197
+ org_id: Mapped[uuid.UUID] = mapped_column(
198
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
199
+ )
200
+ email: Mapped[str] = mapped_column(String(255), index=True)
201
+ role: Mapped[str] = mapped_column(String(64), nullable=False)
202
+ token_hash: Mapped[str] = mapped_column(String(64), index=True)
203
+ expires_at: Mapped[Optional[datetime]] = mapped_column(
204
+ DateTime(timezone=True), index=True
205
+ )
206
+ created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
207
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
208
+ )
209
+ created_at = mapped_column(
210
+ DateTime(timezone=True),
211
+ server_default=text("CURRENT_TIMESTAMP"),
212
+ nullable=False,
213
+ )
214
+ last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
215
+ resend_count: Mapped[int] = mapped_column(default=0)
216
+ used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
217
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
218
+
219
+
220
+ # ------------------------ OAuth Provider Accounts -----------------------------
221
+ # MOVED to svc_infra.security.oauth_models for opt-in OAuth support
222
+ # Projects that enable OAuth should import ProviderAccount from there
223
+
224
+
225
+ # ------------------------ Utilities -------------------------------------------
226
+
227
+
228
+ def generate_refresh_token() -> str:
229
+ """Generate a random refresh token (opaque)."""
230
+ return uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
231
+
232
+
233
+ def hash_refresh_token(raw: str) -> str:
234
+ return hashlib.sha256(raw.encode()).hexdigest()
235
+
236
+
237
+ def compute_audit_hash(
238
+ prev_hash: Optional[str],
239
+ *,
240
+ ts: datetime,
241
+ actor_id: Optional[uuid.UUID],
242
+ tenant_id: Optional[str],
243
+ event_type: str,
244
+ resource_ref: Optional[str],
245
+ metadata: dict,
246
+ ) -> str:
247
+ """Compute SHA256 hash chaining previous hash + canonical event payload."""
248
+ prev = prev_hash or "0" * 64
249
+ payload = {
250
+ "ts": ts.isoformat(),
251
+ "actor_id": str(actor_id) if actor_id else None,
252
+ "tenant_id": tenant_id,
253
+ "event_type": event_type,
254
+ "resource_ref": resource_ref,
255
+ "metadata": metadata,
256
+ }
257
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
258
+ return hashlib.sha256((prev + canonical).encode()).hexdigest()
259
+
260
+
261
+ def rotate_refresh_token(
262
+ current_hash: str, *, ttl_minutes: int = 10080
263
+ ) -> tuple[str, str, datetime]:
264
+ """Rotate: returns (new_raw, new_hash, expires_at)."""
265
+ new_raw = generate_refresh_token()
266
+ new_hash = hash_refresh_token(new_raw)
267
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
268
+ return new_raw, new_hash, expires_at
269
+
270
+
271
+ __all__ = [
272
+ "AuthSession",
273
+ "RefreshToken",
274
+ "RefreshTokenRevocation",
275
+ "FailedAuthAttempt",
276
+ "RolePermission",
277
+ "AuditLog",
278
+ "Organization",
279
+ "Team",
280
+ "OrganizationMembership",
281
+ "OrganizationInvitation",
282
+ # ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
283
+ "generate_refresh_token",
284
+ "hash_refresh_token",
285
+ "compute_audit_hash",
286
+ "rotate_refresh_token",
287
+ ]
@@ -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 datetime, timezone
12
+ from typing import TYPE_CHECKING, Optional
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[Optional[str]] = mapped_column(Text)
48
+ refresh_token: Mapped[Optional[str]] = mapped_column(Text)
49
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
50
+ raw_claims: Mapped[Optional[dict]] = 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(timezone.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"]
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import uuid
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional
7
+
8
+ try:
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ except Exception: # pragma: no cover
12
+ AsyncSession = object # type: ignore[misc,assignment]
13
+ select = None # type: ignore[assignment]
14
+
15
+ from .models import OrganizationInvitation, OrganizationMembership
16
+
17
+
18
+ def _hash_token(raw: str) -> str:
19
+ return hashlib.sha256(raw.encode()).hexdigest()
20
+
21
+
22
+ def _new_token() -> str:
23
+ return uuid.uuid4().hex + uuid.uuid4().hex
24
+
25
+
26
+ async def issue_invitation(
27
+ db: Any,
28
+ *,
29
+ org_id: uuid.UUID,
30
+ email: str,
31
+ role: str,
32
+ created_by: Optional[uuid.UUID] = None,
33
+ ttl_hours: int = 72,
34
+ ) -> tuple[str, OrganizationInvitation]:
35
+ """Create a new invitation; revoke any existing active invites for the same email+org."""
36
+ # Revoke existing active invites
37
+ if select is not None and hasattr(db, "execute"):
38
+ try:
39
+ rows = (
40
+ (
41
+ await db.execute(
42
+ select(OrganizationInvitation).where(
43
+ OrganizationInvitation.org_id == org_id,
44
+ OrganizationInvitation.email == email,
45
+ OrganizationInvitation.used_at.is_(None),
46
+ OrganizationInvitation.revoked_at.is_(None),
47
+ )
48
+ )
49
+ )
50
+ .scalars()
51
+ .all()
52
+ )
53
+ now = datetime.now(timezone.utc)
54
+ for r in rows:
55
+ r.revoked_at = now
56
+ except Exception: # pragma: no cover
57
+ pass
58
+ else:
59
+ # FakeDB path: revoke in-memory invites
60
+ if hasattr(db, "added"):
61
+ now = datetime.now(timezone.utc)
62
+ for r in list(getattr(db, "added")):
63
+ if (
64
+ isinstance(r, OrganizationInvitation)
65
+ and r.org_id == org_id
66
+ and r.email == email.lower().strip()
67
+ and r.used_at is None
68
+ and r.revoked_at is None
69
+ ):
70
+ r.revoked_at = now
71
+
72
+ raw = _new_token()
73
+ inv = OrganizationInvitation(
74
+ org_id=org_id,
75
+ email=email.lower().strip(),
76
+ role=role,
77
+ token_hash=_hash_token(raw),
78
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
79
+ created_by=created_by,
80
+ last_sent_at=datetime.now(timezone.utc),
81
+ resend_count=0,
82
+ )
83
+ if hasattr(db, "add"):
84
+ db.add(inv)
85
+ if hasattr(db, "flush"):
86
+ await db.flush()
87
+ return raw, inv
88
+
89
+
90
+ async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
91
+ raw = _new_token()
92
+ invitation.token_hash = _hash_token(raw)
93
+ invitation.last_sent_at = datetime.now(timezone.utc)
94
+ invitation.resend_count = (invitation.resend_count or 0) + 1
95
+ if hasattr(db, "flush"):
96
+ await db.flush()
97
+ return raw
98
+
99
+
100
+ async def accept_invitation(
101
+ db: Any,
102
+ *,
103
+ invitation: OrganizationInvitation,
104
+ user_id: uuid.UUID,
105
+ ) -> OrganizationMembership:
106
+ now = datetime.now(timezone.utc)
107
+ if invitation.revoked_at or invitation.used_at:
108
+ raise ValueError("invitation_unusable")
109
+ if invitation.expires_at and invitation.expires_at < now:
110
+ raise ValueError("invitation_expired")
111
+
112
+ # mark used
113
+ invitation.used_at = now
114
+
115
+ # create membership (upsert-like enforced by DB unique constraint)
116
+ mem = OrganizationMembership(
117
+ org_id=invitation.org_id, user_id=user_id, role=invitation.role
118
+ )
119
+ if hasattr(db, "add"):
120
+ db.add(mem)
121
+ if hasattr(db, "flush"):
122
+ await db.flush()
123
+ return mem
124
+
125
+
126
+ __all__ = [
127
+ "issue_invitation",
128
+ "resend_invitation",
129
+ "accept_invitation",
130
+ ]