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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {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
- @app.on_event("startup")
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; dont crash the whole app by default
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
- # 2) verify status + password
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
- # No password set (likely OAuth-only account)
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, auth_backend: AuthenticationBackend, user: Any
471
+ resp: Response,
472
+ auth_backend: AuthenticationBackend,
473
+ user: Any,
474
+ *,
475
+ refresh_raw: str,
470
476
  ) -> None:
471
- """Set authentication cookie on response."""
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 response with auth cookie
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
- name = _cookie_name(st)
672
- raw = request.cookies.get(name)
673
- if not raw:
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(raw)
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
- # Handle MFA if required
685
- if await policy.should_require_mfa(user):
686
- pre = await get_mfa_pre_jwt_writer().write(user)
687
- redirect_url = str(getattr(st, "post_login_redirect", "/"))
688
- allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
689
- require_https = bool(getattr(st, "session_cookie_secure", False))
690
- _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
691
-
692
- nxt = request.query_params.get("next")
693
- if nxt:
694
- try:
695
- _validate_redirect(nxt, allow_hosts, require_https=require_https)
696
- redirect_url = nxt
697
- except HTTPException:
698
- pass
699
-
700
- qs = urlencode({"mfa": "required", "pre_token": pre})
701
- return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
702
-
703
- # Create response with new token
704
- resp = Response(status_code=204)
705
- await _set_cookie_on_response(resp, auth_backend, user)
706
-
707
- # Optional: notify policy hook
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
- @app.on_event("startup")
9
- async def _startup():
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
- @app.on_event("shutdown")
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
- @app.on_event("startup")
42
- async def _startup() -> None:
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
- @app.on_event("shutdown")
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 r in resources:
67
+ for resource in resources:
66
68
  repo = NoSqlRepository(
67
- collection_name=r.resolved_collection(),
68
- id_field=r.id_field,
69
- soft_delete=r.soft_delete,
70
- soft_delete_field=r.soft_delete_field,
71
- soft_delete_flag_field=r.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 = r.service_factory(repo) if r.service_factory else NoSqlService(repo)
75
+ svc = resource.service_factory(repo) if resource.service_factory else NoSqlService(repo)
74
76
 
75
- if r.read_schema and r.create_schema and r.update_schema:
76
- Read, Create, Update = r.read_schema, r.create_schema, r.update_schema
77
- elif r.document_model is not None:
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
- r.document_model,
81
- create_exclude=r.create_exclude,
82
- read_name=r.read_name,
83
- create_name=r.create_name,
84
- update_name=r.update_name,
85
- read_exclude=r.read_exclude,
86
- update_exclude=r.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 '{r.collection}' requires either explicit schemas "
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=r.resolved_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=r.prefix,
103
- tags=r.tags,
104
- search_fields=r.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
  )