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,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
+ ]
@@ -0,0 +1,212 @@
1
+ """Audit log append & chain verification utilities.
2
+
3
+ Provides helpers to append a new AuditLog entry maintaining a hash-chain
4
+ integrity model and to verify an existing sequence for tampering.
5
+
6
+ Design notes:
7
+ - Each event stores prev_hash (previous event's hash or 64 zeros for genesis).
8
+ - Hash = sha256(prev_hash + canonical_json_payload).
9
+ - Verification recomputes expected hash for each event and compares.
10
+ - If a middle event is altered, that event and all subsequent events will
11
+ fail verification (because their prev_hash links break transitively).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from typing import Any, List, Optional, Protocol, Sequence, Tuple
19
+
20
+ try: # SQLAlchemy may not be present in minimal test context
21
+ from sqlalchemy import select
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+ except Exception: # pragma: no cover
24
+ AsyncSession = Any # type: ignore[misc,assignment]
25
+ select = None # type: ignore[assignment]
26
+
27
+ from svc_infra.security.models import AuditLog, compute_audit_hash
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
+
103
+ async def append_audit_event(
104
+ db: Any,
105
+ *,
106
+ actor_id=None,
107
+ tenant_id: Optional[str] = None,
108
+ event_type: str,
109
+ resource_ref: Optional[str] = None,
110
+ metadata: dict | None = None,
111
+ ts: Optional[datetime] = None,
112
+ prev_event: Optional[AuditLog] = None,
113
+ ) -> AuditLog:
114
+ """Append an audit event returning the persisted row.
115
+
116
+ If prev_event is not supplied, it attempts to fetch the latest event for
117
+ the tenant (or global chain when tenant_id is None).
118
+ """
119
+ metadata = metadata or {}
120
+ ts = ts or datetime.now(timezone.utc)
121
+
122
+ prev_hash: Optional[str] = None
123
+ if prev_event is not None:
124
+ prev_hash = prev_event.hash
125
+ elif select is not None and hasattr(
126
+ db, "execute"
127
+ ): # attempt DB lookup for previous event
128
+ try:
129
+ stmt = (
130
+ select(AuditLog)
131
+ .where(AuditLog.tenant_id == tenant_id)
132
+ .order_by(AuditLog.id.desc())
133
+ .limit(1)
134
+ )
135
+ result = await db.execute(stmt)
136
+ prev = result.scalars().first()
137
+ if prev:
138
+ prev_hash = prev.hash
139
+ except Exception: # pragma: no cover - defensive for minimal fakes
140
+ pass
141
+
142
+ new_hash = compute_audit_hash(
143
+ prev_hash,
144
+ ts=ts,
145
+ actor_id=actor_id,
146
+ tenant_id=tenant_id,
147
+ event_type=event_type,
148
+ resource_ref=resource_ref,
149
+ metadata=metadata,
150
+ )
151
+
152
+ row = AuditLog(
153
+ ts=ts,
154
+ actor_id=actor_id,
155
+ tenant_id=tenant_id,
156
+ event_type=event_type,
157
+ resource_ref=resource_ref,
158
+ event_metadata=metadata,
159
+ prev_hash=prev_hash or "0" * 64,
160
+ hash=new_hash,
161
+ )
162
+ if hasattr(db, "add"):
163
+ try:
164
+ db.add(row)
165
+ except Exception: # pragma: no cover - minimal shim safety
166
+ pass
167
+ if hasattr(db, "flush"):
168
+ try:
169
+ await db.flush()
170
+ except Exception: # pragma: no cover
171
+ pass
172
+ return row
173
+
174
+
175
+ def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
176
+ """Verify a sequence of audit events.
177
+
178
+ Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
179
+ expected hash (based on previous event), its index is recorded. All events are
180
+ checked so callers can analyze extent of tampering.
181
+ """
182
+ broken: List[int] = []
183
+ prev_hash = "0" * 64
184
+ for idx, ev in enumerate(events):
185
+ expected = compute_audit_hash(
186
+ prev_hash if ev.prev_hash == prev_hash else ev.prev_hash,
187
+ ts=ev.ts,
188
+ actor_id=ev.actor_id,
189
+ tenant_id=ev.tenant_id,
190
+ event_type=ev.event_type,
191
+ resource_ref=ev.resource_ref,
192
+ metadata=ev.event_metadata,
193
+ )
194
+ # prev_hash stored should equal previous event hash (or zeros for genesis)
195
+ if (idx == 0 and ev.prev_hash != "0" * 64) or (
196
+ idx > 0 and ev.prev_hash != events[idx - 1].hash
197
+ ):
198
+ broken.append(idx)
199
+ if ev.hash != expected:
200
+ broken.append(idx)
201
+ prev_hash = ev.hash
202
+ ok = not broken
203
+ return ok, sorted(set(broken))
204
+
205
+
206
+ __all__ = [
207
+ "append_audit_event",
208
+ "verify_audit_chain",
209
+ "AuditEvent",
210
+ "AuditLogStore",
211
+ "InMemoryAuditLogStore",
212
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional, Sequence, Tuple
4
+
5
+ try: # optional SQLAlchemy import for environments without SA
6
+ from sqlalchemy import select
7
+ except Exception: # pragma: no cover
8
+ select = None # type: ignore[assignment]
9
+
10
+ from .audit import append_audit_event, verify_audit_chain
11
+ from .models import AuditLog
12
+
13
+
14
+ async def append_event(
15
+ db: Any,
16
+ *,
17
+ actor_id=None,
18
+ tenant_id: Optional[str] = None,
19
+ event_type: str,
20
+ resource_ref: Optional[str] = None,
21
+ metadata: dict | None = None,
22
+ prev_event: Optional[AuditLog] = None,
23
+ ) -> AuditLog:
24
+ """Append an AuditLog event using the shared append utility.
25
+
26
+ If prev_event is not provided, attempts to look up the last event for the tenant.
27
+ """
28
+ return await append_audit_event(
29
+ db,
30
+ actor_id=actor_id,
31
+ tenant_id=tenant_id,
32
+ event_type=event_type,
33
+ resource_ref=resource_ref,
34
+ metadata=metadata,
35
+ prev_event=prev_event,
36
+ )
37
+
38
+
39
+ async def verify_chain_for_tenant(
40
+ db: Any, *, tenant_id: Optional[str] = None
41
+ ) -> Tuple[bool, List[int]]:
42
+ """Fetch all AuditLog events for a tenant and verify hash-chain integrity.
43
+
44
+ Falls back to inspecting an in-memory 'added' list when SQLAlchemy is not available,
45
+ to simplify unit tests with fake DBs.
46
+ """
47
+ events: Sequence[AuditLog] = []
48
+ if select is not None and hasattr(db, "execute"):
49
+ try:
50
+ stmt = select(AuditLog)
51
+ if tenant_id is not None:
52
+ stmt = stmt.where(AuditLog.tenant_id == tenant_id)
53
+ stmt = stmt.order_by(AuditLog.id.asc())
54
+ result = await db.execute(stmt)
55
+ events = list(result.scalars().all())
56
+ except Exception: # pragma: no cover
57
+ events = []
58
+ elif hasattr(db, "added"):
59
+ try:
60
+ pool = getattr(db, "added")
61
+ # Preserve insertion order for in-memory fake DBs where primary keys may be None
62
+ events = [
63
+ e
64
+ for e in pool
65
+ if isinstance(e, AuditLog)
66
+ and (tenant_id is None or e.tenant_id == tenant_id)
67
+ ]
68
+ except Exception: # pragma: no cover
69
+ events = []
70
+
71
+ return verify_audit_chain(list(events))
72
+
73
+
74
+ __all__ = ["append_event", "verify_chain_for_tenant"]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ SECURE_DEFAULTS = {
4
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
5
+ "X-Content-Type-Options": "nosniff",
6
+ "X-Frame-Options": "DENY",
7
+ "Referrer-Policy": "strict-origin-when-cross-origin",
8
+ "X-XSS-Protection": "0",
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
+ ),
24
+ }
25
+
26
+
27
+ class SecurityHeadersMiddleware:
28
+ def __init__(self, app, overrides: dict[str, str] | None = None):
29
+ self.app = app
30
+ self.overrides = overrides or {}
31
+
32
+ async def __call__(self, scope, receive, send):
33
+ if scope.get("type") != "http":
34
+ await self.app(scope, receive, send)
35
+ return
36
+
37
+ async def _send(message):
38
+ if message.get("type") == "http.response.start":
39
+ headers = message.setdefault("headers", [])
40
+ existing = {k.decode(): v.decode() for k, v in headers}
41
+ merged = {**SECURE_DEFAULTS, **existing, **self.overrides}
42
+ # rebuild headers list
43
+ new_headers = []
44
+ for k, v in merged.items():
45
+ new_headers.append((k.encode(), v.encode()))
46
+ message["headers"] = new_headers
47
+ await send(message)
48
+
49
+ await self.app(scope, receive, _send)
50
+
51
+
52
+ __all__ = ["SecurityHeadersMiddleware", "SECURE_DEFAULTS"]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Dict, Optional
8
+
9
+ from svc_infra.http import new_httpx_client
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def sha1_hex(data: str) -> str:
15
+ return hashlib.sha1(data.encode("utf-8")).hexdigest().upper()
16
+
17
+
18
+ @dataclass
19
+ class CacheEntry:
20
+ body: str
21
+ expires_at: float
22
+
23
+
24
+ class HIBPClient:
25
+ """Minimal HaveIBeenPwned range API client with simple in-memory cache.
26
+
27
+ - Uses k-anonymity range query: send first 5 chars of SHA1 hash, receive suffix list.
28
+ - Caches prefix responses for TTL to avoid repeated network calls.
29
+ - Synchronous implementation to allow use in sync validators.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ base_url: str = "https://api.pwnedpasswords.com",
36
+ ttl_seconds: int = 3600,
37
+ timeout: float = 5.0,
38
+ user_agent: str = "svc-infra/hibp",
39
+ ) -> None:
40
+ self.base_url = base_url.rstrip("/")
41
+ self.ttl_seconds = ttl_seconds
42
+ self.timeout = timeout
43
+ self.user_agent = user_agent
44
+ self._cache: Dict[str, CacheEntry] = {}
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
+ )
50
+
51
+ def _get_cached(self, prefix: str) -> Optional[str]:
52
+ now = time.time()
53
+ ent = self._cache.get(prefix)
54
+ if ent and ent.expires_at > now:
55
+ return ent.body
56
+ return None
57
+
58
+ def _set_cache(self, prefix: str, body: str) -> None:
59
+ self._cache[prefix] = CacheEntry(
60
+ body=body, expires_at=time.time() + self.ttl_seconds
61
+ )
62
+
63
+ def range_query(self, prefix: str) -> str:
64
+ cached = self._get_cached(prefix)
65
+ if cached is not None:
66
+ return cached
67
+ url = f"{self.base_url}/range/{prefix}"
68
+ resp = self._http.get(url)
69
+ resp.raise_for_status()
70
+ body = resp.text
71
+ self._set_cache(prefix, body)
72
+ return body
73
+
74
+ def is_breached(self, password: str) -> bool:
75
+ full = sha1_hex(password)
76
+ prefix, suffix = full[:5], full[5:]
77
+ try:
78
+ body = self.range_query(prefix)
79
+ except Exception as e:
80
+ # Fail-open: if HIBP unavailable, do not block users.
81
+ logger.warning("HIBP password check failed (fail-open): %s", e)
82
+ return False
83
+
84
+ for line in body.splitlines():
85
+ # Lines formatted as "SUFFIX:COUNT"
86
+ if not line:
87
+ continue
88
+ parts = line.split(":")
89
+ if len(parts) != 2:
90
+ continue
91
+ sfx = parts[0].strip().upper()
92
+ if sfx == suffix:
93
+ # Count > 0 implies breached
94
+ try:
95
+ return int(parts[1].strip()) > 0
96
+ except ValueError:
97
+ return True
98
+ return False
99
+
100
+
101
+ __all__ = ["HIBPClient", "sha1_hex"]