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
@@ -0,0 +1,167 @@
1
+ """Security module providing authentication, authorization, and protection utilities.
2
+
3
+ This module provides comprehensive security primitives:
4
+
5
+ - **Middleware**: Security headers (CSP, HSTS, etc.) and CORS configuration
6
+ - **Lockout**: Account lockout with exponential backoff for brute force protection
7
+ - **Passwords**: Password policy validation with HIBP breach checking
8
+ - **Sessions**: Session and refresh token management
9
+ - **Audit**: Hash-chain audit logging for tamper detection
10
+ - **JWT Rotation**: Seamless JWT key rotation support
11
+ - **Permissions**: RBAC and ABAC authorization helpers
12
+ - **Signed Cookies**: Cryptographically signed cookies with expiry
13
+
14
+ Example:
15
+ from fastapi import FastAPI
16
+ from svc_infra.security import add_security
17
+
18
+ app = FastAPI()
19
+
20
+ # Add security headers and CORS
21
+ add_security(app, cors_origins=["https://myapp.com"])
22
+
23
+ # Use password validation
24
+ from svc_infra.security import validate_password, PasswordPolicy
25
+
26
+ policy = PasswordPolicy(min_length=12, require_symbol=True)
27
+ validate_password("MyStr0ng!Pass", policy)
28
+
29
+ # Use lockout protection
30
+ from svc_infra.security import get_lockout_status, LockoutConfig
31
+
32
+ status = await get_lockout_status(session, user_id=user.id, ip_hash=ip_hash)
33
+ if status.locked:
34
+ raise HTTPException(429, "Too many attempts")
35
+
36
+ Environment Variables:
37
+ CORS_ALLOW_ORIGINS: Comma-separated list of allowed origins
38
+ CORS_ALLOW_CREDENTIALS: Allow credentials in CORS (true/false)
39
+ CORS_ALLOW_METHODS: Comma-separated list of allowed methods
40
+ CORS_ALLOW_HEADERS: Comma-separated list of allowed headers
41
+
42
+ See Also:
43
+ - docs/security.md for detailed documentation
44
+ - svc_infra.api.fastapi.auth for authentication routes
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ # FastAPI integration
50
+ from .add import add_security
51
+
52
+ # Audit logging
53
+ from .audit import (
54
+ AuditEvent,
55
+ AuditLogStore,
56
+ InMemoryAuditLogStore,
57
+ append_audit_event,
58
+ verify_audit_chain,
59
+ )
60
+ from .audit_service import append_event, verify_chain_for_tenant
61
+
62
+ # Security headers middleware
63
+ from .headers import SECURE_DEFAULTS, SecurityHeadersMiddleware
64
+
65
+ # HIBP breach checking
66
+ from .hibp import HIBPClient
67
+
68
+ # JWT rotation
69
+ from .jwt_rotation import RotatingJWTStrategy
70
+
71
+ # Account lockout
72
+ from .lockout import (
73
+ LockoutConfig,
74
+ LockoutStatus,
75
+ compute_lockout,
76
+ get_lockout_status,
77
+ record_attempt,
78
+ )
79
+
80
+ # Models (for type hints and direct use)
81
+ from .models import (
82
+ AuditLog,
83
+ AuthSession,
84
+ FailedAuthAttempt,
85
+ RefreshToken,
86
+ RefreshTokenRevocation,
87
+ compute_audit_hash,
88
+ )
89
+
90
+ # Password validation
91
+ from .passwords import (
92
+ PasswordPolicy,
93
+ PasswordValidationError,
94
+ configure_breached_checker,
95
+ validate_password,
96
+ )
97
+
98
+ # RBAC/ABAC permissions
99
+ from .permissions import (
100
+ PERMISSION_REGISTRY,
101
+ RequireABAC,
102
+ RequireAnyPermission,
103
+ RequirePermission,
104
+ enforce_abac,
105
+ extend_role,
106
+ get_permissions_for_roles,
107
+ has_permission,
108
+ owns_resource,
109
+ principal_permissions,
110
+ register_role,
111
+ )
112
+
113
+ # Signed cookies
114
+ from .signed_cookies import sign_cookie, verify_cookie
115
+
116
+ __all__ = [
117
+ # FastAPI integration
118
+ "add_security",
119
+ # Headers middleware
120
+ "SecurityHeadersMiddleware",
121
+ "SECURE_DEFAULTS",
122
+ # Lockout
123
+ "LockoutConfig",
124
+ "LockoutStatus",
125
+ "compute_lockout",
126
+ "record_attempt",
127
+ "get_lockout_status",
128
+ # Password validation
129
+ "PasswordPolicy",
130
+ "PasswordValidationError",
131
+ "validate_password",
132
+ "configure_breached_checker",
133
+ # HIBP
134
+ "HIBPClient",
135
+ # Signed cookies
136
+ "sign_cookie",
137
+ "verify_cookie",
138
+ # Audit logging
139
+ "AuditLogStore",
140
+ "AuditEvent",
141
+ "append_audit_event",
142
+ "verify_audit_chain",
143
+ "append_event",
144
+ "verify_chain_for_tenant",
145
+ "InMemoryAuditLogStore",
146
+ # JWT rotation
147
+ "RotatingJWTStrategy",
148
+ # Permissions (RBAC/ABAC)
149
+ "PERMISSION_REGISTRY",
150
+ "register_role",
151
+ "extend_role",
152
+ "get_permissions_for_roles",
153
+ "principal_permissions",
154
+ "has_permission",
155
+ "RequirePermission",
156
+ "RequireAnyPermission",
157
+ "RequireABAC",
158
+ "enforce_abac",
159
+ "owns_resource",
160
+ # Models
161
+ "AuthSession",
162
+ "RefreshToken",
163
+ "RefreshTokenRevocation",
164
+ "FailedAuthAttempt",
165
+ "AuditLog",
166
+ "compute_audit_hash",
167
+ ]
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterable, Mapping
5
+ from typing import 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 if https_only is not None else (https_env if https_env is not None else False)
142
+ )
143
+ same_site_env = env.get("SESSION_COOKIE_SAMESITE")
144
+ same_site_raw = same_site_env.strip() if same_site_env else same_site
145
+ # Validate and narrow to expected Literal type
146
+ same_site_value: Literal["lax", "strict", "none"] = (
147
+ "lax"
148
+ if same_site_raw not in ("lax", "strict", "none")
149
+ else cast("Literal['lax', 'strict', 'none']", same_site_raw)
150
+ )
151
+
152
+ max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
153
+ try:
154
+ max_age_value = int(max_age_env) if max_age_env is not None else max_age
155
+ except ValueError:
156
+ max_age_value = max_age
157
+
158
+ session_cookie_env = env.get("SESSION_COOKIE_NAME")
159
+ session_cookie_value = session_cookie_env.strip() if session_cookie_env else session_cookie
160
+
161
+ app.add_middleware(
162
+ SessionMiddleware,
163
+ secret_key=secret,
164
+ session_cookie=session_cookie_value,
165
+ max_age=max_age_value,
166
+ same_site=same_site_value,
167
+ https_only=effective_https_only,
168
+ )
169
+
170
+
171
+ def add_security(
172
+ app: FastAPI,
173
+ *,
174
+ cors_origins: Iterable[str] | str | None = None,
175
+ headers_overrides: dict[str, str] | None = None,
176
+ allow_credentials: bool = True,
177
+ env: Mapping[str, str] = os.environ,
178
+ enable_hsts_preload: bool | None = None,
179
+ install_session_middleware: bool = False,
180
+ session_secret_key: str | None = None,
181
+ session_cookie_name: str = "svc_session",
182
+ session_cookie_max_age_seconds: int = 4 * 3600,
183
+ session_cookie_samesite: str = "lax",
184
+ session_cookie_https_only: bool | None = None,
185
+ ) -> None:
186
+ """Install security middlewares with svc-infra defaults."""
187
+
188
+ _configure_security_headers(
189
+ app,
190
+ overrides=headers_overrides,
191
+ enable_hsts_preload=enable_hsts_preload,
192
+ )
193
+ _configure_cors(
194
+ app,
195
+ cors_origins=cors_origins,
196
+ allow_credentials=allow_credentials,
197
+ env=env,
198
+ )
199
+ _configure_session_middleware(
200
+ app,
201
+ env=env,
202
+ install=install_session_middleware,
203
+ secret_key=session_secret_key,
204
+ session_cookie=session_cookie_name,
205
+ max_age=session_cookie_max_age_seconds,
206
+ same_site=session_cookie_samesite,
207
+ https_only=session_cookie_https_only,
208
+ )
209
+
210
+
211
+ __all__ = [
212
+ "add_security",
213
+ ]
@@ -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,29 +11,104 @@ Design notes:
13
11
  fail verification (because their prev_hash links break transitively).
14
12
  """
15
13
 
16
- from datetime import datetime, timezone
17
- from typing import Any, List, Optional, Sequence, Tuple
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+ from typing import Any, Protocol
18
20
 
19
21
  try: # SQLAlchemy may not be present in minimal test context
20
22
  from sqlalchemy import select
21
23
  from sqlalchemy.ext.asyncio import AsyncSession
22
24
  except Exception: # pragma: no cover
23
- AsyncSession = Any # type: ignore
24
- select = None # type: ignore
25
+ AsyncSession = Any # type: ignore[misc,assignment]
26
+ select = None # type: ignore[assignment]
25
27
 
26
28
  from svc_infra.security.models import AuditLog, compute_audit_hash
27
29
 
28
30
 
31
+ @dataclass(frozen=True)
32
+ class AuditEvent:
33
+ ts: datetime
34
+ actor_id: Any
35
+ tenant_id: str | None
36
+ event_type: str
37
+ resource_ref: str | None
38
+ metadata: dict
39
+
40
+
41
+ class AuditLogStore(Protocol):
42
+ """Minimal interface for storing audit events.
43
+
44
+ This is intentionally small so applications can swap in a SQL-backed store.
45
+ """
46
+
47
+ def append(
48
+ self,
49
+ *,
50
+ actor_id: Any = None,
51
+ tenant_id: str | None = None,
52
+ event_type: str,
53
+ resource_ref: str | None = None,
54
+ metadata: dict | None = None,
55
+ ts: datetime | None = None,
56
+ ) -> AuditEvent:
57
+ pass
58
+
59
+ def list(
60
+ self,
61
+ *,
62
+ tenant_id: str | None = None,
63
+ limit: int | None = None,
64
+ ) -> list[AuditEvent]:
65
+ pass
66
+
67
+
68
+ class InMemoryAuditLogStore:
69
+ """In-memory audit event store (useful for tests and prototypes)."""
70
+
71
+ def __init__(self):
72
+ self._events: list[AuditEvent] = []
73
+
74
+ def append(
75
+ self,
76
+ *,
77
+ actor_id: Any = None,
78
+ tenant_id: str | None = None,
79
+ event_type: str,
80
+ resource_ref: str | None = None,
81
+ metadata: dict | None = None,
82
+ ts: datetime | None = None,
83
+ ) -> AuditEvent:
84
+ event = AuditEvent(
85
+ ts=ts or datetime.now(UTC),
86
+ actor_id=actor_id,
87
+ tenant_id=tenant_id,
88
+ event_type=event_type,
89
+ resource_ref=resource_ref,
90
+ metadata=dict(metadata or {}),
91
+ )
92
+ self._events.append(event)
93
+ return event
94
+
95
+ def list(self, *, tenant_id: str | None = None, limit: int | None = None) -> list[AuditEvent]:
96
+ out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
97
+ if limit is not None:
98
+ return out[-int(limit) :]
99
+ return out
100
+
101
+
29
102
  async def append_audit_event(
30
103
  db: Any,
31
104
  *,
32
105
  actor_id=None,
33
- tenant_id: Optional[str] = None,
106
+ tenant_id: str | None = None,
34
107
  event_type: str,
35
- resource_ref: Optional[str] = None,
108
+ resource_ref: str | None = None,
36
109
  metadata: dict | None = None,
37
- ts: Optional[datetime] = None,
38
- prev_event: Optional[AuditLog] = None,
110
+ ts: datetime | None = None,
111
+ prev_event: AuditLog | None = None,
39
112
  ) -> AuditLog:
40
113
  """Append an audit event returning the persisted row.
41
114
 
@@ -43,9 +116,9 @@ async def append_audit_event(
43
116
  the tenant (or global chain when tenant_id is None).
44
117
  """
45
118
  metadata = metadata or {}
46
- ts = ts or datetime.now(timezone.utc)
119
+ ts = ts or datetime.now(UTC)
47
120
 
48
- prev_hash: Optional[str] = None
121
+ prev_hash: str | None = None
49
122
  if prev_event is not None:
50
123
  prev_hash = prev_event.hash
51
124
  elif select is not None and hasattr(db, "execute"): # attempt DB lookup for previous event
@@ -56,7 +129,7 @@ async def append_audit_event(
56
129
  .order_by(AuditLog.id.desc())
57
130
  .limit(1)
58
131
  )
59
- result = await db.execute(stmt) # type: ignore[attr-defined]
132
+ result = await db.execute(stmt)
60
133
  prev = result.scalars().first()
61
134
  if prev:
62
135
  prev_hash = prev.hash
@@ -85,25 +158,25 @@ async def append_audit_event(
85
158
  )
86
159
  if hasattr(db, "add"):
87
160
  try:
88
- db.add(row) # type: ignore[attr-defined]
161
+ db.add(row)
89
162
  except Exception: # pragma: no cover - minimal shim safety
90
163
  pass
91
164
  if hasattr(db, "flush"):
92
165
  try:
93
- await db.flush() # type: ignore[attr-defined]
166
+ await db.flush()
94
167
  except Exception: # pragma: no cover
95
168
  pass
96
169
  return row
97
170
 
98
171
 
99
- def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
172
+ def verify_audit_chain(events: Sequence[AuditLog]) -> tuple[bool, list[int]]:
100
173
  """Verify a sequence of audit events.
101
174
 
102
175
  Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
103
176
  expected hash (based on previous event), its index is recorded. All events are
104
177
  checked so callers can analyze extent of tampering.
105
178
  """
106
- broken: List[int] = []
179
+ broken: list[int] = []
107
180
  prev_hash = "0" * 64
108
181
  for idx, ev in enumerate(events):
109
182
  expected = compute_audit_hash(
@@ -127,4 +200,10 @@ def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
127
200
  return ok, sorted(set(broken))
128
201
 
129
202
 
130
- __all__ = ["append_audit_event", "verify_audit_chain"]
203
+ __all__ = [
204
+ "append_audit_event",
205
+ "verify_audit_chain",
206
+ "AuditEvent",
207
+ "AuditLogStore",
208
+ "InMemoryAuditLogStore",
209
+ ]
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, List, Optional, Sequence, Tuple
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
6
  try: # optional SQLAlchemy import for environments without SA
6
7
  from sqlalchemy import select
7
8
  except Exception: # pragma: no cover
8
- select = None # type: ignore
9
+ select = None # type: ignore[assignment]
9
10
 
10
11
  from .audit import append_audit_event, verify_audit_chain
11
12
  from .models import AuditLog
@@ -15,11 +16,11 @@ async def append_event(
15
16
  db: Any,
16
17
  *,
17
18
  actor_id=None,
18
- tenant_id: Optional[str] = None,
19
+ tenant_id: str | None = None,
19
20
  event_type: str,
20
- resource_ref: Optional[str] = None,
21
+ resource_ref: str | None = None,
21
22
  metadata: dict | None = None,
22
- prev_event: Optional[AuditLog] = None,
23
+ prev_event: AuditLog | None = None,
23
24
  ) -> AuditLog:
24
25
  """Append an AuditLog event using the shared append utility.
25
26
 
@@ -37,8 +38,8 @@ async def append_event(
37
38
 
38
39
 
39
40
  async def verify_chain_for_tenant(
40
- db: Any, *, tenant_id: Optional[str] = None
41
- ) -> Tuple[bool, List[int]]:
41
+ db: Any, *, tenant_id: str | None = None
42
+ ) -> tuple[bool, list[int]]:
42
43
  """Fetch all AuditLog events for a tenant and verify hash-chain integrity.
43
44
 
44
45
  Falls back to inspecting an in-memory 'added' list when SQLAlchemy is not available,
@@ -51,13 +52,13 @@ async def verify_chain_for_tenant(
51
52
  if tenant_id is not None:
52
53
  stmt = stmt.where(AuditLog.tenant_id == tenant_id)
53
54
  stmt = stmt.order_by(AuditLog.id.asc())
54
- result = await db.execute(stmt) # type: ignore[attr-defined]
55
+ result = await db.execute(stmt)
55
56
  events = list(result.scalars().all())
56
57
  except Exception: # pragma: no cover
57
58
  events = []
58
59
  elif hasattr(db, "added"):
59
60
  try:
60
- pool = getattr(db, "added")
61
+ pool = db.added
61
62
  # Preserve insertion order for in-memory fake DBs where primary keys may be None
62
63
  events = [
63
64
  e
@@ -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,13 @@
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
- from typing import Dict, Optional
7
7
 
8
- import httpx
8
+ from svc_infra.http import new_httpx_client
9
+
10
+ logger = logging.getLogger(__name__)
9
11
 
10
12
 
11
13
  def sha1_hex(data: str) -> str:
@@ -38,10 +40,14 @@ class HIBPClient:
38
40
  self.ttl_seconds = ttl_seconds
39
41
  self.timeout = timeout
40
42
  self.user_agent = user_agent
41
- self._cache: Dict[str, CacheEntry] = {}
42
- self._http = httpx.Client(timeout=self.timeout, headers={"User-Agent": self.user_agent})
43
-
44
- def _get_cached(self, prefix: str) -> Optional[str]:
43
+ self._cache: dict[str, CacheEntry] = {}
44
+ # Use central factory for consistent defaults; retain explicit timeout override
45
+ self._http = new_httpx_client(
46
+ timeout_seconds=self.timeout,
47
+ headers={"User-Agent": self.user_agent},
48
+ )
49
+
50
+ def _get_cached(self, prefix: str) -> str | None:
45
51
  now = time.time()
46
52
  ent = self._cache.get(prefix)
47
53
  if ent and ent.expires_at > now:
@@ -67,8 +73,9 @@ class HIBPClient:
67
73
  prefix, suffix = full[:5], full[5:]
68
74
  try:
69
75
  body = self.range_query(prefix)
70
- except Exception:
76
+ except Exception as e:
71
77
  # Fail-open: if HIBP unavailable, do not block users.
78
+ logger.warning("HIBP password check failed (fail-open): %s", e)
72
79
  return False
73
80
 
74
81
  for line in body.splitlines():