svc-infra 0.1.506__py3-none-any.whl → 0.1.654__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, SecretStr
|
|
7
|
+
|
|
8
|
+
STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
|
|
9
|
+
STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
|
|
10
|
+
PROVIDER = (os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe")).lower()
|
|
11
|
+
|
|
12
|
+
AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
|
|
13
|
+
AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
|
|
14
|
+
AIYDAN_MERCHANT = os.getenv("AIYDAN_MERCHANT_ACCOUNT")
|
|
15
|
+
AIYDAN_HMAC = os.getenv("AIYDAN_HMAC_KEY")
|
|
16
|
+
AIYDAN_BASE_URL = os.getenv("AIYDAN_BASE_URL")
|
|
17
|
+
AIYDAN_WH = os.getenv("AIYDAN_WH_SECRET")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StripeConfig(BaseModel):
|
|
21
|
+
secret_key: SecretStr
|
|
22
|
+
webhook_secret: Optional[SecretStr] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AiydanConfig(BaseModel):
|
|
26
|
+
api_key: SecretStr
|
|
27
|
+
client_key: Optional[SecretStr] = None
|
|
28
|
+
merchant_account: Optional[str] = None
|
|
29
|
+
hmac_key: Optional[SecretStr] = None
|
|
30
|
+
base_url: Optional[str] = None
|
|
31
|
+
webhook_secret: Optional[SecretStr] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PaymentsSettings(BaseModel):
|
|
35
|
+
default_provider: str = PROVIDER
|
|
36
|
+
|
|
37
|
+
# optional multi-tenant/provider map hook can be added later
|
|
38
|
+
stripe: Optional[StripeConfig] = (
|
|
39
|
+
StripeConfig(
|
|
40
|
+
secret_key=SecretStr(STRIPE_KEY),
|
|
41
|
+
webhook_secret=SecretStr(STRIPE_WH) if STRIPE_WH else None,
|
|
42
|
+
)
|
|
43
|
+
if STRIPE_KEY
|
|
44
|
+
else None
|
|
45
|
+
)
|
|
46
|
+
aiydan: Optional[AiydanConfig] = (
|
|
47
|
+
AiydanConfig(
|
|
48
|
+
api_key=SecretStr(AIYDAN_KEY),
|
|
49
|
+
client_key=SecretStr(AIYDAN_CLIENT_KEY) if AIYDAN_CLIENT_KEY else None,
|
|
50
|
+
merchant_account=AIYDAN_MERCHANT,
|
|
51
|
+
hmac_key=SecretStr(AIYDAN_HMAC) if AIYDAN_HMAC else None,
|
|
52
|
+
base_url=AIYDAN_BASE_URL,
|
|
53
|
+
webhook_secret=SecretStr(AIYDAN_WH) if AIYDAN_WH else None,
|
|
54
|
+
)
|
|
55
|
+
if AIYDAN_KEY
|
|
56
|
+
else None
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_SETTINGS: Optional[PaymentsSettings] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_payments_settings() -> PaymentsSettings:
|
|
64
|
+
global _SETTINGS
|
|
65
|
+
if _SETTINGS is None:
|
|
66
|
+
_SETTINGS = PaymentsSettings()
|
|
67
|
+
return _SETTINGS
|
|
@@ -8,6 +8,7 @@ from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
|
8
8
|
|
|
9
9
|
from .cache.add import setup_caching
|
|
10
10
|
from .ease import easy_service_api, easy_service_app
|
|
11
|
+
from .pagination import cursor_window, sort_by, text_filter, use_pagination
|
|
11
12
|
from .setup import setup_service_api
|
|
12
13
|
|
|
13
14
|
__all__ = [
|
|
@@ -22,4 +23,9 @@ __all__ = [
|
|
|
22
23
|
"easy_service_api",
|
|
23
24
|
"easy_service_app",
|
|
24
25
|
"setup_caching",
|
|
26
|
+
# Pagination
|
|
27
|
+
"use_pagination",
|
|
28
|
+
"text_filter",
|
|
29
|
+
"sort_by",
|
|
30
|
+
"cursor_window",
|
|
25
31
|
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
15
|
+
|
|
16
|
+
from ....app.env import get_current_environment
|
|
17
|
+
from ....security.permissions import RequirePermission
|
|
18
|
+
from ..auth.security import Identity, Principal, _current_principal
|
|
19
|
+
from ..auth.state import get_auth_state
|
|
20
|
+
from ..db.sql.session import SqlSessionDep
|
|
21
|
+
from ..dual.protected import roles_router
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _b64u(data: bytes) -> str:
|
|
27
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _b64u_decode(s: str) -> bytes:
|
|
31
|
+
pad = "=" * ((4 - len(s) % 4) % 4)
|
|
32
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sign(payload: dict, *, secret: str) -> str:
|
|
36
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
37
|
+
sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
38
|
+
return _b64u(body) + "." + _b64u(sig)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _verify(token: str, *, secret: str) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
b64_body, b64_sig = token.split(".", 1)
|
|
44
|
+
body = _b64u_decode(b64_body)
|
|
45
|
+
exp_sig = _b64u_decode(b64_sig)
|
|
46
|
+
got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
47
|
+
if not hmac.compare_digest(exp_sig, got_sig):
|
|
48
|
+
raise ValueError("bad_signature")
|
|
49
|
+
payload = json.loads(body)
|
|
50
|
+
if int(payload.get("exp", 0)) < int(time.time()):
|
|
51
|
+
raise ValueError("expired")
|
|
52
|
+
return payload
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise ValueError("invalid_token") from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
|
|
58
|
+
"""Role-gated admin router for coarse access control.
|
|
59
|
+
|
|
60
|
+
Use permission guards inside endpoints for fine-grained control.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
return roles_router("admin", **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_admin(
|
|
67
|
+
app,
|
|
68
|
+
*,
|
|
69
|
+
base_path: str = "/admin",
|
|
70
|
+
enable_impersonation: bool = True,
|
|
71
|
+
secret: Optional[str] = None,
|
|
72
|
+
ttl_seconds: int = 15 * 60,
|
|
73
|
+
cookie_name: str = "impersonation",
|
|
74
|
+
impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Wire admin surfaces with sensible defaults.
|
|
77
|
+
|
|
78
|
+
- Mounts an admin router under base_path.
|
|
79
|
+
- Optionally enables impersonation start/stop endpoints guarded by permissions.
|
|
80
|
+
- Registers a dependency override to honor impersonation cookie globally (idempotent).
|
|
81
|
+
|
|
82
|
+
impersonation_user_getter: optional callable (request, user_id) -> user object.
|
|
83
|
+
If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Idempotency: only mount once per app instance
|
|
87
|
+
if getattr(app.state, "_admin_added", False):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
env = get_current_environment()
|
|
91
|
+
_secret = (
|
|
92
|
+
secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET") or "dev-secret"
|
|
93
|
+
)
|
|
94
|
+
_ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
|
|
95
|
+
_cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
|
|
96
|
+
|
|
97
|
+
r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
|
|
98
|
+
|
|
99
|
+
async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
|
|
100
|
+
try:
|
|
101
|
+
UserModel, _, _ = get_auth_state()
|
|
102
|
+
except Exception:
|
|
103
|
+
# Fallback: simple shim if auth state not configured
|
|
104
|
+
return SimpleNamespace(id=user_id)
|
|
105
|
+
obj = await session.get(UserModel, user_id)
|
|
106
|
+
if not obj:
|
|
107
|
+
raise HTTPException(404, "user_not_found")
|
|
108
|
+
return obj
|
|
109
|
+
|
|
110
|
+
user_getter = impersonation_user_getter
|
|
111
|
+
|
|
112
|
+
@r.post(
|
|
113
|
+
"/impersonate/start", status_code=204, dependencies=[RequirePermission("admin.impersonate")]
|
|
114
|
+
)
|
|
115
|
+
async def start_impersonation(
|
|
116
|
+
body: dict, request: Request, response: Response, session: SqlSessionDep, identity: Identity
|
|
117
|
+
):
|
|
118
|
+
target_id = (body or {}).get("user_id")
|
|
119
|
+
reason = (body or {}).get("reason", "")
|
|
120
|
+
if not target_id:
|
|
121
|
+
raise HTTPException(422, "user_id_required")
|
|
122
|
+
# Load target for validation (custom getter or default)
|
|
123
|
+
_res = (
|
|
124
|
+
user_getter(request, target_id)
|
|
125
|
+
if user_getter
|
|
126
|
+
else _default_user_getter(request, target_id, session)
|
|
127
|
+
)
|
|
128
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
129
|
+
actor: Principal = identity
|
|
130
|
+
payload = {
|
|
131
|
+
"actor_id": getattr(getattr(actor, "user", None), "id", None),
|
|
132
|
+
"target_id": str(getattr(target, "id", target_id)),
|
|
133
|
+
"iat": int(time.time()),
|
|
134
|
+
"exp": int(time.time()) + _ttl,
|
|
135
|
+
"nonce": _b64u(os.urandom(8)),
|
|
136
|
+
}
|
|
137
|
+
token = _sign(payload, secret=_secret)
|
|
138
|
+
response.set_cookie(
|
|
139
|
+
key=_cookie,
|
|
140
|
+
value=token,
|
|
141
|
+
httponly=True,
|
|
142
|
+
samesite="lax",
|
|
143
|
+
secure=(env in ("prod", "production")),
|
|
144
|
+
path="/",
|
|
145
|
+
max_age=_ttl,
|
|
146
|
+
)
|
|
147
|
+
logger.info(
|
|
148
|
+
"admin.impersonation.started",
|
|
149
|
+
extra={
|
|
150
|
+
"actor_id": payload["actor_id"],
|
|
151
|
+
"target_id": payload["target_id"],
|
|
152
|
+
"reason": reason,
|
|
153
|
+
"expires_in": _ttl,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
# Re-compose override now to wrap any late overrides set by tests/harness
|
|
157
|
+
try:
|
|
158
|
+
_compose_override()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
@r.post("/impersonate/stop", status_code=204)
|
|
163
|
+
async def stop_impersonation(response: Response):
|
|
164
|
+
response.delete_cookie(_cookie, path="/")
|
|
165
|
+
logger.info("admin.impersonation.stopped")
|
|
166
|
+
|
|
167
|
+
app.include_router(r)
|
|
168
|
+
|
|
169
|
+
# Dependency override: wrap the base principal to honor impersonation cookie.
|
|
170
|
+
# Compose with any existing override (e.g., acceptance app/test harness) and
|
|
171
|
+
# re-compose at startup to capture late overrides.
|
|
172
|
+
def _compose_override():
|
|
173
|
+
existing = app.dependency_overrides.get(_current_principal)
|
|
174
|
+
if existing and getattr(existing, "_is_admin_impersonation_override", False):
|
|
175
|
+
dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
|
|
176
|
+
else:
|
|
177
|
+
dep_provider = existing or _current_principal
|
|
178
|
+
|
|
179
|
+
async def _override_current_principal(
|
|
180
|
+
base: Principal = Depends(dep_provider),
|
|
181
|
+
request: Request = None,
|
|
182
|
+
session: SqlSessionDep = None,
|
|
183
|
+
) -> Principal:
|
|
184
|
+
token = request.cookies.get(_cookie) if request else None
|
|
185
|
+
if not token:
|
|
186
|
+
return base
|
|
187
|
+
try:
|
|
188
|
+
payload = _verify(token, secret=_secret)
|
|
189
|
+
except Exception:
|
|
190
|
+
return base
|
|
191
|
+
# Load target user
|
|
192
|
+
target_id = payload.get("target_id")
|
|
193
|
+
if not target_id:
|
|
194
|
+
return base
|
|
195
|
+
# Preserve actor roles/claims so permissions remain that of the actor
|
|
196
|
+
actor_user = getattr(base, "user", None)
|
|
197
|
+
actor_roles = getattr(actor_user, "roles", []) or []
|
|
198
|
+
_res = (
|
|
199
|
+
user_getter(request, target_id)
|
|
200
|
+
if user_getter
|
|
201
|
+
else _default_user_getter(request, target_id, session)
|
|
202
|
+
)
|
|
203
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
204
|
+
# Swap user but keep actor for audit if needed
|
|
205
|
+
setattr(base, "actor", getattr(base, "user", None))
|
|
206
|
+
# If target lacks roles, inherit actor roles to maintain permission checks
|
|
207
|
+
try:
|
|
208
|
+
if not getattr(target, "roles", None):
|
|
209
|
+
setattr(target, "roles", actor_roles)
|
|
210
|
+
except Exception:
|
|
211
|
+
# Best-effort; if target object is immutable, fallback by wrapping
|
|
212
|
+
target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
|
|
213
|
+
base.user = target
|
|
214
|
+
base.via = "impersonated"
|
|
215
|
+
return base
|
|
216
|
+
|
|
217
|
+
app.dependency_overrides[_current_principal] = _override_current_principal
|
|
218
|
+
_override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
|
|
219
|
+
_override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
|
|
220
|
+
|
|
221
|
+
# Compose now (best-effort) and again on startup to wrap any later overrides
|
|
222
|
+
_compose_override()
|
|
223
|
+
try:
|
|
224
|
+
app.add_event_handler("startup", _compose_override)
|
|
225
|
+
except Exception:
|
|
226
|
+
# Best-effort; if app doesn't support event handlers, we already composed once
|
|
227
|
+
pass
|
|
228
|
+
app.state._admin_added = True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# no extra helpers
|
|
File without changes
|