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
|
@@ -19,6 +19,7 @@ from .models import (
|
|
|
19
19
|
PaySetupIntent,
|
|
20
20
|
PaySubscription,
|
|
21
21
|
)
|
|
22
|
+
from .provider.base import ProviderAdapter
|
|
22
23
|
from .provider.registry import get_provider_registry
|
|
23
24
|
from .schemas import (
|
|
24
25
|
BalanceSnapshotOut,
|
|
@@ -84,11 +85,11 @@ class PaymentsService:
|
|
|
84
85
|
self.session = session
|
|
85
86
|
self.tenant_id = tenant_id
|
|
86
87
|
self._provider_name = (provider_name or _default_provider_name()).lower()
|
|
87
|
-
self._adapter = None # resolved on first use
|
|
88
|
+
self._adapter: ProviderAdapter | None = None # resolved on first use
|
|
88
89
|
|
|
89
90
|
# --- internal helpers -----------------------------------------------------
|
|
90
91
|
|
|
91
|
-
def _get_adapter(self):
|
|
92
|
+
def _get_adapter(self) -> ProviderAdapter:
|
|
92
93
|
if self._adapter is not None:
|
|
93
94
|
return self._adapter
|
|
94
95
|
reg = get_provider_registry()
|
|
@@ -143,7 +144,9 @@ class PaymentsService:
|
|
|
143
144
|
|
|
144
145
|
# --- Intents --------------------------------------------------------------
|
|
145
146
|
|
|
146
|
-
async def create_intent(
|
|
147
|
+
async def create_intent(
|
|
148
|
+
self, user_id: Optional[str], data: IntentCreateIn
|
|
149
|
+
) -> IntentOut:
|
|
147
150
|
adapter = self._get_adapter()
|
|
148
151
|
out = await adapter.create_intent(data, user_id=user_id)
|
|
149
152
|
self.session.add(
|
|
@@ -214,7 +217,9 @@ class PaymentsService:
|
|
|
214
217
|
|
|
215
218
|
# --- Webhooks -------------------------------------------------------------
|
|
216
219
|
|
|
217
|
-
async def handle_webhook(
|
|
220
|
+
async def handle_webhook(
|
|
221
|
+
self, provider: str, signature: str | None, payload: bytes
|
|
222
|
+
) -> dict:
|
|
218
223
|
adapter = self._get_adapter()
|
|
219
224
|
parsed = await adapter.verify_and_parse_webhook(signature, payload)
|
|
220
225
|
self.session.add(
|
|
@@ -312,7 +317,9 @@ class PaymentsService:
|
|
|
312
317
|
)
|
|
313
318
|
)
|
|
314
319
|
|
|
315
|
-
async def attach_payment_method(
|
|
320
|
+
async def attach_payment_method(
|
|
321
|
+
self, data: PaymentMethodAttachIn
|
|
322
|
+
) -> PaymentMethodOut:
|
|
316
323
|
out = await self._get_adapter().attach_payment_method(data)
|
|
317
324
|
# Upsert locally for quick listing
|
|
318
325
|
pm = PayPaymentMethod(
|
|
@@ -329,7 +336,9 @@ class PaymentsService:
|
|
|
329
336
|
self.session.add(pm)
|
|
330
337
|
return out
|
|
331
338
|
|
|
332
|
-
async def list_payment_methods(
|
|
339
|
+
async def list_payment_methods(
|
|
340
|
+
self, provider_customer_id: str
|
|
341
|
+
) -> list[PaymentMethodOut]:
|
|
333
342
|
return await self._get_adapter().list_payment_methods(provider_customer_id)
|
|
334
343
|
|
|
335
344
|
async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
|
|
@@ -392,14 +401,18 @@ class PaymentsService:
|
|
|
392
401
|
async def update_subscription(
|
|
393
402
|
self, provider_subscription_id: str, data: SubscriptionUpdateIn
|
|
394
403
|
) -> SubscriptionOut:
|
|
395
|
-
out = await self._get_adapter().update_subscription(
|
|
404
|
+
out = await self._get_adapter().update_subscription(
|
|
405
|
+
provider_subscription_id, data
|
|
406
|
+
)
|
|
396
407
|
# Optionally reflect status/quantity locally (query + update if exists)
|
|
397
408
|
return out
|
|
398
409
|
|
|
399
410
|
async def cancel_subscription(
|
|
400
411
|
self, provider_subscription_id: str, at_period_end: bool = True
|
|
401
412
|
) -> SubscriptionOut:
|
|
402
|
-
out = await self._get_adapter().cancel_subscription(
|
|
413
|
+
out = await self._get_adapter().cancel_subscription(
|
|
414
|
+
provider_subscription_id, at_period_end
|
|
415
|
+
)
|
|
403
416
|
return out
|
|
404
417
|
|
|
405
418
|
# --- Invoices ---
|
|
@@ -444,15 +457,15 @@ class PaymentsService:
|
|
|
444
457
|
q = select(
|
|
445
458
|
func.date_trunc("day", LedgerEntry.ts).label("day"),
|
|
446
459
|
LedgerEntry.currency,
|
|
447
|
-
func.sum(
|
|
448
|
-
"
|
|
449
|
-
),
|
|
450
|
-
func.sum(
|
|
451
|
-
"
|
|
452
|
-
),
|
|
453
|
-
func.sum(
|
|
454
|
-
"
|
|
455
|
-
),
|
|
460
|
+
func.sum(
|
|
461
|
+
func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)
|
|
462
|
+
).label("gross"),
|
|
463
|
+
func.sum(
|
|
464
|
+
func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)
|
|
465
|
+
).label("refunds"),
|
|
466
|
+
func.sum(
|
|
467
|
+
func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)
|
|
468
|
+
).label("fees"),
|
|
456
469
|
func.count().label("count"),
|
|
457
470
|
)
|
|
458
471
|
if date_from:
|
|
@@ -465,9 +478,9 @@ class PaymentsService:
|
|
|
465
478
|
q = q.where(LedgerEntry.ts <= datetime.fromisoformat(date_to))
|
|
466
479
|
except Exception:
|
|
467
480
|
pass
|
|
468
|
-
q = q.group_by(
|
|
469
|
-
func.date_trunc("day", LedgerEntry.ts).
|
|
470
|
-
)
|
|
481
|
+
q = q.group_by(
|
|
482
|
+
func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency
|
|
483
|
+
).order_by(func.date_trunc("day", LedgerEntry.ts).desc())
|
|
471
484
|
|
|
472
485
|
rows = (await self.session.execute(q)).all()
|
|
473
486
|
out: list[StatementRow] = []
|
|
@@ -489,9 +502,12 @@ class PaymentsService:
|
|
|
489
502
|
)
|
|
490
503
|
return out
|
|
491
504
|
|
|
492
|
-
async def capture_intent(
|
|
505
|
+
async def capture_intent(
|
|
506
|
+
self, provider_intent_id: str, data: CaptureIn
|
|
507
|
+
) -> IntentOut:
|
|
493
508
|
out = await self._get_adapter().capture_intent(
|
|
494
|
-
provider_intent_id,
|
|
509
|
+
provider_intent_id,
|
|
510
|
+
amount=int(data.amount) if data.amount is not None else None,
|
|
495
511
|
)
|
|
496
512
|
pi = await self.session.scalar(
|
|
497
513
|
select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
|
|
@@ -523,7 +539,9 @@ class PaymentsService:
|
|
|
523
539
|
)
|
|
524
540
|
return out
|
|
525
541
|
|
|
526
|
-
async def list_intents(
|
|
542
|
+
async def list_intents(
|
|
543
|
+
self, f: IntentListFilter
|
|
544
|
+
) -> tuple[list[IntentOut], str | None]:
|
|
527
545
|
return await self._get_adapter().list_intents(
|
|
528
546
|
customer_provider_id=f.customer_provider_id,
|
|
529
547
|
status=f.status,
|
|
@@ -535,9 +553,13 @@ class PaymentsService:
|
|
|
535
553
|
async def add_invoice_line_item(
|
|
536
554
|
self, provider_invoice_id: str, data: InvoiceLineItemIn
|
|
537
555
|
) -> InvoiceOut:
|
|
538
|
-
return await self._get_adapter().add_invoice_line_item(
|
|
556
|
+
return await self._get_adapter().add_invoice_line_item(
|
|
557
|
+
provider_invoice_id, data
|
|
558
|
+
)
|
|
539
559
|
|
|
540
|
-
async def list_invoices(
|
|
560
|
+
async def list_invoices(
|
|
561
|
+
self, f: InvoicesListFilter
|
|
562
|
+
) -> tuple[list[InvoiceOut], str | None]:
|
|
541
563
|
return await self._get_adapter().list_invoices(
|
|
542
564
|
customer_provider_id=f.customer_provider_id,
|
|
543
565
|
status=f.status,
|
|
@@ -574,7 +596,9 @@ class PaymentsService:
|
|
|
574
596
|
)
|
|
575
597
|
return out
|
|
576
598
|
|
|
577
|
-
async def confirm_setup_intent(
|
|
599
|
+
async def confirm_setup_intent(
|
|
600
|
+
self, provider_setup_intent_id: str
|
|
601
|
+
) -> SetupIntentOut:
|
|
578
602
|
out = await self._get_adapter().confirm_setup_intent(provider_setup_intent_id)
|
|
579
603
|
row = await self.session.scalar(
|
|
580
604
|
select(PaySetupIntent).where(
|
|
@@ -625,13 +649,17 @@ class PaymentsService:
|
|
|
625
649
|
async def list_disputes(
|
|
626
650
|
self, *, status: Optional[str], limit: int, cursor: Optional[str]
|
|
627
651
|
) -> tuple[list[DisputeOut], Optional[str]]:
|
|
628
|
-
return await self._get_adapter().list_disputes(
|
|
652
|
+
return await self._get_adapter().list_disputes(
|
|
653
|
+
status=status, limit=limit, cursor=cursor
|
|
654
|
+
)
|
|
629
655
|
|
|
630
656
|
async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
|
|
631
657
|
out = await self._get_adapter().get_dispute(provider_dispute_id)
|
|
632
658
|
# Upsert locally
|
|
633
659
|
row = await self.session.scalar(
|
|
634
|
-
select(PayDispute).where(
|
|
660
|
+
select(PayDispute).where(
|
|
661
|
+
PayDispute.provider_dispute_id == provider_dispute_id
|
|
662
|
+
)
|
|
635
663
|
)
|
|
636
664
|
if row:
|
|
637
665
|
row.status = out.status
|
|
@@ -652,11 +680,17 @@ class PaymentsService:
|
|
|
652
680
|
)
|
|
653
681
|
return out
|
|
654
682
|
|
|
655
|
-
async def submit_dispute_evidence(
|
|
656
|
-
|
|
683
|
+
async def submit_dispute_evidence(
|
|
684
|
+
self, provider_dispute_id: str, evidence: dict
|
|
685
|
+
) -> DisputeOut:
|
|
686
|
+
out = await self._get_adapter().submit_dispute_evidence(
|
|
687
|
+
provider_dispute_id, evidence
|
|
688
|
+
)
|
|
657
689
|
# reflect status
|
|
658
690
|
row = await self.session.scalar(
|
|
659
|
-
select(PayDispute).where(
|
|
691
|
+
select(PayDispute).where(
|
|
692
|
+
PayDispute.provider_dispute_id == provider_dispute_id
|
|
693
|
+
)
|
|
660
694
|
)
|
|
661
695
|
if row:
|
|
662
696
|
row.status = out.status
|
|
@@ -726,11 +760,16 @@ class PaymentsService:
|
|
|
726
760
|
return len(rows)
|
|
727
761
|
|
|
728
762
|
# ---- Customers ----
|
|
729
|
-
async def list_customers(
|
|
763
|
+
async def list_customers(
|
|
764
|
+
self, f: CustomersListFilter
|
|
765
|
+
) -> tuple[list[CustomerOut], str | None]:
|
|
730
766
|
adapter = self._get_adapter()
|
|
731
767
|
try:
|
|
732
768
|
return await adapter.list_customers(
|
|
733
|
-
provider=f.provider,
|
|
769
|
+
provider=f.provider,
|
|
770
|
+
user_id=f.user_id,
|
|
771
|
+
limit=f.limit or 50,
|
|
772
|
+
cursor=f.cursor,
|
|
734
773
|
)
|
|
735
774
|
except NotImplementedError:
|
|
736
775
|
# Fallback to local DB listing
|
|
@@ -766,7 +805,9 @@ class PaymentsService:
|
|
|
766
805
|
raise RuntimeError("Customer not found")
|
|
767
806
|
# upsert locally
|
|
768
807
|
row = await self.session.scalar(
|
|
769
|
-
select(PayCustomer).where(
|
|
808
|
+
select(PayCustomer).where(
|
|
809
|
+
PayCustomer.provider_customer_id == provider_customer_id
|
|
810
|
+
)
|
|
770
811
|
)
|
|
771
812
|
if not row:
|
|
772
813
|
self.session.add(
|
|
@@ -786,13 +827,19 @@ class PaymentsService:
|
|
|
786
827
|
async def list_products(
|
|
787
828
|
self, *, active: bool | None, limit: int, cursor: str | None
|
|
788
829
|
) -> tuple[list[ProductOut], str | None]:
|
|
789
|
-
return await self._get_adapter().list_products(
|
|
830
|
+
return await self._get_adapter().list_products(
|
|
831
|
+
active=active, limit=limit, cursor=cursor
|
|
832
|
+
)
|
|
790
833
|
|
|
791
|
-
async def update_product(
|
|
834
|
+
async def update_product(
|
|
835
|
+
self, provider_product_id: str, data: ProductUpdateIn
|
|
836
|
+
) -> ProductOut:
|
|
792
837
|
out = await self._get_adapter().update_product(provider_product_id, data)
|
|
793
838
|
# reflect DB
|
|
794
839
|
row = await self.session.scalar(
|
|
795
|
-
select(PayProduct).where(
|
|
840
|
+
select(PayProduct).where(
|
|
841
|
+
PayProduct.provider_product_id == provider_product_id
|
|
842
|
+
)
|
|
796
843
|
)
|
|
797
844
|
if row:
|
|
798
845
|
if data.name is not None:
|
|
@@ -813,10 +860,15 @@ class PaymentsService:
|
|
|
813
860
|
cursor: str | None,
|
|
814
861
|
) -> tuple[list[PriceOut], str | None]:
|
|
815
862
|
return await self._get_adapter().list_prices(
|
|
816
|
-
provider_product_id=provider_product_id,
|
|
863
|
+
provider_product_id=provider_product_id,
|
|
864
|
+
active=active,
|
|
865
|
+
limit=limit,
|
|
866
|
+
cursor=cursor,
|
|
817
867
|
)
|
|
818
868
|
|
|
819
|
-
async def update_price(
|
|
869
|
+
async def update_price(
|
|
870
|
+
self, provider_price_id: str, data: PriceUpdateIn
|
|
871
|
+
) -> PriceOut:
|
|
820
872
|
out = await self._get_adapter().update_price(provider_price_id, data)
|
|
821
873
|
row = await self.session.scalar(
|
|
822
874
|
select(PayPrice).where(PayPrice.provider_price_id == provider_price_id)
|
|
@@ -838,7 +890,10 @@ class PaymentsService:
|
|
|
838
890
|
cursor: str | None,
|
|
839
891
|
) -> tuple[list[SubscriptionOut], str | None]:
|
|
840
892
|
return await self._get_adapter().list_subscriptions(
|
|
841
|
-
customer_provider_id=customer_provider_id,
|
|
893
|
+
customer_provider_id=customer_provider_id,
|
|
894
|
+
status=status,
|
|
895
|
+
limit=limit,
|
|
896
|
+
cursor=cursor,
|
|
842
897
|
)
|
|
843
898
|
|
|
844
899
|
# ---- Payment Methods (get/update) ----
|
|
@@ -868,7 +923,9 @@ class PaymentsService:
|
|
|
868
923
|
self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
|
|
869
924
|
) -> tuple[list[RefundOut], str | None]:
|
|
870
925
|
return await self._get_adapter().list_refunds(
|
|
871
|
-
provider_payment_intent_id=provider_payment_intent_id,
|
|
926
|
+
provider_payment_intent_id=provider_payment_intent_id,
|
|
927
|
+
limit=limit,
|
|
928
|
+
cursor=cursor,
|
|
872
929
|
)
|
|
873
930
|
|
|
874
931
|
async def get_refund(self, provider_refund_id: str) -> RefundOut:
|
|
@@ -7,7 +7,11 @@ from pydantic import BaseModel, SecretStr
|
|
|
7
7
|
|
|
8
8
|
STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
|
|
9
9
|
STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
|
|
10
|
-
PROVIDER = (
|
|
10
|
+
PROVIDER = (
|
|
11
|
+
os.getenv("APF_PAYMENTS_PROVIDER")
|
|
12
|
+
or os.getenv("PAYMENTS_PROVIDER", "stripe")
|
|
13
|
+
or "stripe"
|
|
14
|
+
).lower()
|
|
11
15
|
|
|
12
16
|
AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
|
|
13
17
|
AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
|
svc_infra/api/__init__.py
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""svc-infra API module.
|
|
2
|
+
|
|
3
|
+
Re-exports key API utilities from svc_infra.api.fastapi for convenient imports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# Re-export from fastapi submodule
|
|
9
|
+
from svc_infra.api.fastapi import (
|
|
10
|
+
# Dual routers
|
|
11
|
+
DualAPIRouter,
|
|
12
|
+
dualize_protected,
|
|
13
|
+
dualize_public,
|
|
14
|
+
dualize_user,
|
|
15
|
+
# Service setup
|
|
16
|
+
ServiceInfo,
|
|
17
|
+
APIVersionSpec,
|
|
18
|
+
setup_service_api,
|
|
19
|
+
easy_service_api,
|
|
20
|
+
easy_service_app,
|
|
21
|
+
setup_caching,
|
|
22
|
+
# Health checks
|
|
23
|
+
add_startup_probe,
|
|
24
|
+
add_health_routes,
|
|
25
|
+
add_dependency_health,
|
|
26
|
+
check_database,
|
|
27
|
+
check_redis,
|
|
28
|
+
check_url,
|
|
29
|
+
# Pagination
|
|
30
|
+
use_pagination,
|
|
31
|
+
text_filter,
|
|
32
|
+
sort_by,
|
|
33
|
+
cursor_window,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Dual routers
|
|
38
|
+
"DualAPIRouter",
|
|
39
|
+
"dualize_protected",
|
|
40
|
+
"dualize_public",
|
|
41
|
+
"dualize_user",
|
|
42
|
+
# Service setup
|
|
43
|
+
"ServiceInfo",
|
|
44
|
+
"APIVersionSpec",
|
|
45
|
+
"setup_service_api",
|
|
46
|
+
"easy_service_api",
|
|
47
|
+
"easy_service_app",
|
|
48
|
+
"setup_caching",
|
|
49
|
+
# Health checks
|
|
50
|
+
"add_startup_probe",
|
|
51
|
+
"add_health_routes",
|
|
52
|
+
"add_dependency_health",
|
|
53
|
+
"check_database",
|
|
54
|
+
"check_redis",
|
|
55
|
+
"check_url",
|
|
56
|
+
# Pagination
|
|
57
|
+
"use_pagination",
|
|
58
|
+
"text_filter",
|
|
59
|
+
"sort_by",
|
|
60
|
+
"cursor_window",
|
|
61
|
+
]
|
|
@@ -5,6 +5,14 @@ from svc_infra.api.fastapi.dual import (
|
|
|
5
5
|
dualize_user,
|
|
6
6
|
)
|
|
7
7
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
8
|
+
from svc_infra.health import (
|
|
9
|
+
add_dependency_health,
|
|
10
|
+
add_health_routes,
|
|
11
|
+
add_startup_probe,
|
|
12
|
+
check_database,
|
|
13
|
+
check_redis,
|
|
14
|
+
check_url,
|
|
15
|
+
)
|
|
8
16
|
|
|
9
17
|
from .cache.add import setup_caching
|
|
10
18
|
from .ease import easy_service_api, easy_service_app
|
|
@@ -18,6 +26,13 @@ __all__ = [
|
|
|
18
26
|
"dualize_protected",
|
|
19
27
|
"ServiceInfo",
|
|
20
28
|
"APIVersionSpec",
|
|
29
|
+
# Health
|
|
30
|
+
"add_startup_probe",
|
|
31
|
+
"add_health_routes",
|
|
32
|
+
"add_dependency_health",
|
|
33
|
+
"check_database",
|
|
34
|
+
"check_redis",
|
|
35
|
+
"check_url",
|
|
21
36
|
# Ease
|
|
22
37
|
"setup_service_api",
|
|
23
38
|
"easy_service_api",
|
|
@@ -0,0 +1,245 @@
|
|
|
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, cast
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
15
|
+
|
|
16
|
+
from ....app.env import get_current_environment, require_secret
|
|
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 cast(dict[Any, Any], 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 cast(APIRouter, 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 = require_secret(
|
|
92
|
+
secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
|
|
93
|
+
"ADMIN_IMPERSONATION_SECRET or APP_SECRET",
|
|
94
|
+
dev_default="dev-only-admin-impersonation-secret-not-for-production",
|
|
95
|
+
)
|
|
96
|
+
_ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
|
|
97
|
+
_cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
|
|
98
|
+
|
|
99
|
+
r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
|
|
100
|
+
|
|
101
|
+
async def _default_user_getter(
|
|
102
|
+
request: Request, user_id: str, session: SqlSessionDep
|
|
103
|
+
):
|
|
104
|
+
try:
|
|
105
|
+
UserModel, _, _ = get_auth_state()
|
|
106
|
+
except Exception:
|
|
107
|
+
# Fallback: simple shim if auth state not configured
|
|
108
|
+
return SimpleNamespace(id=user_id)
|
|
109
|
+
obj = await cast(Any, session).get(UserModel, user_id)
|
|
110
|
+
if not obj:
|
|
111
|
+
raise HTTPException(404, "user_not_found")
|
|
112
|
+
return obj
|
|
113
|
+
|
|
114
|
+
user_getter = impersonation_user_getter
|
|
115
|
+
|
|
116
|
+
@r.post(
|
|
117
|
+
"/impersonate/start",
|
|
118
|
+
status_code=204,
|
|
119
|
+
dependencies=[RequirePermission("admin.impersonate")],
|
|
120
|
+
)
|
|
121
|
+
async def start_impersonation(
|
|
122
|
+
body: dict,
|
|
123
|
+
request: Request,
|
|
124
|
+
response: Response,
|
|
125
|
+
session: SqlSessionDep,
|
|
126
|
+
identity: Identity,
|
|
127
|
+
):
|
|
128
|
+
target_id = (body or {}).get("user_id")
|
|
129
|
+
reason = (body or {}).get("reason", "")
|
|
130
|
+
if not target_id:
|
|
131
|
+
raise HTTPException(422, "user_id_required")
|
|
132
|
+
# Load target for validation (custom getter or default)
|
|
133
|
+
_res = (
|
|
134
|
+
user_getter(request, target_id)
|
|
135
|
+
if user_getter
|
|
136
|
+
else _default_user_getter(request, target_id, session)
|
|
137
|
+
)
|
|
138
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
139
|
+
actor: Principal = identity
|
|
140
|
+
payload = {
|
|
141
|
+
"actor_id": getattr(getattr(actor, "user", None), "id", None),
|
|
142
|
+
"target_id": str(getattr(target, "id", target_id)),
|
|
143
|
+
"iat": int(time.time()),
|
|
144
|
+
"exp": int(time.time()) + _ttl,
|
|
145
|
+
"nonce": _b64u(os.urandom(8)),
|
|
146
|
+
}
|
|
147
|
+
token = _sign(payload, secret=_secret)
|
|
148
|
+
response.set_cookie(
|
|
149
|
+
key=_cookie,
|
|
150
|
+
value=token,
|
|
151
|
+
httponly=True,
|
|
152
|
+
samesite="lax",
|
|
153
|
+
secure=(env in ("prod", "production")),
|
|
154
|
+
path="/",
|
|
155
|
+
max_age=_ttl,
|
|
156
|
+
)
|
|
157
|
+
logger.info(
|
|
158
|
+
"admin.impersonation.started",
|
|
159
|
+
extra={
|
|
160
|
+
"actor_id": payload["actor_id"],
|
|
161
|
+
"target_id": payload["target_id"],
|
|
162
|
+
"reason": reason,
|
|
163
|
+
"expires_in": _ttl,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
# Re-compose override now to wrap any late overrides set by tests/harness
|
|
167
|
+
try:
|
|
168
|
+
_compose_override()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
@r.post("/impersonate/stop", status_code=204)
|
|
173
|
+
async def stop_impersonation(response: Response):
|
|
174
|
+
response.delete_cookie(_cookie, path="/")
|
|
175
|
+
logger.info("admin.impersonation.stopped")
|
|
176
|
+
|
|
177
|
+
app.include_router(r)
|
|
178
|
+
|
|
179
|
+
# Dependency override: wrap the base principal to honor impersonation cookie.
|
|
180
|
+
# Compose with any existing override (e.g., acceptance app/test harness) and
|
|
181
|
+
# re-compose at startup to capture late overrides.
|
|
182
|
+
def _compose_override():
|
|
183
|
+
existing = app.dependency_overrides.get(_current_principal)
|
|
184
|
+
if existing and getattr(existing, "_is_admin_impersonation_override", False):
|
|
185
|
+
dep_provider = getattr(
|
|
186
|
+
existing, "_admin_impersonation_base", _current_principal
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
dep_provider = existing or _current_principal
|
|
190
|
+
|
|
191
|
+
async def _override_current_principal(
|
|
192
|
+
request: Request,
|
|
193
|
+
session: SqlSessionDep,
|
|
194
|
+
base: Principal = Depends(dep_provider),
|
|
195
|
+
) -> Principal:
|
|
196
|
+
token = request.cookies.get(_cookie) if request else None
|
|
197
|
+
if not token:
|
|
198
|
+
return base
|
|
199
|
+
try:
|
|
200
|
+
payload = _verify(token, secret=_secret)
|
|
201
|
+
except Exception:
|
|
202
|
+
return base
|
|
203
|
+
# Load target user
|
|
204
|
+
target_id = payload.get("target_id")
|
|
205
|
+
if not target_id:
|
|
206
|
+
return base
|
|
207
|
+
# Preserve actor roles/claims so permissions remain that of the actor
|
|
208
|
+
actor_user = getattr(base, "user", None)
|
|
209
|
+
actor_roles = getattr(actor_user, "roles", []) or []
|
|
210
|
+
_res = (
|
|
211
|
+
user_getter(request, target_id)
|
|
212
|
+
if user_getter
|
|
213
|
+
else _default_user_getter(request, target_id, session)
|
|
214
|
+
)
|
|
215
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
216
|
+
# Swap user but keep actor for audit if needed
|
|
217
|
+
setattr(base, "actor", getattr(base, "user", None))
|
|
218
|
+
# If target lacks roles, inherit actor roles to maintain permission checks
|
|
219
|
+
try:
|
|
220
|
+
if not getattr(target, "roles", None):
|
|
221
|
+
setattr(target, "roles", actor_roles)
|
|
222
|
+
except Exception:
|
|
223
|
+
# Best-effort; if target object is immutable, fallback by wrapping
|
|
224
|
+
target = SimpleNamespace(
|
|
225
|
+
id=getattr(target, "id", target_id), roles=actor_roles
|
|
226
|
+
)
|
|
227
|
+
base.user = target
|
|
228
|
+
base.via = "impersonated"
|
|
229
|
+
return base
|
|
230
|
+
|
|
231
|
+
app.dependency_overrides[_current_principal] = _override_current_principal
|
|
232
|
+
_override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
|
|
233
|
+
_override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
|
|
234
|
+
|
|
235
|
+
# Compose now (best-effort) and again on startup to wrap any later overrides
|
|
236
|
+
_compose_override()
|
|
237
|
+
try:
|
|
238
|
+
app.add_event_handler("startup", _compose_override)
|
|
239
|
+
except Exception:
|
|
240
|
+
# Best-effort; if app doesn't support event handlers, we already composed once
|
|
241
|
+
pass
|
|
242
|
+
app.state._admin_added = True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# no extra helpers
|