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,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
3
|
from sqlalchemy import select
|
|
6
4
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
5
|
|
|
@@ -19,6 +17,7 @@ from .models import (
|
|
|
19
17
|
PaySetupIntent,
|
|
20
18
|
PaySubscription,
|
|
21
19
|
)
|
|
20
|
+
from .provider.base import ProviderAdapter
|
|
22
21
|
from .provider.registry import get_provider_registry
|
|
23
22
|
from .schemas import (
|
|
24
23
|
BalanceSnapshotOut,
|
|
@@ -77,18 +76,18 @@ class PaymentsService:
|
|
|
77
76
|
session: AsyncSession,
|
|
78
77
|
*,
|
|
79
78
|
tenant_id: str,
|
|
80
|
-
provider_name:
|
|
79
|
+
provider_name: str | None = None,
|
|
81
80
|
):
|
|
82
81
|
if not tenant_id:
|
|
83
82
|
raise ValueError("tenant_id is required for PaymentsService")
|
|
84
83
|
self.session = session
|
|
85
84
|
self.tenant_id = tenant_id
|
|
86
85
|
self._provider_name = (provider_name or _default_provider_name()).lower()
|
|
87
|
-
self._adapter = None # resolved on first use
|
|
86
|
+
self._adapter: ProviderAdapter | None = None # resolved on first use
|
|
88
87
|
|
|
89
88
|
# --- internal helpers -----------------------------------------------------
|
|
90
89
|
|
|
91
|
-
def _get_adapter(self):
|
|
90
|
+
def _get_adapter(self) -> ProviderAdapter:
|
|
92
91
|
if self._adapter is not None:
|
|
93
92
|
return self._adapter
|
|
94
93
|
reg = get_provider_registry()
|
|
@@ -143,7 +142,7 @@ class PaymentsService:
|
|
|
143
142
|
|
|
144
143
|
# --- Intents --------------------------------------------------------------
|
|
145
144
|
|
|
146
|
-
async def create_intent(self, user_id:
|
|
145
|
+
async def create_intent(self, user_id: str | None, data: IntentCreateIn) -> IntentOut:
|
|
147
146
|
adapter = self._get_adapter()
|
|
148
147
|
out = await adapter.create_intent(data, user_id=user_id)
|
|
149
148
|
self.session.add(
|
|
@@ -491,7 +490,8 @@ class PaymentsService:
|
|
|
491
490
|
|
|
492
491
|
async def capture_intent(self, provider_intent_id: str, data: CaptureIn) -> IntentOut:
|
|
493
492
|
out = await self._get_adapter().capture_intent(
|
|
494
|
-
provider_intent_id,
|
|
493
|
+
provider_intent_id,
|
|
494
|
+
amount=int(data.amount) if data.amount is not None else None,
|
|
495
495
|
)
|
|
496
496
|
pi = await self.session.scalar(
|
|
497
497
|
select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
|
|
@@ -623,8 +623,8 @@ class PaymentsService:
|
|
|
623
623
|
|
|
624
624
|
# --- Disputes -------------------------------------------------------------
|
|
625
625
|
async def list_disputes(
|
|
626
|
-
self, *, status:
|
|
627
|
-
) -> tuple[list[DisputeOut],
|
|
626
|
+
self, *, status: str | None, limit: int, cursor: str | None
|
|
627
|
+
) -> tuple[list[DisputeOut], str | None]:
|
|
628
628
|
return await self._get_adapter().list_disputes(status=status, limit=limit, cursor=cursor)
|
|
629
629
|
|
|
630
630
|
async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
|
|
@@ -668,8 +668,8 @@ class PaymentsService:
|
|
|
668
668
|
|
|
669
669
|
# --- Payouts --------------------------------------------------------------
|
|
670
670
|
async def list_payouts(
|
|
671
|
-
self, *, limit: int, cursor:
|
|
672
|
-
) -> tuple[list[PayoutOut],
|
|
671
|
+
self, *, limit: int, cursor: str | None
|
|
672
|
+
) -> tuple[list[PayoutOut], str | None]:
|
|
673
673
|
return await self._get_adapter().list_payouts(limit=limit, cursor=cursor)
|
|
674
674
|
|
|
675
675
|
async def get_payout(self, provider_payout_id: str) -> PayoutOut:
|
|
@@ -699,7 +699,7 @@ class PaymentsService:
|
|
|
699
699
|
|
|
700
700
|
# --- Webhook replay -------------------------------------------------------
|
|
701
701
|
async def replay_webhooks(
|
|
702
|
-
self, since:
|
|
702
|
+
self, since: str | None, until: str | None, event_ids: list[str]
|
|
703
703
|
) -> int:
|
|
704
704
|
from datetime import datetime
|
|
705
705
|
|
|
@@ -730,7 +730,10 @@ class PaymentsService:
|
|
|
730
730
|
adapter = self._get_adapter()
|
|
731
731
|
try:
|
|
732
732
|
return await adapter.list_customers(
|
|
733
|
-
provider=f.provider,
|
|
733
|
+
provider=f.provider,
|
|
734
|
+
user_id=f.user_id,
|
|
735
|
+
limit=f.limit or 50,
|
|
736
|
+
cursor=f.cursor,
|
|
734
737
|
)
|
|
735
738
|
except NotImplementedError:
|
|
736
739
|
# Fallback to local DB listing
|
|
@@ -813,7 +816,10 @@ class PaymentsService:
|
|
|
813
816
|
cursor: str | None,
|
|
814
817
|
) -> tuple[list[PriceOut], str | None]:
|
|
815
818
|
return await self._get_adapter().list_prices(
|
|
816
|
-
provider_product_id=provider_product_id,
|
|
819
|
+
provider_product_id=provider_product_id,
|
|
820
|
+
active=active,
|
|
821
|
+
limit=limit,
|
|
822
|
+
cursor=cursor,
|
|
817
823
|
)
|
|
818
824
|
|
|
819
825
|
async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
|
|
@@ -838,7 +844,10 @@ class PaymentsService:
|
|
|
838
844
|
cursor: str | None,
|
|
839
845
|
) -> tuple[list[SubscriptionOut], str | None]:
|
|
840
846
|
return await self._get_adapter().list_subscriptions(
|
|
841
|
-
customer_provider_id=customer_provider_id,
|
|
847
|
+
customer_provider_id=customer_provider_id,
|
|
848
|
+
status=status,
|
|
849
|
+
limit=limit,
|
|
850
|
+
cursor=cursor,
|
|
842
851
|
)
|
|
843
852
|
|
|
844
853
|
# ---- Payment Methods (get/update) ----
|
|
@@ -868,7 +877,9 @@ class PaymentsService:
|
|
|
868
877
|
self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
|
|
869
878
|
) -> tuple[list[RefundOut], str | None]:
|
|
870
879
|
return await self._get_adapter().list_refunds(
|
|
871
|
-
provider_payment_intent_id=provider_payment_intent_id,
|
|
880
|
+
provider_payment_intent_id=provider_payment_intent_id,
|
|
881
|
+
limit=limit,
|
|
882
|
+
cursor=cursor,
|
|
872
883
|
)
|
|
873
884
|
|
|
874
885
|
async def get_refund(self, provider_refund_id: str) -> RefundOut:
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from typing import Optional
|
|
5
4
|
|
|
6
5
|
from pydantic import BaseModel, SecretStr
|
|
7
6
|
|
|
8
7
|
STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
|
|
9
8
|
STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
|
|
10
|
-
PROVIDER = (
|
|
9
|
+
PROVIDER = (
|
|
10
|
+
os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe") or "stripe"
|
|
11
|
+
).lower()
|
|
11
12
|
|
|
12
13
|
AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
|
|
13
14
|
AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
|
|
@@ -19,23 +20,23 @@ AIYDAN_WH = os.getenv("AIYDAN_WH_SECRET")
|
|
|
19
20
|
|
|
20
21
|
class StripeConfig(BaseModel):
|
|
21
22
|
secret_key: SecretStr
|
|
22
|
-
webhook_secret:
|
|
23
|
+
webhook_secret: SecretStr | None = None
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class AiydanConfig(BaseModel):
|
|
26
27
|
api_key: SecretStr
|
|
27
|
-
client_key:
|
|
28
|
-
merchant_account:
|
|
29
|
-
hmac_key:
|
|
30
|
-
base_url:
|
|
31
|
-
webhook_secret:
|
|
28
|
+
client_key: SecretStr | None = None
|
|
29
|
+
merchant_account: str | None = None
|
|
30
|
+
hmac_key: SecretStr | None = None
|
|
31
|
+
base_url: str | None = None
|
|
32
|
+
webhook_secret: SecretStr | None = None
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class PaymentsSettings(BaseModel):
|
|
35
36
|
default_provider: str = PROVIDER
|
|
36
37
|
|
|
37
38
|
# optional multi-tenant/provider map hook can be added later
|
|
38
|
-
stripe:
|
|
39
|
+
stripe: StripeConfig | None = (
|
|
39
40
|
StripeConfig(
|
|
40
41
|
secret_key=SecretStr(STRIPE_KEY),
|
|
41
42
|
webhook_secret=SecretStr(STRIPE_WH) if STRIPE_WH else None,
|
|
@@ -43,7 +44,7 @@ class PaymentsSettings(BaseModel):
|
|
|
43
44
|
if STRIPE_KEY
|
|
44
45
|
else None
|
|
45
46
|
)
|
|
46
|
-
aiydan:
|
|
47
|
+
aiydan: AiydanConfig | None = (
|
|
47
48
|
AiydanConfig(
|
|
48
49
|
api_key=SecretStr(AIYDAN_KEY),
|
|
49
50
|
client_key=SecretStr(AIYDAN_CLIENT_KEY) if AIYDAN_CLIENT_KEY else None,
|
|
@@ -57,7 +58,7 @@ class PaymentsSettings(BaseModel):
|
|
|
57
58
|
)
|
|
58
59
|
|
|
59
60
|
|
|
60
|
-
_SETTINGS:
|
|
61
|
+
_SETTINGS: PaymentsSettings | None = None
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
def get_payments_settings() -> PaymentsSettings:
|
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
|
+
APIVersionSpec,
|
|
11
|
+
# Dual routers
|
|
12
|
+
DualAPIRouter,
|
|
13
|
+
# Service setup
|
|
14
|
+
ServiceInfo,
|
|
15
|
+
add_dependency_health,
|
|
16
|
+
add_health_routes,
|
|
17
|
+
# Health checks
|
|
18
|
+
add_startup_probe,
|
|
19
|
+
check_database,
|
|
20
|
+
check_redis,
|
|
21
|
+
check_url,
|
|
22
|
+
cursor_window,
|
|
23
|
+
dualize_protected,
|
|
24
|
+
dualize_public,
|
|
25
|
+
dualize_user,
|
|
26
|
+
easy_service_api,
|
|
27
|
+
easy_service_app,
|
|
28
|
+
setup_caching,
|
|
29
|
+
setup_service_api,
|
|
30
|
+
sort_by,
|
|
31
|
+
text_filter,
|
|
32
|
+
# Pagination
|
|
33
|
+
use_pagination,
|
|
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
|
+
]
|
|
@@ -4,7 +4,25 @@ from svc_infra.api.fastapi.dual import (
|
|
|
4
4
|
dualize_public,
|
|
5
5
|
dualize_user,
|
|
6
6
|
)
|
|
7
|
+
from svc_infra.api.fastapi.object_router import (
|
|
8
|
+
DEFAULT_EXCEPTION_MAP,
|
|
9
|
+
STATUS_TITLES,
|
|
10
|
+
endpoint,
|
|
11
|
+
endpoint_exclude,
|
|
12
|
+
map_exception_to_http,
|
|
13
|
+
router_from_object,
|
|
14
|
+
router_from_object_with_websocket,
|
|
15
|
+
websocket_endpoint,
|
|
16
|
+
)
|
|
7
17
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
18
|
+
from svc_infra.health import (
|
|
19
|
+
add_dependency_health,
|
|
20
|
+
add_health_routes,
|
|
21
|
+
add_startup_probe,
|
|
22
|
+
check_database,
|
|
23
|
+
check_redis,
|
|
24
|
+
check_url,
|
|
25
|
+
)
|
|
8
26
|
|
|
9
27
|
from .cache.add import setup_caching
|
|
10
28
|
from .ease import easy_service_api, easy_service_app
|
|
@@ -18,6 +36,13 @@ __all__ = [
|
|
|
18
36
|
"dualize_protected",
|
|
19
37
|
"ServiceInfo",
|
|
20
38
|
"APIVersionSpec",
|
|
39
|
+
# Health
|
|
40
|
+
"add_startup_probe",
|
|
41
|
+
"add_health_routes",
|
|
42
|
+
"add_dependency_health",
|
|
43
|
+
"check_database",
|
|
44
|
+
"check_redis",
|
|
45
|
+
"check_url",
|
|
21
46
|
# Ease
|
|
22
47
|
"setup_service_api",
|
|
23
48
|
"easy_service_api",
|
|
@@ -28,4 +53,13 @@ __all__ = [
|
|
|
28
53
|
"text_filter",
|
|
29
54
|
"sort_by",
|
|
30
55
|
"cursor_window",
|
|
56
|
+
# Object Router
|
|
57
|
+
"router_from_object",
|
|
58
|
+
"router_from_object_with_websocket",
|
|
59
|
+
"endpoint",
|
|
60
|
+
"endpoint_exclude",
|
|
61
|
+
"websocket_endpoint",
|
|
62
|
+
"map_exception_to_http",
|
|
63
|
+
"DEFAULT_EXCEPTION_MAP",
|
|
64
|
+
"STATUS_TITLES",
|
|
31
65
|
]
|
|
@@ -0,0 +1,240 @@
|
|
|
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 collections.abc import Callable
|
|
11
|
+
from hashlib import sha256
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
16
|
+
|
|
17
|
+
from ....app.env import get_current_environment, require_secret
|
|
18
|
+
from ....security.permissions import RequirePermission
|
|
19
|
+
from ..auth.security import Identity, Principal, _current_principal
|
|
20
|
+
from ..auth.state import get_auth_state
|
|
21
|
+
from ..db.sql.session import SqlSessionDep
|
|
22
|
+
from ..dual.protected import roles_router
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _b64u(data: bytes) -> str:
|
|
28
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _b64u_decode(s: str) -> bytes:
|
|
32
|
+
pad = "=" * ((4 - len(s) % 4) % 4)
|
|
33
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sign(payload: dict, *, secret: str) -> str:
|
|
37
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
38
|
+
sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
39
|
+
return _b64u(body) + "." + _b64u(sig)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _verify(token: str, *, secret: str) -> dict:
|
|
43
|
+
try:
|
|
44
|
+
b64_body, b64_sig = token.split(".", 1)
|
|
45
|
+
body = _b64u_decode(b64_body)
|
|
46
|
+
exp_sig = _b64u_decode(b64_sig)
|
|
47
|
+
got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
48
|
+
if not hmac.compare_digest(exp_sig, got_sig):
|
|
49
|
+
raise ValueError("bad_signature")
|
|
50
|
+
payload = json.loads(body)
|
|
51
|
+
if int(payload.get("exp", 0)) < int(time.time()):
|
|
52
|
+
raise ValueError("expired")
|
|
53
|
+
return cast("dict[Any, Any]", payload)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise ValueError("invalid_token") from e
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def admin_router(*, dependencies: list[Any] | None = None, **kwargs) -> APIRouter:
|
|
59
|
+
"""Role-gated admin router for coarse access control.
|
|
60
|
+
|
|
61
|
+
Use permission guards inside endpoints for fine-grained control.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return cast("APIRouter", roles_router("admin", **kwargs))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_admin(
|
|
68
|
+
app,
|
|
69
|
+
*,
|
|
70
|
+
base_path: str = "/admin",
|
|
71
|
+
enable_impersonation: bool = True,
|
|
72
|
+
secret: str | None = None,
|
|
73
|
+
ttl_seconds: int = 15 * 60,
|
|
74
|
+
cookie_name: str = "impersonation",
|
|
75
|
+
impersonation_user_getter: Callable[[Any, str], Any] | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Wire admin surfaces with sensible defaults.
|
|
78
|
+
|
|
79
|
+
- Mounts an admin router under base_path.
|
|
80
|
+
- Optionally enables impersonation start/stop endpoints guarded by permissions.
|
|
81
|
+
- Registers a dependency override to honor impersonation cookie globally (idempotent).
|
|
82
|
+
|
|
83
|
+
impersonation_user_getter: optional callable (request, user_id) -> user object.
|
|
84
|
+
If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# Idempotency: only mount once per app instance
|
|
88
|
+
if getattr(app.state, "_admin_added", False):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
env = get_current_environment()
|
|
92
|
+
_secret = require_secret(
|
|
93
|
+
secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
|
|
94
|
+
"ADMIN_IMPERSONATION_SECRET or APP_SECRET",
|
|
95
|
+
dev_default="dev-only-admin-impersonation-secret-not-for-production",
|
|
96
|
+
)
|
|
97
|
+
_ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
|
|
98
|
+
_cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
|
|
99
|
+
|
|
100
|
+
r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
|
|
101
|
+
|
|
102
|
+
async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
|
|
103
|
+
try:
|
|
104
|
+
UserModel, _, _ = get_auth_state()
|
|
105
|
+
except Exception:
|
|
106
|
+
# Fallback: simple shim if auth state not configured
|
|
107
|
+
return SimpleNamespace(id=user_id)
|
|
108
|
+
obj = await cast("Any", session).get(UserModel, user_id)
|
|
109
|
+
if not obj:
|
|
110
|
+
raise HTTPException(404, "user_not_found")
|
|
111
|
+
return obj
|
|
112
|
+
|
|
113
|
+
user_getter = impersonation_user_getter
|
|
114
|
+
|
|
115
|
+
@r.post(
|
|
116
|
+
"/impersonate/start",
|
|
117
|
+
status_code=204,
|
|
118
|
+
dependencies=[RequirePermission("admin.impersonate")],
|
|
119
|
+
)
|
|
120
|
+
async def start_impersonation(
|
|
121
|
+
body: dict,
|
|
122
|
+
request: Request,
|
|
123
|
+
response: Response,
|
|
124
|
+
session: SqlSessionDep,
|
|
125
|
+
identity: Identity,
|
|
126
|
+
):
|
|
127
|
+
target_id = (body or {}).get("user_id")
|
|
128
|
+
reason = (body or {}).get("reason", "")
|
|
129
|
+
if not target_id:
|
|
130
|
+
raise HTTPException(422, "user_id_required")
|
|
131
|
+
# Load target for validation (custom getter or default)
|
|
132
|
+
_res = (
|
|
133
|
+
user_getter(request, target_id)
|
|
134
|
+
if user_getter
|
|
135
|
+
else _default_user_getter(request, target_id, session)
|
|
136
|
+
)
|
|
137
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
138
|
+
actor: Principal = identity
|
|
139
|
+
payload = {
|
|
140
|
+
"actor_id": getattr(getattr(actor, "user", None), "id", None),
|
|
141
|
+
"target_id": str(getattr(target, "id", target_id)),
|
|
142
|
+
"iat": int(time.time()),
|
|
143
|
+
"exp": int(time.time()) + _ttl,
|
|
144
|
+
"nonce": _b64u(os.urandom(8)),
|
|
145
|
+
}
|
|
146
|
+
token = _sign(payload, secret=_secret)
|
|
147
|
+
response.set_cookie(
|
|
148
|
+
key=_cookie,
|
|
149
|
+
value=token,
|
|
150
|
+
httponly=True,
|
|
151
|
+
samesite="lax",
|
|
152
|
+
secure=(env in ("prod", "production")),
|
|
153
|
+
path="/",
|
|
154
|
+
max_age=_ttl,
|
|
155
|
+
)
|
|
156
|
+
logger.info(
|
|
157
|
+
"admin.impersonation.started",
|
|
158
|
+
extra={
|
|
159
|
+
"actor_id": payload["actor_id"],
|
|
160
|
+
"target_id": payload["target_id"],
|
|
161
|
+
"reason": reason,
|
|
162
|
+
"expires_in": _ttl,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
# Re-compose override now to wrap any late overrides set by tests/harness
|
|
166
|
+
try:
|
|
167
|
+
_compose_override()
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
@r.post("/impersonate/stop", status_code=204)
|
|
172
|
+
async def stop_impersonation(response: Response):
|
|
173
|
+
response.delete_cookie(_cookie, path="/")
|
|
174
|
+
logger.info("admin.impersonation.stopped")
|
|
175
|
+
|
|
176
|
+
app.include_router(r)
|
|
177
|
+
|
|
178
|
+
# Dependency override: wrap the base principal to honor impersonation cookie.
|
|
179
|
+
# Compose with any existing override (e.g., acceptance app/test harness) and
|
|
180
|
+
# re-compose at startup to capture late overrides.
|
|
181
|
+
def _compose_override():
|
|
182
|
+
existing = app.dependency_overrides.get(_current_principal)
|
|
183
|
+
if existing and getattr(existing, "_is_admin_impersonation_override", False):
|
|
184
|
+
dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
|
|
185
|
+
else:
|
|
186
|
+
dep_provider = existing or _current_principal
|
|
187
|
+
|
|
188
|
+
async def _override_current_principal(
|
|
189
|
+
request: Request,
|
|
190
|
+
session: SqlSessionDep,
|
|
191
|
+
base: Principal = Depends(dep_provider),
|
|
192
|
+
) -> Principal:
|
|
193
|
+
token = request.cookies.get(_cookie) if request else None
|
|
194
|
+
if not token:
|
|
195
|
+
return base
|
|
196
|
+
try:
|
|
197
|
+
payload = _verify(token, secret=_secret)
|
|
198
|
+
except Exception:
|
|
199
|
+
return base
|
|
200
|
+
# Load target user
|
|
201
|
+
target_id = payload.get("target_id")
|
|
202
|
+
if not target_id:
|
|
203
|
+
return base
|
|
204
|
+
# Preserve actor roles/claims so permissions remain that of the actor
|
|
205
|
+
actor_user = getattr(base, "user", None)
|
|
206
|
+
actor_roles = getattr(actor_user, "roles", []) or []
|
|
207
|
+
_res = (
|
|
208
|
+
user_getter(request, target_id)
|
|
209
|
+
if user_getter
|
|
210
|
+
else _default_user_getter(request, target_id, session)
|
|
211
|
+
)
|
|
212
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
213
|
+
# Swap user but keep actor for audit if needed
|
|
214
|
+
base.actor = getattr(base, "user", None) # type: ignore[attr-defined]
|
|
215
|
+
# If target lacks roles, inherit actor roles to maintain permission checks
|
|
216
|
+
try:
|
|
217
|
+
if not getattr(target, "roles", None):
|
|
218
|
+
target.roles = actor_roles
|
|
219
|
+
except Exception:
|
|
220
|
+
# Best-effort; if target object is immutable, fallback by wrapping
|
|
221
|
+
target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
|
|
222
|
+
base.user = target
|
|
223
|
+
base.via = "impersonated"
|
|
224
|
+
return base
|
|
225
|
+
|
|
226
|
+
app.dependency_overrides[_current_principal] = _override_current_principal
|
|
227
|
+
_override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
|
|
228
|
+
_override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
|
|
229
|
+
|
|
230
|
+
# Compose now (best-effort) and again on startup to wrap any later overrides
|
|
231
|
+
_compose_override()
|
|
232
|
+
try:
|
|
233
|
+
app.add_event_handler("startup", _compose_override)
|
|
234
|
+
except Exception:
|
|
235
|
+
# Best-effort; if app doesn't support event handlers, we already composed once
|
|
236
|
+
pass
|
|
237
|
+
app.state._admin_added = True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# no extra helpers
|