svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -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 +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- 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/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -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 +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -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 +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -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 +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- 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 +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -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 +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -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 +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -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-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Iterable, 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
|
|
142
|
+
if https_only is not None
|
|
143
|
+
else (https_env if https_env is not None else False)
|
|
144
|
+
)
|
|
145
|
+
same_site_env = env.get("SESSION_COOKIE_SAMESITE")
|
|
146
|
+
same_site_raw = same_site_env.strip() if same_site_env else same_site
|
|
147
|
+
# Validate and narrow to expected Literal type
|
|
148
|
+
same_site_value: Literal["lax", "strict", "none"] = (
|
|
149
|
+
"lax"
|
|
150
|
+
if same_site_raw not in ("lax", "strict", "none")
|
|
151
|
+
else cast(Literal["lax", "strict", "none"], same_site_raw)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
max_age_env = env.get("SESSION_COOKIE_MAX_AGE_SECONDS")
|
|
155
|
+
try:
|
|
156
|
+
max_age_value = int(max_age_env) if max_age_env is not None else max_age
|
|
157
|
+
except ValueError:
|
|
158
|
+
max_age_value = max_age
|
|
159
|
+
|
|
160
|
+
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
|
+
)
|
|
164
|
+
|
|
165
|
+
app.add_middleware(
|
|
166
|
+
SessionMiddleware,
|
|
167
|
+
secret_key=secret,
|
|
168
|
+
session_cookie=session_cookie_value,
|
|
169
|
+
max_age=max_age_value,
|
|
170
|
+
same_site=same_site_value,
|
|
171
|
+
https_only=effective_https_only,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def add_security(
|
|
176
|
+
app: FastAPI,
|
|
177
|
+
*,
|
|
178
|
+
cors_origins: Iterable[str] | str | None = None,
|
|
179
|
+
headers_overrides: dict[str, str] | None = None,
|
|
180
|
+
allow_credentials: bool = True,
|
|
181
|
+
env: Mapping[str, str] = os.environ,
|
|
182
|
+
enable_hsts_preload: bool | None = None,
|
|
183
|
+
install_session_middleware: bool = False,
|
|
184
|
+
session_secret_key: str | None = None,
|
|
185
|
+
session_cookie_name: str = "svc_session",
|
|
186
|
+
session_cookie_max_age_seconds: int = 4 * 3600,
|
|
187
|
+
session_cookie_samesite: str = "lax",
|
|
188
|
+
session_cookie_https_only: bool | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Install security middlewares with svc-infra defaults."""
|
|
191
|
+
|
|
192
|
+
_configure_security_headers(
|
|
193
|
+
app,
|
|
194
|
+
overrides=headers_overrides,
|
|
195
|
+
enable_hsts_preload=enable_hsts_preload,
|
|
196
|
+
)
|
|
197
|
+
_configure_cors(
|
|
198
|
+
app,
|
|
199
|
+
cors_origins=cors_origins,
|
|
200
|
+
allow_credentials=allow_credentials,
|
|
201
|
+
env=env,
|
|
202
|
+
)
|
|
203
|
+
_configure_session_middleware(
|
|
204
|
+
app,
|
|
205
|
+
env=env,
|
|
206
|
+
install=install_session_middleware,
|
|
207
|
+
secret_key=session_secret_key,
|
|
208
|
+
session_cookie=session_cookie_name,
|
|
209
|
+
max_age=session_cookie_max_age_seconds,
|
|
210
|
+
same_site=session_cookie_samesite,
|
|
211
|
+
https_only=session_cookie_https_only,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = [
|
|
216
|
+
"add_security",
|
|
217
|
+
]
|
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,19 +11,95 @@ Design notes:
|
|
|
13
11
|
fail verification (because their prev_hash links break transitively).
|
|
14
12
|
"""
|
|
15
13
|
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
16
17
|
from datetime import datetime, timezone
|
|
17
|
-
from typing import Any, List, Optional, Sequence, Tuple
|
|
18
|
+
from typing import Any, List, Optional, Protocol, Sequence, Tuple
|
|
18
19
|
|
|
19
20
|
try: # SQLAlchemy may not be present in minimal test context
|
|
20
21
|
from sqlalchemy import select
|
|
21
22
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
23
|
except Exception: # pragma: no cover
|
|
23
|
-
AsyncSession = Any # type: ignore
|
|
24
|
-
select = None # type: ignore
|
|
24
|
+
AsyncSession = Any # type: ignore[misc,assignment]
|
|
25
|
+
select = None # type: ignore[assignment]
|
|
25
26
|
|
|
26
27
|
from svc_infra.security.models import AuditLog, compute_audit_hash
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AuditEvent:
|
|
32
|
+
ts: datetime
|
|
33
|
+
actor_id: Any
|
|
34
|
+
tenant_id: str | None
|
|
35
|
+
event_type: str
|
|
36
|
+
resource_ref: str | None
|
|
37
|
+
metadata: dict
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuditLogStore(Protocol):
|
|
41
|
+
"""Minimal interface for storing audit events.
|
|
42
|
+
|
|
43
|
+
This is intentionally small so applications can swap in a SQL-backed store.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def append(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
actor_id: Any = None,
|
|
50
|
+
tenant_id: str | None = None,
|
|
51
|
+
event_type: str,
|
|
52
|
+
resource_ref: str | None = None,
|
|
53
|
+
metadata: dict | None = None,
|
|
54
|
+
ts: datetime | None = None,
|
|
55
|
+
) -> AuditEvent:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def list(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
tenant_id: str | None = None,
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
) -> list[AuditEvent]:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InMemoryAuditLogStore:
|
|
68
|
+
"""In-memory audit event store (useful for tests and prototypes)."""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self._events: list[AuditEvent] = []
|
|
72
|
+
|
|
73
|
+
def append(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
actor_id: Any = None,
|
|
77
|
+
tenant_id: str | None = None,
|
|
78
|
+
event_type: str,
|
|
79
|
+
resource_ref: str | None = None,
|
|
80
|
+
metadata: dict | None = None,
|
|
81
|
+
ts: datetime | None = None,
|
|
82
|
+
) -> AuditEvent:
|
|
83
|
+
event = AuditEvent(
|
|
84
|
+
ts=ts or datetime.now(timezone.utc),
|
|
85
|
+
actor_id=actor_id,
|
|
86
|
+
tenant_id=tenant_id,
|
|
87
|
+
event_type=event_type,
|
|
88
|
+
resource_ref=resource_ref,
|
|
89
|
+
metadata=dict(metadata or {}),
|
|
90
|
+
)
|
|
91
|
+
self._events.append(event)
|
|
92
|
+
return event
|
|
93
|
+
|
|
94
|
+
def list(
|
|
95
|
+
self, *, tenant_id: str | None = None, limit: int | None = None
|
|
96
|
+
) -> list[AuditEvent]:
|
|
97
|
+
out = [e for e in self._events if tenant_id is None or e.tenant_id == tenant_id]
|
|
98
|
+
if limit is not None:
|
|
99
|
+
return out[-int(limit) :]
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
29
103
|
async def append_audit_event(
|
|
30
104
|
db: Any,
|
|
31
105
|
*,
|
|
@@ -48,7 +122,9 @@ async def append_audit_event(
|
|
|
48
122
|
prev_hash: Optional[str] = None
|
|
49
123
|
if prev_event is not None:
|
|
50
124
|
prev_hash = prev_event.hash
|
|
51
|
-
elif select is not None and hasattr(
|
|
125
|
+
elif select is not None and hasattr(
|
|
126
|
+
db, "execute"
|
|
127
|
+
): # attempt DB lookup for previous event
|
|
52
128
|
try:
|
|
53
129
|
stmt = (
|
|
54
130
|
select(AuditLog)
|
|
@@ -56,7 +132,7 @@ async def append_audit_event(
|
|
|
56
132
|
.order_by(AuditLog.id.desc())
|
|
57
133
|
.limit(1)
|
|
58
134
|
)
|
|
59
|
-
result = await db.execute(stmt)
|
|
135
|
+
result = await db.execute(stmt)
|
|
60
136
|
prev = result.scalars().first()
|
|
61
137
|
if prev:
|
|
62
138
|
prev_hash = prev.hash
|
|
@@ -85,12 +161,12 @@ async def append_audit_event(
|
|
|
85
161
|
)
|
|
86
162
|
if hasattr(db, "add"):
|
|
87
163
|
try:
|
|
88
|
-
db.add(row)
|
|
164
|
+
db.add(row)
|
|
89
165
|
except Exception: # pragma: no cover - minimal shim safety
|
|
90
166
|
pass
|
|
91
167
|
if hasattr(db, "flush"):
|
|
92
168
|
try:
|
|
93
|
-
await db.flush()
|
|
169
|
+
await db.flush()
|
|
94
170
|
except Exception: # pragma: no cover
|
|
95
171
|
pass
|
|
96
172
|
return row
|
|
@@ -127,4 +203,10 @@ def verify_audit_chain(events: Sequence[AuditLog]) -> Tuple[bool, List[int]]:
|
|
|
127
203
|
return ok, sorted(set(broken))
|
|
128
204
|
|
|
129
205
|
|
|
130
|
-
__all__ = [
|
|
206
|
+
__all__ = [
|
|
207
|
+
"append_audit_event",
|
|
208
|
+
"verify_audit_chain",
|
|
209
|
+
"AuditEvent",
|
|
210
|
+
"AuditLogStore",
|
|
211
|
+
"InMemoryAuditLogStore",
|
|
212
|
+
]
|
|
@@ -5,7 +5,7 @@ from typing import Any, List, Optional, Sequence, Tuple
|
|
|
5
5
|
try: # optional SQLAlchemy import for environments without SA
|
|
6
6
|
from sqlalchemy import select
|
|
7
7
|
except Exception: # pragma: no cover
|
|
8
|
-
select = None # type: ignore
|
|
8
|
+
select = None # type: ignore[assignment]
|
|
9
9
|
|
|
10
10
|
from .audit import append_audit_event, verify_audit_chain
|
|
11
11
|
from .models import AuditLog
|
|
@@ -51,7 +51,7 @@ async def verify_chain_for_tenant(
|
|
|
51
51
|
if tenant_id is not None:
|
|
52
52
|
stmt = stmt.where(AuditLog.tenant_id == tenant_id)
|
|
53
53
|
stmt = stmt.order_by(AuditLog.id.asc())
|
|
54
|
-
result = await db.execute(stmt)
|
|
54
|
+
result = await db.execute(stmt)
|
|
55
55
|
events = list(result.scalars().all())
|
|
56
56
|
except Exception: # pragma: no cover
|
|
57
57
|
events = []
|
|
@@ -62,7 +62,8 @@ async def verify_chain_for_tenant(
|
|
|
62
62
|
events = [
|
|
63
63
|
e
|
|
64
64
|
for e in pool
|
|
65
|
-
if isinstance(e, AuditLog)
|
|
65
|
+
if isinstance(e, AuditLog)
|
|
66
|
+
and (tenant_id is None or e.tenant_id == tenant_id)
|
|
66
67
|
]
|
|
67
68
|
except Exception: # pragma: no cover
|
|
68
69
|
events = []
|
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,14 @@
|
|
|
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
7
|
from typing import Dict, Optional
|
|
7
8
|
|
|
8
|
-
import
|
|
9
|
+
from svc_infra.http import new_httpx_client
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
def sha1_hex(data: str) -> str:
|
|
@@ -39,7 +42,11 @@ class HIBPClient:
|
|
|
39
42
|
self.timeout = timeout
|
|
40
43
|
self.user_agent = user_agent
|
|
41
44
|
self._cache: Dict[str, CacheEntry] = {}
|
|
42
|
-
|
|
45
|
+
# Use central factory for consistent defaults; retain explicit timeout override
|
|
46
|
+
self._http = new_httpx_client(
|
|
47
|
+
timeout_seconds=self.timeout,
|
|
48
|
+
headers={"User-Agent": self.user_agent},
|
|
49
|
+
)
|
|
43
50
|
|
|
44
51
|
def _get_cached(self, prefix: str) -> Optional[str]:
|
|
45
52
|
now = time.time()
|
|
@@ -49,7 +56,9 @@ class HIBPClient:
|
|
|
49
56
|
return None
|
|
50
57
|
|
|
51
58
|
def _set_cache(self, prefix: str, body: str) -> None:
|
|
52
|
-
self._cache[prefix] = CacheEntry(
|
|
59
|
+
self._cache[prefix] = CacheEntry(
|
|
60
|
+
body=body, expires_at=time.time() + self.ttl_seconds
|
|
61
|
+
)
|
|
53
62
|
|
|
54
63
|
def range_query(self, prefix: str) -> str:
|
|
55
64
|
cached = self._get_cached(prefix)
|
|
@@ -67,8 +76,9 @@ class HIBPClient:
|
|
|
67
76
|
prefix, suffix = full[:5], full[5:]
|
|
68
77
|
try:
|
|
69
78
|
body = self.range_query(prefix)
|
|
70
|
-
except Exception:
|
|
79
|
+
except Exception as e:
|
|
71
80
|
# Fail-open: if HIBP unavailable, do not block users.
|
|
81
|
+
logger.warning("HIBP password check failed (fail-open): %s", e)
|
|
72
82
|
return False
|
|
73
83
|
|
|
74
84
|
for line in body.splitlines():
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Iterable, List, Optional, Union
|
|
3
|
+
from typing import Any, Iterable, List, Optional, Union
|
|
4
4
|
|
|
5
|
-
import jwt
|
|
5
|
+
import jwt
|
|
6
6
|
from fastapi_users.authentication.strategy.jwt import JWTStrategy
|
|
7
|
+
from fastapi_users.jwt import decode_jwt
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class RotatingJWTStrategy(JWTStrategy):
|
|
@@ -21,33 +22,84 @@ class RotatingJWTStrategy(JWTStrategy):
|
|
|
21
22
|
old_secrets: Optional[Iterable[str]] = None,
|
|
22
23
|
token_audience: Optional[Union[str, List[str]]] = None,
|
|
23
24
|
):
|
|
25
|
+
# Normalize token_audience to list as required by parent JWTStrategy
|
|
26
|
+
aud_list: list[str] = (
|
|
27
|
+
[token_audience]
|
|
28
|
+
if isinstance(token_audience, str)
|
|
29
|
+
else list(token_audience)
|
|
30
|
+
if token_audience
|
|
31
|
+
else []
|
|
32
|
+
) or ["fastapi-users:auth"]
|
|
24
33
|
super().__init__(
|
|
25
|
-
secret=secret, lifetime_seconds=lifetime_seconds, token_audience=
|
|
34
|
+
secret=secret, lifetime_seconds=lifetime_seconds, token_audience=aud_list
|
|
26
35
|
)
|
|
27
36
|
self._verify_secrets: List[str] = [secret] + list(old_secrets or [])
|
|
37
|
+
self._lifetime_seconds = lifetime_seconds
|
|
28
38
|
|
|
29
|
-
async def read_token(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
async def read_token(
|
|
40
|
+
self,
|
|
41
|
+
token: str | None,
|
|
42
|
+
user_manager: Any = None,
|
|
43
|
+
*,
|
|
44
|
+
audience: str | list[str] | None = None,
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""Read/verify a token against the active + rotated secrets.
|
|
47
|
+
|
|
48
|
+
Compatibility:
|
|
49
|
+
- fastapi-users signature: (token, user_manager) -> user | None
|
|
50
|
+
- legacy/test helper usage: (token, *, audience=...) -> claims | None
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if token is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if user_manager is None:
|
|
57
|
+
aud_list: list[str]
|
|
58
|
+
if audience is None:
|
|
59
|
+
aud_list = self.token_audience
|
|
60
|
+
elif isinstance(audience, str):
|
|
61
|
+
aud_list = [audience]
|
|
62
|
+
else:
|
|
63
|
+
aud_list = audience
|
|
38
64
|
try:
|
|
39
|
-
|
|
40
|
-
token,
|
|
41
|
-
s,
|
|
42
|
-
algorithms=["HS256"],
|
|
43
|
-
audience=eff_aud,
|
|
65
|
+
return decode_jwt(
|
|
66
|
+
token, self.decode_key, aud_list, algorithms=[self.algorithm]
|
|
44
67
|
)
|
|
45
|
-
|
|
46
|
-
return data
|
|
47
|
-
except Exception:
|
|
68
|
+
except jwt.PyJWTError:
|
|
48
69
|
pass
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
|
|
71
|
+
for secret in self._verify_secrets[1:]:
|
|
72
|
+
candidate: JWTStrategy[Any, Any] = JWTStrategy(
|
|
73
|
+
secret=secret,
|
|
74
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
75
|
+
token_audience=self.token_audience,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
return decode_jwt(
|
|
79
|
+
token,
|
|
80
|
+
candidate.decode_key,
|
|
81
|
+
aud_list,
|
|
82
|
+
algorithms=[candidate.algorithm],
|
|
83
|
+
)
|
|
84
|
+
except jwt.PyJWTError:
|
|
85
|
+
continue
|
|
86
|
+
raise ValueError("Invalid token for all configured secrets")
|
|
87
|
+
|
|
88
|
+
user = await super().read_token(token, user_manager)
|
|
89
|
+
if user is not None:
|
|
90
|
+
return user
|
|
91
|
+
|
|
92
|
+
for secret in self._verify_secrets[1:]:
|
|
93
|
+
candidate = JWTStrategy(
|
|
94
|
+
secret=secret,
|
|
95
|
+
lifetime_seconds=self._lifetime_seconds,
|
|
96
|
+
token_audience=self.token_audience,
|
|
97
|
+
)
|
|
98
|
+
user = await candidate.read_token(token, user_manager)
|
|
99
|
+
if user is not None:
|
|
100
|
+
return user
|
|
101
|
+
|
|
102
|
+
return None
|
|
51
103
|
|
|
52
104
|
|
|
53
105
|
__all__ = ["RotatingJWTStrategy"]
|
svc_infra/security/lockout.py
CHANGED
|
@@ -6,11 +6,12 @@ from datetime import datetime, timedelta, timezone
|
|
|
6
6
|
from typing import Any, Optional, Sequence
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
|
-
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy import or_, select
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
11
|
except Exception: # pragma: no cover - optional import for type hints
|
|
12
|
-
AsyncSession = Any # type: ignore[misc]
|
|
13
|
-
select = None # type: ignore
|
|
12
|
+
AsyncSession = Any # type: ignore[misc,assignment]
|
|
13
|
+
select = None # type: ignore[assignment]
|
|
14
|
+
or_ = None # type: ignore[assignment]
|
|
14
15
|
|
|
15
16
|
from svc_infra.security.models import FailedAuthAttempt
|
|
16
17
|
|
|
@@ -77,10 +78,15 @@ async def get_lockout_status(
|
|
|
77
78
|
FailedAuthAttempt.ts >= window_start,
|
|
78
79
|
FailedAuthAttempt.success == False, # noqa: E712
|
|
79
80
|
)
|
|
81
|
+
# Use OR logic: lock out if EITHER user_id OR ip_hash has too many failures
|
|
82
|
+
# This prevents attackers from rotating IPs to bypass lockout
|
|
83
|
+
filters = []
|
|
80
84
|
if user_id:
|
|
81
|
-
|
|
85
|
+
filters.append(FailedAuthAttempt.user_id == user_id)
|
|
82
86
|
if ip_hash:
|
|
83
|
-
|
|
87
|
+
filters.append(FailedAuthAttempt.ip_hash == ip_hash)
|
|
88
|
+
if filters:
|
|
89
|
+
q = q.where(or_(*filters))
|
|
84
90
|
|
|
85
91
|
rows: Sequence[FailedAuthAttempt] = (await session.execute(q)).scalars().all()
|
|
86
92
|
fail_count = len(rows)
|