codexLB-up 1.25.0b10__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 (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. config/additional_quota_registry.json +11 -0
@@ -0,0 +1,1384 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import secrets
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from io import BytesIO
10
+ from time import time
11
+ from typing import Literal, Protocol, cast
12
+
13
+ import bcrypt
14
+ import segno
15
+
16
+ from app.core.audit.service import AuditActor, AuditAuthMethod, AuditService, AuditSeverity, AuditTarget
17
+ from app.core.auth.dashboard_access import (
18
+ ADMIN_GRANTS,
19
+ ASSIGNABLE_PRESET_ROLES,
20
+ GUEST_GRANTS,
21
+ PRESET_ROLE_IDS,
22
+ DashboardRole,
23
+ Grants,
24
+ Permission,
25
+ PresetRoleSlug,
26
+ permission_strings,
27
+ totp_policy_applies,
28
+ )
29
+ from app.core.auth.dashboard_mode import DashboardAuthMode
30
+ from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
31
+ from app.core.auth.providers.registry import ActiveProvider, get_auth_provider_registry
32
+ from app.core.auth.step_up import (
33
+ StepUpMethod,
34
+ account_step_up_methods,
35
+ is_step_up_fresh,
36
+ step_up_expires_at,
37
+ step_up_methods,
38
+ )
39
+ from app.core.auth.totp import build_otpauth_uri, generate_totp_secret, verify_totp_code
40
+ from app.core.config.settings import get_settings
41
+ from app.core.crypto import TokenEncryptor
42
+ from app.core.rate_limiter.db_rate_limiter import DatabaseRateLimiter
43
+ from app.db.models import AuthProviderKind, DashboardUser, DashboardUserStatus
44
+ from app.modules.dashboard_auth.oidc_flows import OIDC_LOGIN_START_PATH
45
+ from app.modules.dashboard_auth.schemas import (
46
+ DashboardAccessSummary,
47
+ DashboardAuthSessionResponse,
48
+ DashboardLoginHint,
49
+ DashboardLoginProvider,
50
+ DashboardMeResponse,
51
+ DashboardSessionUser,
52
+ DashboardStepUpState,
53
+ DashboardUserRoleSummary,
54
+ LocalLoginPolicyValue,
55
+ LoginProviderKind,
56
+ TotpSetupStartResponse,
57
+ )
58
+ from app.modules.dashboard_roles.service import resolve_role_grants
59
+ from app.modules.dashboard_users.break_glass import (
60
+ assert_break_glass_remains,
61
+ break_glass_second_factor_required,
62
+ local_login_admits,
63
+ )
64
+ from app.modules.dashboard_users.credentials import assert_credential_remains
65
+ from app.modules.dashboard_users.repository import (
66
+ DashboardUserCounts,
67
+ LocalAuthState,
68
+ is_valid_username,
69
+ normalize_username,
70
+ )
71
+
72
+ DASHBOARD_SESSION_COOKIE = "codex_lb_dashboard_session"
73
+ #: Cookie payload format. Version 1 carried ``pw``/``tv``/``role``/``gv`` and no
74
+ #: user id; it is rejected outright so a stale cookie can never resolve to a
75
+ #: user it was not issued for.
76
+ SESSION_PAYLOAD_VERSION = 2
77
+ _TOTP_ISSUER = "codex-lb"
78
+ AUTH_METHOD_PASSWORD = "password"
79
+
80
+
81
+ class DashboardAuthSettingsProtocol(Protocol):
82
+ guest_access_enabled: bool
83
+ guest_password_hash: str | None
84
+ guest_session_generation: int
85
+ totp_required_on_login: bool
86
+ totp_required_for_admin_role: bool
87
+ local_login_policy: str
88
+
89
+
90
+ class DashboardAuthRepositoryProtocol(Protocol):
91
+ async def get_settings(self) -> DashboardAuthSettingsProtocol: ...
92
+
93
+ async def get_local_auth_state(self) -> LocalAuthState: ...
94
+
95
+ async def get_user_by_id(self, user_id: str) -> DashboardUser | None: ...
96
+
97
+ async def get_user_by_username(self, normalized_username: str) -> DashboardUser | None: ...
98
+
99
+ async def list_active_local_password_users(self) -> Sequence[DashboardUser]: ...
100
+
101
+ async def count_user_identities(self, user_id: str) -> int: ...
102
+
103
+ async def count_live_invites(self) -> int: ...
104
+
105
+ async def acquire_write_intent(self) -> None: ...
106
+
107
+ async def get_user_counts(self) -> DashboardUserCounts: ...
108
+
109
+ async def count_qualifying_break_glass(self, *, exclude_user_id: str | None = None) -> int: ...
110
+
111
+ async def count_custom_roles(self) -> int: ...
112
+
113
+ async def count_role_mappings(self) -> int: ...
114
+
115
+ async def count_scim_tokens(self) -> int: ...
116
+
117
+ async def create_first_admin(self, password_hash: str) -> DashboardUser | None: ...
118
+
119
+ async def rotate_user_password(self, user_id: str, password_hash: str) -> DashboardUser: ...
120
+
121
+ async def set_user_totp_secret(
122
+ self,
123
+ user_id: str,
124
+ secret_encrypted: bytes | None,
125
+ *,
126
+ bump_generation: bool = False,
127
+ preserve_policy: bool = False,
128
+ ) -> DashboardUser: ...
129
+
130
+ async def try_advance_user_totp_step(self, user_id: str, step: int) -> bool: ...
131
+
132
+ async def bump_session_generation(self, user_id: str) -> int: ...
133
+
134
+ async def clear_user_credentials(self, user_id: str) -> DashboardUser: ...
135
+
136
+ async def touch_last_login(self, user_id: str) -> None: ...
137
+
138
+ async def set_guest_password_hash(self, password_hash: str) -> DashboardAuthSettingsProtocol: ...
139
+
140
+ async def clear_guest_password_hash(self) -> DashboardAuthSettingsProtocol: ...
141
+
142
+ async def bump_guest_session_generation(self) -> DashboardAuthSettingsProtocol: ...
143
+
144
+
145
+ class TotpAlreadyConfiguredError(ValueError):
146
+ pass
147
+
148
+
149
+ class TotpNotConfiguredError(ValueError):
150
+ pass
151
+
152
+
153
+ class TotpInvalidCodeError(ValueError):
154
+ pass
155
+
156
+
157
+ class TotpInvalidSetupError(ValueError):
158
+ pass
159
+
160
+
161
+ class TotpVerificationRequiredError(ValueError):
162
+ pass
163
+
164
+
165
+ class TotpEnrollmentRequiredError(ValueError):
166
+ pass
167
+
168
+
169
+ class PasswordAlreadyConfiguredError(ValueError):
170
+ pass
171
+
172
+
173
+ class PasswordNotConfiguredError(ValueError):
174
+ pass
175
+
176
+
177
+ class InvalidCredentialsError(ValueError):
178
+ pass
179
+
180
+
181
+ class UsernameRequiredError(ValueError):
182
+ pass
183
+
184
+
185
+ class OtherUsersExistError(ValueError):
186
+ pass
187
+
188
+
189
+ class GuestAccessDisabledError(ValueError):
190
+ pass
191
+
192
+
193
+ class PasswordSessionRequiredError(ValueError):
194
+ pass
195
+
196
+
197
+ class StepUpUnavailableError(ValueError):
198
+ """The account holds neither a password nor a TOTP secret, so nothing can be re-verified."""
199
+
200
+
201
+ SessionKind = Literal["user", "guest"]
202
+
203
+
204
+ @dataclass(slots=True, frozen=True)
205
+ class DashboardSessionState:
206
+ """Decoded session cookie (payload version 2).
207
+
208
+ ``kind == "user"`` carries the account id and the ``session_generation`` the
209
+ cookie was minted under; the request path re-reads the user row and rejects
210
+ the cookie when the account is gone, disabled, or has revoked its sessions.
211
+ ``kind == "guest"`` carries only the guest generation. ``step_up_verified_at``
212
+ (``su``) is when the account last re-verified a credential for a sensitive
213
+ change; absent until it does. ``break_glass`` (``bg``) marks an emergency
214
+ session so the dashboard can say so; it is optional and its absence means
215
+ false, which is why the payload version does not change for it.
216
+ """
217
+
218
+ expires_at: int
219
+ issued_at: int
220
+ kind: SessionKind
221
+ user_id: str | None = None
222
+ session_generation: int | None = None
223
+ password_verified: bool = False
224
+ totp_verified: bool = False
225
+ auth_method: str | None = None
226
+ guest_session_generation: int | None = None
227
+ step_up_verified_at: int | None = None
228
+ break_glass: bool = False
229
+
230
+ @property
231
+ def is_user(self) -> bool:
232
+ return self.kind == "user"
233
+
234
+ @property
235
+ def is_guest(self) -> bool:
236
+ return self.kind == "guest"
237
+
238
+ @property
239
+ def role(self) -> DashboardRole:
240
+ """Coarse wire role: every user session is ``admin`` on the wire this release."""
241
+
242
+ return DashboardRole.GUEST if self.kind == "guest" else DashboardRole.ADMIN
243
+
244
+
245
+ def _as_int(value: object) -> int | None:
246
+ if isinstance(value, bool) or not isinstance(value, int):
247
+ return None
248
+ return value
249
+
250
+
251
+ class DashboardSessionStore:
252
+ def __init__(self) -> None:
253
+ self._encryptor: TokenEncryptor | None = None
254
+
255
+ def _get_encryptor(self) -> TokenEncryptor:
256
+ if self._encryptor is None:
257
+ self._encryptor = TokenEncryptor()
258
+ return self._encryptor
259
+
260
+ def _seal(self, data: dict[str, object]) -> str:
261
+ payload = json.dumps(data, separators=(",", ":"))
262
+ return self._get_encryptor().encrypt(payload).decode("ascii")
263
+
264
+ def create_user_session(
265
+ self,
266
+ user_id: str,
267
+ session_generation: int,
268
+ *,
269
+ password_verified: bool,
270
+ totp_verified: bool,
271
+ ttl_seconds: int,
272
+ auth_method: str = AUTH_METHOD_PASSWORD,
273
+ step_up_verified_at: int | None = None,
274
+ break_glass: bool = False,
275
+ ) -> str:
276
+ now = int(time())
277
+ payload: dict[str, object] = {
278
+ "v": SESSION_PAYLOAD_VERSION,
279
+ "exp": now + ttl_seconds,
280
+ "iat": now,
281
+ "uid": user_id,
282
+ "sg": session_generation,
283
+ "pv": password_verified,
284
+ "tp": totp_verified,
285
+ "am": auth_method,
286
+ }
287
+ if step_up_verified_at is not None:
288
+ payload["su"] = step_up_verified_at
289
+ if break_glass:
290
+ payload["bg"] = True
291
+ return self._seal(payload)
292
+
293
+ def create_guest_session(self, *, ttl_seconds: int, guest_session_generation: int) -> str:
294
+ now = int(time())
295
+ return self._seal(
296
+ {
297
+ "v": SESSION_PAYLOAD_VERSION,
298
+ "exp": now + ttl_seconds,
299
+ "iat": now,
300
+ "guest": True,
301
+ "gg": guest_session_generation,
302
+ }
303
+ )
304
+
305
+ def get(self, session_id: str | None) -> DashboardSessionState | None:
306
+ if not session_id:
307
+ return None
308
+ token = session_id.strip()
309
+ if not token:
310
+ return None
311
+ try:
312
+ raw = self._get_encryptor().decrypt(token.encode("ascii"))
313
+ except Exception:
314
+ return None
315
+ try:
316
+ data = json.loads(raw)
317
+ except Exception:
318
+ return None
319
+ if not isinstance(data, dict) or data.get("v") != SESSION_PAYLOAD_VERSION:
320
+ return None
321
+ exp = _as_int(data.get("exp"))
322
+ iat = _as_int(data.get("iat"))
323
+ if exp is None or iat is None or exp < int(time()):
324
+ return None
325
+ if data.get("guest") is True:
326
+ gg = _as_int(data.get("gg"))
327
+ if gg is None:
328
+ return None
329
+ return DashboardSessionState(expires_at=exp, issued_at=iat, kind="guest", guest_session_generation=gg)
330
+ uid = data.get("uid")
331
+ sg = _as_int(data.get("sg"))
332
+ pv = data.get("pv")
333
+ tp = data.get("tp")
334
+ am = data.get("am")
335
+ if not isinstance(uid, str) or not uid or sg is None:
336
+ return None
337
+ if not isinstance(pv, bool) or not isinstance(tp, bool) or not isinstance(am, str):
338
+ return None
339
+ return DashboardSessionState(
340
+ expires_at=exp,
341
+ issued_at=iat,
342
+ kind="user",
343
+ user_id=uid,
344
+ session_generation=sg,
345
+ password_verified=pv,
346
+ totp_verified=tp,
347
+ auth_method=am,
348
+ step_up_verified_at=_as_int(data.get("su")),
349
+ break_glass=data.get("bg") is True,
350
+ )
351
+
352
+ def delete(self, session_id: str | None) -> None:
353
+ # Stateless: deletion is handled by clearing the cookie client-side.
354
+ return
355
+
356
+
357
+ def session_clock() -> int:
358
+ """The clock sessions and step-ups are minted and checked against (one clock, one truth)."""
359
+
360
+ return int(time())
361
+
362
+
363
+ def is_local_password_session(state: DashboardSessionState) -> bool:
364
+ """Whether this cookie stands for *a local password*, which is what ``local_login_policy`` governs.
365
+
366
+ ``password_verified`` is the flag that admits a cookie at all, so every
367
+ account session sets it -- including one minted by the identity provider.
368
+ The local login policy is about the door that does not depend on that
369
+ provider, so it keys on the method too: without this an OIDC session would
370
+ count as the local fallback and single sign-on would silently defeat
371
+ ``admins_only`` and ``break_glass_only``, the two policies that exist to
372
+ close the local door once single sign-on is on. One function, so the gate
373
+ and the session response that advertises the fallback cannot disagree.
374
+ """
375
+
376
+ return state.password_verified and state.auth_method == AUTH_METHOD_PASSWORD
377
+
378
+
379
+ class StepUpCookieStore:
380
+ """The ``codex_lb_step_up`` cookie: a step-up for principals without a session cookie.
381
+
382
+ Trusted-header accounts prove who they are on every request through the
383
+ proxy header and carry no session cookie, so their re-verification rides
384
+ in this separate short-lived cookie (``{v, uid, sg, su, exp}``). It is
385
+ only honoured for the account it was minted for, and only while that
386
+ account's ``session_generation`` is unchanged: resetting its TOTP or
387
+ revoking its sessions must void the proof, not leave it usable for the
388
+ rest of the window.
389
+ """
390
+
391
+ PAYLOAD_VERSION = 1
392
+
393
+ def __init__(self) -> None:
394
+ self._encryptor: TokenEncryptor | None = None
395
+
396
+ def _get_encryptor(self) -> TokenEncryptor:
397
+ if self._encryptor is None:
398
+ self._encryptor = TokenEncryptor()
399
+ return self._encryptor
400
+
401
+ def create(self, user_id: str, *, session_generation: int, verified_at: int) -> str:
402
+ payload = {
403
+ "v": self.PAYLOAD_VERSION,
404
+ "uid": user_id,
405
+ "sg": session_generation,
406
+ "su": verified_at,
407
+ "exp": step_up_expires_at(verified_at),
408
+ }
409
+ return self._get_encryptor().encrypt(json.dumps(payload, separators=(",", ":"))).decode("ascii")
410
+
411
+ def get(self, token: str | None, *, user_id: str, session_generation: int) -> int | None:
412
+ """The verification time the cookie records for this account, or ``None``.
413
+
414
+ A generation bump (TOTP reset, sessions revoked) voids the proof.
415
+ """
416
+
417
+ if not token or not token.strip():
418
+ return None
419
+ try:
420
+ data = json.loads(self._get_encryptor().decrypt(token.strip().encode("ascii")))
421
+ except Exception:
422
+ return None
423
+ if not isinstance(data, dict) or data.get("v") != self.PAYLOAD_VERSION or data.get("uid") != user_id:
424
+ return None
425
+ if _as_int(data.get("sg")) != session_generation:
426
+ return None
427
+ verified_at, exp = _as_int(data.get("su")), _as_int(data.get("exp"))
428
+ if verified_at is None or exp is None or exp < session_clock():
429
+ return None
430
+ return verified_at
431
+
432
+
433
+ @dataclass(slots=True, frozen=True)
434
+ class ResolvedUserSession:
435
+ """A decoded user cookie whose account is still active and whose generation still matches."""
436
+
437
+ user: DashboardUser
438
+ state: DashboardSessionState
439
+
440
+
441
+ @dataclass(slots=True, frozen=True)
442
+ class SessionDescription:
443
+ """The session response together with the account it was resolved for (reused by the API layer)."""
444
+
445
+ response: DashboardAuthSessionResponse
446
+ resolved: ResolvedUserSession | None
447
+
448
+
449
+ AuthStateProvider = Callable[[], Awaitable[LocalAuthState]]
450
+ ActiveProvidersProvider = Callable[[], Awaitable[list[ActiveProvider]]]
451
+
452
+
453
+ @dataclass(slots=True, frozen=True)
454
+ class GuestVerification:
455
+ """Outcome of a guest credential check plus the generation of the very row it was checked against.
456
+
457
+ The cookie must be stamped with this generation: re-reading settings after
458
+ the check could pick up a bump (guest password just enabled) and mint a
459
+ cookie that outlives the credential it never satisfied.
460
+ """
461
+
462
+ password_verified: bool
463
+ guest_session_generation: int
464
+
465
+
466
+ @dataclass(slots=True, frozen=True)
467
+ class LoginTarget:
468
+ """Who a password login attempt is for, before the password is checked.
469
+
470
+ ``username`` is the normalized submitted (or resolved) username and is
471
+ ``None`` only when no username could be resolved; ``user`` is ``None``
472
+ when no active account with that username exists. Callers must not leak
473
+ the difference to the client.
474
+ """
475
+
476
+ username: str | None
477
+ user: DashboardUser | None
478
+ #: Why no account was resolved (audit ``login_failed.reason``); ``None`` when ``user`` is set.
479
+ refusal_reason: str | None = None
480
+
481
+
482
+ def _user_is_active(user: DashboardUser | None) -> bool:
483
+ return user is not None and user.status == DashboardUserStatus.ACTIVE.value
484
+
485
+
486
+ def _user_actor(user: DashboardUser, auth_method: str) -> AuditActor:
487
+ return AuditActor(user_id=user.id, username=user.username, role_slug=user.role.slug, auth_method=auth_method)
488
+
489
+
490
+ def _user_target(user: DashboardUser) -> AuditTarget:
491
+ return AuditTarget("user", user.id)
492
+
493
+
494
+ _GUEST_ACTOR = AuditActor(
495
+ user_id=None,
496
+ username=None,
497
+ role_slug=PresetRoleSlug.GUEST.value,
498
+ auth_method=AuditAuthMethod.GUEST.value,
499
+ )
500
+
501
+
502
+ def log_login_failed(actor_ip: str | None, method: str, reason: str, *, username: str | None = None) -> None:
503
+ """Audit a refused sign-in: no actor (nobody authenticated), warning severity, a fixed reason."""
504
+
505
+ AuditService.log_async(
506
+ "login_failed",
507
+ actor_ip=actor_ip,
508
+ details={"method": method, "username": username, "reason": reason},
509
+ severity=AuditSeverity.WARNING,
510
+ )
511
+
512
+
513
+ def role_summary(user: DashboardUser) -> DashboardUserRoleSummary:
514
+ return DashboardUserRoleSummary(id=user.role.id, slug=user.role.slug, name=user.role.name, kind=user.role.kind)
515
+
516
+
517
+ def session_user(user: DashboardUser) -> DashboardSessionUser:
518
+ return DashboardSessionUser(
519
+ id=user.id,
520
+ username=user.username,
521
+ display_name=user.display_name,
522
+ role=role_summary(user),
523
+ )
524
+
525
+
526
+ class DashboardAuthService:
527
+ def __init__(
528
+ self,
529
+ repository: DashboardAuthRepositoryProtocol,
530
+ session_store: DashboardSessionStore,
531
+ *,
532
+ auth_state_provider: AuthStateProvider | None = None,
533
+ active_providers_provider: ActiveProvidersProvider | None = None,
534
+ ) -> None:
535
+ self._repository = repository
536
+ self._session_store = session_store
537
+ self._encryptor = TokenEncryptor()
538
+ # The session response is served on every page load; it reads the derived
539
+ # auth state and the active providers through the process caches. Login
540
+ # and setup decisions keep reading the repository directly.
541
+ self._auth_state = auth_state_provider or _cached_local_auth_state
542
+ self._active_providers = active_providers_provider or _cached_active_providers
543
+
544
+ # --- session resolution ---
545
+
546
+ async def resolve_user_session(self, session_id: str | None) -> ResolvedUserSession | None:
547
+ state = self._session_store.get(session_id)
548
+ return await self._resolve_state(state)
549
+
550
+ async def _resolve_state(self, state: DashboardSessionState | None) -> ResolvedUserSession | None:
551
+ if state is None or not state.is_user or state.user_id is None:
552
+ return None
553
+ user = await self._repository.get_user_by_id(state.user_id)
554
+ if not _user_is_active(user) or user is None:
555
+ return None
556
+ if user.session_generation != state.session_generation:
557
+ return None
558
+ return ResolvedUserSession(user=user, state=state)
559
+
560
+ async def require_password_session(self, session_id: str | None) -> ResolvedUserSession:
561
+ resolved = await self.resolve_user_session(session_id)
562
+ if resolved is None or not resolved.state.password_verified:
563
+ raise PasswordSessionRequiredError("Password-authenticated session is required")
564
+ return resolved
565
+
566
+ async def require_management_session(
567
+ self, session_id: str | None, *, allow_unenrolled: bool = False
568
+ ) -> ResolvedUserSession:
569
+ """A password session that has also passed TOTP when the install requires it.
570
+
571
+ With the TOTP policy on, an account that has no secret yet is refused
572
+ (``TotpEnrollmentRequiredError``) unless ``allow_unenrolled`` marks the
573
+ caller as one of the self-service routes an unenrolled account may use.
574
+ """
575
+
576
+ resolved = await self.require_password_session(session_id)
577
+ if await self._totp_required_for(resolved.user):
578
+ if resolved.user.totp_secret_encrypted is None:
579
+ if not allow_unenrolled:
580
+ raise TotpEnrollmentRequiredError("TOTP enrollment is required before dashboard access")
581
+ elif not resolved.state.totp_verified:
582
+ raise TotpVerificationRequiredError("TOTP verification is required for dashboard access")
583
+ return resolved
584
+
585
+ async def _totp_required_for(self, user: DashboardUser) -> bool:
586
+ """The TOTP policy as it binds ``user``.
587
+
588
+ The global toggle, the admin-role toggle for admin-level roles, and --
589
+ whatever either says -- an emergency account that holds a secret.
590
+ """
591
+
592
+ settings = await self._repository.get_settings()
593
+ return totp_policy_applies(
594
+ required_on_login=settings.totp_required_on_login,
595
+ required_for_admin_role=settings.totp_required_for_admin_role,
596
+ grants=resolve_role_grants(user.role),
597
+ ) or break_glass_second_factor_required(user)
598
+
599
+ async def _require_totp_verified_session(self, session_id: str | None) -> ResolvedUserSession:
600
+ resolved = await self.require_password_session(session_id)
601
+ if resolved.user.totp_secret_encrypted is None and await self._totp_required_for(resolved.user):
602
+ raise TotpEnrollmentRequiredError("TOTP enrollment is required before dashboard access")
603
+ if not resolved.state.totp_verified:
604
+ raise PasswordSessionRequiredError("TOTP-verified session is required")
605
+ return resolved
606
+
607
+ async def ensure_active_password_session(self, session_id: str | None) -> None:
608
+ await self.require_password_session(session_id)
609
+
610
+ async def ensure_totp_verified_session(self, session_id: str | None) -> None:
611
+ await self._require_totp_verified_session(session_id)
612
+
613
+ # --- session response ---
614
+
615
+ async def get_session_state(self, session_id: str | None) -> DashboardAuthSessionResponse:
616
+ return (await self.describe_session(session_id)).response
617
+
618
+ async def describe_session(self, session_id: str | None) -> SessionDescription:
619
+ settings = await self._repository.get_settings()
620
+ auth_state = await self._auth_state()
621
+ password_required = auth_state.requires_auth
622
+ guest_access_enabled = settings.guest_access_enabled
623
+ guest_password_required = guest_access_enabled and settings.guest_password_hash is not None
624
+ state = self._session_store.get(session_id) if password_required or guest_access_enabled else None
625
+ resolved = await self._resolve_state(state)
626
+ public_guest_authenticated = bool(
627
+ guest_access_enabled
628
+ and not guest_password_required
629
+ and password_required
630
+ and get_settings().dashboard_auth_mode == DashboardAuthMode.STANDARD
631
+ )
632
+
633
+ user: DashboardUser | None = None
634
+ grants: Grants
635
+ step_up: DashboardStepUpState | None = None
636
+ totp_configured = False
637
+ totp_pending = False
638
+ totp_enrollment_required = False
639
+ auth_method: str | None = None
640
+ if (
641
+ state is not None
642
+ and state.is_guest
643
+ and guest_access_enabled
644
+ and state.guest_session_generation == settings.guest_session_generation
645
+ ):
646
+ authenticated = True
647
+ role = DashboardRole.GUEST
648
+ grants = GUEST_GRANTS
649
+ elif resolved is not None and resolved.state.password_verified:
650
+ user = resolved.user
651
+ grants = resolve_role_grants(user.role)
652
+ totp_policy = totp_policy_applies(
653
+ required_on_login=settings.totp_required_on_login,
654
+ required_for_admin_role=settings.totp_required_for_admin_role,
655
+ grants=grants,
656
+ ) or break_glass_second_factor_required(user)
657
+ totp_configured = user.totp_secret_encrypted is not None
658
+ totp_pending = totp_policy and totp_configured and not resolved.state.totp_verified
659
+ totp_enrollment_required = totp_policy and not totp_configured
660
+ authenticated = not totp_pending
661
+ role = DashboardRole.ADMIN
662
+ auth_method = resolved.state.auth_method
663
+ step_up = await step_up_state(user, verified_at=resolved.state.step_up_verified_at)
664
+ elif not password_required:
665
+ authenticated = True
666
+ role = DashboardRole.ADMIN
667
+ grants = ADMIN_GRANTS
668
+ elif public_guest_authenticated:
669
+ authenticated = True
670
+ role = DashboardRole.GUEST
671
+ grants = GUEST_GRANTS
672
+ else:
673
+ authenticated = False
674
+ role = DashboardRole.ADMIN
675
+ grants = ADMIN_GRANTS
676
+
677
+ # Team facts and assignable roles are for signed-in accounts holding
678
+ # users:manage only; the implicit admin has no account and gets none.
679
+ manages_users = bool(
680
+ authenticated and user is not None and not totp_enrollment_required and Permission.USERS_MANAGE in grants
681
+ )
682
+ response = DashboardAuthSessionResponse(
683
+ authenticated=authenticated,
684
+ password_required=password_required,
685
+ local_password_configured=auth_state.active_local_password_users > 0,
686
+ totp_required_on_login=totp_pending,
687
+ totp_configured=totp_configured,
688
+ role=role,
689
+ permissions=permission_strings(grants),
690
+ guest_access_enabled=guest_access_enabled,
691
+ guest_password_required=guest_password_required,
692
+ user=session_user(user) if user is not None else None,
693
+ auth_method=auth_method,
694
+ must_change_password=bool(user is not None and user.must_change_password),
695
+ totp_enrollment_required=totp_enrollment_required,
696
+ login=await self.login_hint(auth_state, settings.local_login_policy),
697
+ access_summary=await self.access_summary() if manages_users else None,
698
+ assignable_role_ids=assignable_role_ids() if manages_users else [],
699
+ step_up=step_up,
700
+ break_glass_session=bool(resolved is not None and resolved.state.break_glass and user is not None),
701
+ )
702
+ return SessionDescription(response=response, resolved=resolved)
703
+
704
+ async def login_hint(self, auth_state: LocalAuthState, policy: str) -> DashboardLoginHint:
705
+ """The login screen's facts, served to unauthenticated clients too — never a username."""
706
+
707
+ return DashboardLoginHint(
708
+ username_field="hidden" if auth_state.active_local_password_users == 1 else "shown",
709
+ providers=[
710
+ DashboardLoginProvider(
711
+ # The registry only activates kinds with an implementation, which are the wire kinds.
712
+ kind=cast(LoginProviderKind, item.row.kind),
713
+ provider_key=item.row.provider_key,
714
+ label=item.row.label,
715
+ # A redirect-style provider names where its sign-in starts;
716
+ # the others have no URL of their own. The client that does
717
+ # not know the kind simply ignores the entry.
718
+ login_url=OIDC_LOGIN_START_PATH if item.row.kind == AuthProviderKind.OIDC.value else None,
719
+ )
720
+ for item in await self._active_providers()
721
+ ],
722
+ local_login=cast(LocalLoginPolicyValue, policy),
723
+ )
724
+
725
+ async def access_summary(self) -> DashboardAccessSummary:
726
+ counts = await self._repository.get_user_counts()
727
+ return DashboardAccessSummary(
728
+ users_total=counts.total,
729
+ users_active=counts.active,
730
+ users_invited=counts.invited,
731
+ users_disabled=counts.disabled,
732
+ pending_invites=counts.pending_invites,
733
+ non_admin_users=counts.non_admin,
734
+ custom_roles=await self._repository.count_custom_roles(),
735
+ providers_enabled=[item.row.kind for item in await self._active_providers()],
736
+ role_mappings=await self._repository.count_role_mappings(),
737
+ scim_tokens=await self._repository.count_scim_tokens(),
738
+ audit_sinks=0,
739
+ local_login_policy=cast(LocalLoginPolicyValue, (await self._repository.get_settings()).local_login_policy),
740
+ )
741
+
742
+ async def me(self, session_id: str | None) -> DashboardMeResponse:
743
+ resolved = await self.require_management_session(session_id, allow_unenrolled=True)
744
+ user = resolved.user
745
+ return DashboardMeResponse(
746
+ id=user.id,
747
+ username=user.username,
748
+ display_name=user.display_name,
749
+ email=user.email,
750
+ role=role_summary(user),
751
+ auth_method=resolved.state.auth_method,
752
+ totp_configured=user.totp_secret_encrypted is not None,
753
+ must_change_password=user.must_change_password,
754
+ )
755
+
756
+ # --- password ---
757
+
758
+ async def setup_password(self, password: str) -> DashboardUser:
759
+ user = await self._repository.create_first_admin(hash_password(password))
760
+ if user is None:
761
+ raise PasswordAlreadyConfiguredError("Password is already configured")
762
+ return user
763
+
764
+ async def resolve_login_target(self, username: str | None) -> LoginTarget:
765
+ """Pick the account a login attempt is for.
766
+
767
+ A single-user install accepts a login without a username. With more
768
+ than one active local password user the username is mandatory
769
+ (``UsernameRequiredError``); that refusal must not spend any rate-limit
770
+ budget, and the route decides whether to audit it. Why a username did
771
+ not resolve is kept on the target for the audit row only; the client
772
+ never learns it -- including when ``local_login_policy`` is what
773
+ refused the account.
774
+ """
775
+
776
+ auth_state = await self._repository.get_local_auth_state()
777
+ if auth_state.active_local_password_users == 0:
778
+ raise PasswordNotConfiguredError("Password is not configured")
779
+ policy = (await self._repository.get_settings()).local_login_policy
780
+ if username is None:
781
+ if auth_state.sole_local_password_user_id is None:
782
+ raise UsernameRequiredError("Username is required")
783
+ user = await self._repository.get_user_by_id(auth_state.sole_local_password_user_id)
784
+ if user is not None and not local_login_admits(user, policy):
785
+ return LoginTarget(username=user.username, user=None, refusal_reason="login_policy")
786
+ return LoginTarget(username=user.username if user is not None else None, user=user)
787
+ normalized = normalize_username(username)
788
+ if not is_valid_username(normalized):
789
+ return LoginTarget(username=normalized, user=None, refusal_reason="invalid_username")
790
+ user = await self._repository.get_user_by_username(normalized)
791
+ if user is None:
792
+ return LoginTarget(username=normalized, user=None, refusal_reason="unknown_identity")
793
+ if not _user_is_active(user):
794
+ return LoginTarget(username=normalized, user=None, refusal_reason="disabled_user")
795
+ # A policy refusal drops the account exactly like an unknown username
796
+ # does: ``verify_user_password`` still runs its one hash comparison, so
797
+ # the answer, its body and its timing are the same.
798
+ if not local_login_admits(user, policy):
799
+ return LoginTarget(username=normalized, user=None, refusal_reason="login_policy")
800
+ return LoginTarget(username=normalized, user=user)
801
+
802
+ async def verify_user_password(
803
+ self,
804
+ target: LoginTarget,
805
+ password: str,
806
+ *,
807
+ actor_ip: str | None = None,
808
+ ) -> DashboardUser:
809
+ """Check ``password`` for ``target``; unknown accounts take the same path and time.
810
+
811
+ Exactly one bcrypt verification runs whether or not the account exists
812
+ or holds a password (a memoised dummy hash stands in), so response
813
+ timing cannot reveal which usernames are real. The audit record only
814
+ keeps well-formed usernames.
815
+ """
816
+
817
+ user = target.user
818
+ stored_hash = user.password_hash if user is not None else None
819
+ matched = _check_password(password, stored_hash if stored_hash is not None else _dummy_password_hash())
820
+ if user is None or stored_hash is None or not matched:
821
+ username_is_valid = target.username is not None and is_valid_username(target.username)
822
+ log_login_failed(
823
+ actor_ip,
824
+ AUTH_METHOD_PASSWORD,
825
+ target.refusal_reason or "bad_password",
826
+ username=target.username if username_is_valid else None,
827
+ )
828
+ raise InvalidCredentialsError("Invalid credentials")
829
+ await self._repository.touch_last_login(user.id)
830
+ if user.totp_secret_encrypted is None or not await self._totp_required_for(user):
831
+ AuditService.log_async(
832
+ "login_success",
833
+ actor_ip=actor_ip,
834
+ details={"method": AUTH_METHOD_PASSWORD, "username": user.username},
835
+ actor=_user_actor(user, AUTH_METHOD_PASSWORD),
836
+ target=_user_target(user),
837
+ )
838
+ return user
839
+
840
+ async def verify_password(self, password: str, *, actor_ip: str | None = None) -> DashboardUser:
841
+ """Single-user convenience used by tests and callers without a username."""
842
+
843
+ return await self.verify_user_password(await self.resolve_login_target(None), password, actor_ip=actor_ip)
844
+
845
+ async def verify_guest_password(self, password: str | None, *, actor_ip: str | None = None) -> GuestVerification:
846
+ """Check the guest credential against one settings row read from the database.
847
+
848
+ The returned generation belongs to that same row; callers stamp it into
849
+ the cookie instead of reading settings again.
850
+ """
851
+
852
+ settings = await self._repository.get_settings()
853
+ if not settings.guest_access_enabled:
854
+ raise GuestAccessDisabledError("Guest access is disabled")
855
+ generation = settings.guest_session_generation
856
+ current = settings.guest_password_hash
857
+ if current is None:
858
+ AuditService.log_async("login_success", actor_ip=actor_ip, details={"method": "guest"}, actor=_GUEST_ACTOR)
859
+ return GuestVerification(password_verified=False, guest_session_generation=generation)
860
+ if password is None or not _check_password(password, current):
861
+ log_login_failed(actor_ip, AuditAuthMethod.GUEST.value, "bad_password")
862
+ raise InvalidCredentialsError("Invalid credentials")
863
+ AuditService.log_async("login_success", actor_ip=actor_ip, details={"method": "guest"}, actor=_GUEST_ACTOR)
864
+ return GuestVerification(password_verified=True, guest_session_generation=generation)
865
+
866
+ async def change_password(
867
+ self,
868
+ user: DashboardUser,
869
+ current_password: str,
870
+ new_password: str,
871
+ *,
872
+ actor_ip: str | None = None,
873
+ auth_method: str | None = None,
874
+ ) -> int:
875
+ """Rotate the password and revoke every other session; returns the new generation.
876
+
877
+ ``auth_method`` is how the calling session authenticated (audit actor).
878
+ """
879
+
880
+ if user.password_hash is None:
881
+ raise PasswordNotConfiguredError("Password is not configured")
882
+ if not _check_password(current_password, user.password_hash):
883
+ raise InvalidCredentialsError("Invalid credentials")
884
+ rotated = await self._repository.rotate_user_password(user.id, hash_password(new_password))
885
+ AuditService.log_async(
886
+ "password_changed",
887
+ actor_ip=actor_ip,
888
+ details={"username": user.username},
889
+ actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
890
+ target=_user_target(user),
891
+ )
892
+ return rotated.session_generation
893
+
894
+ async def remove_password(
895
+ self,
896
+ user: DashboardUser,
897
+ password: str,
898
+ *,
899
+ actor_ip: str | None = None,
900
+ auth_method: str | None = None,
901
+ ) -> None:
902
+ """Solo-install only: drop the user's credentials so the install is passwordless again.
903
+
904
+ "Solo" is every account the install holds, in any status -- not just
905
+ the active ones. A disabled account is still an account: it keeps its
906
+ role, its owned keys and (if it ever had one) its password hash, and
907
+ only an account with ``users:manage`` can bring it back. Removing the
908
+ last *active* password while one exists would hand the install to the
909
+ implicit local admin, which holds no account and therefore cannot
910
+ manage users at all, stranding the disabled row with no way to enable,
911
+ delete or sign in as it -- while the install-wide TOTP requirements
912
+ this write also clears were only ever justified by "this account *is*
913
+ the install". Deleting the other accounts first is the way through.
914
+ """
915
+
916
+ if user.password_hash is None:
917
+ raise PasswordNotConfiguredError("Password is not configured")
918
+ if not _check_password(password, user.password_hash):
919
+ raise InvalidCredentialsError("Invalid credentials")
920
+ # Serialised with account mutations and invite acceptance: a pending
921
+ # invite would otherwise turn a passwordless install into one where
922
+ # authentication is mandatory again but no admin holds a password.
923
+ await self._repository.acquire_write_intent()
924
+ counts = await self._repository.get_user_counts()
925
+ solo_install = counts.total == 1 and counts.active == 1
926
+ identities = await self._repository.count_user_identities(user.id)
927
+ if not solo_install or identities or await self._repository.count_live_invites():
928
+ raise OtherUsersExistError(
929
+ "Other accounts exist or invites are pending; delete the other accounts "
930
+ "or revoke pending invites instead"
931
+ )
932
+ assert_credential_remains(password_hash=None, identity_count=identities, solo_install=solo_install)
933
+ # Dropping the password is one more way to lose the last qualifying
934
+ # emergency account: a restricted policy would survive the removal, and
935
+ # the re-bootstrapped ``admin`` is designated but never qualifying, so
936
+ # the next sign-in would meet a door nothing can open. Same guard, same
937
+ # refusal, still under the write intent taken above.
938
+ await self._assert_break_glass_remains(user, has_password=False, has_totp=False)
939
+ await self._repository.clear_user_credentials(user.id)
940
+ AuditService.log_async(
941
+ "password_removed",
942
+ actor_ip=actor_ip,
943
+ details={"username": user.username},
944
+ actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
945
+ target=_user_target(user),
946
+ )
947
+
948
+ async def revoke_user_sessions(
949
+ self,
950
+ user: DashboardUser,
951
+ *,
952
+ actor_ip: str | None = None,
953
+ auth_method: str | None = None,
954
+ ) -> int:
955
+ generation = await self._repository.bump_session_generation(user.id)
956
+ AuditService.log_async(
957
+ "user_sessions_revoked",
958
+ actor_ip=actor_ip,
959
+ details={"username": user.username, "scope": "self"},
960
+ actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
961
+ target=_user_target(user),
962
+ )
963
+ return generation
964
+
965
+ async def set_guest_password(self, password: str) -> None:
966
+ await self._repository.set_guest_password_hash(hash_password(password))
967
+
968
+ async def clear_guest_password(self) -> None:
969
+ await self._repository.clear_guest_password_hash()
970
+
971
+ async def revoke_guest_sessions(self) -> None:
972
+ """Invalidate every outstanding guest session cookie."""
973
+
974
+ await self._repository.bump_guest_session_generation()
975
+
976
+ # --- TOTP (per user) ---
977
+
978
+ async def _account_for_totp(
979
+ self, session_id: str | None, resolved: ResolvedUserSession | None
980
+ ) -> ResolvedUserSession:
981
+ """The account a TOTP route acts on: the password session, or the account the route already resolved.
982
+
983
+ Routes pass ``resolved`` for a trusted-header account, which has no
984
+ session cookie but may still enrol TOTP (it is its only step-up method).
985
+ """
986
+
987
+ return resolved if resolved is not None else await self.require_password_session(session_id)
988
+
989
+ async def start_totp_setup(
990
+ self, *, session_id: str | None, resolved: ResolvedUserSession | None = None
991
+ ) -> TotpSetupStartResponse:
992
+ resolved = await self._account_for_totp(session_id, resolved)
993
+ if resolved.user.totp_secret_encrypted is not None:
994
+ raise TotpAlreadyConfiguredError("TOTP is already configured. Disable it before setting a new secret")
995
+ secret = generate_totp_secret()
996
+ otpauth_uri = build_otpauth_uri(secret, issuer=_TOTP_ISSUER, account_name=resolved.user.username)
997
+ return TotpSetupStartResponse(
998
+ secret=secret,
999
+ otpauth_uri=otpauth_uri,
1000
+ qr_svg_data_uri=_qr_svg_data_uri(otpauth_uri),
1001
+ )
1002
+
1003
+ async def confirm_totp_setup(
1004
+ self,
1005
+ *,
1006
+ session_id: str | None,
1007
+ secret: str,
1008
+ code: str,
1009
+ actor_ip: str | None = None,
1010
+ resolved: ResolvedUserSession | None = None,
1011
+ ) -> None:
1012
+ resolved = await self._account_for_totp(session_id, resolved)
1013
+ if resolved.user.totp_secret_encrypted is not None:
1014
+ raise TotpAlreadyConfiguredError("TOTP is already configured. Disable it before setting a new secret")
1015
+ try:
1016
+ verification = verify_totp_code(secret, code, window=1)
1017
+ except ValueError as exc:
1018
+ raise TotpInvalidSetupError("Invalid TOTP setup payload") from exc
1019
+ if not verification.is_valid:
1020
+ raise TotpInvalidCodeError("Invalid TOTP code")
1021
+ await self._repository.set_user_totp_secret(resolved.user.id, self._encryptor.encrypt(secret))
1022
+ AuditService.log_async(
1023
+ "totp_enabled",
1024
+ actor_ip=actor_ip,
1025
+ details={"username": resolved.user.username},
1026
+ actor=_user_actor(resolved.user, resolved.state.auth_method or AUTH_METHOD_PASSWORD),
1027
+ target=_user_target(resolved.user),
1028
+ )
1029
+
1030
+ async def verify_totp(
1031
+ self,
1032
+ *,
1033
+ session_id: str | None,
1034
+ code: str,
1035
+ ttl_seconds: int,
1036
+ actor_ip: str | None = None,
1037
+ ) -> tuple[str, int]:
1038
+ resolved = await self.require_password_session(session_id)
1039
+ user, existing_state = resolved.user, resolved.state
1040
+ secret_encrypted = user.totp_secret_encrypted
1041
+ if secret_encrypted is None:
1042
+ raise TotpNotConfiguredError("TOTP is not configured")
1043
+ secret = self._encryptor.decrypt(secret_encrypted)
1044
+ verification = verify_totp_code(
1045
+ secret,
1046
+ code,
1047
+ window=1,
1048
+ last_verified_step=user.totp_last_verified_step,
1049
+ )
1050
+ # Snapshot the attribution before the counter write: a refused replay
1051
+ # rolls the session back, which expires ``user`` and would turn a later
1052
+ # attribute read into a lazy load outside the async context.
1053
+ username = user.username
1054
+ actor = _user_actor(user, AuditAuthMethod.TOTP.value)
1055
+ target = _user_target(user)
1056
+ if not verification.is_valid or verification.matched_step is None:
1057
+ log_login_failed(actor_ip, AuditAuthMethod.TOTP.value, "bad_totp", username=username)
1058
+ raise TotpInvalidCodeError("Invalid TOTP code")
1059
+ updated = await self._repository.try_advance_user_totp_step(user.id, verification.matched_step)
1060
+ if not updated:
1061
+ log_login_failed(actor_ip, AuditAuthMethod.TOTP.value, "bad_totp", username=username)
1062
+ raise TotpInvalidCodeError("Invalid TOTP code")
1063
+ AuditService.log_async(
1064
+ "login_success",
1065
+ actor_ip=actor_ip,
1066
+ details={"method": "totp", "username": username},
1067
+ actor=actor,
1068
+ target=target,
1069
+ )
1070
+ if user.is_break_glass:
1071
+ # The emergency door was used. This is the first (and, with the CLI,
1072
+ # one of two) critical-severity events the product writes: an
1073
+ # operator reading the audit log must never have to infer it.
1074
+ AuditService.log_async(
1075
+ "break_glass_login",
1076
+ actor_ip=actor_ip,
1077
+ details={"method": "totp", "username": username},
1078
+ actor=actor,
1079
+ target=target,
1080
+ severity=AuditSeverity.CRITICAL,
1081
+ )
1082
+ # Honor the existing password-session expiry so that a TTL change
1083
+ # mid-flow (between password login and TOTP submission) cannot extend
1084
+ # an already-issued session, while still applying the TTL cap resolved
1085
+ # for the current TOTP request. The state captured by
1086
+ # require_password_session is reused so a second store.get() race
1087
+ # cannot turn a near-expiry password session into a full-length one.
1088
+ now = int(time())
1089
+ inherited_ttl = max(1, existing_state.expires_at - now)
1090
+ applied_ttl = min(inherited_ttl, ttl_seconds)
1091
+ # Completing the second factor of a sign-in that just happened is a
1092
+ # step-up: both credentials were presented within the window. Verifying
1093
+ # against an old session is not — the password behind it was proven
1094
+ # long ago (and whoever holds the cookie could have enrolled the secret
1095
+ # themselves), so that path keeps whatever step-up the session already
1096
+ # carried and leaves /step-up to ask for the password.
1097
+ #
1098
+ # "A sign-in that just happened" means a *local password* sign-in. A
1099
+ # session minted by the identity provider is fresh too, and its
1100
+ # ``password_verified`` flag is set because that is the flag the gate
1101
+ # reads — but no password was presented to obtain it. Without the
1102
+ # method test, an account holding a password could sign in through
1103
+ # single sign-on, enrol a TOTP secret, verify a code and hold a step-up
1104
+ # covering every security mutation, having proven one of the two
1105
+ # factors it is required to present. The OIDC step-up flow is the path
1106
+ # for that account, and it refuses an account that holds a local factor.
1107
+ completing_fresh_login = is_local_password_session(existing_state) and is_step_up_fresh(
1108
+ existing_state.issued_at, now=now
1109
+ )
1110
+ step_up_verified_at = now if completing_fresh_login else existing_state.step_up_verified_at
1111
+ new_session_id = self._session_store.create_user_session(
1112
+ user.id,
1113
+ user.session_generation,
1114
+ password_verified=True,
1115
+ totp_verified=True,
1116
+ ttl_seconds=applied_ttl,
1117
+ auth_method=existing_state.auth_method or AUTH_METHOD_PASSWORD,
1118
+ step_up_verified_at=step_up_verified_at,
1119
+ break_glass=user.is_break_glass,
1120
+ )
1121
+ return new_session_id, applied_ttl
1122
+
1123
+ async def _consume_totp_code(self, user: DashboardUser, code: str) -> None:
1124
+ """Check ``code`` against the account's secret and advance its replay counter, or raise."""
1125
+
1126
+ secret_encrypted = user.totp_secret_encrypted
1127
+ if secret_encrypted is None:
1128
+ raise TotpNotConfiguredError("TOTP is not configured")
1129
+ secret = self._encryptor.decrypt(secret_encrypted)
1130
+ verification = verify_totp_code(secret, code, window=1, last_verified_step=user.totp_last_verified_step)
1131
+ if not verification.is_valid or verification.matched_step is None:
1132
+ raise TotpInvalidCodeError("Invalid TOTP code")
1133
+ if not await self._repository.try_advance_user_totp_step(user.id, verification.matched_step):
1134
+ raise TotpInvalidCodeError("Invalid TOTP code")
1135
+
1136
+ async def disable_totp(
1137
+ self,
1138
+ *,
1139
+ session_id: str | None,
1140
+ code: str,
1141
+ actor_ip: str | None = None,
1142
+ resolved: ResolvedUserSession | None = None,
1143
+ ) -> DashboardUser:
1144
+ """Drop the account's secret after a valid code; returns the account.
1145
+
1146
+ A header account never carries a TOTP-verified cookie; its valid
1147
+ ``code`` is the verification (a password session still needs ``tp``).
1148
+ """
1149
+
1150
+ resolved = resolved if resolved is not None else await self._require_totp_verified_session(session_id)
1151
+ user = resolved.user
1152
+ # The code is consumed *first* because advancing the replay counter
1153
+ # commits, and a commit between the guard's count and the secret write
1154
+ # would drop the write-intent lock the guard took -- two concurrent
1155
+ # disables would then both pass the count and clear both secrets. The
1156
+ # cost is that a refused removal still spends the code the caller
1157
+ # typed; the guard is what must not be racy.
1158
+ await self._consume_totp_code(user, code)
1159
+ await self._assert_break_glass_remains(user, has_totp=False)
1160
+ await self._repository.set_user_totp_secret(user.id, None)
1161
+ AuditService.log_async(
1162
+ "totp_disabled",
1163
+ actor_ip=actor_ip,
1164
+ details={"username": user.username},
1165
+ actor=_user_actor(user, resolved.state.auth_method or AUTH_METHOD_PASSWORD),
1166
+ target=_user_target(user),
1167
+ )
1168
+ return user
1169
+
1170
+ async def _assert_break_glass_remains(
1171
+ self, user: DashboardUser, *, has_totp: bool | None = None, has_password: bool | None = None
1172
+ ) -> None:
1173
+ """The shared break-glass guard, reached from the self-service side.
1174
+
1175
+ Same function, same refusal as the management paths: an account that
1176
+ is the install's only qualifying emergency account cannot remove its
1177
+ own second factor (or its own password) while local sign-in is
1178
+ restricted.
1179
+
1180
+ The caller must not commit between this call and its write: the write
1181
+ intent taken here is what keeps two simultaneous removals from both
1182
+ passing the count, and a commit releases it.
1183
+ """
1184
+
1185
+ # Serialised with the management paths *before the policy is read*: the
1186
+ # secret write has no conditional form, so the lock is what keeps two
1187
+ # simultaneous removals from both passing the count -- and a policy read
1188
+ # that happens outside it can be a tightening older than the guard, which
1189
+ # would let this removal take the last qualifying account away.
1190
+ await self._repository.acquire_write_intent()
1191
+ settings = await self._repository.get_settings()
1192
+ await assert_break_glass_remains(
1193
+ user,
1194
+ policy=settings.local_login_policy,
1195
+ count_other_qualifying=lambda: self._repository.count_qualifying_break_glass(exclude_user_id=user.id),
1196
+ has_totp=has_totp,
1197
+ has_password=has_password,
1198
+ )
1199
+
1200
+ # --- step-up (per user) ---
1201
+
1202
+ async def verify_step_up(
1203
+ self,
1204
+ user: DashboardUser,
1205
+ *,
1206
+ password: str | None,
1207
+ code: str | None,
1208
+ actor_ip: str | None = None,
1209
+ auth_method: str | None = None,
1210
+ ) -> list[StepUpMethod]:
1211
+ """Re-verify ``user`` with every factor it holds; returns the methods that were checked.
1212
+
1213
+ Password accounts present the password (and a TOTP code when the account
1214
+ has a secret); provider-only accounts present a TOTP code. Every refusal
1215
+ is the same ``InvalidCredentialsError``. An account with no factor at all
1216
+ raises ``StepUpUnavailableError`` instead — the client is told to enrol.
1217
+ """
1218
+
1219
+ methods = step_up_methods(user)
1220
+ if not methods:
1221
+ raise StepUpUnavailableError("The account holds no credential to re-verify")
1222
+ username = user.username
1223
+ actor, target = _user_actor(user, auth_method or AUTH_METHOD_PASSWORD), _user_target(user)
1224
+ if "password" in methods and (
1225
+ user.password_hash is None or not _check_password(password or "", user.password_hash)
1226
+ ):
1227
+ log_login_failed(actor_ip, "step_up", "bad_password", username=username)
1228
+ raise InvalidCredentialsError("Invalid credentials")
1229
+ if "totp" in methods:
1230
+ try:
1231
+ await self._consume_totp_code(user, code or "")
1232
+ except (TotpInvalidCodeError, TotpNotConfiguredError) as exc:
1233
+ log_login_failed(actor_ip, "step_up", "bad_totp", username=username)
1234
+ raise InvalidCredentialsError("Invalid credentials") from exc
1235
+ AuditService.log_async(
1236
+ "step_up_verified",
1237
+ actor_ip=actor_ip,
1238
+ details={"username": username, "methods": list(methods)},
1239
+ actor=actor,
1240
+ target=target,
1241
+ )
1242
+ return methods
1243
+
1244
+ def logout(self, session_id: str | None) -> None:
1245
+ self._session_store.delete(session_id)
1246
+
1247
+
1248
+ async def step_up_state(user: DashboardUser, *, verified_at: int | None) -> DashboardStepUpState:
1249
+ """The session response's ``step_up`` block: a still-fresh verification and the account's methods.
1250
+
1251
+ Async because the methods come from :func:`account_step_up_methods`: what
1252
+ the dashboard shows and what the gate accepts have to be the same list, and
1253
+ for an account whose only factor is its identity provider that list is not
1254
+ a property of the row alone.
1255
+ """
1256
+
1257
+ fresh = verified_at if is_step_up_fresh(verified_at, now=session_clock()) else None
1258
+ return DashboardStepUpState(
1259
+ verified_at=fresh,
1260
+ expires_at=step_up_expires_at(fresh) if fresh is not None else None,
1261
+ methods=await account_step_up_methods(user),
1262
+ )
1263
+
1264
+
1265
+ def assignable_role_ids() -> list[str]:
1266
+ return [PRESET_ROLE_IDS[slug] for slug in sorted(ASSIGNABLE_PRESET_ROLES, key=lambda slug: slug.value)]
1267
+
1268
+
1269
+ async def _cached_local_auth_state() -> LocalAuthState:
1270
+ return await get_dashboard_users_cache().local_auth_state()
1271
+
1272
+
1273
+ async def _cached_active_providers() -> list[ActiveProvider]:
1274
+ return await get_auth_provider_registry().get_active_providers(get_settings().dashboard_auth_mode)
1275
+
1276
+
1277
+ _dashboard_session_store = DashboardSessionStore()
1278
+ _step_up_cookie_store = StepUpCookieStore()
1279
+ _totp_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="totp")
1280
+ _password_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="password")
1281
+ #: The ceiling on failed password logins from one address, whatever usernames
1282
+ #: they name. The per-(address, username) bucket above keeps one account's
1283
+ #: failures from barring another, which also means one address can mint a fresh
1284
+ #: bucket per username it invents; without a coarse address bucket the endpoint
1285
+ #: has no ceiling at all and every attempt costs a blocking password-hash
1286
+ #: comparison on the event loop that serves the proxy.
1287
+ #:
1288
+ #: 60/60 s is seven and a half times the per-account allowance: a NATed office
1289
+ #: whose people are all mistyping at once never reaches it (one sign-in form
1290
+ #: submission is one request), while a sprayer behind one address is held to a
1291
+ #: bounded number of hash comparisons per minute. It is deliberately *not* the
1292
+ #: per-account limit, so the 8/60 s semantics and the tests that pin them are
1293
+ #: untouched.
1294
+ _password_address_rate_limiter = DatabaseRateLimiter(max_attempts=60, window_seconds=60, type="password_address")
1295
+ _guest_password_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="guest_password")
1296
+ #: Bounds the anonymous ``login_failed`` rows a client can append from refusals
1297
+ #: that by design spend no password budget (``username_required``).
1298
+ _login_failed_audit_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="login_failed_audit")
1299
+ #: The ceiling on the two public OIDC routes, per client address. One sign-in
1300
+ #: is two requests (start, callback), so 30/60 s is generous for a person and
1301
+ #: bounded for a client replaying a captured callback URL. It is deliberately
1302
+ #: never cleared on success: it is the endpoints' only ceiling.
1303
+ _oidc_address_rate_limiter = DatabaseRateLimiter(max_attempts=30, window_seconds=60, type="oidc_address")
1304
+ #: The per-``state`` budget on the callback, keyed on the hash so the table
1305
+ #: never holds a live state value. Same shape as ``invite_accept_token``: a
1306
+ #: legitimate flow spends one, a replay of one URL spends the rest.
1307
+ _oidc_state_rate_limiter = DatabaseRateLimiter(max_attempts=5, window_seconds=60, type="oidc_callback_state")
1308
+ _invite_lookup_rate_limiter = DatabaseRateLimiter(max_attempts=30, window_seconds=60, type="invite_lookup")
1309
+ _invite_accept_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="invite_accept")
1310
+ _invite_accept_token_rate_limiter = DatabaseRateLimiter(max_attempts=5, window_seconds=60, type="invite_accept_token")
1311
+
1312
+
1313
+ def get_dashboard_session_store() -> DashboardSessionStore:
1314
+ return _dashboard_session_store
1315
+
1316
+
1317
+ def get_step_up_cookie_store() -> StepUpCookieStore:
1318
+ return _step_up_cookie_store
1319
+
1320
+
1321
+ def get_totp_rate_limiter() -> DatabaseRateLimiter:
1322
+ return _totp_rate_limiter
1323
+
1324
+
1325
+ def get_password_rate_limiter() -> DatabaseRateLimiter:
1326
+ return _password_rate_limiter
1327
+
1328
+
1329
+ def get_password_address_rate_limiter() -> DatabaseRateLimiter:
1330
+ return _password_address_rate_limiter
1331
+
1332
+
1333
+ def get_guest_password_rate_limiter() -> DatabaseRateLimiter:
1334
+ return _guest_password_rate_limiter
1335
+
1336
+
1337
+ def get_login_failed_audit_rate_limiter() -> DatabaseRateLimiter:
1338
+ return _login_failed_audit_rate_limiter
1339
+
1340
+
1341
+ def get_oidc_address_rate_limiter() -> DatabaseRateLimiter:
1342
+ return _oidc_address_rate_limiter
1343
+
1344
+
1345
+ def get_oidc_state_rate_limiter() -> DatabaseRateLimiter:
1346
+ return _oidc_state_rate_limiter
1347
+
1348
+
1349
+ def get_invite_lookup_rate_limiter() -> DatabaseRateLimiter:
1350
+ return _invite_lookup_rate_limiter
1351
+
1352
+
1353
+ def get_invite_accept_rate_limiter() -> DatabaseRateLimiter:
1354
+ return _invite_accept_rate_limiter
1355
+
1356
+
1357
+ def get_invite_accept_token_rate_limiter() -> DatabaseRateLimiter:
1358
+ return _invite_accept_token_rate_limiter
1359
+
1360
+
1361
+ def _qr_svg_data_uri(payload: str) -> str:
1362
+ qr = segno.make(payload)
1363
+ buffer = BytesIO()
1364
+ qr.save(buffer, kind="svg", xmldecl=False, scale=6, border=2)
1365
+ raw = buffer.getvalue()
1366
+ return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
1367
+
1368
+
1369
+ def hash_password(password: str) -> str:
1370
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
1371
+
1372
+
1373
+ @lru_cache(maxsize=1)
1374
+ def _dummy_password_hash() -> str:
1375
+ """A throwaway hash verified against when no real one exists (built on first use, not at import)."""
1376
+
1377
+ return hash_password(secrets.token_urlsafe(32))
1378
+
1379
+
1380
+ def _check_password(password: str, password_hash: str) -> bool:
1381
+ try:
1382
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
1383
+ except ValueError:
1384
+ return False