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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {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
|
+
]
|
svc_infra/security/audit.py
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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:
|
|
106
|
+
tenant_id: str | None = None,
|
|
34
107
|
event_type: str,
|
|
35
|
-
resource_ref:
|
|
108
|
+
resource_ref: str | None = None,
|
|
36
109
|
metadata: dict | None = None,
|
|
37
|
-
ts:
|
|
38
|
-
prev_event:
|
|
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(
|
|
119
|
+
ts = ts or datetime.now(UTC)
|
|
47
120
|
|
|
48
|
-
prev_hash:
|
|
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)
|
|
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)
|
|
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()
|
|
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]) ->
|
|
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:
|
|
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__ = [
|
|
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
|
|
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:
|
|
19
|
+
tenant_id: str | None = None,
|
|
19
20
|
event_type: str,
|
|
20
|
-
resource_ref:
|
|
21
|
+
resource_ref: str | None = None,
|
|
21
22
|
metadata: dict | None = None,
|
|
22
|
-
prev_event:
|
|
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:
|
|
41
|
-
) ->
|
|
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)
|
|
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 =
|
|
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
|
svc_infra/security/headers.py
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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
|
|
svc_infra/security/hibp.py
CHANGED
|
@@ -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
|
|
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:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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():
|