svc-infra 0.1.706__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/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- 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 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- 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 +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import hashlib
|
|
5
5
|
import secrets
|
|
6
|
-
from datetime import datetime, timedelta
|
|
7
|
-
from typing import Any,
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
8
|
from urllib.parse import urlencode, urlparse
|
|
9
9
|
|
|
10
10
|
import jwt
|
|
@@ -44,9 +44,7 @@ def _gen_pkce_pair() -> tuple[str, str]:
|
|
|
44
44
|
return verifier, challenge
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
def _validate_redirect(
|
|
48
|
-
url: str, allow_hosts: list[str], *, require_https: bool
|
|
49
|
-
) -> None:
|
|
47
|
+
def _validate_redirect(url: str, allow_hosts: list[str], *, require_https: bool) -> None:
|
|
50
48
|
"""Validate that a redirect URL is allowed and secure."""
|
|
51
49
|
p = urlparse(url)
|
|
52
50
|
if not p.netloc:
|
|
@@ -71,13 +69,13 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
|
|
|
71
69
|
v = float(token["expires_at"])
|
|
72
70
|
if v > 1e12: # ms -> s
|
|
73
71
|
v /= 1000.0
|
|
74
|
-
return datetime.fromtimestamp(v, tz=
|
|
72
|
+
return datetime.fromtimestamp(v, tz=UTC)
|
|
75
73
|
except Exception:
|
|
76
74
|
pass
|
|
77
75
|
if token.get("expires_in") is not None:
|
|
78
76
|
try:
|
|
79
77
|
secs = int(token["expires_in"])
|
|
80
|
-
return datetime.now(
|
|
78
|
+
return datetime.now(UTC) + timedelta(seconds=secs)
|
|
81
79
|
except Exception:
|
|
82
80
|
pass
|
|
83
81
|
return None
|
|
@@ -86,11 +84,7 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
|
|
|
86
84
|
def _cookie_name(st) -> str:
|
|
87
85
|
"""Get the cookie name with appropriate security prefix."""
|
|
88
86
|
name = getattr(st, "auth_cookie_name", "svc_auth")
|
|
89
|
-
if (
|
|
90
|
-
st.session_cookie_secure
|
|
91
|
-
and not st.session_cookie_domain
|
|
92
|
-
and not name.startswith("__Host-")
|
|
93
|
-
):
|
|
87
|
+
if st.session_cookie_secure and not st.session_cookie_domain and not name.startswith("__Host-"):
|
|
94
88
|
name = "__Host-" + name
|
|
95
89
|
return name
|
|
96
90
|
|
|
@@ -101,9 +95,7 @@ def _cookie_domain(st):
|
|
|
101
95
|
return d or None
|
|
102
96
|
|
|
103
97
|
|
|
104
|
-
def _register_oauth_providers(
|
|
105
|
-
oauth: OAuth, providers: Dict[str, Dict[str, Any]]
|
|
106
|
-
) -> None:
|
|
98
|
+
def _register_oauth_providers(oauth: OAuth, providers: dict[str, dict[str, Any]]) -> None:
|
|
107
99
|
"""Register all OAuth providers with the OAuth client."""
|
|
108
100
|
for name, cfg in providers.items():
|
|
109
101
|
kind = cfg.get("kind")
|
|
@@ -202,9 +194,7 @@ async def _extract_user_info_github(
|
|
|
202
194
|
"""Extract user information from GitHub provider."""
|
|
203
195
|
u = (await client.get("user", token=token)).json()
|
|
204
196
|
emails_resp = (await client.get("user/emails", token=token)).json()
|
|
205
|
-
primary = next(
|
|
206
|
-
(e for e in emails_resp if e.get("primary") and e.get("verified")), None
|
|
207
|
-
)
|
|
197
|
+
primary = next((e for e in emails_resp if e.get("primary") and e.get("verified")), None)
|
|
208
198
|
|
|
209
199
|
if not primary:
|
|
210
200
|
raise HTTPException(400, "unverified_email")
|
|
@@ -212,9 +202,7 @@ async def _extract_user_info_github(
|
|
|
212
202
|
email = primary["email"]
|
|
213
203
|
email_verified = True
|
|
214
204
|
full_name = u.get("name") or u.get("login")
|
|
215
|
-
provider_user_id = (
|
|
216
|
-
str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
|
|
217
|
-
)
|
|
205
|
+
provider_user_id = str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
|
|
218
206
|
|
|
219
207
|
return email, full_name, provider_user_id, email_verified, {"user": u}
|
|
220
208
|
|
|
@@ -229,9 +217,7 @@ async def _extract_user_info_linkedin(
|
|
|
229
217
|
)
|
|
230
218
|
|
|
231
219
|
em = (
|
|
232
|
-
await client.get(
|
|
233
|
-
"emailAddress?q=members&projection=(elements*(handle~))", token=token
|
|
234
|
-
)
|
|
220
|
+
await client.get("emailAddress?q=members&projection=(elements*(handle~))", token=token)
|
|
235
221
|
).json()
|
|
236
222
|
|
|
237
223
|
email = None
|
|
@@ -270,15 +256,9 @@ async def _extract_user_info_from_provider(
|
|
|
270
256
|
raise HTTPException(400, "Unsupported provider kind")
|
|
271
257
|
|
|
272
258
|
|
|
273
|
-
async def _find_or_create_user(
|
|
274
|
-
session, user_model, email: str, full_name: str | None
|
|
275
|
-
) -> Any:
|
|
259
|
+
async def _find_or_create_user(session, user_model, email: str, full_name: str | None) -> Any:
|
|
276
260
|
"""Find existing user by email or create a new one."""
|
|
277
|
-
existing = (
|
|
278
|
-
(await session.execute(select(user_model).filter_by(email=email)))
|
|
279
|
-
.scalars()
|
|
280
|
-
.first()
|
|
281
|
-
)
|
|
261
|
+
existing = (await session.execute(select(user_model).filter_by(email=email))).scalars().first()
|
|
282
262
|
|
|
283
263
|
if existing:
|
|
284
264
|
return existing
|
|
@@ -300,7 +280,7 @@ async def _find_or_create_user(
|
|
|
300
280
|
user.password_hash = PasswordHelper().hash(random_password)
|
|
301
281
|
|
|
302
282
|
if full_name and hasattr(user, "full_name"):
|
|
303
|
-
|
|
283
|
+
user.full_name = full_name
|
|
304
284
|
|
|
305
285
|
session.add(user)
|
|
306
286
|
await session.flush() # ensure user.id exists
|
|
@@ -365,11 +345,11 @@ async def _update_provider_account(
|
|
|
365
345
|
expires_at = _coerce_expires_at(tok)
|
|
366
346
|
|
|
367
347
|
if not link:
|
|
368
|
-
values =
|
|
369
|
-
user_id
|
|
370
|
-
provider
|
|
371
|
-
provider_account_id
|
|
372
|
-
|
|
348
|
+
values = {
|
|
349
|
+
"user_id": user.id,
|
|
350
|
+
"provider": provider,
|
|
351
|
+
"provider_account_id": provider_user_id,
|
|
352
|
+
}
|
|
373
353
|
if hasattr(provider_account_model, "access_token"):
|
|
374
354
|
values["access_token"] = access_token
|
|
375
355
|
if hasattr(provider_account_model, "refresh_token"):
|
|
@@ -384,18 +364,10 @@ async def _update_provider_account(
|
|
|
384
364
|
else:
|
|
385
365
|
# Update existing link if values have changed
|
|
386
366
|
dirty = False
|
|
387
|
-
if (
|
|
388
|
-
hasattr(link, "access_token")
|
|
389
|
-
and access_token
|
|
390
|
-
and link.access_token != access_token
|
|
391
|
-
):
|
|
367
|
+
if hasattr(link, "access_token") and access_token and link.access_token != access_token:
|
|
392
368
|
link.access_token = access_token
|
|
393
369
|
dirty = True
|
|
394
|
-
if (
|
|
395
|
-
hasattr(link, "refresh_token")
|
|
396
|
-
and refresh_token
|
|
397
|
-
and link.refresh_token != refresh_token
|
|
398
|
-
):
|
|
370
|
+
if hasattr(link, "refresh_token") and refresh_token and link.refresh_token != refresh_token:
|
|
399
371
|
link.refresh_token = refresh_token
|
|
400
372
|
dirty = True
|
|
401
373
|
if hasattr(link, "expires_at") and expires_at and link.expires_at != expires_at:
|
|
@@ -408,24 +380,18 @@ async def _update_provider_account(
|
|
|
408
380
|
await session.flush()
|
|
409
381
|
|
|
410
382
|
|
|
411
|
-
def _determine_final_redirect_url(
|
|
412
|
-
request: Request, provider: str, post_login_redirect: str
|
|
413
|
-
) -> str:
|
|
383
|
+
def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
|
|
414
384
|
"""Determine the final redirect URL after successful authentication."""
|
|
415
385
|
st = get_auth_settings()
|
|
416
386
|
# Prioritize the parameter passed to the router over settings
|
|
417
387
|
redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
|
|
418
|
-
allow_hosts = parse_redirect_allow_hosts(
|
|
419
|
-
getattr(st, "redirect_allow_hosts_raw", None)
|
|
420
|
-
)
|
|
388
|
+
allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
|
|
421
389
|
require_https = bool(getattr(st, "session_cookie_secure", False))
|
|
422
390
|
|
|
423
391
|
_validate_redirect(redirect_url, allow_hosts, require_https=require_https)
|
|
424
392
|
|
|
425
393
|
# Prefer ?next or the stashed value from /login
|
|
426
|
-
nxt = request.query_params.get("next") or request.session.pop(
|
|
427
|
-
f"oauth:{provider}:next", None
|
|
428
|
-
)
|
|
394
|
+
nxt = request.query_params.get("next") or request.session.pop(f"oauth:{provider}:next", None)
|
|
429
395
|
if nxt:
|
|
430
396
|
try:
|
|
431
397
|
_validate_redirect(nxt, allow_hosts, require_https=require_https)
|
|
@@ -436,9 +402,7 @@ def _determine_final_redirect_url(
|
|
|
436
402
|
return redirect_url
|
|
437
403
|
|
|
438
404
|
|
|
439
|
-
async def _validate_oauth_state(
|
|
440
|
-
request: Request, provider: str
|
|
441
|
-
) -> tuple[str | None, str | None]:
|
|
405
|
+
async def _validate_oauth_state(request: Request, provider: str) -> tuple[str | None, str | None]:
|
|
442
406
|
"""Validate OAuth state and extract session values."""
|
|
443
407
|
provided_state = request.query_params.get("state")
|
|
444
408
|
expected_state = request.session.pop(f"oauth:{provider}:state", None)
|
|
@@ -451,9 +415,7 @@ async def _validate_oauth_state(
|
|
|
451
415
|
return verifier, nonce
|
|
452
416
|
|
|
453
417
|
|
|
454
|
-
async def _exchange_code_for_token(
|
|
455
|
-
client, request: Request, verifier: str | None, provider: str
|
|
456
|
-
):
|
|
418
|
+
async def _exchange_code_for_token(client, request: Request, verifier: str | None, provider: str):
|
|
457
419
|
"""Exchange OAuth authorization code for access token."""
|
|
458
420
|
try:
|
|
459
421
|
return await client.authorize_access_token(request, code_verifier=verifier)
|
|
@@ -500,9 +462,7 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
|
|
|
500
462
|
"""Validate and decode JWT token to extract user ID."""
|
|
501
463
|
st = get_auth_settings()
|
|
502
464
|
jwt_settings = getattr(st, "jwt", None)
|
|
503
|
-
jwt_secret = (
|
|
504
|
-
getattr(jwt_settings, "secret", None) if jwt_settings is not None else None
|
|
505
|
-
)
|
|
465
|
+
jwt_secret = getattr(jwt_settings, "secret", None) if jwt_settings is not None else None
|
|
506
466
|
if jwt_secret:
|
|
507
467
|
secret = jwt_secret.get_secret_value()
|
|
508
468
|
else:
|
|
@@ -522,7 +482,7 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
|
|
|
522
482
|
user_id = payload.get("sub")
|
|
523
483
|
if not user_id:
|
|
524
484
|
raise HTTPException(401, "invalid_token")
|
|
525
|
-
return cast(str, user_id)
|
|
485
|
+
return cast("str", user_id)
|
|
526
486
|
except Exception:
|
|
527
487
|
raise HTTPException(401, "invalid_token")
|
|
528
488
|
|
|
@@ -539,12 +499,10 @@ async def _set_cookie_on_response(
|
|
|
539
499
|
jwt_token = await strategy.write_token(user)
|
|
540
500
|
|
|
541
501
|
same_site_lit = cast(
|
|
542
|
-
Literal[
|
|
502
|
+
"Literal['lax', 'strict', 'none']", str(st.session_cookie_samesite).lower()
|
|
543
503
|
)
|
|
544
504
|
if same_site_lit == "none" and not bool(st.session_cookie_secure):
|
|
545
|
-
raise HTTPException(
|
|
546
|
-
500, "session_cookie_samesite=None requires session_cookie_secure=True"
|
|
547
|
-
)
|
|
505
|
+
raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
|
|
548
506
|
|
|
549
507
|
# Access/Auth cookie (short-lived JWT)
|
|
550
508
|
resp.set_cookie(
|
|
@@ -586,15 +544,13 @@ async def _handle_mfa_redirect(
|
|
|
586
544
|
|
|
587
545
|
pre = await get_mfa_pre_jwt_writer().write(user)
|
|
588
546
|
qs = urlencode({"mfa": "required", "pre_token": pre})
|
|
589
|
-
return RedirectResponse(
|
|
590
|
-
url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND
|
|
591
|
-
)
|
|
547
|
+
return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
|
|
592
548
|
|
|
593
549
|
|
|
594
550
|
def oauth_router_with_backend(
|
|
595
551
|
user_model: type,
|
|
596
552
|
auth_backend: AuthenticationBackend,
|
|
597
|
-
providers:
|
|
553
|
+
providers: dict[str, dict[str, Any]],
|
|
598
554
|
post_login_redirect: str = "/",
|
|
599
555
|
provider_account_model: type | None = None,
|
|
600
556
|
auth_policy: AuthPolicy | None = None,
|
|
@@ -612,7 +568,7 @@ def oauth_router_with_backend(
|
|
|
612
568
|
def _create_oauth_router(
|
|
613
569
|
user_model: type,
|
|
614
570
|
auth_backend: AuthenticationBackend,
|
|
615
|
-
providers:
|
|
571
|
+
providers: dict[str, dict[str, Any]],
|
|
616
572
|
post_login_redirect: str = "/",
|
|
617
573
|
provider_account_model: type | None = None,
|
|
618
574
|
auth_policy: AuthPolicy | None = None,
|
|
@@ -697,9 +653,7 @@ def _create_oauth_router(
|
|
|
697
653
|
provider_user_id,
|
|
698
654
|
email_verified,
|
|
699
655
|
raw_claims,
|
|
700
|
-
) = await _extract_user_info_from_provider(
|
|
701
|
-
request, client, token, provider, cfg, nonce
|
|
702
|
-
)
|
|
656
|
+
) = await _extract_user_info_from_provider(request, client, token, provider, cfg, nonce)
|
|
703
657
|
|
|
704
658
|
if email_verified is False:
|
|
705
659
|
raise HTTPException(400, "unverified_email")
|
|
@@ -726,9 +680,7 @@ def _create_oauth_router(
|
|
|
726
680
|
raise HTTPException(401, "account_disabled")
|
|
727
681
|
|
|
728
682
|
# Determine final redirect URL
|
|
729
|
-
redirect_url = _determine_final_redirect_url(
|
|
730
|
-
request, provider, post_login_redirect
|
|
731
|
-
)
|
|
683
|
+
redirect_url = _determine_final_redirect_url(request, provider, post_login_redirect)
|
|
732
684
|
|
|
733
685
|
# Handle MFA if required (do NOT set last_login yet; do it after MFA)
|
|
734
686
|
mfa_response = await _handle_mfa_redirect(policy, user, redirect_url)
|
|
@@ -737,7 +689,7 @@ def _create_oauth_router(
|
|
|
737
689
|
return mfa_response
|
|
738
690
|
|
|
739
691
|
# NEW: set last_login only when we are actually logging in now
|
|
740
|
-
user.last_login = datetime.now(
|
|
692
|
+
user.last_login = datetime.now(UTC)
|
|
741
693
|
await session.commit()
|
|
742
694
|
|
|
743
695
|
# Create session + initial refresh token
|
|
@@ -804,7 +756,7 @@ def _create_oauth_router(
|
|
|
804
756
|
user_id = await _validate_and_decode_jwt_token(raw_auth)
|
|
805
757
|
|
|
806
758
|
# Load user
|
|
807
|
-
user = await cast(Any, session).get(user_model, user_id)
|
|
759
|
+
user = await cast("Any", session).get(user_model, user_id)
|
|
808
760
|
if not user:
|
|
809
761
|
raise HTTPException(401, "invalid_token")
|
|
810
762
|
|
|
@@ -832,7 +784,7 @@ def _create_oauth_router(
|
|
|
832
784
|
if (
|
|
833
785
|
not found
|
|
834
786
|
or found.revoked_at
|
|
835
|
-
or (found.expires_at and found.expires_at < datetime.now(
|
|
787
|
+
or (found.expires_at and found.expires_at < datetime.now(UTC))
|
|
836
788
|
):
|
|
837
789
|
raise HTTPException(401, "invalid_refresh_token")
|
|
838
790
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import
|
|
4
|
-
from typing import List
|
|
3
|
+
from datetime import UTC, datetime
|
|
5
4
|
|
|
6
5
|
from fastapi import APIRouter, HTTPException
|
|
7
6
|
from sqlalchemy import select
|
|
@@ -20,9 +19,7 @@ def build_session_router() -> APIRouter:
|
|
|
20
19
|
response_model=list[dict],
|
|
21
20
|
dependencies=[RequirePermission("security.session.list")],
|
|
22
21
|
)
|
|
23
|
-
async def list_my_sessions(
|
|
24
|
-
identity: Identity, session: SqlSessionDep
|
|
25
|
-
) -> List[dict]:
|
|
22
|
+
async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> list[dict]:
|
|
26
23
|
stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
|
|
27
24
|
rows = (await session.execute(stmt)).scalars().all()
|
|
28
25
|
return [
|
|
@@ -52,7 +49,7 @@ def build_session_router() -> APIRouter:
|
|
|
52
49
|
raise HTTPException(403, "forbidden")
|
|
53
50
|
if s.revoked_at:
|
|
54
51
|
return # already revoked
|
|
55
|
-
s.revoked_at = datetime.now(
|
|
52
|
+
s.revoked_at = datetime.now(UTC)
|
|
56
53
|
s.revoke_reason = "user_revoked"
|
|
57
54
|
# Revoke all refresh tokens for this session
|
|
58
55
|
for rt in s.refresh_tokens:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Annotated, Any, cast
|
|
5
6
|
|
|
6
7
|
from fastapi import Depends, HTTPException, Request
|
|
7
8
|
from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer
|
|
@@ -16,12 +17,8 @@ from svc_infra.db.sql.apikey import get_apikey_model
|
|
|
16
17
|
|
|
17
18
|
# ---------- OpenAPI security schemes (appear in docs) ----------
|
|
18
19
|
auth_login_path = USER_PREFIX + LOGIN_PATH
|
|
19
|
-
oauth2_scheme_optional = OAuth2PasswordBearer(
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
cookie_auth_optional = APIKeyCookie(
|
|
23
|
-
name=get_auth_settings().auth_cookie_name, auto_error=False
|
|
24
|
-
)
|
|
20
|
+
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl=auth_login_path, auto_error=False)
|
|
21
|
+
cookie_auth_optional = APIKeyCookie(name=get_auth_settings().auth_cookie_name, auto_error=False)
|
|
25
22
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
26
23
|
|
|
27
24
|
|
|
@@ -47,7 +44,7 @@ class Principal:
|
|
|
47
44
|
async def resolve_api_key(
|
|
48
45
|
request: Request,
|
|
49
46
|
session: SqlSessionDep,
|
|
50
|
-
) ->
|
|
47
|
+
) -> Principal | None:
|
|
51
48
|
raw = (request.headers.get("x-api-key") or "").strip()
|
|
52
49
|
if not raw:
|
|
53
50
|
return None
|
|
@@ -77,26 +74,20 @@ async def resolve_api_key(
|
|
|
77
74
|
raise HTTPException(401, "invalid_api_key")
|
|
78
75
|
if not apikey.active:
|
|
79
76
|
raise HTTPException(401, "api_key_revoked")
|
|
80
|
-
if apikey.expires_at and datetime.now(
|
|
77
|
+
if apikey.expires_at and datetime.now(UTC) > apikey.expires_at:
|
|
81
78
|
raise HTTPException(401, "api_key_expired")
|
|
82
79
|
|
|
83
80
|
apikey.mark_used()
|
|
84
81
|
await session.flush()
|
|
85
|
-
return Principal(
|
|
86
|
-
user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey
|
|
87
|
-
)
|
|
82
|
+
return Principal(user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey)
|
|
88
83
|
|
|
89
84
|
|
|
90
85
|
async def resolve_bearer_or_cookie_principal(
|
|
91
86
|
request: Request, session: SqlSessionDep
|
|
92
|
-
) ->
|
|
87
|
+
) -> Principal | None:
|
|
93
88
|
st = get_auth_settings()
|
|
94
89
|
raw_auth = (request.headers.get("authorization") or "").strip()
|
|
95
|
-
token = (
|
|
96
|
-
raw_auth.split(" ", 1)[1].strip()
|
|
97
|
-
if raw_auth.lower().startswith("bearer ")
|
|
98
|
-
else ""
|
|
99
|
-
)
|
|
90
|
+
token = raw_auth.split(" ", 1)[1].strip() if raw_auth.lower().startswith("bearer ") else ""
|
|
100
91
|
if not token:
|
|
101
92
|
token = (request.cookies.get(st.auth_cookie_name) or "").strip()
|
|
102
93
|
if not token:
|
|
@@ -126,7 +117,7 @@ async def resolve_bearer_or_cookie_principal(
|
|
|
126
117
|
if not user:
|
|
127
118
|
return None
|
|
128
119
|
|
|
129
|
-
db_user = await cast(Any, session).get(UserModel, user.id)
|
|
120
|
+
db_user = await cast("Any", session).get(UserModel, user.id)
|
|
130
121
|
if not db_user:
|
|
131
122
|
return None
|
|
132
123
|
if not getattr(db_user, "is_active", True):
|
|
@@ -142,8 +133,8 @@ async def resolve_bearer_or_cookie_principal(
|
|
|
142
133
|
async def _current_principal(
|
|
143
134
|
request: Request,
|
|
144
135
|
session: SqlSessionDep,
|
|
145
|
-
jwt_or_cookie:
|
|
146
|
-
ak:
|
|
136
|
+
jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
|
|
137
|
+
ak: Principal | None = Depends(resolve_api_key),
|
|
147
138
|
) -> Principal:
|
|
148
139
|
if jwt_or_cookie:
|
|
149
140
|
return jwt_or_cookie
|
|
@@ -155,9 +146,9 @@ async def _current_principal(
|
|
|
155
146
|
async def _optional_principal(
|
|
156
147
|
request: Request,
|
|
157
148
|
session: SqlSessionDep,
|
|
158
|
-
jwt_or_cookie:
|
|
159
|
-
ak:
|
|
160
|
-
) ->
|
|
149
|
+
jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
|
|
150
|
+
ak: Principal | None = Depends(resolve_api_key),
|
|
151
|
+
) -> Principal | None:
|
|
161
152
|
return jwt_or_cookie or ak or None
|
|
162
153
|
|
|
163
154
|
|
|
@@ -173,9 +164,7 @@ AllowIdentity = Depends(_optional_principal) # same, but optional
|
|
|
173
164
|
# ---------- DX: small guard factories ----------
|
|
174
165
|
def RequireRoles(*roles: str, resolver: Callable[[Any], list[str]] | None = None):
|
|
175
166
|
async def _guard(p: Identity):
|
|
176
|
-
have = set(
|
|
177
|
-
(resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
|
|
178
|
-
)
|
|
167
|
+
have = set(resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
|
|
179
168
|
if not set(roles).issubset(have):
|
|
180
169
|
raise HTTPException(403, "forbidden")
|
|
181
170
|
return p
|
|
@@ -20,9 +20,7 @@ class ConsoleSender:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class SMTPSender:
|
|
23
|
-
def __init__(
|
|
24
|
-
self, host: str, port: int, username: str, password: str, from_addr: str
|
|
25
|
-
) -> None:
|
|
23
|
+
def __init__(self, host: str, port: int, username: str, password: str, from_addr: str) -> None:
|
|
26
24
|
self.host = host
|
|
27
25
|
self.port = port
|
|
28
26
|
self.username = username
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import List, Optional
|
|
5
4
|
|
|
6
5
|
from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr
|
|
7
6
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
@@ -19,7 +18,7 @@ class JWTSettings(BaseModel):
|
|
|
19
18
|
secret: SecretStr
|
|
20
19
|
lifetime_seconds: int = 60 * 60 * 24 * 7
|
|
21
20
|
# Optional older secrets accepted for verification during rotation window
|
|
22
|
-
old_secrets:
|
|
21
|
+
old_secrets: list[SecretStr] = Field(default_factory=list)
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
class PasswordClient(BaseModel):
|
|
@@ -29,10 +28,10 @@ class PasswordClient(BaseModel):
|
|
|
29
28
|
|
|
30
29
|
class AuthSettings(BaseSettings):
|
|
31
30
|
# ---- JWT ----
|
|
32
|
-
jwt:
|
|
31
|
+
jwt: JWTSettings | None = None
|
|
33
32
|
|
|
34
33
|
# ---- Password login ----
|
|
35
|
-
password_clients:
|
|
34
|
+
password_clients: list[PasswordClient] = Field(default_factory=list)
|
|
36
35
|
require_client_secret_on_password_login: bool = False
|
|
37
36
|
|
|
38
37
|
# ---- MFA / TOTP ----
|
|
@@ -50,26 +49,26 @@ class AuthSettings(BaseSettings):
|
|
|
50
49
|
email_otp_attempts: int = 5
|
|
51
50
|
|
|
52
51
|
# ---- Email/SMTP (verification, reset, etc.) ----
|
|
53
|
-
smtp_host:
|
|
52
|
+
smtp_host: str | None = None
|
|
54
53
|
smtp_port: int = 587
|
|
55
|
-
smtp_username:
|
|
56
|
-
smtp_password:
|
|
57
|
-
smtp_from:
|
|
54
|
+
smtp_username: str | None = None
|
|
55
|
+
smtp_password: SecretStr | None = None
|
|
56
|
+
smtp_from: str | None = None
|
|
58
57
|
|
|
59
58
|
# Dev convenience: auto-verify users without sending email
|
|
60
59
|
auto_verify_in_dev: bool = True
|
|
61
60
|
|
|
62
61
|
# ---- Built-in provider creds (optional) ----
|
|
63
|
-
google_client_id:
|
|
64
|
-
google_client_secret:
|
|
65
|
-
github_client_id:
|
|
66
|
-
github_client_secret:
|
|
67
|
-
ms_client_id:
|
|
68
|
-
ms_client_secret:
|
|
69
|
-
ms_tenant:
|
|
70
|
-
li_client_id:
|
|
71
|
-
li_client_secret:
|
|
72
|
-
oidc_providers:
|
|
62
|
+
google_client_id: str | None = None
|
|
63
|
+
google_client_secret: SecretStr | None = None
|
|
64
|
+
github_client_id: str | None = None
|
|
65
|
+
github_client_secret: SecretStr | None = None
|
|
66
|
+
ms_client_id: str | None = None
|
|
67
|
+
ms_client_secret: SecretStr | None = None
|
|
68
|
+
ms_tenant: str | None = None
|
|
69
|
+
li_client_id: str | None = None
|
|
70
|
+
li_client_secret: SecretStr | None = None
|
|
71
|
+
oidc_providers: list[OIDCProvider] = Field(default_factory=list)
|
|
73
72
|
|
|
74
73
|
# ---- Redirect + cookie settings ----
|
|
75
74
|
post_login_redirect: AnyHttpUrl | str = "http://localhost:3000/app"
|
|
@@ -79,7 +78,7 @@ class AuthSettings(BaseSettings):
|
|
|
79
78
|
auth_cookie_name: str = "svc_auth"
|
|
80
79
|
session_cookie_secure: bool = False
|
|
81
80
|
session_cookie_samesite: str = "lax"
|
|
82
|
-
session_cookie_domain:
|
|
81
|
+
session_cookie_domain: str | None = None
|
|
83
82
|
session_cookie_max_age_seconds: int = 60 * 60 * 4
|
|
84
83
|
|
|
85
84
|
model_config = SettingsConfigDict(
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
|
-
_UserModel:
|
|
6
|
-
_GetStrategy:
|
|
6
|
+
_UserModel: type | None = None
|
|
7
|
+
_GetStrategy: Callable[[], Any] | None = None
|
|
7
8
|
_AuthPrefix: str = "/auth"
|
|
8
|
-
_UserScopeResolver:
|
|
9
|
+
_UserScopeResolver: Callable[[Any], list[str]] | None = None
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def set_auth_state(
|
|
@@ -19,9 +20,7 @@ def set_auth_state(
|
|
|
19
20
|
|
|
20
21
|
def get_auth_state() -> tuple[type, Callable[[], Any], str]:
|
|
21
22
|
if _UserModel is None or _GetStrategy is None:
|
|
22
|
-
raise RuntimeError(
|
|
23
|
-
"Auth state not initialized; call set_auth_state() in add_auth_users()."
|
|
24
|
-
)
|
|
23
|
+
raise RuntimeError("Auth state not initialized; call set_auth_state() in add_auth_users().")
|
|
25
24
|
return _UserModel, _GetStrategy, _AuthPrefix
|
|
26
25
|
|
|
27
26
|
|
|
@@ -113,7 +113,7 @@ def _decode_jwt(token: str) -> dict:
|
|
|
113
113
|
|
|
114
114
|
secret = settings.jwt.secret.get_secret_value()
|
|
115
115
|
old_secrets = [s.get_secret_value() for s in (settings.jwt.old_secrets or [])]
|
|
116
|
-
all_secrets = [secret
|
|
116
|
+
all_secrets = [secret, *old_secrets]
|
|
117
117
|
|
|
118
118
|
last_error: Exception | None = None
|
|
119
119
|
|
|
@@ -125,7 +125,7 @@ def _decode_jwt(token: str) -> dict:
|
|
|
125
125
|
algorithms=["HS256"],
|
|
126
126
|
options={"require": ["sub", "exp"]},
|
|
127
127
|
)
|
|
128
|
-
return cast(dict[Any, Any], payload)
|
|
128
|
+
return cast("dict[Any, Any]", payload)
|
|
129
129
|
except jwt.ExpiredSignatureError:
|
|
130
130
|
raise WebSocketException(
|
|
131
131
|
code=status.WS_1008_POLICY_VIOLATION,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from datetime import
|
|
4
|
-
from typing import Annotated
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Annotated
|
|
5
5
|
|
|
6
6
|
from fastapi import APIRouter, Depends, Response, status
|
|
7
7
|
|
|
@@ -35,7 +35,7 @@ async def record_usage(
|
|
|
35
35
|
svc: Annotated[AsyncBillingService, Depends(get_service)],
|
|
36
36
|
response: Response,
|
|
37
37
|
):
|
|
38
|
-
at = data.at or datetime.now(tz=
|
|
38
|
+
at = data.at or datetime.now(tz=UTC)
|
|
39
39
|
evt_id = await svc.record_usage(
|
|
40
40
|
metric=data.metric,
|
|
41
41
|
amount=int(data.amount),
|
|
@@ -54,13 +54,11 @@ async def record_usage(
|
|
|
54
54
|
)
|
|
55
55
|
async def list_aggregates(
|
|
56
56
|
metric: str,
|
|
57
|
-
date_from:
|
|
58
|
-
date_to:
|
|
57
|
+
date_from: datetime | None = None,
|
|
58
|
+
date_to: datetime | None = None,
|
|
59
59
|
svc: Annotated[AsyncBillingService, Depends(get_service)] = None, # type: ignore[assignment]
|
|
60
60
|
):
|
|
61
|
-
rows = await svc.list_daily_aggregates(
|
|
62
|
-
metric=metric, date_from=date_from, date_to=date_to
|
|
63
|
-
)
|
|
61
|
+
rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
|
|
64
62
|
items = [
|
|
65
63
|
UsageAggregateRow(
|
|
66
64
|
period_start=r.period_start,
|