svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +142 -4
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +178 -12
- svc_infra/apf_payments/provider/stripe.py +757 -48
- svc_infra/apf_payments/schemas.py +163 -1
- svc_infra/apf_payments/service.py +582 -42
- svc_infra/apf_payments/settings.py +22 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/router.py +792 -73
- svc_infra/api/fastapi/apf_payments/setup.py +13 -4
- svc_infra/api/fastapi/auth/add.py +10 -4
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/settings.py +2 -0
- 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/db/sql/users.py +13 -1
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- 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/idempotency.py +82 -42
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +244 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +133 -32
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +23 -14
- 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 +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -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/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -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/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -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/api.md +59 -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/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/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
- svc_infra-0.1.562.dist-info/METADATA +0 -79
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
|
@@ -24,6 +23,12 @@ def _maybe_register_default_providers(
|
|
|
24
23
|
reg.register(StripeAdapter())
|
|
25
24
|
except Exception:
|
|
26
25
|
pass
|
|
26
|
+
try:
|
|
27
|
+
from svc_infra.apf_payments.provider.aiydan import AiydanAdapter
|
|
28
|
+
|
|
29
|
+
reg.register(AiydanAdapter())
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
27
32
|
if adapters:
|
|
28
33
|
for a in adapters:
|
|
29
34
|
reg.register(a) # must implement ProviderAdapter protocol (name, create_intent, etc.)
|
|
@@ -45,14 +50,13 @@ def add_payments(
|
|
|
45
50
|
- Reuses your OpenAPI defaults (security + responses) via DualAPIRouter factories.
|
|
46
51
|
"""
|
|
47
52
|
_maybe_register_default_providers(register_default_providers, adapters)
|
|
48
|
-
add_prefixed_docs(app, prefix=prefix, title="Payments")
|
|
49
53
|
|
|
50
54
|
for r in build_payments_routers(prefix=prefix):
|
|
51
55
|
app.include_router(
|
|
52
56
|
r, include_in_schema=True if include_in_docs is None else bool(include_in_docs)
|
|
53
57
|
)
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
# Store the startup function to be called by lifespan if needed
|
|
56
60
|
async def _payments_startup_check():
|
|
57
61
|
try:
|
|
58
62
|
reg = get_provider_registry()
|
|
@@ -60,5 +64,10 @@ def add_payments(
|
|
|
60
64
|
# Try a cheap call (Stripe: read account or key balance; we just access .name)
|
|
61
65
|
_ = adapter.name
|
|
62
66
|
except Exception as e:
|
|
63
|
-
# Log loud; don
|
|
67
|
+
# Log loud; don't crash the whole app by default
|
|
64
68
|
logger.warning(f"[payments] Provider adapter not ready: {e}")
|
|
69
|
+
|
|
70
|
+
# Add to app state for potential lifespan usage
|
|
71
|
+
if not hasattr(app.state, "startup_events"):
|
|
72
|
+
app.state.startup_events = []
|
|
73
|
+
app.state.startup_events.append(_payments_startup_check)
|
|
@@ -11,12 +11,12 @@ from svc_infra.api.fastapi.auth.mfa.router import mfa_router
|
|
|
11
11
|
from svc_infra.api.fastapi.auth.routers.account import account_router
|
|
12
12
|
from svc_infra.api.fastapi.auth.routers.apikey_router import apikey_router
|
|
13
13
|
from svc_infra.api.fastapi.auth.routers.oauth_router import oauth_router_with_backend
|
|
14
|
+
from svc_infra.api.fastapi.auth.routers.session_router import build_session_router
|
|
14
15
|
from svc_infra.api.fastapi.db.sql.users import get_fastapi_users
|
|
15
16
|
from svc_infra.api.fastapi.paths.prefix import AUTH_PREFIX, USER_PREFIX
|
|
16
17
|
from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
|
|
17
18
|
from svc_infra.db.sql.apikey import bind_apikey_model
|
|
18
19
|
|
|
19
|
-
from ..docs.scoped import add_prefixed_docs
|
|
20
20
|
from .policy import AuthPolicy, DefaultAuthPolicy
|
|
21
21
|
from .providers import providers_from_settings
|
|
22
22
|
from .settings import get_auth_settings
|
|
@@ -73,6 +73,15 @@ def install_user_routers(
|
|
|
73
73
|
include_in_schema=include_in_docs,
|
|
74
74
|
dependencies=[Depends(login_client_gaurd)],
|
|
75
75
|
)
|
|
76
|
+
# Session/device listing & revocation endpoints (AuthSession model)
|
|
77
|
+
# Mounted under the user prefix so final paths become /{user_prefix}/sessions/... (e.g., /users/sessions/me)
|
|
78
|
+
# The router itself has a /sessions prefix.
|
|
79
|
+
app.include_router(
|
|
80
|
+
build_session_router(),
|
|
81
|
+
prefix=user_prefix,
|
|
82
|
+
tags=["Session Management"],
|
|
83
|
+
include_in_schema=include_in_docs,
|
|
84
|
+
)
|
|
76
85
|
app.include_router(
|
|
77
86
|
register_router,
|
|
78
87
|
prefix=user_prefix,
|
|
@@ -283,9 +292,6 @@ def add_auth_users(
|
|
|
283
292
|
https_only=bool(getattr(settings_obj, "session_cookie_secure", False)),
|
|
284
293
|
)
|
|
285
294
|
|
|
286
|
-
add_prefixed_docs(app, prefix=user_prefix, title="Users")
|
|
287
|
-
add_prefixed_docs(app, prefix=auth_prefix, title="Auth")
|
|
288
|
-
|
|
289
295
|
if enable_password:
|
|
290
296
|
setup_password_authentication(
|
|
291
297
|
app,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
|
@@ -11,6 +12,7 @@ from fastapi_users.password import PasswordHelper
|
|
|
11
12
|
from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
|
|
12
13
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
13
14
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
15
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
14
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
15
17
|
|
|
16
18
|
_pwd = PasswordHelper()
|
|
@@ -65,9 +67,12 @@ def auth_session_router(
|
|
|
65
67
|
router = public_router()
|
|
66
68
|
policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
|
|
67
69
|
|
|
70
|
+
from svc_infra.security.lockout import get_lockout_status, record_attempt
|
|
71
|
+
|
|
68
72
|
@router.post("/login", name="auth:jwt.login")
|
|
69
73
|
async def login(
|
|
70
74
|
request: Request,
|
|
75
|
+
session: SqlSessionDep,
|
|
71
76
|
username: str = Form(...),
|
|
72
77
|
password: str = Form(...),
|
|
73
78
|
scope: str = Form(""),
|
|
@@ -75,26 +80,76 @@ def auth_session_router(
|
|
|
75
80
|
client_secret: str | None = Form(None),
|
|
76
81
|
user_manager=Depends(fapi.get_user_manager),
|
|
77
82
|
):
|
|
78
|
-
# 1) lookup user (normalize email)
|
|
79
83
|
strategy = auth_backend.get_strategy()
|
|
80
|
-
|
|
81
84
|
email = username.strip().lower()
|
|
85
|
+
# Compute IP hash for lockout correlation
|
|
86
|
+
client_ip = getattr(request.client, "host", None)
|
|
87
|
+
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest() if client_ip else None
|
|
88
|
+
|
|
89
|
+
# Pre-check lockout by IP to avoid enumeration
|
|
90
|
+
try:
|
|
91
|
+
status_lo = await get_lockout_status(session, user_id=None, ip_hash=ip_hash)
|
|
92
|
+
if status_lo.locked and status_lo.next_allowed_at:
|
|
93
|
+
retry = int(
|
|
94
|
+
(status_lo.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
|
|
95
|
+
)
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=429,
|
|
98
|
+
detail="account_locked",
|
|
99
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
100
|
+
)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Lookup user
|
|
82
105
|
user = await user_manager.user_db.get_by_email(email)
|
|
83
106
|
if not user:
|
|
84
107
|
_, _ = _pwd.verify_and_update(password, _DUMMY_BCRYPT)
|
|
108
|
+
try:
|
|
109
|
+
await record_attempt(session, user_id=None, ip_hash=ip_hash, success=False)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
85
112
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
86
113
|
|
|
87
|
-
#
|
|
114
|
+
# Status checks
|
|
88
115
|
if not getattr(user, "is_active", True):
|
|
89
116
|
raise HTTPException(401, "account_disabled")
|
|
90
117
|
|
|
91
118
|
hashed = getattr(user, "hashed_password", None) or getattr(user, "password_hash", None)
|
|
92
119
|
if not hashed:
|
|
93
|
-
|
|
120
|
+
try:
|
|
121
|
+
await record_attempt(
|
|
122
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
|
|
123
|
+
)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
94
126
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
95
127
|
|
|
128
|
+
# Check lockout for this user + IP before verifying password
|
|
129
|
+
try:
|
|
130
|
+
status_user = await get_lockout_status(
|
|
131
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash
|
|
132
|
+
)
|
|
133
|
+
if status_user.locked and status_user.next_allowed_at:
|
|
134
|
+
retry = int(
|
|
135
|
+
(status_user.next_allowed_at - datetime.now(timezone.utc)).total_seconds()
|
|
136
|
+
)
|
|
137
|
+
raise HTTPException(
|
|
138
|
+
status_code=429,
|
|
139
|
+
detail="account_locked",
|
|
140
|
+
headers={"Retry-After": str(max(0, retry))},
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
96
145
|
ok, new_hash = _pwd.verify_and_update(password, hashed)
|
|
97
146
|
if not ok:
|
|
147
|
+
try:
|
|
148
|
+
await record_attempt(
|
|
149
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=False
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
98
153
|
raise HTTPException(400, "LOGIN_BAD_CREDENTIALS")
|
|
99
154
|
|
|
100
155
|
# If the hash needs upgrading, persist it (optional but recommended)
|
|
@@ -106,7 +161,6 @@ def auth_session_router(
|
|
|
106
161
|
try:
|
|
107
162
|
await user_manager.user_db.update(user)
|
|
108
163
|
except Exception:
|
|
109
|
-
# don't block login if updating hash fails; log if you have logging here
|
|
110
164
|
pass
|
|
111
165
|
|
|
112
166
|
if getattr(user, "is_verified") is False:
|
|
@@ -130,6 +184,14 @@ def auth_session_router(
|
|
|
130
184
|
# don’t block login if this write fails
|
|
131
185
|
pass
|
|
132
186
|
|
|
187
|
+
# Record successful attempt (for audit)
|
|
188
|
+
try:
|
|
189
|
+
await record_attempt(
|
|
190
|
+
session, user_id=getattr(user, "id", None), ip_hash=ip_hash, success=True
|
|
191
|
+
)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
133
195
|
# 5) mint token and set cookie
|
|
134
196
|
token = await strategy.write_token(user)
|
|
135
197
|
st = get_auth_settings()
|
|
@@ -28,6 +28,8 @@ from svc_infra.api.fastapi.paths.auth import (
|
|
|
28
28
|
OAUTH_LOGIN_PATH,
|
|
29
29
|
OAUTH_REFRESH_PATH,
|
|
30
30
|
)
|
|
31
|
+
from svc_infra.security.models import RefreshToken
|
|
32
|
+
from svc_infra.security.session import issue_session_and_refresh, rotate_session_refresh
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _gen_pkce_pair() -> tuple[str, str]:
|
|
@@ -466,9 +468,13 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
|
|
|
466
468
|
|
|
467
469
|
|
|
468
470
|
async def _set_cookie_on_response(
|
|
469
|
-
resp: Response,
|
|
471
|
+
resp: Response,
|
|
472
|
+
auth_backend: AuthenticationBackend,
|
|
473
|
+
user: Any,
|
|
474
|
+
*,
|
|
475
|
+
refresh_raw: str,
|
|
470
476
|
) -> None:
|
|
471
|
-
"""Set authentication
|
|
477
|
+
"""Set authentication (JWT) and refresh cookies on response."""
|
|
472
478
|
st = get_auth_settings()
|
|
473
479
|
strategy = auth_backend.get_strategy()
|
|
474
480
|
jwt_token = await strategy.write_token(user)
|
|
@@ -477,6 +483,7 @@ async def _set_cookie_on_response(
|
|
|
477
483
|
if same_site_lit == "none" and not bool(st.session_cookie_secure):
|
|
478
484
|
raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
|
|
479
485
|
|
|
486
|
+
# Access/Auth cookie (short-lived JWT)
|
|
480
487
|
resp.set_cookie(
|
|
481
488
|
key=_cookie_name(st),
|
|
482
489
|
value=jwt_token,
|
|
@@ -488,6 +495,18 @@ async def _set_cookie_on_response(
|
|
|
488
495
|
path="/",
|
|
489
496
|
)
|
|
490
497
|
|
|
498
|
+
# Refresh cookie (opaque token, longer lived)
|
|
499
|
+
resp.set_cookie(
|
|
500
|
+
key=getattr(st, "session_cookie_name", "svc_session"),
|
|
501
|
+
value=refresh_raw,
|
|
502
|
+
max_age=60 * 60 * 24 * 7, # 7 days default
|
|
503
|
+
httponly=True,
|
|
504
|
+
secure=bool(st.session_cookie_secure),
|
|
505
|
+
samesite=same_site_lit,
|
|
506
|
+
domain=_cookie_domain(st),
|
|
507
|
+
path="/",
|
|
508
|
+
)
|
|
509
|
+
|
|
491
510
|
|
|
492
511
|
def _clean_oauth_session_state(request: Request, provider: str) -> None:
|
|
493
512
|
"""Clean up transient OAuth session state."""
|
|
@@ -641,9 +660,18 @@ def _create_oauth_router(
|
|
|
641
660
|
user.last_login = datetime.now(timezone.utc)
|
|
642
661
|
await session.commit()
|
|
643
662
|
|
|
644
|
-
# Create
|
|
663
|
+
# Create session + initial refresh token
|
|
664
|
+
raw_refresh, _rt = await issue_session_and_refresh(
|
|
665
|
+
session,
|
|
666
|
+
user_id=user.id,
|
|
667
|
+
tenant_id=getattr(user, "tenant_id", None),
|
|
668
|
+
user_agent=str(request.headers.get("user-agent", ""))[:512],
|
|
669
|
+
ip_hash=None,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Create response with auth + refresh cookies
|
|
645
673
|
resp = RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
|
|
646
|
-
await _set_cookie_on_response(resp, auth_backend, user)
|
|
674
|
+
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=raw_refresh)
|
|
647
675
|
|
|
648
676
|
# Clean up session state
|
|
649
677
|
_clean_oauth_session_state(request, provider)
|
|
@@ -667,44 +695,55 @@ def _create_oauth_router(
|
|
|
667
695
|
"""Refresh authentication token."""
|
|
668
696
|
st = get_auth_settings()
|
|
669
697
|
|
|
670
|
-
# Read and validate cookie
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if not
|
|
698
|
+
# Read and validate auth JWT cookie
|
|
699
|
+
name_auth = _cookie_name(st)
|
|
700
|
+
raw_auth = request.cookies.get(name_auth)
|
|
701
|
+
if not raw_auth:
|
|
674
702
|
raise HTTPException(401, "missing_token")
|
|
675
703
|
|
|
676
|
-
# Validate and decode JWT token
|
|
677
|
-
user_id = await _validate_and_decode_jwt_token(
|
|
704
|
+
# Validate and decode JWT token to get user id
|
|
705
|
+
user_id = await _validate_and_decode_jwt_token(raw_auth)
|
|
678
706
|
|
|
679
707
|
# Load user
|
|
680
708
|
user = await session.get(user_model, user_id)
|
|
681
709
|
if not user:
|
|
682
710
|
raise HTTPException(401, "invalid_token")
|
|
683
711
|
|
|
684
|
-
#
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
712
|
+
# Obtain refresh cookie
|
|
713
|
+
refresh_cookie_name = getattr(st, "session_cookie_name", "svc_session")
|
|
714
|
+
raw_refresh = request.cookies.get(refresh_cookie_name)
|
|
715
|
+
if not raw_refresh:
|
|
716
|
+
raise HTTPException(401, "missing_refresh_token")
|
|
717
|
+
|
|
718
|
+
# Lookup refresh token row by hash
|
|
719
|
+
from sqlalchemy import select
|
|
720
|
+
|
|
721
|
+
from svc_infra.security.models import hash_refresh_token
|
|
722
|
+
|
|
723
|
+
token_hash = hash_refresh_token(raw_refresh)
|
|
724
|
+
found: RefreshToken | None = (
|
|
725
|
+
(
|
|
726
|
+
await session.execute(
|
|
727
|
+
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
.scalars()
|
|
731
|
+
.first()
|
|
732
|
+
)
|
|
733
|
+
if (
|
|
734
|
+
not found
|
|
735
|
+
or found.revoked_at
|
|
736
|
+
or (found.expires_at and found.expires_at < datetime.now(timezone.utc))
|
|
737
|
+
):
|
|
738
|
+
raise HTTPException(401, "invalid_refresh_token")
|
|
739
|
+
|
|
740
|
+
# Rotate refresh token
|
|
741
|
+
new_raw, _new_rt = await rotate_session_refresh(session, current=found)
|
|
742
|
+
|
|
743
|
+
# Write response (204) with new cookies
|
|
744
|
+
resp = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
745
|
+
await _set_cookie_on_response(resp, auth_backend, user, refresh_raw=new_raw)
|
|
746
|
+
# Policy hook: trigger after successful rotation; suppress hook errors
|
|
708
747
|
if hasattr(policy, "on_token_refresh"):
|
|
709
748
|
try:
|
|
710
749
|
await policy.on_token_refresh(user)
|
|
@@ -713,4 +752,5 @@ def _create_oauth_router(
|
|
|
713
752
|
|
|
714
753
|
return resp
|
|
715
754
|
|
|
755
|
+
# Return router at end of factory
|
|
716
756
|
return router
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
|
|
9
|
+
from svc_infra.api.fastapi.auth.security import Identity
|
|
10
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
11
|
+
from svc_infra.security.models import AuthSession
|
|
12
|
+
from svc_infra.security.permissions import RequirePermission
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_session_router() -> APIRouter:
|
|
16
|
+
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
17
|
+
|
|
18
|
+
@router.get(
|
|
19
|
+
"/me", response_model=list[dict], dependencies=[RequirePermission("security.session.list")]
|
|
20
|
+
)
|
|
21
|
+
async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> List[dict]:
|
|
22
|
+
stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
|
|
23
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
"id": str(r.id),
|
|
27
|
+
"user_agent": r.user_agent,
|
|
28
|
+
"ip_hash": r.ip_hash,
|
|
29
|
+
"revoked": bool(r.revoked_at),
|
|
30
|
+
"last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None,
|
|
31
|
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
32
|
+
}
|
|
33
|
+
for r in rows
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@router.post(
|
|
37
|
+
"/{session_id}/revoke",
|
|
38
|
+
status_code=204,
|
|
39
|
+
dependencies=[RequirePermission("security.session.revoke")],
|
|
40
|
+
)
|
|
41
|
+
async def revoke_session(session_id: str, identity: Identity, db: SqlSessionDep):
|
|
42
|
+
# Load session and ensure it belongs to the user (non-admin users cannot revoke others)
|
|
43
|
+
s = await db.get(AuthSession, session_id)
|
|
44
|
+
if not s:
|
|
45
|
+
raise HTTPException(404, "session_not_found")
|
|
46
|
+
# Basic ownership check; could extend for admin bypass later
|
|
47
|
+
if s.user_id != identity.user.id:
|
|
48
|
+
raise HTTPException(403, "forbidden")
|
|
49
|
+
if s.revoked_at:
|
|
50
|
+
return # already revoked
|
|
51
|
+
s.revoked_at = datetime.now(timezone.utc)
|
|
52
|
+
s.revoke_reason = "user_revoked"
|
|
53
|
+
# Revoke all refresh tokens for this session
|
|
54
|
+
for rt in s.refresh_tokens:
|
|
55
|
+
if not rt.revoked_at:
|
|
56
|
+
rt.revoked_at = s.revoked_at
|
|
57
|
+
rt.revoke_reason = "session_revoked"
|
|
58
|
+
await db.flush()
|
|
59
|
+
|
|
60
|
+
return router
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["build_session_router"]
|
|
@@ -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):
|
|
@@ -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
|
)
|