svc-infra 0.1.506__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.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
|
+
]
|