svc-infra 0.1.589__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/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- 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 +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- 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 +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- 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 +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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 +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- 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 +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- 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 -56
- 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/models_schemas/auth/schemas.py.tmpl +1 -1
- 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 +52 -0
- 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 +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- 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.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
|
-
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
from typing import Annotated, Any, Callable, Optional, cast
|
|
5
5
|
|
|
6
6
|
from fastapi import Depends, HTTPException, Request
|
|
7
7
|
from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer
|
|
@@ -16,8 +16,12 @@ from svc_infra.db.sql.apikey import get_apikey_model
|
|
|
16
16
|
|
|
17
17
|
# ---------- OpenAPI security schemes (appear in docs) ----------
|
|
18
18
|
auth_login_path = USER_PREFIX + LOGIN_PATH
|
|
19
|
-
oauth2_scheme_optional = OAuth2PasswordBearer(
|
|
20
|
-
|
|
19
|
+
oauth2_scheme_optional = OAuth2PasswordBearer(
|
|
20
|
+
tokenUrl=auth_login_path, auto_error=False
|
|
21
|
+
)
|
|
22
|
+
cookie_auth_optional = APIKeyCookie(
|
|
23
|
+
name=get_auth_settings().auth_cookie_name, auto_error=False
|
|
24
|
+
)
|
|
21
25
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
22
26
|
|
|
23
27
|
|
|
@@ -26,7 +30,12 @@ class Principal:
|
|
|
26
30
|
"""Unified identity: user via JWT/cookie or service via API key."""
|
|
27
31
|
|
|
28
32
|
def __init__(
|
|
29
|
-
self,
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
user=None,
|
|
36
|
+
scopes: list[str] | None = None,
|
|
37
|
+
via: str = "jwt",
|
|
38
|
+
api_key=None,
|
|
30
39
|
):
|
|
31
40
|
self.user = user
|
|
32
41
|
self.scopes = scopes or []
|
|
@@ -51,7 +60,11 @@ async def resolve_api_key(
|
|
|
51
60
|
apikey = None
|
|
52
61
|
if prefix:
|
|
53
62
|
apikey = (
|
|
54
|
-
(
|
|
63
|
+
(
|
|
64
|
+
await session.execute(
|
|
65
|
+
select(ApiKey).where(ApiKey.key_prefix == prefix) # type: ignore[attr-defined]
|
|
66
|
+
)
|
|
67
|
+
)
|
|
55
68
|
.scalars()
|
|
56
69
|
.first()
|
|
57
70
|
)
|
|
@@ -69,7 +82,9 @@ async def resolve_api_key(
|
|
|
69
82
|
|
|
70
83
|
apikey.mark_used()
|
|
71
84
|
await session.flush()
|
|
72
|
-
return Principal(
|
|
85
|
+
return Principal(
|
|
86
|
+
user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey
|
|
87
|
+
)
|
|
73
88
|
|
|
74
89
|
|
|
75
90
|
async def resolve_bearer_or_cookie_principal(
|
|
@@ -77,7 +92,11 @@ async def resolve_bearer_or_cookie_principal(
|
|
|
77
92
|
) -> Optional[Principal]:
|
|
78
93
|
st = get_auth_settings()
|
|
79
94
|
raw_auth = (request.headers.get("authorization") or "").strip()
|
|
80
|
-
token =
|
|
95
|
+
token = (
|
|
96
|
+
raw_auth.split(" ", 1)[1].strip()
|
|
97
|
+
if raw_auth.lower().startswith("bearer ")
|
|
98
|
+
else ""
|
|
99
|
+
)
|
|
81
100
|
if not token:
|
|
82
101
|
token = (request.cookies.get(st.auth_cookie_name) or "").strip()
|
|
83
102
|
if not token:
|
|
@@ -89,7 +108,7 @@ async def resolve_bearer_or_cookie_principal(
|
|
|
89
108
|
from fastapi_users.manager import BaseUserManager, UUIDIDMixin
|
|
90
109
|
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
|
|
91
110
|
|
|
92
|
-
user_db = SQLAlchemyUserDatabase(session, UserModel)
|
|
111
|
+
user_db: Any = SQLAlchemyUserDatabase(session, UserModel)
|
|
93
112
|
|
|
94
113
|
class _ShimManager(UUIDIDMixin, BaseUserManager[Any, Any]):
|
|
95
114
|
reset_password_token_secret = "unused"
|
|
@@ -107,7 +126,7 @@ async def resolve_bearer_or_cookie_principal(
|
|
|
107
126
|
if not user:
|
|
108
127
|
return None
|
|
109
128
|
|
|
110
|
-
db_user = await session.get(UserModel, user.id)
|
|
129
|
+
db_user = await cast(Any, session).get(UserModel, user.id)
|
|
111
130
|
if not db_user:
|
|
112
131
|
return None
|
|
113
132
|
if not getattr(db_user, "is_active", True):
|
|
@@ -154,7 +173,9 @@ AllowIdentity = Depends(_optional_principal) # same, but optional
|
|
|
154
173
|
# ---------- DX: small guard factories ----------
|
|
155
174
|
def RequireRoles(*roles: str, resolver: Callable[[Any], list[str]] | None = None):
|
|
156
175
|
async def _guard(p: Identity):
|
|
157
|
-
have = set(
|
|
176
|
+
have = set(
|
|
177
|
+
(resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
|
|
178
|
+
)
|
|
158
179
|
if not set(roles).issubset(have):
|
|
159
180
|
raise HTTPException(403, "forbidden")
|
|
160
181
|
return p
|
|
@@ -20,7 +20,9 @@ class ConsoleSender:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class SMTPSender:
|
|
23
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self, host: str, port: int, username: str, password: str, from_addr: str
|
|
25
|
+
) -> None:
|
|
24
26
|
self.host = host
|
|
25
27
|
self.port = port
|
|
26
28
|
self.username = username
|
|
@@ -59,4 +61,9 @@ def get_sender() -> Sender:
|
|
|
59
61
|
if not configured:
|
|
60
62
|
return ConsoleSender()
|
|
61
63
|
|
|
64
|
+
# At this point, all values must be set
|
|
65
|
+
assert host is not None
|
|
66
|
+
assert user is not None
|
|
67
|
+
assert pw is not None
|
|
68
|
+
assert frm is not None
|
|
62
69
|
return SMTPSender(host, st.smtp_port, user, pw, frm)
|
|
@@ -18,6 +18,8 @@ class OIDCProvider(BaseModel):
|
|
|
18
18
|
class JWTSettings(BaseModel):
|
|
19
19
|
secret: SecretStr
|
|
20
20
|
lifetime_seconds: int = 60 * 60 * 24 * 7
|
|
21
|
+
# Optional older secrets accepted for verification during rotation window
|
|
22
|
+
old_secrets: List[SecretStr] = Field(default_factory=list)
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class PasswordClient(BaseModel):
|
|
@@ -19,7 +19,9 @@ def set_auth_state(
|
|
|
19
19
|
|
|
20
20
|
def get_auth_state() -> tuple[type, Callable[[], Any], str]:
|
|
21
21
|
if _UserModel is None or _GetStrategy is None:
|
|
22
|
-
raise RuntimeError(
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"Auth state not initialized; call set_auth_state() in add_auth_users()."
|
|
24
|
+
)
|
|
23
25
|
return _UserModel, _GetStrategy, _AuthPrefix
|
|
24
26
|
|
|
25
27
|
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""WebSocket authentication primitives.
|
|
2
|
+
|
|
3
|
+
This module provides lightweight JWT-based authentication for WebSocket endpoints.
|
|
4
|
+
Unlike HTTP auth which requires DB access, WS auth uses JWT claims only, making it
|
|
5
|
+
suitable for high-frequency real-time connections.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from svc_infra.api.fastapi.auth.ws_security import WSIdentity
|
|
9
|
+
|
|
10
|
+
@router.websocket("/ws")
|
|
11
|
+
async def ws_handler(websocket: WebSocket, user: WSIdentity):
|
|
12
|
+
# user.id, user.email, user.scopes available from JWT claims
|
|
13
|
+
await websocket.accept()
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
For router-level dependencies (protects all endpoints):
|
|
17
|
+
from svc_infra.api.fastapi.auth.ws_security import RequireWSIdentity
|
|
18
|
+
|
|
19
|
+
router = DualAPIRouter(dependencies=[RequireWSIdentity])
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Annotated, Any, cast
|
|
26
|
+
|
|
27
|
+
import jwt
|
|
28
|
+
from fastapi import Depends, WebSocket, WebSocketException, status
|
|
29
|
+
|
|
30
|
+
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------- WSPrincipal ----------
|
|
34
|
+
@dataclass
|
|
35
|
+
class WSPrincipal:
|
|
36
|
+
"""Lightweight principal for WebSocket connections.
|
|
37
|
+
|
|
38
|
+
Unlike the HTTP `Principal` which loads the full user from DB,
|
|
39
|
+
`WSPrincipal` contains only JWT claims. This makes it suitable
|
|
40
|
+
for high-frequency real-time connections without DB overhead.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
id: User ID from JWT 'sub' claim (typically UUID string)
|
|
44
|
+
email: User email from JWT 'email' claim (if present)
|
|
45
|
+
scopes: List of scopes/permissions from JWT 'scopes' claim
|
|
46
|
+
claims: Full JWT payload for custom claim access
|
|
47
|
+
via: Authentication method ('query', 'header', 'subprotocol')
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
email: str | None = None
|
|
52
|
+
scopes: list[str] = field(default_factory=list)
|
|
53
|
+
claims: dict = field(default_factory=dict)
|
|
54
|
+
via: str = "query" # 'query' | 'header' | 'subprotocol'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------- Token extraction ----------
|
|
58
|
+
def _extract_token(websocket: WebSocket) -> tuple[str | None, str]:
|
|
59
|
+
"""Extract JWT token from WebSocket connection.
|
|
60
|
+
|
|
61
|
+
Tries extraction in order:
|
|
62
|
+
1. Query parameter: ?token=xxx
|
|
63
|
+
2. Authorization header: Bearer xxx
|
|
64
|
+
3. Sec-WebSocket-Protocol header (for browser clients that can't set headers)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Tuple of (token, source) where source is 'query', 'header', or 'subprotocol'
|
|
68
|
+
"""
|
|
69
|
+
# 1. Query parameter (most common for WebSocket)
|
|
70
|
+
token = websocket.query_params.get("token")
|
|
71
|
+
if token:
|
|
72
|
+
return token.strip(), "query"
|
|
73
|
+
|
|
74
|
+
# 2. Authorization header
|
|
75
|
+
auth_header = websocket.headers.get("authorization", "")
|
|
76
|
+
if auth_header.lower().startswith("bearer "):
|
|
77
|
+
token = auth_header.split(" ", 1)[1].strip()
|
|
78
|
+
if token:
|
|
79
|
+
return token, "header"
|
|
80
|
+
|
|
81
|
+
# 3. Sec-WebSocket-Protocol (browser workaround)
|
|
82
|
+
# Some clients send token as: Sec-WebSocket-Protocol: bearer, <token>
|
|
83
|
+
protocol = websocket.headers.get("sec-websocket-protocol", "")
|
|
84
|
+
if protocol:
|
|
85
|
+
parts = [p.strip() for p in protocol.split(",")]
|
|
86
|
+
# Look for token after 'bearer' protocol
|
|
87
|
+
for i, part in enumerate(parts):
|
|
88
|
+
if part.lower() == "bearer" and i + 1 < len(parts):
|
|
89
|
+
return parts[i + 1], "subprotocol"
|
|
90
|
+
|
|
91
|
+
return None, ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decode_jwt(token: str) -> dict:
|
|
95
|
+
"""Decode and validate JWT token.
|
|
96
|
+
|
|
97
|
+
Uses the same JWT settings as HTTP auth (AUTH_JWT__SECRET).
|
|
98
|
+
Supports key rotation via old_secrets.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
JWT payload dict
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
WebSocketException: If token is invalid or expired
|
|
105
|
+
"""
|
|
106
|
+
settings = get_auth_settings()
|
|
107
|
+
|
|
108
|
+
if not settings.jwt:
|
|
109
|
+
raise WebSocketException(
|
|
110
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
111
|
+
reason="JWT not configured",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
secret = settings.jwt.secret.get_secret_value()
|
|
115
|
+
old_secrets = [s.get_secret_value() for s in (settings.jwt.old_secrets or [])]
|
|
116
|
+
all_secrets = [secret] + old_secrets
|
|
117
|
+
|
|
118
|
+
last_error: Exception | None = None
|
|
119
|
+
|
|
120
|
+
for s in all_secrets:
|
|
121
|
+
try:
|
|
122
|
+
payload = jwt.decode(
|
|
123
|
+
token,
|
|
124
|
+
s,
|
|
125
|
+
algorithms=["HS256"],
|
|
126
|
+
options={"require": ["sub", "exp"]},
|
|
127
|
+
)
|
|
128
|
+
return cast(dict[Any, Any], payload)
|
|
129
|
+
except jwt.ExpiredSignatureError:
|
|
130
|
+
raise WebSocketException(
|
|
131
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
132
|
+
reason="Token expired",
|
|
133
|
+
)
|
|
134
|
+
except jwt.InvalidTokenError as e:
|
|
135
|
+
last_error = e
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# None of the secrets worked
|
|
139
|
+
raise WebSocketException(
|
|
140
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
141
|
+
reason=f"Invalid token: {last_error}",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------- Resolvers ----------
|
|
146
|
+
async def resolve_ws_bearer_principal(websocket: WebSocket) -> WSPrincipal | None:
|
|
147
|
+
"""Extract and validate JWT from WebSocket, returning WSPrincipal or None.
|
|
148
|
+
|
|
149
|
+
This is the optional resolver - returns None if no token present.
|
|
150
|
+
Use `_ws_current_principal` for required authentication.
|
|
151
|
+
|
|
152
|
+
Token sources (in order):
|
|
153
|
+
1. Query parameter: ?token=xxx
|
|
154
|
+
2. Authorization header: Bearer xxx
|
|
155
|
+
3. Sec-WebSocket-Protocol: bearer, xxx
|
|
156
|
+
"""
|
|
157
|
+
token, source = _extract_token(websocket)
|
|
158
|
+
if not token:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
payload = _decode_jwt(token)
|
|
162
|
+
|
|
163
|
+
return WSPrincipal(
|
|
164
|
+
id=str(payload.get("sub", "")),
|
|
165
|
+
email=payload.get("email"),
|
|
166
|
+
scopes=payload.get("scopes", []) or payload.get("scope", "").split(),
|
|
167
|
+
claims=payload,
|
|
168
|
+
via=source,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _ws_current_principal(
|
|
173
|
+
websocket: WebSocket,
|
|
174
|
+
principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
|
|
175
|
+
) -> WSPrincipal:
|
|
176
|
+
"""Require authenticated WebSocket connection.
|
|
177
|
+
|
|
178
|
+
Use this as a dependency to require authentication.
|
|
179
|
+
Closes connection with 1008 (Policy Violation) if no valid token.
|
|
180
|
+
"""
|
|
181
|
+
if not principal:
|
|
182
|
+
raise WebSocketException(
|
|
183
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
184
|
+
reason="Missing or invalid authentication",
|
|
185
|
+
)
|
|
186
|
+
return principal
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _ws_optional_principal(
|
|
190
|
+
websocket: WebSocket,
|
|
191
|
+
principal: WSPrincipal | None = Depends(resolve_ws_bearer_principal),
|
|
192
|
+
) -> WSPrincipal | None:
|
|
193
|
+
"""Optional WebSocket authentication.
|
|
194
|
+
|
|
195
|
+
Returns None if no token present, WSPrincipal if valid token.
|
|
196
|
+
"""
|
|
197
|
+
return principal
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------- DX: types for endpoint params ----------
|
|
201
|
+
WSIdentity = Annotated[WSPrincipal, Depends(_ws_current_principal)]
|
|
202
|
+
"""Annotated type for required WebSocket authentication.
|
|
203
|
+
|
|
204
|
+
Usage:
|
|
205
|
+
@router.websocket("/ws")
|
|
206
|
+
async def handler(websocket: WebSocket, user: WSIdentity):
|
|
207
|
+
# user.id, user.email, user.scopes available
|
|
208
|
+
...
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
OptionalWSIdentity = Annotated[WSPrincipal | None, Depends(_ws_optional_principal)]
|
|
212
|
+
"""Annotated type for optional WebSocket authentication.
|
|
213
|
+
|
|
214
|
+
Usage:
|
|
215
|
+
@router.websocket("/ws")
|
|
216
|
+
async def handler(websocket: WebSocket, user: OptionalWSIdentity):
|
|
217
|
+
if user:
|
|
218
|
+
# authenticated
|
|
219
|
+
else:
|
|
220
|
+
# anonymous
|
|
221
|
+
...
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------- DX: constants for router-level dependencies ----------
|
|
226
|
+
RequireWSIdentity = Depends(_ws_current_principal)
|
|
227
|
+
"""Router-level dependency for required WebSocket authentication.
|
|
228
|
+
|
|
229
|
+
Usage:
|
|
230
|
+
router = DualAPIRouter(dependencies=[RequireWSIdentity])
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
AllowWSIdentity = Depends(_ws_optional_principal)
|
|
234
|
+
"""Router-level dependency for optional WebSocket authentication.
|
|
235
|
+
|
|
236
|
+
Usage:
|
|
237
|
+
router = DualAPIRouter(dependencies=[AllowWSIdentity])
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------- DX: guard factories ----------
|
|
242
|
+
def RequireWSScopes(*needed: str):
|
|
243
|
+
"""Require specific scopes for WebSocket connection.
|
|
244
|
+
|
|
245
|
+
Usage:
|
|
246
|
+
router = DualAPIRouter(dependencies=[RequireWSScopes("chat:read", "chat:write")])
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
async def _guard(principal: WSIdentity) -> WSPrincipal:
|
|
250
|
+
if not set(needed).issubset(set(principal.scopes or [])):
|
|
251
|
+
raise WebSocketException(
|
|
252
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
253
|
+
reason="Insufficient scope",
|
|
254
|
+
)
|
|
255
|
+
return principal
|
|
256
|
+
|
|
257
|
+
return Depends(_guard)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def RequireWSAnyScope(*candidates: str):
|
|
261
|
+
"""Require at least one of the specified scopes.
|
|
262
|
+
|
|
263
|
+
Usage:
|
|
264
|
+
router = DualAPIRouter(dependencies=[RequireWSAnyScope("admin", "moderator")])
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
async def _guard(principal: WSIdentity) -> WSPrincipal:
|
|
268
|
+
if not set(principal.scopes or []) & set(candidates):
|
|
269
|
+
raise WebSocketException(
|
|
270
|
+
code=status.WS_1008_POLICY_VIOLATION,
|
|
271
|
+
reason="Insufficient scope",
|
|
272
|
+
)
|
|
273
|
+
return principal
|
|
274
|
+
|
|
275
|
+
return Depends(_guard)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Response, status
|
|
7
|
+
|
|
8
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
9
|
+
from svc_infra.api.fastapi.middleware.idempotency import require_idempotency_key
|
|
10
|
+
from svc_infra.api.fastapi.tenancy.context import TenantId
|
|
11
|
+
from svc_infra.billing.async_service import AsyncBillingService
|
|
12
|
+
from svc_infra.billing.schemas import (
|
|
13
|
+
UsageAckOut,
|
|
14
|
+
UsageAggregateRow,
|
|
15
|
+
UsageAggregatesOut,
|
|
16
|
+
UsageIn,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/_billing", tags=["Billing"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
|
|
23
|
+
return AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post(
|
|
27
|
+
"/usage",
|
|
28
|
+
name="billing_record_usage",
|
|
29
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
30
|
+
response_model=UsageAckOut,
|
|
31
|
+
dependencies=[Depends(require_idempotency_key)],
|
|
32
|
+
)
|
|
33
|
+
async def record_usage(
|
|
34
|
+
data: UsageIn,
|
|
35
|
+
svc: Annotated[AsyncBillingService, Depends(get_service)],
|
|
36
|
+
response: Response,
|
|
37
|
+
):
|
|
38
|
+
at = data.at or datetime.now(tz=timezone.utc)
|
|
39
|
+
evt_id = await svc.record_usage(
|
|
40
|
+
metric=data.metric,
|
|
41
|
+
amount=int(data.amount),
|
|
42
|
+
at=at,
|
|
43
|
+
idempotency_key=data.idempotency_key,
|
|
44
|
+
metadata=data.metadata,
|
|
45
|
+
)
|
|
46
|
+
# For 202, no Location header is required, but we can surface the id in the body
|
|
47
|
+
return UsageAckOut(id=evt_id, accepted=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get(
|
|
51
|
+
"/usage",
|
|
52
|
+
name="billing_list_aggregates",
|
|
53
|
+
response_model=UsageAggregatesOut,
|
|
54
|
+
)
|
|
55
|
+
async def list_aggregates(
|
|
56
|
+
metric: str,
|
|
57
|
+
date_from: Optional[datetime] = None,
|
|
58
|
+
date_to: Optional[datetime] = None,
|
|
59
|
+
svc: Annotated[AsyncBillingService, Depends(get_service)] = None, # type: ignore[assignment]
|
|
60
|
+
):
|
|
61
|
+
rows = await svc.list_daily_aggregates(
|
|
62
|
+
metric=metric, date_from=date_from, date_to=date_to
|
|
63
|
+
)
|
|
64
|
+
items = [
|
|
65
|
+
UsageAggregateRow(
|
|
66
|
+
period_start=r.period_start,
|
|
67
|
+
granularity=r.granularity,
|
|
68
|
+
metric=r.metric,
|
|
69
|
+
total=int(r.total),
|
|
70
|
+
)
|
|
71
|
+
for r in rows
|
|
72
|
+
]
|
|
73
|
+
return UsageAggregatesOut(items=items, next_cursor=None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
|
|
5
|
+
from .router import router as billing_router
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_billing(app: FastAPI, *, prefix: str = "/_billing") -> None:
|
|
9
|
+
# Mount under the chosen prefix; default is /_billing
|
|
10
|
+
if prefix and prefix != "/_billing":
|
|
11
|
+
# If a custom prefix is desired, clone router with new prefix
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
|
|
14
|
+
custom = APIRouter(prefix=prefix, tags=["Billing"])
|
|
15
|
+
for route in billing_router.routes:
|
|
16
|
+
custom.routes.append(route)
|
|
17
|
+
app.include_router(custom)
|
|
18
|
+
else:
|
|
19
|
+
app.include_router(billing_router)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
1
3
|
from fastapi import FastAPI
|
|
2
4
|
|
|
3
5
|
from svc_infra.cache.backend import shutdown_cache
|
|
@@ -5,10 +7,12 @@ from svc_infra.cache.decorators import init_cache
|
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
def setup_caching(app: FastAPI) -> None:
|
|
8
|
-
@
|
|
9
|
-
async def
|
|
10
|
+
@asynccontextmanager
|
|
11
|
+
async def lifespan(_app: FastAPI):
|
|
10
12
|
init_cache()
|
|
13
|
+
try:
|
|
14
|
+
yield
|
|
15
|
+
finally:
|
|
16
|
+
await shutdown_cache()
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
async def _shutdown():
|
|
14
|
-
await shutdown_cache()
|
|
18
|
+
app.router.lifespan_context = lifespan
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
from svc_infra.api.fastapi.db.nosql import
|
|
1
|
+
from svc_infra.api.fastapi.db.nosql import (
|
|
2
|
+
add_mongo_db,
|
|
3
|
+
add_mongo_health,
|
|
4
|
+
add_mongo_resources,
|
|
5
|
+
)
|
|
2
6
|
from svc_infra.api.fastapi.db.sql import add_sql_db, add_sql_health, add_sql_resources
|
|
3
7
|
|
|
4
8
|
__all__ = [
|
svc_infra/api/fastapi/db/http.py
CHANGED
|
@@ -25,7 +25,9 @@ class OrderParams(BaseModel):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def dep_order(
|
|
28
|
-
order_by: Optional[str] = Query(
|
|
28
|
+
order_by: Optional[str] = Query(
|
|
29
|
+
None, description="Comma-separated fields; '-' for DESC"
|
|
30
|
+
),
|
|
29
31
|
) -> OrderParams:
|
|
30
32
|
return OrderParams(order_by=order_by)
|
|
31
33
|
|
|
@@ -1,4 +1,42 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from svc_infra.db.nosql.resource import NoSqlResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _missing_mongo_dependency() -> ModuleNotFoundError:
|
|
13
|
+
return ModuleNotFoundError(
|
|
14
|
+
"MongoDB support is an optional dependency. Install pymongo (and motor) to use "
|
|
15
|
+
"Mongo helpers like add_mongo_db/add_mongo_health/add_mongo_resources."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from .mongo.add import add_mongo_db, add_mongo_health, add_mongo_resources
|
|
21
|
+
except ModuleNotFoundError as exc:
|
|
22
|
+
mongo_import_error = exc
|
|
23
|
+
|
|
24
|
+
# NOTE: pymongo provides `bson`, which can be absent in minimal installs/CI.
|
|
25
|
+
# We keep imports working for non-mongo users/tests by providing stubs.
|
|
26
|
+
def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
27
|
+
raise _missing_mongo_dependency() from mongo_import_error
|
|
28
|
+
|
|
29
|
+
def add_mongo_health(
|
|
30
|
+
app: FastAPI,
|
|
31
|
+
*,
|
|
32
|
+
prefix: str = "/_mongo/health",
|
|
33
|
+
include_in_schema: bool = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
raise _missing_mongo_dependency() from mongo_import_error
|
|
36
|
+
|
|
37
|
+
def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
|
|
38
|
+
raise _missing_mongo_dependency() from mongo_import_error
|
|
39
|
+
|
|
2
40
|
|
|
3
41
|
__all__ = [
|
|
4
42
|
# MongoDB
|