svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +28 -8
- svc_infra/cli/cmds/__init__.py +8 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- 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 +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/storage.md +982 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- 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 +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
15
|
+
|
|
16
|
+
from ....app.env import get_current_environment
|
|
17
|
+
from ....security.permissions import RequirePermission
|
|
18
|
+
from ..auth.security import Identity, Principal, _current_principal
|
|
19
|
+
from ..auth.state import get_auth_state
|
|
20
|
+
from ..db.sql.session import SqlSessionDep
|
|
21
|
+
from ..dual.protected import roles_router
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _b64u(data: bytes) -> str:
|
|
27
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _b64u_decode(s: str) -> bytes:
|
|
31
|
+
pad = "=" * ((4 - len(s) % 4) % 4)
|
|
32
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sign(payload: dict, *, secret: str) -> str:
|
|
36
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
37
|
+
sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
38
|
+
return _b64u(body) + "." + _b64u(sig)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _verify(token: str, *, secret: str) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
b64_body, b64_sig = token.split(".", 1)
|
|
44
|
+
body = _b64u_decode(b64_body)
|
|
45
|
+
exp_sig = _b64u_decode(b64_sig)
|
|
46
|
+
got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
|
|
47
|
+
if not hmac.compare_digest(exp_sig, got_sig):
|
|
48
|
+
raise ValueError("bad_signature")
|
|
49
|
+
payload = json.loads(body)
|
|
50
|
+
if int(payload.get("exp", 0)) < int(time.time()):
|
|
51
|
+
raise ValueError("expired")
|
|
52
|
+
return payload
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise ValueError("invalid_token") from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
|
|
58
|
+
"""Role-gated admin router for coarse access control.
|
|
59
|
+
|
|
60
|
+
Use permission guards inside endpoints for fine-grained control.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
return roles_router("admin", **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_admin(
|
|
67
|
+
app,
|
|
68
|
+
*,
|
|
69
|
+
base_path: str = "/admin",
|
|
70
|
+
enable_impersonation: bool = True,
|
|
71
|
+
secret: Optional[str] = None,
|
|
72
|
+
ttl_seconds: int = 15 * 60,
|
|
73
|
+
cookie_name: str = "impersonation",
|
|
74
|
+
impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Wire admin surfaces with sensible defaults.
|
|
77
|
+
|
|
78
|
+
- Mounts an admin router under base_path.
|
|
79
|
+
- Optionally enables impersonation start/stop endpoints guarded by permissions.
|
|
80
|
+
- Registers a dependency override to honor impersonation cookie globally (idempotent).
|
|
81
|
+
|
|
82
|
+
impersonation_user_getter: optional callable (request, user_id) -> user object.
|
|
83
|
+
If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Idempotency: only mount once per app instance
|
|
87
|
+
if getattr(app.state, "_admin_added", False):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
env = get_current_environment()
|
|
91
|
+
_secret = (
|
|
92
|
+
secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET") or "dev-secret"
|
|
93
|
+
)
|
|
94
|
+
_ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
|
|
95
|
+
_cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
|
|
96
|
+
|
|
97
|
+
r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
|
|
98
|
+
|
|
99
|
+
async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
|
|
100
|
+
try:
|
|
101
|
+
UserModel, _, _ = get_auth_state()
|
|
102
|
+
except Exception:
|
|
103
|
+
# Fallback: simple shim if auth state not configured
|
|
104
|
+
return SimpleNamespace(id=user_id)
|
|
105
|
+
obj = await session.get(UserModel, user_id)
|
|
106
|
+
if not obj:
|
|
107
|
+
raise HTTPException(404, "user_not_found")
|
|
108
|
+
return obj
|
|
109
|
+
|
|
110
|
+
user_getter = impersonation_user_getter
|
|
111
|
+
|
|
112
|
+
@r.post(
|
|
113
|
+
"/impersonate/start", status_code=204, dependencies=[RequirePermission("admin.impersonate")]
|
|
114
|
+
)
|
|
115
|
+
async def start_impersonation(
|
|
116
|
+
body: dict, request: Request, response: Response, session: SqlSessionDep, identity: Identity
|
|
117
|
+
):
|
|
118
|
+
target_id = (body or {}).get("user_id")
|
|
119
|
+
reason = (body or {}).get("reason", "")
|
|
120
|
+
if not target_id:
|
|
121
|
+
raise HTTPException(422, "user_id_required")
|
|
122
|
+
# Load target for validation (custom getter or default)
|
|
123
|
+
_res = (
|
|
124
|
+
user_getter(request, target_id)
|
|
125
|
+
if user_getter
|
|
126
|
+
else _default_user_getter(request, target_id, session)
|
|
127
|
+
)
|
|
128
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
129
|
+
actor: Principal = identity
|
|
130
|
+
payload = {
|
|
131
|
+
"actor_id": getattr(getattr(actor, "user", None), "id", None),
|
|
132
|
+
"target_id": str(getattr(target, "id", target_id)),
|
|
133
|
+
"iat": int(time.time()),
|
|
134
|
+
"exp": int(time.time()) + _ttl,
|
|
135
|
+
"nonce": _b64u(os.urandom(8)),
|
|
136
|
+
}
|
|
137
|
+
token = _sign(payload, secret=_secret)
|
|
138
|
+
response.set_cookie(
|
|
139
|
+
key=_cookie,
|
|
140
|
+
value=token,
|
|
141
|
+
httponly=True,
|
|
142
|
+
samesite="lax",
|
|
143
|
+
secure=(env in ("prod", "production")),
|
|
144
|
+
path="/",
|
|
145
|
+
max_age=_ttl,
|
|
146
|
+
)
|
|
147
|
+
logger.info(
|
|
148
|
+
"admin.impersonation.started",
|
|
149
|
+
extra={
|
|
150
|
+
"actor_id": payload["actor_id"],
|
|
151
|
+
"target_id": payload["target_id"],
|
|
152
|
+
"reason": reason,
|
|
153
|
+
"expires_in": _ttl,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
# Re-compose override now to wrap any late overrides set by tests/harness
|
|
157
|
+
try:
|
|
158
|
+
_compose_override()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
@r.post("/impersonate/stop", status_code=204)
|
|
163
|
+
async def stop_impersonation(response: Response):
|
|
164
|
+
response.delete_cookie(_cookie, path="/")
|
|
165
|
+
logger.info("admin.impersonation.stopped")
|
|
166
|
+
|
|
167
|
+
app.include_router(r)
|
|
168
|
+
|
|
169
|
+
# Dependency override: wrap the base principal to honor impersonation cookie.
|
|
170
|
+
# Compose with any existing override (e.g., acceptance app/test harness) and
|
|
171
|
+
# re-compose at startup to capture late overrides.
|
|
172
|
+
def _compose_override():
|
|
173
|
+
existing = app.dependency_overrides.get(_current_principal)
|
|
174
|
+
if existing and getattr(existing, "_is_admin_impersonation_override", False):
|
|
175
|
+
dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
|
|
176
|
+
else:
|
|
177
|
+
dep_provider = existing or _current_principal
|
|
178
|
+
|
|
179
|
+
async def _override_current_principal(
|
|
180
|
+
base: Principal = Depends(dep_provider),
|
|
181
|
+
request: Request = None,
|
|
182
|
+
session: SqlSessionDep = None,
|
|
183
|
+
) -> Principal:
|
|
184
|
+
token = request.cookies.get(_cookie) if request else None
|
|
185
|
+
if not token:
|
|
186
|
+
return base
|
|
187
|
+
try:
|
|
188
|
+
payload = _verify(token, secret=_secret)
|
|
189
|
+
except Exception:
|
|
190
|
+
return base
|
|
191
|
+
# Load target user
|
|
192
|
+
target_id = payload.get("target_id")
|
|
193
|
+
if not target_id:
|
|
194
|
+
return base
|
|
195
|
+
# Preserve actor roles/claims so permissions remain that of the actor
|
|
196
|
+
actor_user = getattr(base, "user", None)
|
|
197
|
+
actor_roles = getattr(actor_user, "roles", []) or []
|
|
198
|
+
_res = (
|
|
199
|
+
user_getter(request, target_id)
|
|
200
|
+
if user_getter
|
|
201
|
+
else _default_user_getter(request, target_id, session)
|
|
202
|
+
)
|
|
203
|
+
target = await _res if inspect.isawaitable(_res) else _res
|
|
204
|
+
# Swap user but keep actor for audit if needed
|
|
205
|
+
setattr(base, "actor", getattr(base, "user", None))
|
|
206
|
+
# If target lacks roles, inherit actor roles to maintain permission checks
|
|
207
|
+
try:
|
|
208
|
+
if not getattr(target, "roles", None):
|
|
209
|
+
setattr(target, "roles", actor_roles)
|
|
210
|
+
except Exception:
|
|
211
|
+
# Best-effort; if target object is immutable, fallback by wrapping
|
|
212
|
+
target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
|
|
213
|
+
base.user = target
|
|
214
|
+
base.via = "impersonated"
|
|
215
|
+
return base
|
|
216
|
+
|
|
217
|
+
app.dependency_overrides[_current_principal] = _override_current_principal
|
|
218
|
+
_override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
|
|
219
|
+
_override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
|
|
220
|
+
|
|
221
|
+
# Compose now (best-effort) and again on startup to wrap any later overrides
|
|
222
|
+
_compose_override()
|
|
223
|
+
try:
|
|
224
|
+
app.add_event_handler("startup", _compose_override)
|
|
225
|
+
except Exception:
|
|
226
|
+
# Best-effort; if app doesn't support event handlers, we already composed once
|
|
227
|
+
pass
|
|
228
|
+
app.state._admin_added = True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# no extra helpers
|
|
@@ -7,7 +7,6 @@ from fastapi import FastAPI
|
|
|
7
7
|
|
|
8
8
|
from svc_infra.apf_payments.provider.registry import get_provider_registry
|
|
9
9
|
from svc_infra.api.fastapi.apf_payments.router import build_payments_routers
|
|
10
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
11
10
|
|
|
12
11
|
logger = logging.getLogger(__name__)
|
|
13
12
|
|
|
@@ -51,7 +50,6 @@ def add_payments(
|
|
|
51
50
|
- Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
|
|
52
51
|
"""
|
|
53
52
|
_maybe_register_default_providers(register_default_providers, adapters)
|
|
54
|
-
add_prefixed_docs(app, prefix=prefix, title="Payments")
|
|
55
53
|
|
|
56
54
|
for r in build_payments_routers(prefix=prefix):
|
|
57
55
|
app.include_router(
|
|
@@ -17,7 +17,6 @@ from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
|
|
|
17
17
|
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
18
18
|
from svc_infra.db.sql.apikey import bind_apikey_model
|
|
19
19
|
|
|
20
|
-
from ..docs.scoped import add_prefixed_docs
|
|
21
20
|
from .policy import AuthPolicy, DefaultAuthPolicy
|
|
22
21
|
from .providers import providers_from_settings
|
|
23
22
|
from .settings import get_auth_settings
|
|
@@ -293,9 +292,6 @@ def add_auth_users(
|
|
|
293
292
|
https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
|
|
294
293
|
)
|
|
295
294
|
|
|
296
|
-
add_prefixed_docs(app, prefix=user_prefix, title="Users")
|
|
297
|
-
add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
|
|
298
|
-
|
|
299
295
|
if enable_password:
|
|
300
296
|
setup_password_authentication(
|
|
301
297
|
app,
|
|
@@ -373,9 +373,8 @@ async def _update_provider_account(
|
|
|
373
373
|
def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
|
|
374
374
|
"""Determine the final redirect URL after successful authentication."""
|
|
375
375
|
st = get_auth_settings()
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
)
|
|
376
|
+
# Prioritize the parameter passed to the router over settings
|
|
377
|
+
redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
|
|
379
378
|
allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
|
|
380
379
|
require_https = bool(getattr(st, "session_cookie_secure", False))
|
|
381
380
|
|
|
@@ -669,7 +668,23 @@ def _create_oauth_router(
|
|
|
669
668
|
ip_hash=None,
|
|
670
669
|
)
|
|
671
670
|
|
|
672
|
-
#
|
|
671
|
+
# Generate JWT token for the response
|
|
672
|
+
strategy = auth_backend.get_strategy()
|
|
673
|
+
jwt_token = await strategy.write_token(user)
|
|
674
|
+
|
|
675
|
+
# If redirecting to a different origin, append token as URL fragment for frontend to extract
|
|
676
|
+
# This handles cross-port scenarios like localhost:8000 -> localhost:3000
|
|
677
|
+
parsed_redirect = urlparse(redirect_url)
|
|
678
|
+
request_origin = f"{request.url.scheme}://{request.url.netloc}"
|
|
679
|
+
redirect_origin = f"{parsed_redirect.scheme}://{parsed_redirect.netloc}"
|
|
680
|
+
|
|
681
|
+
if redirect_origin and redirect_origin != request_origin:
|
|
682
|
+
# Cross-origin redirect: append token as URL fragment
|
|
683
|
+
# Fragment is not sent to server, only accessible to client-side JS
|
|
684
|
+
separator = "#" if not parsed_redirect.fragment else "&"
|
|
685
|
+
redirect_url = f"{redirect_url}{separator}access_token={jwt_token}"
|
|
686
|
+
|
|
687
|
+
# Create response with auth + refresh cookies (for same-origin requests)
|
|
673
688
|
resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
|
674
689
|
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
|
|
675
690
|
|
|
@@ -0,0 +1,64 @@
|
|
|
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 UsageAckOut, UsageAggregateRow, UsageAggregatesOut, UsageIn
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/_billing", tags=["Billing"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_service(tenant_id: TenantId, session: SqlSessionDep) -> AsyncBillingService:
|
|
18
|
+
return AsyncBillingService(session=session, tenant_id=tenant_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post(
|
|
22
|
+
"/usage",
|
|
23
|
+
name="billing_record_usage",
|
|
24
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
25
|
+
response_model=UsageAckOut,
|
|
26
|
+
dependencies=[Depends(require_idempotency_key)],
|
|
27
|
+
)
|
|
28
|
+
async def record_usage(
|
|
29
|
+
data: UsageIn, svc: Annotated[AsyncBillingService, Depends(get_service)], response: Response
|
|
30
|
+
):
|
|
31
|
+
at = data.at or datetime.now(tz=timezone.utc)
|
|
32
|
+
evt_id = await svc.record_usage(
|
|
33
|
+
metric=data.metric,
|
|
34
|
+
amount=int(data.amount),
|
|
35
|
+
at=at,
|
|
36
|
+
idempotency_key=data.idempotency_key,
|
|
37
|
+
metadata=data.metadata,
|
|
38
|
+
)
|
|
39
|
+
# For 202, no Location header is required, but we can surface the id in the body
|
|
40
|
+
return UsageAckOut(id=evt_id, accepted=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get(
|
|
44
|
+
"/usage",
|
|
45
|
+
name="billing_list_aggregates",
|
|
46
|
+
response_model=UsageAggregatesOut,
|
|
47
|
+
)
|
|
48
|
+
async def list_aggregates(
|
|
49
|
+
metric: str,
|
|
50
|
+
date_from: Optional[datetime] = None,
|
|
51
|
+
date_to: Optional[datetime] = None,
|
|
52
|
+
svc: Annotated[AsyncBillingService, Depends(get_service)] = None,
|
|
53
|
+
):
|
|
54
|
+
rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
|
|
55
|
+
items = [
|
|
56
|
+
UsageAggregateRow(
|
|
57
|
+
period_start=r.period_start,
|
|
58
|
+
granularity=r.granularity,
|
|
59
|
+
metric=r.metric,
|
|
60
|
+
total=int(r.total),
|
|
61
|
+
)
|
|
62
|
+
for r in rows
|
|
63
|
+
]
|
|
64
|
+
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
|
|
@@ -38,8 +38,8 @@ def add_mongo_db_with_url(app: FastAPI, url: str, db_name: str) -> None:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
41
|
-
@
|
|
42
|
-
async def
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_app: FastAPI):
|
|
43
43
|
if not os.getenv(dsn_env):
|
|
44
44
|
raise RuntimeError(f"Missing environment variable {dsn_env} for Mongo URL")
|
|
45
45
|
await init_mongo()
|
|
@@ -47,10 +47,12 @@ def add_mongo_db(app: FastAPI, *, dsn_env: str = "MONGO_URL") -> None:
|
|
|
47
47
|
db = await acquire_db()
|
|
48
48
|
if expected and db.name != expected:
|
|
49
49
|
raise RuntimeError(f"Connected to Mongo DB '{db.name}', expected '{expected}'.")
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
await close_mongo()
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
async def _shutdown() -> None:
|
|
53
|
-
await close_mongo()
|
|
55
|
+
app.router.lifespan_context = lifespan
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def add_mongo_health(
|
|
@@ -62,46 +64,50 @@ def add_mongo_health(
|
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
def add_mongo_resources(app: FastAPI, resources: Sequence[NoSqlResource]) -> None:
|
|
65
|
-
for
|
|
67
|
+
for resource in resources:
|
|
66
68
|
repo = NoSqlRepository(
|
|
67
|
-
collection_name=
|
|
68
|
-
id_field=
|
|
69
|
-
soft_delete=
|
|
70
|
-
soft_delete_field=
|
|
71
|
-
soft_delete_flag_field=
|
|
69
|
+
collection_name=resource.resolved_collection(),
|
|
70
|
+
id_field=resource.id_field,
|
|
71
|
+
soft_delete=resource.soft_delete,
|
|
72
|
+
soft_delete_field=resource.soft_delete_field,
|
|
73
|
+
soft_delete_flag_field=resource.soft_delete_flag_field,
|
|
72
74
|
)
|
|
73
|
-
svc =
|
|
75
|
+
svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
|
|
74
76
|
|
|
75
|
-
if
|
|
76
|
-
Read, Create, Update =
|
|
77
|
-
|
|
77
|
+
if resource.read_schema and resource.create_schema and resource.update_schema:
|
|
78
|
+
Read, Create, Update = (
|
|
79
|
+
resource.read_schema,
|
|
80
|
+
resource.create_schema,
|
|
81
|
+
resource.update_schema,
|
|
82
|
+
)
|
|
83
|
+
elif resource.document_model is not None:
|
|
78
84
|
# CRITICAL: teach Pydantic to dump ObjectId/PyObjectId
|
|
79
85
|
Read, Create, Update = make_document_crud_schemas(
|
|
80
|
-
|
|
81
|
-
create_exclude=
|
|
82
|
-
read_name=
|
|
83
|
-
create_name=
|
|
84
|
-
update_name=
|
|
85
|
-
read_exclude=
|
|
86
|
-
update_exclude=
|
|
86
|
+
resource.document_model,
|
|
87
|
+
create_exclude=resource.create_exclude,
|
|
88
|
+
read_name=resource.read_name,
|
|
89
|
+
create_name=resource.create_name,
|
|
90
|
+
update_name=resource.update_name,
|
|
91
|
+
read_exclude=resource.read_exclude,
|
|
92
|
+
update_exclude=resource.update_exclude,
|
|
87
93
|
json_encoders={ObjectId: str, PyObjectId: str},
|
|
88
94
|
)
|
|
89
95
|
else:
|
|
90
96
|
raise RuntimeError(
|
|
91
|
-
f"Resource for collection '{
|
|
97
|
+
f"Resource for collection '{resource.collection}' requires either explicit schemas "
|
|
92
98
|
f"(read/create/update) or a 'document_model' to derive them."
|
|
93
99
|
)
|
|
94
100
|
|
|
95
101
|
router = make_crud_router_plus_mongo(
|
|
96
|
-
collection=
|
|
102
|
+
collection=resource.resolved_collection(),
|
|
97
103
|
repo=repo,
|
|
98
104
|
service=svc,
|
|
99
105
|
read_schema=Read,
|
|
100
106
|
create_schema=Create,
|
|
101
107
|
update_schema=Update,
|
|
102
|
-
prefix=
|
|
103
|
-
tags=
|
|
104
|
-
search_fields=
|
|
108
|
+
prefix=resource.prefix,
|
|
109
|
+
tags=resource.tags,
|
|
110
|
+
search_fields=resource.search_fields,
|
|
105
111
|
default_ordering=None,
|
|
106
112
|
allowed_order_fields=None,
|
|
107
113
|
)
|
|
@@ -10,7 +10,7 @@ from svc_infra.db.sql.management import make_crud_schemas
|
|
|
10
10
|
from svc_infra.db.sql.repository import SqlRepository
|
|
11
11
|
from svc_infra.db.sql.resource import SqlResource
|
|
12
12
|
|
|
13
|
-
from .crud_router import make_crud_router_plus_sql
|
|
13
|
+
from .crud_router import make_crud_router_plus_sql, make_tenant_crud_router_plus_sql
|
|
14
14
|
from .health import _make_db_health_router
|
|
15
15
|
from .session import dispose_session, initialize_session
|
|
16
16
|
|
|
@@ -37,18 +37,37 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
|
37
37
|
update_name=r.update_name,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
if r.tenant_field:
|
|
41
|
+
# wrap service factory/instance through tenant router
|
|
42
|
+
def _factory():
|
|
43
|
+
return svc
|
|
44
|
+
|
|
45
|
+
router = make_tenant_crud_router_plus_sql(
|
|
46
|
+
model=r.model,
|
|
47
|
+
service_factory=_factory,
|
|
48
|
+
read_schema=Read,
|
|
49
|
+
create_schema=Create,
|
|
50
|
+
update_schema=Update,
|
|
51
|
+
prefix=r.prefix,
|
|
52
|
+
tenant_field=r.tenant_field,
|
|
53
|
+
tags=r.tags,
|
|
54
|
+
search_fields=r.search_fields,
|
|
55
|
+
default_ordering=r.ordering_default,
|
|
56
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
router = make_crud_router_plus_sql(
|
|
60
|
+
model=r.model,
|
|
61
|
+
service=svc,
|
|
62
|
+
read_schema=Read,
|
|
63
|
+
create_schema=Create,
|
|
64
|
+
update_schema=Update,
|
|
65
|
+
prefix=r.prefix,
|
|
66
|
+
tags=r.tags,
|
|
67
|
+
search_fields=r.search_fields,
|
|
68
|
+
default_ordering=r.ordering_default,
|
|
69
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
70
|
+
)
|
|
52
71
|
app.include_router(router)
|
|
53
72
|
|
|
54
73
|
|
|
@@ -67,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
|
|
|
67
86
|
app.router.lifespan_context = lifespan
|
|
68
87
|
return
|
|
69
88
|
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
# Use lifespan context manager instead of deprecated on_event
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def lifespan(_app: FastAPI):
|
|
72
92
|
env_url = os.getenv(dsn_env)
|
|
73
93
|
if not env_url:
|
|
74
94
|
raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
|
|
75
95
|
initialize_session(env_url)
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
finally:
|
|
99
|
+
await dispose_session()
|
|
76
100
|
|
|
77
|
-
|
|
78
|
-
async def _shutdown() -> None: # noqa: ANN202
|
|
79
|
-
await dispose_session()
|
|
101
|
+
app.router.lifespan_context = lifespan
|
|
80
102
|
|
|
81
103
|
|
|
82
104
|
def add_sql_health(
|