svc-infra 0.1.706__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 (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.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
+ ]
svc_infra/security/add.py CHANGED
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from collections.abc import Mapping
5
- from typing import Iterable, Literal, cast
4
+ from collections.abc import Iterable, Mapping
5
+ from typing import Literal, cast
6
6
 
7
7
  from fastapi import FastAPI
8
8
  from fastapi.middleware.cors import CORSMiddleware
@@ -138,9 +138,7 @@ def _configure_session_middleware(
138
138
  )
139
139
  https_env = _parse_bool(env.get("SESSION_COOKIE_SECURE"))
140
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)
141
+ https_only if https_only is not None else (https_env if https_env is not None else False)
144
142
  )
145
143
  same_site_env = env.get("SESSION_COOKIE_SAMESITE")
146
144
  same_site_raw = same_site_env.strip() if same_site_env else same_site
@@ -148,7 +146,7 @@ def _configure_session_middleware(
148
146
  same_site_value: Literal["lax", "strict", "none"] = (
149
147
  "lax"
150
148
  if same_site_raw not in ("lax", "strict", "none")
151
- else cast(Literal["lax", "strict", "none"], same_site_raw)
149
+ else cast("Literal['lax', 'strict', 'none']", same_site_raw)
152
150
  )
153
151
 
154
152
  max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
@@ -158,9 +156,7 @@ def _configure_session_middleware(
158
156
  max_age_value = max_age
159
157
 
160
158
  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
- )
159
+ session_cookie_value = session_cookie_env.strip() if session_cookie_env else session_cookie
164
160
 
165
161
  app.add_middleware(
166
162
  SessionMiddleware,
@@ -13,9 +13,10 @@ Design notes:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ from collections.abc import Sequence
16
17
  from dataclasses import dataclass
17
- from datetime import datetime, timezone
18
- from typing import Any, List, Optional, Protocol, Sequence, Tuple
18
+ from datetime import UTC, datetime
19
+ from typing import Any, Protocol
19
20
 
20
21
  try: # SQLAlchemy may not be present in minimal test context
21
22
  from sqlalchemy import select
@@ -81,7 +82,7 @@ class InMemoryAuditLogStore:
81
82
  ts: datetime | None = None,
82
83
  ) -> AuditEvent:
83
84
  event = AuditEvent(
84
- ts=ts or datetime.now(timezone.utc),
85
+ ts=ts or datetime.now(UTC),
85
86
  actor_id=actor_id,
86
87
  tenant_id=tenant_id,
87
88
  event_type=event_type,
@@ -91,9 +92,7 @@ class InMemoryAuditLogStore:
91
92
  self._events.append(event)
92
93
  return event
93
94
 
94
- def list(
95
- self, *, tenant_id: str | None = None, limit: int | None = None
96
- ) -> list[AuditEvent]:
95
+ def list(self, *, tenant_id: str | None = None, limit: int | None = None) -> list[AuditEvent]:
97
96
  out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
98
97
  if limit is not None:
99
98
  return out[-int(limit) :]
@@ -104,12 +103,12 @@ async def append_audit_event(
104
103
  db: Any,
105
104
  *,
106
105
  actor_id=None,
107
- tenant_id: Optional[str] = None,
106
+ tenant_id: str | None = None,
108
107
  event_type: str,
109
- resource_ref: Optional[str] = None,
108
+ resource_ref: str | None = None,
110
109
  metadata: dict | None = None,
111
- ts: Optional[datetime] = None,
112
- prev_event: Optional[AuditLog] = None,
110
+ ts: datetime | None = None,
111
+ prev_event: AuditLog | None = None,
113
112
  ) -> AuditLog:
114
113
  """Append an audit event returning the persisted row.
115
114
 
@@ -117,14 +116,12 @@ async def append_audit_event(
117
116
  the tenant (or global chain when tenant_id is None).
118
117
  """
119
118
  metadata = metadata or {}
120
- ts = ts or datetime.now(timezone.utc)
119
+ ts = ts or datetime.now(UTC)
121
120
 
122
- prev_hash: Optional[str] = None
121
+ prev_hash: str | None = None
123
122
  if prev_event is not None:
124
123
  prev_hash = prev_event.hash
125
- elif select is not None and hasattr(
126
- db, "execute"
127
- ): # attempt DB lookup for previous event
124
+ elif select is not None and hasattr(db, "execute"): # attempt DB lookup for previous event
128
125
  try:
129
126
  stmt = (
130
127
  select(AuditLog)
@@ -172,14 +169,14 @@ async def append_audit_event(
172
169
  return row
173
170
 
174
171
 
175
- def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
172
+ def verify_audit_chain(events: Sequence[AuditLog]) -> tuple[bool, list[int]]:
176
173
  """Verify a sequence of audit events.
177
174
 
178
175
  Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
179
176
  expected hash (based on previous event), its index is recorded. All events are
180
177
  checked so callers can analyze extent of tampering.
181
178
  """
182
- broken: List[int] = []
179
+ broken: list[int] = []
183
180
  prev_hash = "0" * 64
184
181
  for idx, ev in enumerate(events):
185
182
  expected = compute_audit_hash(
@@ -1,6 +1,7 @@
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
@@ -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,
@@ -57,13 +58,12 @@ async def verify_chain_for_tenant(
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
64
65
  for e in pool
65
- if isinstance(e, AuditLog)
66
- and (tenant_id is None or e.tenant_id == tenant_id)
66
+ if isinstance(e, AuditLog) and (tenant_id is None or e.tenant_id == tenant_id)
67
67
  ]
68
68
  except Exception: # pragma: no cover
69
69
  events = []
@@ -4,7 +4,6 @@ import hashlib
4
4
  import logging
5
5
  import time
6
6
  from dataclasses import dataclass
7
- from typing import Dict, Optional
8
7
 
9
8
  from svc_infra.http import new_httpx_client
10
9
 
@@ -41,14 +40,14 @@ class HIBPClient:
41
40
  self.ttl_seconds = ttl_seconds
42
41
  self.timeout = timeout
43
42
  self.user_agent = user_agent
44
- self._cache: Dict[str, CacheEntry] = {}
43
+ self._cache: dict[str, CacheEntry] = {}
45
44
  # Use central factory for consistent defaults; retain explicit timeout override
46
45
  self._http = new_httpx_client(
47
46
  timeout_seconds=self.timeout,
48
47
  headers={"User-Agent": self.user_agent},
49
48
  )
50
49
 
51
- def _get_cached(self, prefix: str) -> Optional[str]:
50
+ def _get_cached(self, prefix: str) -> str | None:
52
51
  now = time.time()
53
52
  ent = self._cache.get(prefix)
54
53
  if ent and ent.expires_at > now:
@@ -56,9 +55,7 @@ class HIBPClient:
56
55
  return None
57
56
 
58
57
  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
- )
58
+ self._cache[prefix] = CacheEntry(body=body, expires_at=time.time() + self.ttl_seconds)
62
59
 
63
60
  def range_query(self, prefix: str) -> str:
64
61
  cached = self._get_cached(prefix)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Iterable, List, Optional, Union
3
+ from collections.abc import Iterable
4
+ from typing import Any
4
5
 
5
6
  import jwt
6
7
  from fastapi_users.authentication.strategy.jwt import JWTStrategy
@@ -19,8 +20,8 @@ class RotatingJWTStrategy(JWTStrategy):
19
20
  *,
20
21
  secret: str,
21
22
  lifetime_seconds: int,
22
- old_secrets: Optional[Iterable[str]] = None,
23
- token_audience: Optional[Union[str, List[str]]] = None,
23
+ old_secrets: Iterable[str] | None = None,
24
+ token_audience: str | list[str] | None = None,
24
25
  ):
25
26
  # Normalize token_audience to list as required by parent JWTStrategy
26
27
  aud_list: list[str] = (
@@ -30,10 +31,8 @@ class RotatingJWTStrategy(JWTStrategy):
30
31
  if token_audience
31
32
  else []
32
33
  ) or ["fastapi-users:auth"]
33
- super().__init__(
34
- secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list
35
- )
36
- self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
34
+ super().__init__(secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list)
35
+ self._verify_secrets: list[str] = [secret, *list(old_secrets or [])]
37
36
  self._lifetime_seconds = lifetime_seconds
38
37
 
39
38
  async def read_token(
@@ -62,9 +61,7 @@ class RotatingJWTStrategy(JWTStrategy):
62
61
  else:
63
62
  aud_list = audience
64
63
  try:
65
- return decode_jwt(
66
- token, self.decode_key, aud_list, algorithms=[self.algorithm]
67
- )
64
+ return decode_jwt(token, self.decode_key, aud_list, algorithms=[self.algorithm])
68
65
  except jwt.PyJWTError:
69
66
  pass
70
67
 
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import uuid
4
+ from collections.abc import Sequence
4
5
  from dataclasses import dataclass
5
- from datetime import datetime, timedelta, timezone
6
- from typing import Any, Optional, Sequence
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any
7
8
 
8
9
  try:
9
10
  from sqlalchemy import or_, select
@@ -27,7 +28,7 @@ class LockoutConfig:
27
28
  @dataclass
28
29
  class LockoutStatus:
29
30
  locked: bool
30
- next_allowed_at: Optional[datetime]
31
+ next_allowed_at: datetime | None
31
32
  failure_count: int
32
33
 
33
34
 
@@ -35,9 +36,9 @@ class LockoutStatus:
35
36
 
36
37
 
37
38
  def compute_lockout(
38
- fail_count: int, *, cfg: LockoutConfig, now: Optional[datetime] = None
39
+ fail_count: int, *, cfg: LockoutConfig, now: datetime | None = None
39
40
  ) -> LockoutStatus:
40
- now = now or datetime.now(timezone.utc)
41
+ now = now or datetime.now(UTC)
41
42
  if fail_count < cfg.threshold:
42
43
  return LockoutStatus(False, None, fail_count)
43
44
  # cooldown factor exponent = fail_count - threshold
@@ -54,8 +55,8 @@ def compute_lockout(
54
55
  async def record_attempt(
55
56
  session: AsyncSession,
56
57
  *,
57
- user_id: Optional[uuid.UUID],
58
- ip_hash: Optional[str],
58
+ user_id: uuid.UUID | None,
59
+ ip_hash: str | None,
59
60
  success: bool,
60
61
  ) -> None:
61
62
  attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
@@ -66,12 +67,12 @@ async def record_attempt(
66
67
  async def get_lockout_status(
67
68
  session: AsyncSession,
68
69
  *,
69
- user_id: Optional[uuid.UUID],
70
- ip_hash: Optional[str],
71
- cfg: Optional[LockoutConfig] = None,
70
+ user_id: uuid.UUID | None,
71
+ ip_hash: str | None,
72
+ cfg: LockoutConfig | None = None,
72
73
  ) -> LockoutStatus:
73
74
  cfg = cfg or LockoutConfig()
74
- now = datetime.now(timezone.utc)
75
+ now = datetime.now(UTC)
75
76
  window_start = now - timedelta(minutes=cfg.window_minutes)
76
77
 
77
78
  q = select(FailedAuthAttempt).where(
@@ -3,8 +3,7 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import json
5
5
  import uuid
6
- from datetime import datetime, timedelta, timezone
7
- from typing import Optional
6
+ from datetime import UTC, datetime, timedelta
8
7
 
9
8
  from sqlalchemy import (
10
9
  JSON,
@@ -32,14 +31,14 @@ class AuthSession(ModelBase):
32
31
  user_id: Mapped[uuid.UUID] = mapped_column(
33
32
  GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
34
33
  )
35
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
36
- user_agent: Mapped[Optional[str]] = mapped_column(String(512))
37
- ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
38
- last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
39
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
40
- revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
41
-
42
- refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
34
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
35
+ user_agent: Mapped[str | None] = mapped_column(String(512))
36
+ ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
37
+ last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
38
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
39
+ revoke_reason: Mapped[str | None] = mapped_column(Text)
40
+
41
+ refresh_tokens: Mapped[list[RefreshToken]] = relationship(
43
42
  back_populates="session", cascade="all, delete-orphan", lazy="selectin"
44
43
  )
45
44
 
@@ -60,12 +59,10 @@ class RefreshToken(ModelBase):
60
59
  session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
61
60
 
62
61
  token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
63
- rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
64
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
65
- revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
66
- expires_at: Mapped[Optional[datetime]] = mapped_column(
67
- DateTime(timezone=True), index=True
68
- )
62
+ rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
63
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
64
+ revoke_reason: Mapped[str | None] = mapped_column(Text)
65
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
69
66
 
70
67
  created_at = mapped_column(
71
68
  DateTime(timezone=True),
@@ -81,24 +78,22 @@ class RefreshTokenRevocation(ModelBase):
81
78
 
82
79
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
83
80
  token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
84
- revoked_at: Mapped[datetime] = mapped_column(
85
- DateTime(timezone=True), nullable=False
86
- )
87
- reason: Mapped[Optional[str]] = mapped_column(Text)
81
+ revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
82
+ reason: Mapped[str | None] = mapped_column(Text)
88
83
 
89
84
 
90
85
  class FailedAuthAttempt(ModelBase):
91
86
  __tablename__ = "failed_auth_attempts"
92
87
 
93
88
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
94
- user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
89
+ user_id: Mapped[uuid.UUID | None] = mapped_column(
95
90
  GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
96
91
  )
97
- ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
92
+ ip_hash: Mapped[str | None] = mapped_column(String(64), index=True)
98
93
  ts: Mapped[datetime] = mapped_column(
99
94
  DateTime(timezone=True),
100
95
  nullable=False,
101
- default=lambda: datetime.now(timezone.utc),
96
+ default=lambda: datetime.now(UTC),
102
97
  )
103
98
  success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
104
99
 
@@ -119,17 +114,17 @@ class AuditLog(ModelBase):
119
114
  ts: Mapped[datetime] = mapped_column(
120
115
  DateTime(timezone=True),
121
116
  nullable=False,
122
- default=lambda: datetime.now(timezone.utc),
117
+ default=lambda: datetime.now(UTC),
123
118
  index=True,
124
119
  )
125
- actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
120
+ actor_id: Mapped[uuid.UUID | None] = mapped_column(
126
121
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
127
122
  )
128
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
123
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
129
124
  event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
130
- resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
125
+ resource_ref: Mapped[str | None] = mapped_column(String(255), index=True)
131
126
  event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
132
- prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
127
+ prev_hash: Mapped[str | None] = mapped_column(String(64))
133
128
  hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
134
129
 
135
130
  __table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
@@ -143,8 +138,8 @@ class Organization(ModelBase):
143
138
 
144
139
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
145
140
  name: Mapped[str] = mapped_column(String(128), nullable=False)
146
- slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
147
- tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
141
+ slug: Mapped[str | None] = mapped_column(String(64), index=True)
142
+ tenant_id: Mapped[str | None] = mapped_column(String(64), index=True)
148
143
  created_at = mapped_column(
149
144
  DateTime(timezone=True),
150
145
  server_default=text("CURRENT_TIMESTAMP"),
@@ -183,11 +178,9 @@ class OrganizationMembership(ModelBase):
183
178
  server_default=text("CURRENT_TIMESTAMP"),
184
179
  nullable=False,
185
180
  )
186
- deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
181
+ deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
187
182
 
188
- __table_args__ = (
189
- UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),
190
- )
183
+ __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
191
184
 
192
185
 
193
186
  class OrganizationInvitation(ModelBase):
@@ -200,10 +193,8 @@ class OrganizationInvitation(ModelBase):
200
193
  email: Mapped[str] = mapped_column(String(255), index=True)
201
194
  role: Mapped[str] = mapped_column(String(64), nullable=False)
202
195
  token_hash: Mapped[str] = mapped_column(String(64), index=True)
203
- expires_at: Mapped[Optional[datetime]] = mapped_column(
204
- DateTime(timezone=True), index=True
205
- )
206
- created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
196
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
197
+ created_by: Mapped[uuid.UUID | None] = mapped_column(
207
198
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
208
199
  )
209
200
  created_at = mapped_column(
@@ -211,10 +202,10 @@ class OrganizationInvitation(ModelBase):
211
202
  server_default=text("CURRENT_TIMESTAMP"),
212
203
  nullable=False,
213
204
  )
214
- last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
205
+ last_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
215
206
  resend_count: Mapped[int] = mapped_column(default=0)
216
- used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
217
- revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
207
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
208
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
218
209
 
219
210
 
220
211
  # ------------------------ OAuth Provider Accounts -----------------------------
@@ -235,13 +226,13 @@ def hash_refresh_token(raw: str) -> str:
235
226
 
236
227
 
237
228
  def compute_audit_hash(
238
- prev_hash: Optional[str],
229
+ prev_hash: str | None,
239
230
  *,
240
231
  ts: datetime,
241
- actor_id: Optional[uuid.UUID],
242
- tenant_id: Optional[str],
232
+ actor_id: uuid.UUID | None,
233
+ tenant_id: str | None,
243
234
  event_type: str,
244
- resource_ref: Optional[str],
235
+ resource_ref: str | None,
245
236
  metadata: dict,
246
237
  ) -> str:
247
238
  """Compute SHA256 hash chaining previous hash + canonical event payload."""
@@ -264,7 +255,7 @@ def rotate_refresh_token(
264
255
  """Rotate: returns (new_raw, new_hash, expires_at)."""
265
256
  new_raw = generate_refresh_token()
266
257
  new_hash = hash_refresh_token(new_raw)
267
- expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
258
+ expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
268
259
  return new_raw, new_hash, expires_at
269
260
 
270
261