svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ """Audit log append & chain verification utilities.
4
+
5
+ Provides helpers to append a new AuditLog entry maintaining a hash-chain
6
+ integrity model and to verify an existing sequence for tampering.
7
+
8
+ Design notes:
9
+ - Each event stores prev_hash (previous event's hash or 64 zeros for genesis).
10
+ - Hash = sha256(prev_hash + canonical_json_payload).
11
+ - Verification recomputes expected hash for each event and compares.
12
+ - If a middle event is altered, that event and all subsequent events will
13
+ fail verification (because their prev_hash links break transitively).
14
+ """
15
+
16
+ from datetime import datetime, timezone
17
+ from typing import Any, List, Optional, Sequence, Tuple
18
+
19
+ try: # SQLAlchemy may not be present in minimal test context
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+ except Exception: # pragma: no cover
23
+ AsyncSession = Any # type: ignore
24
+ select = None # type: ignore
25
+
26
+ from svc_infra.security.models import AuditLog, compute_audit_hash
27
+
28
+
29
+ async def append_audit_event(
30
+ db: Any,
31
+ *,
32
+ actor_id=None,
33
+ tenant_id: Optional[str] = None,
34
+ event_type: str,
35
+ resource_ref: Optional[str] = None,
36
+ metadata: dict | None = None,
37
+ ts: Optional[datetime] = None,
38
+ prev_event: Optional[AuditLog] = None,
39
+ ) -> AuditLog:
40
+ """Append an audit event returning the persisted row.
41
+
42
+ If prev_event is not supplied, it attempts to fetch the latest event for
43
+ the tenant (or global chain when tenant_id is None).
44
+ """
45
+ metadata = metadata or {}
46
+ ts = ts or datetime.now(timezone.utc)
47
+
48
+ prev_hash: Optional[str] = None
49
+ if prev_event is not None:
50
+ prev_hash = prev_event.hash
51
+ elif select is not None and hasattr(db, "execute"): # attempt DB lookup for previous event
52
+ try:
53
+ stmt = (
54
+ select(AuditLog)
55
+ .where(AuditLog.tenant_id == tenant_id)
56
+ .order_by(AuditLog.id.desc())
57
+ .limit(1)
58
+ )
59
+ result = await db.execute(stmt) # type: ignore[attr-defined]
60
+ prev = result.scalars().first()
61
+ if prev:
62
+ prev_hash = prev.hash
63
+ except Exception: # pragma: no cover - defensive for minimal fakes
64
+ pass
65
+
66
+ new_hash = compute_audit_hash(
67
+ prev_hash,
68
+ ts=ts,
69
+ actor_id=actor_id,
70
+ tenant_id=tenant_id,
71
+ event_type=event_type,
72
+ resource_ref=resource_ref,
73
+ metadata=metadata,
74
+ )
75
+
76
+ row = AuditLog(
77
+ ts=ts,
78
+ actor_id=actor_id,
79
+ tenant_id=tenant_id,
80
+ event_type=event_type,
81
+ resource_ref=resource_ref,
82
+ event_metadata=metadata,
83
+ prev_hash=prev_hash or "0" * 64,
84
+ hash=new_hash,
85
+ )
86
+ if hasattr(db, "add"):
87
+ try:
88
+ db.add(row) # type: ignore[attr-defined]
89
+ except Exception: # pragma: no cover - minimal shim safety
90
+ pass
91
+ if hasattr(db, "flush"):
92
+ try:
93
+ await db.flush() # type: ignore[attr-defined]
94
+ except Exception: # pragma: no cover
95
+ pass
96
+ return row
97
+
98
+
99
+ def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
100
+ """Verify a sequence of audit events.
101
+
102
+ Returns (ok, broken_indices). If any event's hash doesn't match the recomputed
103
+ expected hash (based on previous event), its index is recorded. All events are
104
+ checked so callers can analyze extent of tampering.
105
+ """
106
+ broken: List[int] = []
107
+ prev_hash = "0" * 64
108
+ for idx, ev in enumerate(events):
109
+ expected = compute_audit_hash(
110
+ prev_hash if ev.prev_hash == prev_hash else ev.prev_hash,
111
+ ts=ev.ts,
112
+ actor_id=ev.actor_id,
113
+ tenant_id=ev.tenant_id,
114
+ event_type=ev.event_type,
115
+ resource_ref=ev.resource_ref,
116
+ metadata=ev.event_metadata,
117
+ )
118
+ # prev_hash stored should equal previous event hash (or zeros for genesis)
119
+ if (idx == 0 and ev.prev_hash != "0" * 64) or (
120
+ idx > 0 and ev.prev_hash != events[idx - 1].hash
121
+ ):
122
+ broken.append(idx)
123
+ if ev.hash != expected:
124
+ broken.append(idx)
125
+ prev_hash = ev.hash
126
+ ok = not broken
127
+ return ok, sorted(set(broken))
128
+
129
+
130
+ __all__ = ["append_audit_event", "verify_audit_chain"]
@@ -0,0 +1,73 @@
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
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) # type: ignore[attr-defined]
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) and (tenant_id is None or e.tenant_id == tenant_id)
66
+ ]
67
+ except Exception: # pragma: no cover
68
+ events = []
69
+
70
+ return verify_audit_chain(list(events))
71
+
72
+
73
+ __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,95 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Optional
7
+
8
+ from svc_infra.http import new_httpx_client
9
+
10
+
11
+ def sha1_hex(data: str) -> str:
12
+ return hashlib.sha1(data.encode("utf-8")).hexdigest().upper()
13
+
14
+
15
+ @dataclass
16
+ class CacheEntry:
17
+ body: str
18
+ expires_at: float
19
+
20
+
21
+ class HIBPClient:
22
+ """Minimal HaveIBeenPwned range API client with simple in-memory cache.
23
+
24
+ - Uses k-anonymity range query: send first 5 chars of SHA1 hash, receive suffix list.
25
+ - Caches prefix responses for TTL to avoid repeated network calls.
26
+ - Synchronous implementation to allow use in sync validators.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ base_url: str = "https://api.pwnedpasswords.com",
33
+ ttl_seconds: int = 3600,
34
+ timeout: float = 5.0,
35
+ user_agent: str = "svc-infra/hibp",
36
+ ) -> None:
37
+ self.base_url = base_url.rstrip("/")
38
+ self.ttl_seconds = ttl_seconds
39
+ self.timeout = timeout
40
+ self.user_agent = user_agent
41
+ self._cache: Dict[str, CacheEntry] = {}
42
+ # Use central factory for consistent defaults; retain explicit timeout override
43
+ self._http = new_httpx_client(
44
+ timeout_seconds=self.timeout,
45
+ headers={"User-Agent": self.user_agent},
46
+ )
47
+
48
+ def _get_cached(self, prefix: str) -> Optional[str]:
49
+ now = time.time()
50
+ ent = self._cache.get(prefix)
51
+ if ent and ent.expires_at > now:
52
+ return ent.body
53
+ return None
54
+
55
+ def _set_cache(self, prefix: str, body: str) -> None:
56
+ self._cache[prefix] = CacheEntry(body=body, expires_at=time.time() + self.ttl_seconds)
57
+
58
+ def range_query(self, prefix: str) -> str:
59
+ cached = self._get_cached(prefix)
60
+ if cached is not None:
61
+ return cached
62
+ url = f"{self.base_url}/range/{prefix}"
63
+ resp = self._http.get(url)
64
+ resp.raise_for_status()
65
+ body = resp.text
66
+ self._set_cache(prefix, body)
67
+ return body
68
+
69
+ def is_breached(self, password: str) -> bool:
70
+ full = sha1_hex(password)
71
+ prefix, suffix = full[:5], full[5:]
72
+ try:
73
+ body = self.range_query(prefix)
74
+ except Exception:
75
+ # Fail-open: if HIBP unavailable, do not block users.
76
+ return False
77
+
78
+ for line in body.splitlines():
79
+ # Lines formatted as "SUFFIX:COUNT"
80
+ if not line:
81
+ continue
82
+ parts = line.split(":")
83
+ if len(parts) != 2:
84
+ continue
85
+ sfx = parts[0].strip().upper()
86
+ if sfx == suffix:
87
+ # Count > 0 implies breached
88
+ try:
89
+ return int(parts[1].strip()) > 0
90
+ except ValueError:
91
+ return True
92
+ return False
93
+
94
+
95
+ __all__ = ["HIBPClient", "sha1_hex"]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, List, Optional, Union
4
+
5
+ import jwt as pyjwt
6
+ from fastapi_users.authentication.strategy.jwt import JWTStrategy
7
+
8
+
9
+ class RotatingJWTStrategy(JWTStrategy):
10
+ """JWTStrategy that can verify tokens against multiple secrets.
11
+
12
+ Signing uses the primary secret (as in base class). Verification accepts any of
13
+ the provided secrets: [primary] + old_secrets.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ secret: str,
20
+ lifetime_seconds: int,
21
+ old_secrets: Optional[Iterable[str]] = None,
22
+ token_audience: Optional[Union[str, List[str]]] = None,
23
+ ):
24
+ super().__init__(
25
+ secret=secret, lifetime_seconds=lifetime_seconds, token_audience=token_audience
26
+ )
27
+ self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
28
+
29
+ async def read_token(self, token: str, audience: Optional[str] = None): # type: ignore[override]
30
+ # Try with current strategy's configured secret first
31
+ eff_aud = audience or self.token_audience
32
+ try:
33
+ return await super().read_token(token, audience=eff_aud)
34
+ except Exception:
35
+ pass
36
+ # Try older secrets
37
+ for s in self._verify_secrets[1:]:
38
+ try:
39
+ data = pyjwt.decode(
40
+ token,
41
+ s,
42
+ algorithms=["HS256"],
43
+ audience=eff_aud,
44
+ )
45
+ if data is not None:
46
+ return data
47
+ except Exception:
48
+ pass
49
+ # If none of the secrets validated the token, raise a generic error
50
+ raise ValueError("Invalid token for all configured secrets")
51
+
52
+
53
+ __all__ = ["RotatingJWTStrategy"]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional, Sequence
7
+
8
+ try:
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ except Exception: # pragma: no cover - optional import for type hints
12
+ AsyncSession = Any # type: ignore[misc]
13
+ select = None # type: ignore
14
+
15
+ from svc_infra.security.models import FailedAuthAttempt
16
+
17
+
18
+ @dataclass
19
+ class LockoutConfig:
20
+ threshold: int = 5 # failures before cooldown starts
21
+ window_minutes: int = 15 # look-back window for counting failures
22
+ base_cooldown_seconds: int = 30 # initial cooldown once threshold reached
23
+ max_cooldown_seconds: int = 3600 # cap exponential growth at 1 hour
24
+
25
+
26
+ @dataclass
27
+ class LockoutStatus:
28
+ locked: bool
29
+ next_allowed_at: Optional[datetime]
30
+ failure_count: int
31
+
32
+
33
+ # ---------------- Pure calculation -----------------
34
+
35
+
36
+ def compute_lockout(
37
+ fail_count: int, *, cfg: LockoutConfig, now: Optional[datetime] = None
38
+ ) -> LockoutStatus:
39
+ now = now or datetime.now(timezone.utc)
40
+ if fail_count < cfg.threshold:
41
+ return LockoutStatus(False, None, fail_count)
42
+ # cooldown factor exponent = fail_count - threshold
43
+ exponent = fail_count - cfg.threshold
44
+ cooldown = cfg.base_cooldown_seconds * (2**exponent)
45
+ if cooldown > cfg.max_cooldown_seconds:
46
+ cooldown = cfg.max_cooldown_seconds
47
+ return LockoutStatus(True, now + timedelta(seconds=cooldown), fail_count)
48
+
49
+
50
+ # ---------------- Persistence helpers (async) ---------------
51
+
52
+
53
+ async def record_attempt(
54
+ session: AsyncSession,
55
+ *,
56
+ user_id: Optional[uuid.UUID],
57
+ ip_hash: Optional[str],
58
+ success: bool,
59
+ ) -> None:
60
+ attempt = FailedAuthAttempt(user_id=user_id, ip_hash=ip_hash, success=success)
61
+ session.add(attempt)
62
+ await session.flush()
63
+
64
+
65
+ async def get_lockout_status(
66
+ session: AsyncSession,
67
+ *,
68
+ user_id: Optional[uuid.UUID],
69
+ ip_hash: Optional[str],
70
+ cfg: Optional[LockoutConfig] = None,
71
+ ) -> LockoutStatus:
72
+ cfg = cfg or LockoutConfig()
73
+ now = datetime.now(timezone.utc)
74
+ window_start = now - timedelta(minutes=cfg.window_minutes)
75
+
76
+ q = select(FailedAuthAttempt).where(
77
+ FailedAuthAttempt.ts >= window_start,
78
+ FailedAuthAttempt.success == False, # noqa: E712
79
+ )
80
+ if user_id:
81
+ q = q.where(FailedAuthAttempt.user_id == user_id)
82
+ if ip_hash:
83
+ q = q.where(FailedAuthAttempt.ip_hash == ip_hash)
84
+
85
+ rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
86
+ fail_count = len(rows)
87
+ return compute_lockout(fail_count, cfg=cfg, now=now)
88
+
89
+
90
+ __all__ = [
91
+ "LockoutConfig",
92
+ "LockoutStatus",
93
+ "compute_lockout",
94
+ "record_attempt",
95
+ "get_lockout_status",
96
+ ]