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,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
4
- from typing import List
3
+ from datetime import UTC, datetime
5
4
 
6
5
  from fastapi import APIRouter, HTTPException
7
6
  from sqlalchemy import select
@@ -16,9 +15,11 @@ def build_session_router() -> APIRouter:
16
15
  router = APIRouter(prefix="/sessions", tags=["sessions"])
17
16
 
18
17
  @router.get(
19
- "/me", response_model=list[dict], dependencies=[RequirePermission("security.session.list")]
18
+ "/me",
19
+ response_model=list[dict],
20
+ dependencies=[RequirePermission("security.session.list")],
20
21
  )
21
- async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> List[dict]:
22
+ async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> list[dict]:
22
23
  stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
23
24
  rows = (await session.execute(stmt)).scalars().all()
24
25
  return [
@@ -48,7 +49,7 @@ def build_session_router() -> APIRouter:
48
49
  raise HTTPException(403, "forbidden")
49
50
  if s.revoked_at:
50
51
  return # already revoked
51
- s.revoked_at = datetime.now(timezone.utc)
52
+ s.revoked_at = datetime.now(UTC)
52
53
  s.revoke_reason = "user_revoked"
53
54
  # Revoke all refresh tokens for this session
54
55
  for rt in s.refresh_tokens:
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
4
- from typing import Annotated, Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from datetime import UTC, datetime
5
+ from typing import Annotated, Any, cast
5
6
 
6
7
  from fastapi import Depends, HTTPException, Request
7
8
  from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer
@@ -26,7 +27,12 @@ class Principal:
26
27
  """Unified identity: user via JWT/cookie or service via API key."""
27
28
 
28
29
  def __init__(
29
- self, *, user=None, scopes: list[str] | None = None, via: str = "jwt", api_key=None
30
+ self,
31
+ *,
32
+ user=None,
33
+ scopes: list[str] | None = None,
34
+ via: str = "jwt",
35
+ api_key=None,
30
36
  ):
31
37
  self.user = user
32
38
  self.scopes = scopes or []
@@ -38,7 +44,7 @@ class Principal:
38
44
  async def resolve_api_key(
39
45
  request: Request,
40
46
  session: SqlSessionDep,
41
- ) -> Optional[Principal]:
47
+ ) -> Principal | None:
42
48
  raw = (request.headers.get("x-api-key") or "").strip()
43
49
  if not raw:
44
50
  return None
@@ -51,7 +57,11 @@ async def resolve_api_key(
51
57
  apikey = None
52
58
  if prefix:
53
59
  apikey = (
54
- (await session.execute(select(ApiKey).where(ApiKey.key_prefix == prefix)))
60
+ (
61
+ await session.execute(
62
+ select(ApiKey).where(ApiKey.key_prefix == prefix) # type: ignore[attr-defined]
63
+ )
64
+ )
55
65
  .scalars()
56
66
  .first()
57
67
  )
@@ -64,7 +74,7 @@ async def resolve_api_key(
64
74
  raise HTTPException(401, "invalid_api_key")
65
75
  if not apikey.active:
66
76
  raise HTTPException(401, "api_key_revoked")
67
- if apikey.expires_at and datetime.now(timezone.utc) > apikey.expires_at:
77
+ if apikey.expires_at and datetime.now(UTC) > apikey.expires_at:
68
78
  raise HTTPException(401, "api_key_expired")
69
79
 
70
80
  apikey.mark_used()
@@ -74,7 +84,7 @@ async def resolve_api_key(
74
84
 
75
85
  async def resolve_bearer_or_cookie_principal(
76
86
  request: Request, session: SqlSessionDep
77
- ) -> Optional[Principal]:
87
+ ) -> Principal | None:
78
88
  st = get_auth_settings()
79
89
  raw_auth = (request.headers.get("authorization") or "").strip()
80
90
  token = raw_auth.split(" ", 1)[1].strip() if raw_auth.lower().startswith("bearer ") else ""
@@ -89,7 +99,7 @@ async def resolve_bearer_or_cookie_principal(
89
99
  from fastapi_users.manager import BaseUserManager, UUIDIDMixin
90
100
  from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
91
101
 
92
- user_db = SQLAlchemyUserDatabase(session, UserModel)
102
+ user_db: Any = SQLAlchemyUserDatabase(session, UserModel)
93
103
 
94
104
  class _ShimManager(UUIDIDMixin, BaseUserManager[Any, Any]):
95
105
  reset_password_token_secret = "unused"
@@ -107,7 +117,7 @@ async def resolve_bearer_or_cookie_principal(
107
117
  if not user:
108
118
  return None
109
119
 
110
- db_user = await session.get(UserModel, user.id)
120
+ db_user = await cast("Any", session).get(UserModel, user.id)
111
121
  if not db_user:
112
122
  return None
113
123
  if not getattr(db_user, "is_active", True):
@@ -123,8 +133,8 @@ async def resolve_bearer_or_cookie_principal(
123
133
  async def _current_principal(
124
134
  request: Request,
125
135
  session: SqlSessionDep,
126
- jwt_or_cookie: Optional[Principal] = Depends(resolve_bearer_or_cookie_principal),
127
- ak: Optional[Principal] = Depends(resolve_api_key),
136
+ jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
137
+ ak: Principal | None = Depends(resolve_api_key),
128
138
  ) -> Principal:
129
139
  if jwt_or_cookie:
130
140
  return jwt_or_cookie
@@ -136,9 +146,9 @@ async def _current_principal(
136
146
  async def _optional_principal(
137
147
  request: Request,
138
148
  session: SqlSessionDep,
139
- jwt_or_cookie: Optional[Principal] = Depends(resolve_bearer_or_cookie_principal),
140
- ak: Optional[Principal] = Depends(resolve_api_key),
141
- ) -> Optional[Principal]:
149
+ jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
150
+ ak: Principal | None = Depends(resolve_api_key),
151
+ ) -> Principal | None:
142
152
  return jwt_or_cookie or ak or None
143
153
 
144
154
 
@@ -154,7 +164,7 @@ AllowIdentity = Depends(_optional_principal) # same, but optional
154
164
  # ---------- DX: small guard factories ----------
155
165
  def RequireRoles(*roles: str, resolver: Callable[[Any], list[str]] | None = None):
156
166
  async def _guard(p: Identity):
157
- have = set((resolver(p.user) if resolver else getattr(p.user, "roles", []) or []))
167
+ have = set(resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
158
168
  if not set(roles).issubset(have):
159
169
  raise HTTPException(403, "forbidden")
160
170
  return p
@@ -59,4 +59,9 @@ def get_sender() -> Sender:
59
59
  if not configured:
60
60
  return ConsoleSender()
61
61
 
62
+ # At this point, all values must be set
63
+ assert host is not None
64
+ assert user is not None
65
+ assert pw is not None
66
+ assert frm is not None
62
67
  return SMTPSender(host, st.smtp_port, user, pw, frm)
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import List, Optional
5
4
 
6
5
  from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr
7
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -19,7 +18,7 @@ class JWTSettings(BaseModel):
19
18
  secret: SecretStr
20
19
  lifetime_seconds: int = 60 * 60 * 24 * 7
21
20
  # Optional older secrets accepted for verification during rotation window
22
- old_secrets: List[SecretStr] = Field(default_factory=list)
21
+ old_secrets: list[SecretStr] = Field(default_factory=list)
23
22
 
24
23
 
25
24
  class PasswordClient(BaseModel):
@@ -29,10 +28,10 @@ class PasswordClient(BaseModel):
29
28
 
30
29
  class AuthSettings(BaseSettings):
31
30
  # ---- JWT ----
32
- jwt: Optional[JWTSettings] = None
31
+ jwt: JWTSettings | None = None
33
32
 
34
33
  # ---- Password login ----
35
- password_clients: List[PasswordClient] = Field(default_factory=list)
34
+ password_clients: list[PasswordClient] = Field(default_factory=list)
36
35
  require_client_secret_on_password_login: bool = False
37
36
 
38
37
  # ---- MFA / TOTP ----
@@ -50,26 +49,26 @@ class AuthSettings(BaseSettings):
50
49
  email_otp_attempts: int = 5
51
50
 
52
51
  # ---- Email/SMTP (verification, reset, etc.) ----
53
- smtp_host: Optional[str] = None
52
+ smtp_host: str | None = None
54
53
  smtp_port: int = 587
55
- smtp_username: Optional[str] = None
56
- smtp_password: Optional[SecretStr] = None
57
- smtp_from: Optional[str] = None
54
+ smtp_username: str | None = None
55
+ smtp_password: SecretStr | None = None
56
+ smtp_from: str | None = None
58
57
 
59
58
  # Dev convenience: auto-verify users without sending email
60
59
  auto_verify_in_dev: bool = True
61
60
 
62
61
  # ---- Built-in provider creds (optional) ----
63
- google_client_id: Optional[str] = None
64
- google_client_secret: Optional[SecretStr] = None
65
- github_client_id: Optional[str] = None
66
- github_client_secret: Optional[SecretStr] = None
67
- ms_client_id: Optional[str] = None
68
- ms_client_secret: Optional[SecretStr] = None
69
- ms_tenant: Optional[str] = None
70
- li_client_id: Optional[str] = None
71
- li_client_secret: Optional[SecretStr] = None
72
- oidc_providers: List[OIDCProvider] = Field(default_factory=list)
62
+ google_client_id: str | None = None
63
+ google_client_secret: SecretStr | None = None
64
+ github_client_id: str | None = None
65
+ github_client_secret: SecretStr | None = None
66
+ ms_client_id: str | None = None
67
+ ms_client_secret: SecretStr | None = None
68
+ ms_tenant: str | None = None
69
+ li_client_id: str | None = None
70
+ li_client_secret: SecretStr | None = None
71
+ oidc_providers: list[OIDCProvider] = Field(default_factory=list)
73
72
 
74
73
  # ---- Redirect + cookie settings ----
75
74
  post_login_redirect: AnyHttpUrl | str = "http://localhost:3000/app"
@@ -79,7 +78,7 @@ class AuthSettings(BaseSettings):
79
78
  auth_cookie_name: str = "svc_auth"
80
79
  session_cookie_secure: bool = False
81
80
  session_cookie_samesite: str = "lax"
82
- session_cookie_domain: Optional[str] = None
81
+ session_cookie_domain: str | None = None
83
82
  session_cookie_max_age_seconds: int = 60 * 60 * 4
84
83
 
85
84
  model_config = SettingsConfigDict(
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
- _UserModel: Optional[type] = None
6
- _GetStrategy: Optional[Callable[[], Any]] = None
6
+ _UserModel: type | None = None
7
+ _GetStrategy: Callable[[], Any] | None = None
7
8
  _AuthPrefix: str = "/auth"
8
- _UserScopeResolver: Optional[Callable[[Any], list[str]]] = None
9
+ _UserScopeResolver: Callable[[Any], list[str]] | None = None
9
10
 
10
11
 
11
12
  def set_auth_state(
@@ -0,0 +1,275 @@
1
+ """WebSocket authentication primitives.
2
+
3
+ This module provides lightweight JWT-based authentication for WebSocket endpoints.
4
+ Unlike HTTP auth which requires DB access, WS auth uses JWT claims only, making it
5
+ suitable for high-frequency real-time connections.
6
+
7
+ Usage:
8
+ from svc_infra.api.fastapi.auth.ws_security import WSIdentity
9
+
10
+ @router.websocket("/ws")
11
+ async def ws_handler(websocket: WebSocket, user: WSIdentity):
12
+ # user.id, user.email, user.scopes available from JWT claims
13
+ await websocket.accept()
14
+ ...
15
+
16
+ For router-level dependencies (protects all endpoints):
17
+ from svc_infra.api.fastapi.auth.ws_security import RequireWSIdentity
18
+
19
+ router = DualAPIRouter(dependencies=[RequireWSIdentity])
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Annotated, Any, cast
26
+
27
+ import jwt
28
+ from fastapi import Depends, WebSocket, WebSocketException, status
29
+
30
+ from svc_infra.api.fastapi.auth.settings import get_auth_settings
31
+
32
+
33
+ # ---------- WSPrincipal ----------
34
+ @dataclass
35
+ class WSPrincipal:
36
+ """Lightweight principal for WebSocket connections.
37
+
38
+ Unlike the HTTP `Principal` which loads the full user from DB,
39
+ `WSPrincipal` contains only JWT claims. This makes it suitable
40
+ for high-frequency real-time connections without DB overhead.
41
+
42
+ Attributes:
43
+ id: User ID from JWT 'sub' claim (typically UUID string)
44
+ email: User email from JWT 'email' claim (if present)
45
+ scopes: List of scopes/permissions from JWT 'scopes' claim
46
+ claims: Full JWT payload for custom claim access
47
+ via: Authentication method ('query', 'header', 'subprotocol')
48
+ """
49
+
50
+ id: str
51
+ email: str | None = None
52
+ scopes: list[str] = field(default_factory=list)
53
+ claims: dict = field(default_factory=dict)
54
+ via: str = "query" # 'query' | 'header' | 'subprotocol'
55
+
56
+
57
+ # ---------- Token extraction ----------
58
+ def _extract_token(websocket: WebSocket) -> tuple[str | None, str]:
59
+ """Extract JWT token from WebSocket connection.
60
+
61
+ Tries extraction in order:
62
+ 1. Query parameter: ?token=xxx
63
+ 2. Authorization header: Bearer xxx
64
+ 3. Sec-WebSocket-Protocol header (for browser clients that can't set headers)
65
+
66
+ Returns:
67
+ Tuple of (token, source) where source is 'query', 'header', or 'subprotocol'
68
+ """
69
+ # 1. Query parameter (most common for WebSocket)
70
+ token = websocket.query_params.get("token")
71
+ if token:
72
+ return token.strip(), "query"
73
+
74
+ # 2. Authorization header
75
+ auth_header = websocket.headers.get("authorization", "")
76
+ if auth_header.lower().startswith("bearer "):
77
+ token = auth_header.split(" ", 1)[1].strip()
78
+ if token:
79
+ return token, "header"
80
+
81
+ # 3. Sec-WebSocket-Protocol (browser workaround)
82
+ # Some clients send token as: Sec-WebSocket-Protocol: bearer, <token>
83
+ protocol = websocket.headers.get("sec-websocket-protocol", "")
84
+ if protocol:
85
+ parts = [p.strip() for p in protocol.split(",")]
86
+ # Look for token after 'bearer' protocol
87
+ for i, part in enumerate(parts):
88
+ if part.lower() == "bearer" and i + 1 < len(parts):
89
+ return parts[i + 1], "subprotocol"
90
+
91
+ return None, ""
92
+
93
+
94
+ def _decode_jwt(token: str) -> dict:
95
+ """Decode and validate JWT token.
96
+
97
+ Uses the same JWT settings as HTTP auth (AUTH_JWT__SECRET).
98
+ Supports key rotation via old_secrets.
99
+
100
+ Returns:
101
+ JWT payload dict
102
+
103
+ Raises:
104
+ WebSocketException: If token is invalid or expired
105
+ """
106
+ settings = get_auth_settings()
107
+
108
+ if not settings.jwt:
109
+ raise WebSocketException(
110
+ code=status.WS_1008_POLICY_VIOLATION,
111
+ reason="JWT not configured",
112
+ )
113
+
114
+ secret = settings.jwt.secret.get_secret_value()
115
+ old_secrets = [s.get_secret_value() for s in (settings.jwt.old_secrets or [])]
116
+ all_secrets = [secret, *old_secrets]
117
+
118
+ last_error: Exception | None = None
119
+
120
+ for s in all_secrets:
121
+ try:
122
+ payload = jwt.decode(
123
+ token,
124
+ s,
125
+ algorithms=["HS256"],
126
+ options={"require": ["sub", "exp"]},
127
+ )
128
+ return cast("dict[Any, Any]", payload)
129
+ except jwt.ExpiredSignatureError:
130
+ raise WebSocketException(
131
+ code=status.WS_1008_POLICY_VIOLATION,
132
+ reason="Token expired",
133
+ )
134
+ except jwt.InvalidTokenError as e:
135
+ last_error = e
136
+ continue
137
+
138
+ # None of the secrets worked
139
+ raise WebSocketException(
140
+ code=status.WS_1008_POLICY_VIOLATION,
141
+ reason=f"Invalid token: {last_error}",
142
+ )
143
+
144
+
145
+ # ---------- Resolvers ----------
146
+ async def resolve_ws_bearer_principal(websocket: WebSocket) -> WSPrincipal | None:
147
+ """Extract and validate JWT from WebSocket, returning WSPrincipal or None.
148
+
149
+ This is the optional resolver - returns None if no token present.
150
+ Use `_ws_current_principal` for required authentication.
151
+
152
+ Token sources (in order):
153
+ 1. Query parameter: ?token=xxx
154
+ 2. Authorization header: Bearer xxx
155
+ 3. Sec-WebSocket-Protocol: bearer, xxx
156
+ """
157
+ token, source = _extract_token(websocket)
158
+ if not token:
159
+ return None
160
+
161
+ payload = _decode_jwt(token)
162
+
163
+ return WSPrincipal(
164
+ id=str(payload.get("sub", "")),
165
+ email=payload.get("email"),
166
+ scopes=payload.get("scopes", []) or payload.get("scope", "").split(),
167
+ claims=payload,
168
+ via=source,
169
+ )
170
+
171
+
172
+ async def _ws_current_principal(
173
+ websocket: WebSocket,
174
+ principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
175
+ ) -> WSPrincipal:
176
+ """Require authenticated WebSocket connection.
177
+
178
+ Use this as a dependency to require authentication.
179
+ Closes connection with 1008 (Policy Violation) if no valid token.
180
+ """
181
+ if not principal:
182
+ raise WebSocketException(
183
+ code=status.WS_1008_POLICY_VIOLATION,
184
+ reason="Missing or invalid authentication",
185
+ )
186
+ return principal
187
+
188
+
189
+ async def _ws_optional_principal(
190
+ websocket: WebSocket,
191
+ principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
192
+ ) -> WSPrincipal | None:
193
+ """Optional WebSocket authentication.
194
+
195
+ Returns None if no token present, WSPrincipal if valid token.
196
+ """
197
+ return principal
198
+
199
+
200
+ # ---------- DX: types for endpoint params ----------
201
+ WSIdentity = Annotated[WSPrincipal, Depends(_ws_current_principal)]
202
+ """Annotated type for required WebSocket authentication.
203
+
204
+ Usage:
205
+ @router.websocket("/ws")
206
+ async def handler(websocket: WebSocket, user: WSIdentity):
207
+ # user.id, user.email, user.scopes available
208
+ ...
209
+ """
210
+
211
+ OptionalWSIdentity = Annotated[WSPrincipal | None, Depends(_ws_optional_principal)]
212
+ """Annotated type for optional WebSocket authentication.
213
+
214
+ Usage:
215
+ @router.websocket("/ws")
216
+ async def handler(websocket: WebSocket, user: OptionalWSIdentity):
217
+ if user:
218
+ # authenticated
219
+ else:
220
+ # anonymous
221
+ ...
222
+ """
223
+
224
+
225
+ # ---------- DX: constants for router-level dependencies ----------
226
+ RequireWSIdentity = Depends(_ws_current_principal)
227
+ """Router-level dependency for required WebSocket authentication.
228
+
229
+ Usage:
230
+ router = DualAPIRouter(dependencies=[RequireWSIdentity])
231
+ """
232
+
233
+ AllowWSIdentity = Depends(_ws_optional_principal)
234
+ """Router-level dependency for optional WebSocket authentication.
235
+
236
+ Usage:
237
+ router = DualAPIRouter(dependencies=[AllowWSIdentity])
238
+ """
239
+
240
+
241
+ # ---------- DX: guard factories ----------
242
+ def RequireWSScopes(*needed: str):
243
+ """Require specific scopes for WebSocket connection.
244
+
245
+ Usage:
246
+ router = DualAPIRouter(dependencies=[RequireWSScopes("chat:read", "chat:write")])
247
+ """
248
+
249
+ async def _guard(principal: WSIdentity) -> WSPrincipal:
250
+ if not set(needed).issubset(set(principal.scopes or [])):
251
+ raise WebSocketException(
252
+ code=status.WS_1008_POLICY_VIOLATION,
253
+ reason="Insufficient scope",
254
+ )
255
+ return principal
256
+
257
+ return Depends(_guard)
258
+
259
+
260
+ def RequireWSAnyScope(*candidates: str):
261
+ """Require at least one of the specified scopes.
262
+
263
+ Usage:
264
+ router = DualAPIRouter(dependencies=[RequireWSAnyScope("admin", "moderator")])
265
+ """
266
+
267
+ async def _guard(principal: WSIdentity) -> WSPrincipal:
268
+ if not set(principal.scopes or []) & set(candidates):
269
+ raise WebSocketException(
270
+ code=status.WS_1008_POLICY_VIOLATION,
271
+ reason="Insufficient scope",
272
+ )
273
+ return principal
274
+
275
+ return Depends(_guard)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, Response, status
7
+
8
+ from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
9
+ from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
10
+ from svc_infra.api.fastapi.tenancy.context import TenantId
11
+ from svc_infra.billing.async_service import AsyncBillingService
12
+ from svc_infra.billing.schemas import (
13
+ UsageAckOut,
14
+ UsageAggregateRow,
15
+ UsageAggregatesOut,
16
+ UsageIn,
17
+ )
18
+
19
+ router = APIRouter(prefix="/_billing", tags=["Billing"])
20
+
21
+
22
+ def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
23
+ return AsyncBillingService(session=session, tenant_id=tenant_id)
24
+
25
+
26
+ @router.post(
27
+ "/usage",
28
+ name="billing_record_usage",
29
+ status_code=status.HTTP_202_ACCEPTED,
30
+ response_model=UsageAckOut,
31
+ dependencies=[Depends(require_idempotency_key)],
32
+ )
33
+ async def record_usage(
34
+ data: UsageIn,
35
+ svc: Annotated[AsyncBillingService, Depends(get_service)],
36
+ response: Response,
37
+ ):
38
+ at = data.at or datetime.now(tz=UTC)
39
+ evt_id = await svc.record_usage(
40
+ metric=data.metric,
41
+ amount=int(data.amount),
42
+ at=at,
43
+ idempotency_key=data.idempotency_key,
44
+ metadata=data.metadata,
45
+ )
46
+ # For 202, no Location header is required, but we can surface the id in the body
47
+ return UsageAckOut(id=evt_id, accepted=True)
48
+
49
+
50
+ @router.get(
51
+ "/usage",
52
+ name="billing_list_aggregates",
53
+ response_model=UsageAggregatesOut,
54
+ )
55
+ async def list_aggregates(
56
+ metric: str,
57
+ date_from: datetime | None = None,
58
+ date_to: datetime | None = None,
59
+ svc: Annotated[AsyncBillingService, Depends(get_service)] = None, # type: ignore[assignment]
60
+ ):
61
+ rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
62
+ items = [
63
+ UsageAggregateRow(
64
+ period_start=r.period_start,
65
+ granularity=r.granularity,
66
+ metric=r.metric,
67
+ total=int(r.total),
68
+ )
69
+ for r in rows
70
+ ]
71
+ return UsageAggregatesOut(items=items, next_cursor=None)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from .router import router as billing_router
6
+
7
+
8
+ def add_billing(app: FastAPI, *, prefix: str = "/_billing") -> None:
9
+ # Mount under the chosen prefix; default is /_billing
10
+ if prefix and prefix != "/_billing":
11
+ # If a custom prefix is desired, clone router with new prefix
12
+ from fastapi import APIRouter
13
+
14
+ custom = APIRouter(prefix=prefix, tags=["Billing"])
15
+ for route in billing_router.routes:
16
+ custom.routes.append(route)
17
+ app.include_router(custom)
18
+ else:
19
+ app.include_router(billing_router)
@@ -1,3 +1,5 @@
1
+ from contextlib import asynccontextmanager
2
+
1
3
  from fastapi import FastAPI
2
4
 
3
5
  from svc_infra.cache.backend import shutdown_cache
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
5
7
 
6
8
 
7
9
  def setup_caching(app: FastAPI) -> None:
8
- @app.on_event("startup")
9
- async def _startup():
10
+ @asynccontextmanager
11
+ async def lifespan(_app: FastAPI):
10
12
  init_cache()
13
+ try:
14
+ yield
15
+ finally:
16
+ await shutdown_cache()
11
17
 
12
- @app.on_event("shutdown")
13
- async def _shutdown():
14
- await shutdown_cache()
18
+ app.router.lifespan_context = lifespan
@@ -1,4 +1,8 @@
1
- from svc_infra.api.fastapi.db.nosql import add_mongo_db, add_mongo_health, add_mongo_resources
1
+ from svc_infra.api.fastapi.db.nosql import (
2
+ add_mongo_db,
3
+ add_mongo_health,
4
+ add_mongo_resources,
5
+ )
2
6
  from svc_infra.api.fastapi.db.sql import add_sql_db, add_sql_health, add_sql_resources
3
7
 
4
8
  __all__ = [