svc-infra 0.1.595__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,217 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from typing import Iterable, Literal, cast
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from starlette.middleware.sessions import SessionMiddleware
10
+
11
+ from svc_infra.app.env import require_secret
12
+ from svc_infra.security.headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
13
+
14
+ DEFAULT_SESSION_SECRET = "dev-only-session-secret-not-for-production"
15
+
16
+
17
+ def _parse_bool(value: str | None) -> bool | None:
18
+ if value is None:
19
+ return None
20
+ lowered = value.strip().lower()
21
+ if lowered in {"1", "true", "yes", "on"}:
22
+ return True
23
+ if lowered in {"0", "false", "no", "off"}:
24
+ return False
25
+ return None
26
+
27
+
28
+ def _normalize_origins(value: Iterable[str] | str | None) -> list[str]:
29
+ if value is None:
30
+ return []
31
+ if isinstance(value, str):
32
+ parts = [p.strip() for p in value.split(",")]
33
+ else:
34
+ parts = [str(v).strip() for v in value]
35
+ return [p for p in parts if p]
36
+
37
+
38
+ def _resolve_cors_origins(
39
+ provided: Iterable[str] | str | None,
40
+ env: Mapping[str, str],
41
+ ) -> list[str]:
42
+ if provided is not None:
43
+ return _normalize_origins(provided)
44
+ return _normalize_origins(env.get("CORS_ALLOW_ORIGINS"))
45
+
46
+
47
+ def _resolve_allow_credentials(
48
+ allow_credentials: bool,
49
+ env: Mapping[str, str],
50
+ ) -> bool:
51
+ env_value = _parse_bool(env.get("CORS_ALLOW_CREDENTIALS"))
52
+ if env_value is None:
53
+ return allow_credentials
54
+ # Allow explicit overrides via function arguments.
55
+ if allow_credentials is not True:
56
+ return allow_credentials
57
+ return env_value
58
+
59
+
60
+ def _configure_cors(
61
+ app: FastAPI,
62
+ *,
63
+ cors_origins: Iterable[str] | str | None,
64
+ allow_credentials: bool,
65
+ env: Mapping[str, str],
66
+ ) -> None:
67
+ origins = _resolve_cors_origins(cors_origins, env)
68
+ if not origins:
69
+ return
70
+
71
+ allow_methods = _normalize_origins(env.get("CORS_ALLOW_METHODS")) or ["*"]
72
+ allow_headers = _normalize_origins(env.get("CORS_ALLOW_HEADERS")) or ["*"]
73
+
74
+ credentials = _resolve_allow_credentials(allow_credentials, env)
75
+
76
+ wildcard_origins = "*" in origins
77
+
78
+ cors_kwargs: dict[str, object] = {
79
+ "allow_credentials": credentials,
80
+ "allow_methods": allow_methods,
81
+ "allow_headers": allow_headers,
82
+ "allow_origins": ["*"] if wildcard_origins else origins,
83
+ }
84
+ origin_regex = env.get("CORS_ALLOW_ORIGIN_REGEX")
85
+ if wildcard_origins:
86
+ cors_kwargs["allow_origin_regex"] = origin_regex or ".*"
87
+ else:
88
+ if origin_regex:
89
+ cors_kwargs["allow_origin_regex"] = origin_regex
90
+
91
+ app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
92
+
93
+
94
+ def _configure_security_headers(
95
+ app: FastAPI,
96
+ *,
97
+ overrides: dict[str, str] | None,
98
+ enable_hsts_preload: bool | None,
99
+ ) -> None:
100
+ merged_overrides = dict(overrides or {})
101
+ if enable_hsts_preload is not None:
102
+ current = merged_overrides.get(
103
+ "Strict-Transport-Security",
104
+ SECURE_DEFAULTS["Strict-Transport-Security"],
105
+ )
106
+ directives = [p.strip() for p in current.split(";") if p.strip()]
107
+ directives = [d for d in directives if d.lower() != "preload"]
108
+ if enable_hsts_preload:
109
+ directives.append("preload")
110
+ merged_overrides["Strict-Transport-Security"] = "; ".join(directives)
111
+
112
+ app.add_middleware(SecurityHeadersMiddleware, overrides=merged_overrides)
113
+
114
+
115
+ def _should_add_session_middleware(app: FastAPI) -> bool:
116
+ return not any(m.cls is SessionMiddleware for m in app.user_middleware)
117
+
118
+
119
+ def _configure_session_middleware(
120
+ app: FastAPI,
121
+ *,
122
+ env: Mapping[str, str],
123
+ install: bool,
124
+ secret_key: str | None,
125
+ session_cookie: str,
126
+ max_age: int,
127
+ same_site: str,
128
+ https_only: bool | None,
129
+ ) -> None:
130
+ if not install or not _should_add_session_middleware(app):
131
+ return
132
+
133
+ # Use require_secret to ensure secrets are set in production
134
+ secret = require_secret(
135
+ secret_key or env.get("SESSION_SECRET"),
136
+ "SESSION_SECRET",
137
+ dev_default=DEFAULT_SESSION_SECRET,
138
+ )
139
+ https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
140
+ effective_https_only = (
141
+ https_only
142
+ if https_only is not None
143
+ else (https_env if https_env is not None else False)
144
+ )
145
+ same_site_env = env.get("SESSION_COOKIE_SAMESITE")
146
+ same_site_raw = same_site_env.strip() if same_site_env else same_site
147
+ # Validate and narrow to expected Literal type
148
+ same_site_value: Literal["lax", "strict", "none"] = (
149
+ "lax"
150
+ if same_site_raw not in ("lax", "strict", "none")
151
+ else cast(Literal["lax", "strict", "none"], same_site_raw)
152
+ )
153
+
154
+ max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
155
+ try:
156
+ max_age_value = int(max_age_env) if max_age_env is not None else max_age
157
+ except ValueError:
158
+ max_age_value = max_age
159
+
160
+ session_cookie_env = env.get("SESSION_COOKIE_NAME")
161
+ session_cookie_value = (
162
+ session_cookie_env.strip() if session_cookie_env else session_cookie
163
+ )
164
+
165
+ app.add_middleware(
166
+ SessionMiddleware,
167
+ secret_key=secret,
168
+ session_cookie=session_cookie_value,
169
+ max_age=max_age_value,
170
+ same_site=same_site_value,
171
+ https_only=effective_https_only,
172
+ )
173
+
174
+
175
+ def add_security(
176
+ app: FastAPI,
177
+ *,
178
+ cors_origins: Iterable[str] | str | None = None,
179
+ headers_overrides: dict[str, str] | None = None,
180
+ allow_credentials: bool = True,
181
+ env: Mapping[str, str] = os.environ,
182
+ enable_hsts_preload: bool | None = None,
183
+ install_session_middleware: bool = False,
184
+ session_secret_key: str | None = None,
185
+ session_cookie_name: str = "svc_session",
186
+ session_cookie_max_age_seconds: int = 4 * 3600,
187
+ session_cookie_samesite: str = "lax",
188
+ session_cookie_https_only: bool | None = None,
189
+ ) -> None:
190
+ """Install security middlewares with svc-infra defaults."""
191
+
192
+ _configure_security_headers(
193
+ app,
194
+ overrides=headers_overrides,
195
+ enable_hsts_preload=enable_hsts_preload,
196
+ )
197
+ _configure_cors(
198
+ app,
199
+ cors_origins=cors_origins,
200
+ allow_credentials=allow_credentials,
201
+ env=env,
202
+ )
203
+ _configure_session_middleware(
204
+ app,
205
+ env=env,
206
+ install=install_session_middleware,
207
+ secret_key=session_secret_key,
208
+ session_cookie=session_cookie_name,
209
+ max_age=session_cookie_max_age_seconds,
210
+ same_site=session_cookie_samesite,
211
+ https_only=session_cookie_https_only,
212
+ )
213
+
214
+
215
+ __all__ = [
216
+ "add_security",
217
+ ]
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  """Audit log append & chain verification utilities.
4
2
 
5
3
  Provides helpers to append a new AuditLog entry maintaining a hash-chain
@@ -13,19 +11,95 @@ Design notes:
13
11
  fail verification (because their prev_hash links break transitively).
14
12
  """
15
13
 
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
16
17
  from datetime import datetime, timezone
17
- from typing import Any, List, Optional, Sequence, Tuple
18
+ from typing import Any, List, Optional, Protocol, Sequence, Tuple
18
19
 
19
20
  try: # SQLAlchemy may not be present in minimal test context
20
21
  from sqlalchemy import select
21
22
  from sqlalchemy.ext.asyncio import AsyncSession
22
23
  except Exception: # pragma: no cover
23
- AsyncSession = Any # type: ignore
24
- select = None # type: ignore
24
+ AsyncSession = Any # type: ignore[misc,assignment]
25
+ select = None # type: ignore[assignment]
25
26
 
26
27
  from svc_infra.security.models import AuditLog, compute_audit_hash
27
28
 
28
29
 
30
+ @dataclass(frozen=True)
31
+ class AuditEvent:
32
+ ts: datetime
33
+ actor_id: Any
34
+ tenant_id: str | None
35
+ event_type: str
36
+ resource_ref: str | None
37
+ metadata: dict
38
+
39
+
40
+ class AuditLogStore(Protocol):
41
+ """Minimal interface for storing audit events.
42
+
43
+ This is intentionally small so applications can swap in a SQL-backed store.
44
+ """
45
+
46
+ def append(
47
+ self,
48
+ *,
49
+ actor_id: Any = None,
50
+ tenant_id: str | None = None,
51
+ event_type: str,
52
+ resource_ref: str | None = None,
53
+ metadata: dict | None = None,
54
+ ts: datetime | None = None,
55
+ ) -> AuditEvent:
56
+ pass
57
+
58
+ def list(
59
+ self,
60
+ *,
61
+ tenant_id: str | None = None,
62
+ limit: int | None = None,
63
+ ) -> list[AuditEvent]:
64
+ pass
65
+
66
+
67
+ class InMemoryAuditLogStore:
68
+ """In-memory audit event store (useful for tests and prototypes)."""
69
+
70
+ def __init__(self):
71
+ self._events: list[AuditEvent] = []
72
+
73
+ def append(
74
+ self,
75
+ *,
76
+ actor_id: Any = None,
77
+ tenant_id: str | None = None,
78
+ event_type: str,
79
+ resource_ref: str | None = None,
80
+ metadata: dict | None = None,
81
+ ts: datetime | None = None,
82
+ ) -> AuditEvent:
83
+ event = AuditEvent(
84
+ ts=ts or datetime.now(timezone.utc),
85
+ actor_id=actor_id,
86
+ tenant_id=tenant_id,
87
+ event_type=event_type,
88
+ resource_ref=resource_ref,
89
+ metadata=dict(metadata or {}),
90
+ )
91
+ self._events.append(event)
92
+ return event
93
+
94
+ def list(
95
+ self, *, tenant_id: str | None = None, limit: int | None = None
96
+ ) -> list[AuditEvent]:
97
+ out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
98
+ if limit is not None:
99
+ return out[-int(limit) :]
100
+ return out
101
+
102
+
29
103
  async def append_audit_event(
30
104
  db: Any,
31
105
  *,
@@ -48,7 +122,9 @@ async def append_audit_event(
48
122
  prev_hash: Optional[str] = None
49
123
  if prev_event is not None:
50
124
  prev_hash = prev_event.hash
51
- elif select is not None and hasattr(db, "execute"): # attempt DB lookup for previous event
125
+ elif select is not None and hasattr(
126
+ db, "execute"
127
+ ): # attempt DB lookup for previous event
52
128
  try:
53
129
  stmt = (
54
130
  select(AuditLog)
@@ -56,7 +132,7 @@ async def append_audit_event(
56
132
  .order_by(AuditLog.id.desc())
57
133
  .limit(1)
58
134
  )
59
- result = await db.execute(stmt) # type: ignore[attr-defined]
135
+ result = await db.execute(stmt)
60
136
  prev = result.scalars().first()
61
137
  if prev:
62
138
  prev_hash = prev.hash
@@ -85,12 +161,12 @@ async def append_audit_event(
85
161
  )
86
162
  if hasattr(db, "add"):
87
163
  try:
88
- db.add(row) # type: ignore[attr-defined]
164
+ db.add(row)
89
165
  except Exception: # pragma: no cover - minimal shim safety
90
166
  pass
91
167
  if hasattr(db, "flush"):
92
168
  try:
93
- await db.flush() # type: ignore[attr-defined]
169
+ await db.flush()
94
170
  except Exception: # pragma: no cover
95
171
  pass
96
172
  return row
@@ -127,4 +203,10 @@ def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
127
203
  return ok, sorted(set(broken))
128
204
 
129
205
 
130
- __all__ = ["append_audit_event", "verify_audit_chain"]
206
+ __all__ = [
207
+ "append_audit_event",
208
+ "verify_audit_chain",
209
+ "AuditEvent",
210
+ "AuditLogStore",
211
+ "InMemoryAuditLogStore",
212
+ ]
@@ -5,7 +5,7 @@ from typing import Any, List, Optional, Sequence, Tuple
5
5
  try: # optional SQLAlchemy import for environments without SA
6
6
  from sqlalchemy import select
7
7
  except Exception: # pragma: no cover
8
- select = None # type: ignore
8
+ select = None # type: ignore[assignment]
9
9
 
10
10
  from .audit import append_audit_event, verify_audit_chain
11
11
  from .models import AuditLog
@@ -51,7 +51,7 @@ async def verify_chain_for_tenant(
51
51
  if tenant_id is not None:
52
52
  stmt = stmt.where(AuditLog.tenant_id == tenant_id)
53
53
  stmt = stmt.order_by(AuditLog.id.asc())
54
- result = await db.execute(stmt) # type: ignore[attr-defined]
54
+ result = await db.execute(stmt)
55
55
  events = list(result.scalars().all())
56
56
  except Exception: # pragma: no cover
57
57
  events = []
@@ -62,7 +62,8 @@ async def verify_chain_for_tenant(
62
62
  events = [
63
63
  e
64
64
  for e in pool
65
- if isinstance(e, AuditLog) and (tenant_id is None or e.tenant_id == tenant_id)
65
+ if isinstance(e, AuditLog)
66
+ and (tenant_id is None or e.tenant_id == tenant_id)
66
67
  ]
67
68
  except Exception: # pragma: no cover
68
69
  events = []
@@ -6,8 +6,21 @@ SECURE_DEFAULTS = {
6
6
  "X-Frame-Options": "DENY",
7
7
  "Referrer-Policy": "strict-origin-when-cross-origin",
8
8
  "X-XSS-Protection": "0",
9
- # CSP kept minimal; allow config override
10
- "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
9
+ # CSP with practical defaults - allows inline styles/scripts and data URIs for images
10
+ # Also allows cdn.jsdelivr.net for FastAPI docs (Swagger UI, ReDoc)
11
+ # Still secure: blocks arbitrary external scripts, prevents framing, restricts form actions
12
+ # Override via headers_overrides in add_security() for stricter or custom policies
13
+ "Content-Security-Policy": (
14
+ "default-src 'self'; "
15
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
16
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
17
+ "img-src 'self' data: https:; "
18
+ "connect-src 'self'; "
19
+ "font-src 'self' https://cdn.jsdelivr.net; "
20
+ "frame-ancestors 'none'; "
21
+ "base-uri 'self'; "
22
+ "form-action 'self'"
23
+ ),
11
24
  }
12
25
 
13
26
 
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ import logging
4
5
  import time
5
6
  from dataclasses import dataclass
6
7
  from typing import Dict, Optional
7
8
 
8
- import httpx
9
+ from svc_infra.http import new_httpx_client
10
+
11
+ logger = logging.getLogger(__name__)
9
12
 
10
13
 
11
14
  def sha1_hex(data: str) -> str:
@@ -39,7 +42,11 @@ class HIBPClient:
39
42
  self.timeout = timeout
40
43
  self.user_agent = user_agent
41
44
  self._cache: Dict[str, CacheEntry] = {}
42
- self._http = httpx.Client(timeout=self.timeout, headers={"User-Agent": self.user_agent})
45
+ # Use central factory for consistent defaults; retain explicit timeout override
46
+ self._http = new_httpx_client(
47
+ timeout_seconds=self.timeout,
48
+ headers={"User-Agent": self.user_agent},
49
+ )
43
50
 
44
51
  def _get_cached(self, prefix: str) -> Optional[str]:
45
52
  now = time.time()
@@ -49,7 +56,9 @@ class HIBPClient:
49
56
  return None
50
57
 
51
58
  def _set_cache(self, prefix: str, body: str) -> None:
52
- self._cache[prefix] = CacheEntry(body=body, expires_at=time.time() + self.ttl_seconds)
59
+ self._cache[prefix] = CacheEntry(
60
+ body=body, expires_at=time.time() + self.ttl_seconds
61
+ )
53
62
 
54
63
  def range_query(self, prefix: str) -> str:
55
64
  cached = self._get_cached(prefix)
@@ -67,8 +76,9 @@ class HIBPClient:
67
76
  prefix, suffix = full[:5], full[5:]
68
77
  try:
69
78
  body = self.range_query(prefix)
70
- except Exception:
79
+ except Exception as e:
71
80
  # Fail-open: if HIBP unavailable, do not block users.
81
+ logger.warning("HIBP password check failed (fail-open): %s", e)
72
82
  return False
73
83
 
74
84
  for line in body.splitlines():
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Iterable, List, Optional, Union
3
+ from typing import Any, Iterable, List, Optional, Union
4
4
 
5
- import jwt as pyjwt
5
+ import jwt
6
6
  from fastapi_users.authentication.strategy.jwt import JWTStrategy
7
+ from fastapi_users.jwt import decode_jwt
7
8
 
8
9
 
9
10
  class RotatingJWTStrategy(JWTStrategy):
@@ -21,33 +22,84 @@ class RotatingJWTStrategy(JWTStrategy):
21
22
  old_secrets: Optional[Iterable[str]] = None,
22
23
  token_audience: Optional[Union[str, List[str]]] = None,
23
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"]
24
33
  super().__init__(
25
- secret=secret, lifetime_seconds=lifetime_seconds, token_audience=token_audience
34
+ secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list
26
35
  )
27
36
  self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
37
+ self._lifetime_seconds = lifetime_seconds
28
38
 
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:]:
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
38
64
  try:
39
- data = pyjwt.decode(
40
- token,
41
- s,
42
- algorithms=["HS256"],
43
- audience=eff_aud,
65
+ return decode_jwt(
66
+ token, self.decode_key, aud_list, algorithms=[self.algorithm]
44
67
  )
45
- if data is not None:
46
- return data
47
- except Exception:
68
+ except jwt.PyJWTError:
48
69
  pass
49
- # If none of the secrets validated the token, raise a generic error
50
- raise ValueError("Invalid token for all configured secrets")
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
51
103
 
52
104
 
53
105
  __all__ = ["RotatingJWTStrategy"]
@@ -6,11 +6,12 @@ from datetime import datetime, timedelta, timezone
6
6
  from typing import Any, Optional, Sequence
7
7
 
8
8
  try:
9
- from sqlalchemy import select
9
+ from sqlalchemy import or_, select
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  except Exception: # pragma: no cover - optional import for type hints
12
- AsyncSession = Any # type: ignore[misc]
13
- select = None # type: ignore
12
+ AsyncSession = Any # type: ignore[misc,assignment]
13
+ select = None # type: ignore[assignment]
14
+ or_ = None # type: ignore[assignment]
14
15
 
15
16
  from svc_infra.security.models import FailedAuthAttempt
16
17
 
@@ -77,10 +78,15 @@ async def get_lockout_status(
77
78
  FailedAuthAttempt.ts >= window_start,
78
79
  FailedAuthAttempt.success == False, # noqa: E712
79
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 = []
80
84
  if user_id:
81
- q = q.where(FailedAuthAttempt.user_id == user_id)
85
+ filters.append(FailedAuthAttempt.user_id == user_id)
82
86
  if ip_hash:
83
- q = q.where(FailedAuthAttempt.ip_hash == ip_hash)
87
+ filters.append(FailedAuthAttempt.ip_hash == ip_hash)
88
+ if filters:
89
+ q = q.where(or_(*filters))
84
90
 
85
91
  rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
86
92
  fail_count = len(rows)