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
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
-
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
6
|
+
from typing import Any
|
|
5
7
|
|
|
6
8
|
from fastapi import Depends, HTTPException
|
|
7
9
|
|
|
8
10
|
from svc_infra.api.fastapi.auth.security import Identity
|
|
9
11
|
|
|
12
|
+
# Thread-safe permission registry
|
|
13
|
+
_PERMISSION_LOCK = threading.Lock()
|
|
14
|
+
|
|
10
15
|
# Central role -> permissions mapping. Projects can extend at startup.
|
|
11
|
-
PERMISSION_REGISTRY:
|
|
16
|
+
PERMISSION_REGISTRY: dict[str, set[str]] = {
|
|
12
17
|
"admin": {
|
|
13
18
|
"user.read",
|
|
14
19
|
"user.write",
|
|
@@ -16,20 +21,37 @@ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
|
|
|
16
21
|
"billing.write",
|
|
17
22
|
"security.session.revoke",
|
|
18
23
|
"security.session.list",
|
|
24
|
+
"admin.impersonate",
|
|
19
25
|
},
|
|
20
26
|
"support": {"user.read", "billing.read"},
|
|
21
27
|
"auditor": {"user.read", "billing.read", "audit.read"},
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
def register_role(role: str, permissions: set[str]) -> None:
|
|
32
|
+
"""Thread-safe registration of a role and its permissions."""
|
|
33
|
+
with _PERMISSION_LOCK:
|
|
34
|
+
PERMISSION_REGISTRY[role] = permissions
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extend_role(role: str, permissions: set[str]) -> None:
|
|
38
|
+
"""Thread-safe extension of an existing role's permissions."""
|
|
39
|
+
with _PERMISSION_LOCK:
|
|
40
|
+
if role in PERMISSION_REGISTRY:
|
|
41
|
+
PERMISSION_REGISTRY[role] |= permissions
|
|
42
|
+
else:
|
|
43
|
+
PERMISSION_REGISTRY[role] = permissions
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_permissions_for_roles(roles: Iterable[str]) -> set[str]:
|
|
47
|
+
perms: set[str] = set()
|
|
48
|
+
with _PERMISSION_LOCK:
|
|
49
|
+
for r in roles:
|
|
50
|
+
perms |= PERMISSION_REGISTRY.get(r, set())
|
|
29
51
|
return perms
|
|
30
52
|
|
|
31
53
|
|
|
32
|
-
def principal_permissions(principal: Identity) ->
|
|
54
|
+
def principal_permissions(principal: Identity) -> set[str]:
|
|
33
55
|
roles = getattr(principal.user, "roles", []) or []
|
|
34
56
|
return get_permissions_for_roles(roles)
|
|
35
57
|
|
|
@@ -137,6 +159,8 @@ def RequireABAC(
|
|
|
137
159
|
|
|
138
160
|
__all__ = [
|
|
139
161
|
"PERMISSION_REGISTRY",
|
|
162
|
+
"register_role",
|
|
163
|
+
"extend_role",
|
|
140
164
|
"get_permissions_for_roles",
|
|
141
165
|
"principal_permissions",
|
|
142
166
|
"has_permission",
|
svc_infra/security/session.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from datetime import datetime, timedelta
|
|
5
|
-
from typing import Optional
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
6
5
|
|
|
7
6
|
try:
|
|
8
7
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
8
|
except Exception: # pragma: no cover
|
|
10
|
-
AsyncSession = object # type: ignore
|
|
9
|
+
AsyncSession = object # type: ignore[misc,assignment]
|
|
11
10
|
|
|
12
11
|
from svc_infra.security.models import (
|
|
13
12
|
AuthSession,
|
|
@@ -25,9 +24,9 @@ async def issue_session_and_refresh(
|
|
|
25
24
|
db: AsyncSession,
|
|
26
25
|
*,
|
|
27
26
|
user_id: uuid.UUID,
|
|
28
|
-
tenant_id:
|
|
29
|
-
user_agent:
|
|
30
|
-
ip_hash:
|
|
27
|
+
tenant_id: str | None = None,
|
|
28
|
+
user_agent: str | None = None,
|
|
29
|
+
ip_hash: str | None = None,
|
|
31
30
|
ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
|
|
32
31
|
) -> tuple[str, RefreshToken]:
|
|
33
32
|
"""Persist a new AuthSession + initial RefreshToken and return raw refresh token.
|
|
@@ -43,7 +42,7 @@ async def issue_session_and_refresh(
|
|
|
43
42
|
db.add(session_row)
|
|
44
43
|
raw = generate_refresh_token()
|
|
45
44
|
token_hash = hash_refresh_token(raw)
|
|
46
|
-
expires_at = datetime.now(
|
|
45
|
+
expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
|
|
47
46
|
rt = RefreshToken(
|
|
48
47
|
session=session_row,
|
|
49
48
|
token_hash=token_hash,
|
|
@@ -64,7 +63,7 @@ async def rotate_session_refresh(
|
|
|
64
63
|
|
|
65
64
|
Returns: (new_raw_refresh_token, new_refresh_token_model)
|
|
66
65
|
"""
|
|
67
|
-
rotation_ts = datetime.now(
|
|
66
|
+
rotation_ts = datetime.now(UTC)
|
|
68
67
|
if current.revoked_at:
|
|
69
68
|
raise ValueError("refresh token already revoked")
|
|
70
69
|
if current.expires_at and current.expires_at <= rotation_ts:
|
|
@@ -5,7 +5,7 @@ import hmac
|
|
|
5
5
|
import json
|
|
6
6
|
import time
|
|
7
7
|
from hashlib import sha256
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _b64e(b: bytes) -> str:
|
|
@@ -26,19 +26,28 @@ def _now() -> int:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def sign_cookie(
|
|
29
|
-
payload:
|
|
29
|
+
payload: dict[str, Any],
|
|
30
30
|
*,
|
|
31
31
|
key: str,
|
|
32
|
-
expires_in:
|
|
32
|
+
expires_in: int | None = None,
|
|
33
|
+
path: str | None = None,
|
|
34
|
+
domain: str | None = None,
|
|
33
35
|
) -> str:
|
|
34
|
-
"""Produce a compact signed cookie value with optional expiry.
|
|
36
|
+
"""Produce a compact signed cookie value with optional expiry and scope binding.
|
|
35
37
|
|
|
36
38
|
Format: base64url(json).base64url(hmac)
|
|
37
39
|
If expires_in is provided, 'exp' epoch seconds is injected into payload prior to signing.
|
|
40
|
+
If path or domain is provided, they are included in the signed payload to prevent
|
|
41
|
+
cookie replay attacks across different paths/domains.
|
|
38
42
|
"""
|
|
39
43
|
body = dict(payload)
|
|
40
44
|
if expires_in is not None:
|
|
41
45
|
body.setdefault("exp", _now() + int(expires_in))
|
|
46
|
+
# Include scope in signature to prevent replay across paths/domains
|
|
47
|
+
if path is not None:
|
|
48
|
+
body.setdefault("_path", path)
|
|
49
|
+
if domain is not None:
|
|
50
|
+
body.setdefault("_domain", domain)
|
|
42
51
|
data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
|
43
52
|
sig = _sign(data, key.encode())
|
|
44
53
|
return f"{_b64e(data)}.{sig}"
|
|
@@ -48,12 +57,16 @@ def verify_cookie(
|
|
|
48
57
|
value: str,
|
|
49
58
|
*,
|
|
50
59
|
key: str,
|
|
51
|
-
old_keys:
|
|
52
|
-
|
|
60
|
+
old_keys: list[str] | None = None,
|
|
61
|
+
expected_path: str | None = None,
|
|
62
|
+
expected_domain: str | None = None,
|
|
63
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
53
64
|
"""Verify a signed cookie against the primary key or any old key.
|
|
54
65
|
|
|
55
66
|
Returns (ok, payload). If ok is False, payload will be None.
|
|
56
67
|
Rejects if exp is present and in the past.
|
|
68
|
+
If expected_path or expected_domain is provided, verifies the cookie was signed
|
|
69
|
+
for that scope (prevents replay attacks across paths/domains).
|
|
57
70
|
"""
|
|
58
71
|
if not value or "." not in value:
|
|
59
72
|
return False, None
|
|
@@ -72,6 +85,13 @@ def verify_cookie(
|
|
|
72
85
|
# Expire when current time reaches or exceeds exp
|
|
73
86
|
if "exp" in payload and _now() >= int(payload["exp"]):
|
|
74
87
|
return False, None
|
|
88
|
+
# Verify scope binding if expected
|
|
89
|
+
if expected_path is not None:
|
|
90
|
+
if payload.get("_path") != expected_path:
|
|
91
|
+
return False, None
|
|
92
|
+
if expected_domain is not None:
|
|
93
|
+
if payload.get("_domain") != expected_domain:
|
|
94
|
+
return False, None
|
|
75
95
|
return True, payload
|
|
76
96
|
except Exception:
|
|
77
97
|
return False, None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic file storage system for svc-infra.
|
|
3
|
+
|
|
4
|
+
Provides backend-agnostic file storage with support for multiple providers:
|
|
5
|
+
- Local filesystem (Railway volumes, Render, development)
|
|
6
|
+
- S3-compatible (AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio)
|
|
7
|
+
- Google Cloud Storage (coming soon)
|
|
8
|
+
- Cloudinary (coming soon)
|
|
9
|
+
- In-memory (testing)
|
|
10
|
+
|
|
11
|
+
Quick Start:
|
|
12
|
+
>>> from svc_infra.storage import add_storage, easy_storage
|
|
13
|
+
>>> from fastapi import FastAPI
|
|
14
|
+
>>>
|
|
15
|
+
>>> app = FastAPI()
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Auto-detect backend from environment
|
|
18
|
+
>>> storage = add_storage(app)
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Or explicit backend
|
|
21
|
+
>>> backend = easy_storage(backend="s3", bucket="my-uploads")
|
|
22
|
+
>>> storage = add_storage(app, backend)
|
|
23
|
+
|
|
24
|
+
Usage in Routes:
|
|
25
|
+
>>> from svc_infra.storage import get_storage, StorageBackend
|
|
26
|
+
>>> from fastapi import Depends, UploadFile
|
|
27
|
+
>>>
|
|
28
|
+
>>> @router.post("/upload")
|
|
29
|
+
>>> async def upload_file(
|
|
30
|
+
... file: UploadFile,
|
|
31
|
+
... storage: StorageBackend = Depends(get_storage),
|
|
32
|
+
... ):
|
|
33
|
+
... content = await file.read()
|
|
34
|
+
... url = await storage.put(
|
|
35
|
+
... key=f"uploads/{file.filename}",
|
|
36
|
+
... data=content,
|
|
37
|
+
... content_type=file.content_type or "application/octet-stream",
|
|
38
|
+
... metadata={"user_id": "user_123"}
|
|
39
|
+
... )
|
|
40
|
+
... return {"url": url}
|
|
41
|
+
|
|
42
|
+
Environment Variables:
|
|
43
|
+
STORAGE_BACKEND: Backend type (local, s3, gcs, cloudinary, memory)
|
|
44
|
+
|
|
45
|
+
Local:
|
|
46
|
+
STORAGE_BASE_PATH: Directory for files (default: /data/uploads)
|
|
47
|
+
STORAGE_BASE_URL: URL for file serving (default: http://localhost:8000/files)
|
|
48
|
+
|
|
49
|
+
S3:
|
|
50
|
+
STORAGE_S3_BUCKET: Bucket name (required)
|
|
51
|
+
STORAGE_S3_REGION: AWS region (default: us-east-1)
|
|
52
|
+
STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services
|
|
53
|
+
STORAGE_S3_ACCESS_KEY: Access key (falls back to AWS_ACCESS_KEY_ID)
|
|
54
|
+
STORAGE_S3_SECRET_KEY: Secret key (falls back to AWS_SECRET_ACCESS_KEY)
|
|
55
|
+
|
|
56
|
+
See Also:
|
|
57
|
+
- ADR-0012: Generic File Storage System design
|
|
58
|
+
- docs/storage.md: Comprehensive storage guide
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from .add import add_storage, get_storage, health_check_storage
|
|
62
|
+
from .backends import LocalBackend, MemoryBackend, S3Backend
|
|
63
|
+
from .base import (
|
|
64
|
+
FileNotFoundError,
|
|
65
|
+
InvalidKeyError,
|
|
66
|
+
PermissionDeniedError,
|
|
67
|
+
QuotaExceededError,
|
|
68
|
+
StorageBackend,
|
|
69
|
+
StorageError,
|
|
70
|
+
)
|
|
71
|
+
from .easy import easy_storage
|
|
72
|
+
from .settings import StorageSettings
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
# Main API
|
|
76
|
+
"add_storage",
|
|
77
|
+
"easy_storage",
|
|
78
|
+
"get_storage",
|
|
79
|
+
"health_check_storage",
|
|
80
|
+
# Base types
|
|
81
|
+
"StorageBackend",
|
|
82
|
+
"StorageSettings",
|
|
83
|
+
# Backends
|
|
84
|
+
"LocalBackend",
|
|
85
|
+
"MemoryBackend",
|
|
86
|
+
"S3Backend",
|
|
87
|
+
# Exceptions
|
|
88
|
+
"StorageError",
|
|
89
|
+
"FileNotFoundError",
|
|
90
|
+
"PermissionDeniedError",
|
|
91
|
+
"QuotaExceededError",
|
|
92
|
+
"InvalidKeyError",
|
|
93
|
+
]
|
svc_infra/storage/add.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI integration for storage system.
|
|
3
|
+
|
|
4
|
+
Provides helpers to integrate storage backends with FastAPI applications.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
12
|
+
from fastapi.responses import StreamingResponse
|
|
13
|
+
|
|
14
|
+
from .base import FileNotFoundError, PermissionDeniedError, StorageBackend, StorageError
|
|
15
|
+
from .easy import easy_storage
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_storage(
|
|
21
|
+
app: FastAPI,
|
|
22
|
+
backend: StorageBackend | None = None,
|
|
23
|
+
serve_files: bool = False,
|
|
24
|
+
file_route_prefix: str = "/files",
|
|
25
|
+
) -> StorageBackend:
|
|
26
|
+
"""
|
|
27
|
+
Add storage backend to FastAPI application.
|
|
28
|
+
|
|
29
|
+
This function:
|
|
30
|
+
- Stores backend in app.state.storage
|
|
31
|
+
- Registers startup/shutdown hooks
|
|
32
|
+
- Optionally mounts file serving route
|
|
33
|
+
- Adds health check integration
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
app: FastAPI application instance
|
|
37
|
+
backend: Storage backend instance (auto-detected if None)
|
|
38
|
+
serve_files: If True, mount route to serve files (LocalBackend only)
|
|
39
|
+
file_route_prefix: URL prefix for file serving (default: "/files")
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Storage backend instance
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> from fastapi import FastAPI
|
|
46
|
+
>>> from svc_infra.storage import add_storage, easy_storage
|
|
47
|
+
>>>
|
|
48
|
+
>>> app = FastAPI()
|
|
49
|
+
>>>
|
|
50
|
+
>>> # Auto-detect backend
|
|
51
|
+
>>> storage = add_storage(app)
|
|
52
|
+
>>>
|
|
53
|
+
>>> # Explicit backend
|
|
54
|
+
>>> backend = easy_storage(backend="s3", bucket="my-uploads")
|
|
55
|
+
>>> storage = add_storage(app, backend)
|
|
56
|
+
>>>
|
|
57
|
+
>>> # With file serving (LocalBackend only)
|
|
58
|
+
>>> backend = easy_storage(backend="local")
|
|
59
|
+
>>> storage = add_storage(app, backend, serve_files=True)
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
File serving is only supported for LocalBackend. For S3/GCS,
|
|
63
|
+
use presigned URLs instead.
|
|
64
|
+
"""
|
|
65
|
+
# Auto-detect backend if not provided
|
|
66
|
+
if backend is None:
|
|
67
|
+
backend = easy_storage()
|
|
68
|
+
|
|
69
|
+
# Store in app state
|
|
70
|
+
app.state.storage = backend
|
|
71
|
+
|
|
72
|
+
# Get existing lifespan or create new one
|
|
73
|
+
existing_lifespan = getattr(app.router, "lifespan_context", None)
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def storage_lifespan(app: FastAPI):
|
|
77
|
+
# Startup
|
|
78
|
+
logger.info(f"Storage backend initialized: {backend.__class__.__name__}")
|
|
79
|
+
|
|
80
|
+
# Test connection for S3 backend
|
|
81
|
+
if hasattr(backend, "bucket"):
|
|
82
|
+
try:
|
|
83
|
+
# Try to list keys (limit 1 to minimize cost)
|
|
84
|
+
await backend.list_keys(limit=1)
|
|
85
|
+
logger.info(f"Successfully connected to storage: {backend.bucket}")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Failed to connect to storage: {e}")
|
|
88
|
+
# Don't fail startup, let health check catch it
|
|
89
|
+
|
|
90
|
+
# Call existing lifespan if present
|
|
91
|
+
if existing_lifespan is not None:
|
|
92
|
+
async with existing_lifespan(app):
|
|
93
|
+
yield
|
|
94
|
+
else:
|
|
95
|
+
yield
|
|
96
|
+
|
|
97
|
+
# Shutdown
|
|
98
|
+
logger.info("Storage backend shutdown")
|
|
99
|
+
|
|
100
|
+
# Replace lifespan
|
|
101
|
+
app.router.lifespan_context = storage_lifespan
|
|
102
|
+
|
|
103
|
+
# Mount file serving route if requested (LocalBackend only)
|
|
104
|
+
if serve_files:
|
|
105
|
+
from .backends.local import LocalBackend
|
|
106
|
+
|
|
107
|
+
if not isinstance(backend, LocalBackend):
|
|
108
|
+
logger.warning(
|
|
109
|
+
f"File serving only supported for LocalBackend, "
|
|
110
|
+
f"got {backend.__class__.__name__}. Skipping route mount."
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# Create file serving route
|
|
114
|
+
@app.get(f"{file_route_prefix}/{{key:path}}")
|
|
115
|
+
async def serve_file(
|
|
116
|
+
key: str,
|
|
117
|
+
expires: str = Query(..., description="Expiration timestamp"),
|
|
118
|
+
signature: str = Query(..., description="HMAC signature"),
|
|
119
|
+
download: bool = Query(False, description="Force download"),
|
|
120
|
+
):
|
|
121
|
+
"""
|
|
122
|
+
Serve files from local storage with signature validation.
|
|
123
|
+
|
|
124
|
+
Requires valid signature generated by LocalBackend.get_url().
|
|
125
|
+
"""
|
|
126
|
+
# Verify signature
|
|
127
|
+
if not backend.verify_url(key, expires, signature, download):
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=403,
|
|
130
|
+
detail="Invalid or expired signature",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Get file
|
|
134
|
+
try:
|
|
135
|
+
data = await backend.get(key)
|
|
136
|
+
metadata = await backend.get_metadata(key)
|
|
137
|
+
|
|
138
|
+
# Determine content disposition
|
|
139
|
+
if download:
|
|
140
|
+
filename = key.split("/")[-1]
|
|
141
|
+
content_disposition = f'attachment; filename="{filename}"'
|
|
142
|
+
else:
|
|
143
|
+
content_disposition = "inline"
|
|
144
|
+
|
|
145
|
+
# Return file
|
|
146
|
+
return StreamingResponse(
|
|
147
|
+
iter([data]),
|
|
148
|
+
media_type=metadata.get("content_type", "application/octet-stream"),
|
|
149
|
+
headers={
|
|
150
|
+
"Content-Disposition": content_disposition,
|
|
151
|
+
"Content-Length": str(len(data)),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
157
|
+
except PermissionDeniedError:
|
|
158
|
+
raise HTTPException(status_code=403, detail="Permission denied")
|
|
159
|
+
except StorageError as e:
|
|
160
|
+
logger.error(f"Storage error serving file {key}: {e}")
|
|
161
|
+
raise HTTPException(status_code=500, detail="Storage error")
|
|
162
|
+
|
|
163
|
+
logger.info(f"File serving enabled at {file_route_prefix}")
|
|
164
|
+
|
|
165
|
+
return backend
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_storage(request: Request) -> StorageBackend:
|
|
169
|
+
"""
|
|
170
|
+
FastAPI dependency to inject storage backend.
|
|
171
|
+
|
|
172
|
+
Use this in route handlers to access the storage backend.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
>>> from fastapi import APIRouter, Depends, UploadFile
|
|
176
|
+
>>> from svc_infra.storage import get_storage, StorageBackend
|
|
177
|
+
>>>
|
|
178
|
+
>>> router = APIRouter()
|
|
179
|
+
>>>
|
|
180
|
+
>>> @router.post("/upload")
|
|
181
|
+
>>> async def upload_file(
|
|
182
|
+
... file: UploadFile,
|
|
183
|
+
... storage: StorageBackend = Depends(get_storage),
|
|
184
|
+
... ):
|
|
185
|
+
... content = await file.read()
|
|
186
|
+
... url = await storage.put(
|
|
187
|
+
... key=f"uploads/{file.filename}",
|
|
188
|
+
... data=content,
|
|
189
|
+
... content_type=file.content_type or "application/octet-stream"
|
|
190
|
+
... )
|
|
191
|
+
... return {"url": url}
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
RuntimeError: If storage not initialized with add_storage()
|
|
195
|
+
"""
|
|
196
|
+
if not hasattr(request.app.state, "storage"):
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"Storage not initialized. Call add_storage(app) during application setup."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return cast("StorageBackend", request.app.state.storage)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def health_check_storage(request: Request) -> dict:
|
|
205
|
+
"""
|
|
206
|
+
Health check for storage backend.
|
|
207
|
+
|
|
208
|
+
Returns storage status and basic statistics.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> from fastapi import FastAPI
|
|
212
|
+
>>> from svc_infra.storage import add_storage, health_check_storage
|
|
213
|
+
>>>
|
|
214
|
+
>>> app = FastAPI()
|
|
215
|
+
>>> add_storage(app)
|
|
216
|
+
>>>
|
|
217
|
+
>>> @app.get("/_health/storage")
|
|
218
|
+
>>> async def storage_health(request: Request):
|
|
219
|
+
... return await health_check_storage(request)
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Dict with status and backend information
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
storage = get_storage(request)
|
|
226
|
+
|
|
227
|
+
# Get backend type
|
|
228
|
+
backend_type = storage.__class__.__name__.replace("Backend", "").lower()
|
|
229
|
+
|
|
230
|
+
# Try a simple operation
|
|
231
|
+
await storage.list_keys(limit=1)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"status": "healthy",
|
|
235
|
+
"backend": backend_type,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.error(f"Storage health check failed: {e}")
|
|
240
|
+
return {
|
|
241
|
+
"status": "unhealthy",
|
|
242
|
+
"error": str(e),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = [
|
|
247
|
+
"add_storage",
|
|
248
|
+
"get_storage",
|
|
249
|
+
"health_check_storage",
|
|
250
|
+
]
|