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.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- config/additional_quota_registry.json +11 -0
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from time import time
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, Request, Response
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from app.core.audit.service import AuditActor, AuditService, AuditTarget
|
|
11
|
+
from app.core.auth.dashboard_access import (
|
|
12
|
+
ADMIN_GRANTS,
|
|
13
|
+
GUEST_GRANTS,
|
|
14
|
+
DashboardPrincipal,
|
|
15
|
+
DashboardRole,
|
|
16
|
+
Permission,
|
|
17
|
+
PresetRoleSlug,
|
|
18
|
+
permission_strings,
|
|
19
|
+
)
|
|
20
|
+
from app.core.auth.dashboard_mode import (
|
|
21
|
+
DashboardAuthMode,
|
|
22
|
+
get_dashboard_request_auth,
|
|
23
|
+
password_management_enabled,
|
|
24
|
+
)
|
|
25
|
+
from app.core.auth.dashboard_session_ttl import (
|
|
26
|
+
resolve_admin_dashboard_session_ttl_seconds,
|
|
27
|
+
resolve_dashboard_session_ttl_seconds,
|
|
28
|
+
)
|
|
29
|
+
from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
|
|
30
|
+
from app.core.auth.dependencies import (
|
|
31
|
+
ensure_dashboard_permission,
|
|
32
|
+
recorded_step_up,
|
|
33
|
+
require_dashboard_permission,
|
|
34
|
+
set_dashboard_error_format,
|
|
35
|
+
validate_dashboard_session,
|
|
36
|
+
)
|
|
37
|
+
from app.core.auth.external_identity import ExternalResolution, resolve_trusted_header_request
|
|
38
|
+
from app.core.auth.providers.registry import get_auth_provider_registry
|
|
39
|
+
from app.core.auth.step_up import STEP_UP_COOKIE, STEP_UP_UNAVAILABLE_MESSAGE, step_up_expires_at
|
|
40
|
+
from app.core.bootstrap import (
|
|
41
|
+
ensure_auto_bootstrap_token,
|
|
42
|
+
get_bootstrap_validation_status,
|
|
43
|
+
has_active_bootstrap_token,
|
|
44
|
+
log_bootstrap_token,
|
|
45
|
+
)
|
|
46
|
+
from app.core.config.settings import get_settings
|
|
47
|
+
from app.core.config.settings_cache import get_settings_cache
|
|
48
|
+
from app.core.exceptions import (
|
|
49
|
+
DashboardAuthError,
|
|
50
|
+
DashboardBadRequestError,
|
|
51
|
+
DashboardConflictError,
|
|
52
|
+
DashboardNotFoundError,
|
|
53
|
+
DashboardPermissionError,
|
|
54
|
+
DashboardRateLimitError,
|
|
55
|
+
DashboardValidationError,
|
|
56
|
+
)
|
|
57
|
+
from app.core.rate_limiter.db_rate_limiter import DatabaseRateLimiter
|
|
58
|
+
from app.core.request_locality import is_local_request
|
|
59
|
+
from app.db.models import DashboardUser
|
|
60
|
+
from app.dependencies import (
|
|
61
|
+
DashboardAuthContext,
|
|
62
|
+
DashboardUsersContext,
|
|
63
|
+
get_dashboard_auth_context,
|
|
64
|
+
get_dashboard_users_context,
|
|
65
|
+
)
|
|
66
|
+
from app.modules.dashboard_auth.oidc_flows import (
|
|
67
|
+
OIDC_PENDING_COOKIE,
|
|
68
|
+
clear_pending_marker,
|
|
69
|
+
get_oidc_pending_cookie_store,
|
|
70
|
+
)
|
|
71
|
+
from app.modules.dashboard_auth.schemas import (
|
|
72
|
+
DashboardAuthSessionResponse,
|
|
73
|
+
DashboardLoginHint,
|
|
74
|
+
DashboardMeResponse,
|
|
75
|
+
DashboardPendingArrival,
|
|
76
|
+
GuestLoginRequest,
|
|
77
|
+
GuestPasswordSetRequest,
|
|
78
|
+
InviteAcceptRequest,
|
|
79
|
+
InviteDescriptionResponse,
|
|
80
|
+
PasswordChangeRequest,
|
|
81
|
+
PasswordLoginRequest,
|
|
82
|
+
PasswordRemoveRequest,
|
|
83
|
+
PasswordSetupRequest,
|
|
84
|
+
StepUpRequest,
|
|
85
|
+
StepUpResponse,
|
|
86
|
+
TotpSetupConfirmRequest,
|
|
87
|
+
TotpSetupStartResponse,
|
|
88
|
+
TotpVerifyRequest,
|
|
89
|
+
)
|
|
90
|
+
from app.modules.dashboard_auth.service import (
|
|
91
|
+
DASHBOARD_SESSION_COOKIE,
|
|
92
|
+
DashboardSessionState,
|
|
93
|
+
GuestAccessDisabledError,
|
|
94
|
+
InvalidCredentialsError,
|
|
95
|
+
OtherUsersExistError,
|
|
96
|
+
PasswordAlreadyConfiguredError,
|
|
97
|
+
PasswordNotConfiguredError,
|
|
98
|
+
PasswordSessionRequiredError,
|
|
99
|
+
ResolvedUserSession,
|
|
100
|
+
SessionDescription,
|
|
101
|
+
StepUpUnavailableError,
|
|
102
|
+
TotpAlreadyConfiguredError,
|
|
103
|
+
TotpEnrollmentRequiredError,
|
|
104
|
+
TotpInvalidCodeError,
|
|
105
|
+
TotpInvalidSetupError,
|
|
106
|
+
TotpNotConfiguredError,
|
|
107
|
+
TotpVerificationRequiredError,
|
|
108
|
+
UsernameRequiredError,
|
|
109
|
+
assignable_role_ids,
|
|
110
|
+
get_dashboard_session_store,
|
|
111
|
+
get_guest_password_rate_limiter,
|
|
112
|
+
get_invite_accept_rate_limiter,
|
|
113
|
+
get_invite_accept_token_rate_limiter,
|
|
114
|
+
get_invite_lookup_rate_limiter,
|
|
115
|
+
get_login_failed_audit_rate_limiter,
|
|
116
|
+
get_password_address_rate_limiter,
|
|
117
|
+
get_password_rate_limiter,
|
|
118
|
+
get_step_up_cookie_store,
|
|
119
|
+
get_totp_rate_limiter,
|
|
120
|
+
hash_password,
|
|
121
|
+
is_local_password_session,
|
|
122
|
+
log_login_failed,
|
|
123
|
+
session_clock,
|
|
124
|
+
session_user,
|
|
125
|
+
step_up_state,
|
|
126
|
+
)
|
|
127
|
+
from app.modules.dashboard_roles.service import resolve_role_grants
|
|
128
|
+
from app.modules.dashboard_users.api import mapped_user_errors
|
|
129
|
+
from app.modules.dashboard_users.break_glass import (
|
|
130
|
+
LastBreakGlassProtectedError,
|
|
131
|
+
local_login_admits,
|
|
132
|
+
)
|
|
133
|
+
from app.modules.dashboard_users.credentials import CredentialRequiredError
|
|
134
|
+
from app.modules.dashboard_users.schemas import ProfileUpdateRequest
|
|
135
|
+
from app.modules.dashboard_users.service import InviteNotFoundError, UsernameLockedError, invite_token_hash
|
|
136
|
+
|
|
137
|
+
router = APIRouter(
|
|
138
|
+
prefix="/api/dashboard-auth",
|
|
139
|
+
tags=["dashboard"],
|
|
140
|
+
dependencies=[Depends(set_dashboard_error_format)],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
logger = logging.getLogger(__name__)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _client_host(request: Request) -> str | None:
|
|
147
|
+
return request.client.host if request.client else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _session_client_key(request: Request, *, prefix: str) -> str:
|
|
151
|
+
return f"{prefix}:{request.client.host if request.client else 'unknown'}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _password_login_address_key(request: Request) -> str:
|
|
155
|
+
"""The coarse per-address failed-login budget (PLAN §4.4: ``password_login:{ip}``).
|
|
156
|
+
|
|
157
|
+
This is the endpoint's actual ceiling. The per-account bucket below is
|
|
158
|
+
deliberately local — a limit for one username at one address bars nothing
|
|
159
|
+
else — which on its own would let a single address mint an unlimited number
|
|
160
|
+
of buckets by inventing usernames, each of them costing a blocking
|
|
161
|
+
password-hash comparison. The two are added, never swapped.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
return _session_client_key(request, prefix="password_login")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _password_login_key(request: Request, username: str | None) -> str:
|
|
168
|
+
"""The failed-login budget: one bucket per (username, address) pair.
|
|
169
|
+
|
|
170
|
+
Keyed on the *normalized* username so case variants share a budget, and
|
|
171
|
+
hashed so the rate-limit table never holds a username in the clear. The
|
|
172
|
+
pair is what makes a limit local: eight failures for one account from one
|
|
173
|
+
address bar neither that account elsewhere nor another account from the
|
|
174
|
+
same address.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
digest = hashlib.sha256((username or "").encode("utf-8")).hexdigest()
|
|
178
|
+
return f"{_password_login_address_key(request)}:{digest}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _session_ttl_seconds(request: Request, user: DashboardUser, configured_ttl_seconds: int) -> int:
|
|
182
|
+
if user.role.slug == PresetRoleSlug.ADMIN.value:
|
|
183
|
+
return resolve_admin_dashboard_session_ttl_seconds(request, configured_ttl_seconds)
|
|
184
|
+
return resolve_dashboard_session_ttl_seconds(request, configured_ttl_seconds)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _create_user_session(
|
|
188
|
+
request: Request,
|
|
189
|
+
user: DashboardUser,
|
|
190
|
+
*,
|
|
191
|
+
totp_verified: bool,
|
|
192
|
+
auth_method: str,
|
|
193
|
+
max_ttl_seconds: int | None = None,
|
|
194
|
+
step_up_verified_at: int | None = None,
|
|
195
|
+
) -> tuple[str, int]:
|
|
196
|
+
settings = await get_settings_cache().get()
|
|
197
|
+
ttl_seconds = _session_ttl_seconds(request, user, settings.dashboard_session_ttl_seconds)
|
|
198
|
+
if max_ttl_seconds is not None:
|
|
199
|
+
ttl_seconds = max(1, min(ttl_seconds, max_ttl_seconds))
|
|
200
|
+
session_id = get_dashboard_session_store().create_user_session(
|
|
201
|
+
user.id,
|
|
202
|
+
user.session_generation,
|
|
203
|
+
password_verified=True,
|
|
204
|
+
totp_verified=totp_verified,
|
|
205
|
+
ttl_seconds=ttl_seconds,
|
|
206
|
+
auth_method=auth_method,
|
|
207
|
+
step_up_verified_at=step_up_verified_at,
|
|
208
|
+
break_glass=user.is_break_glass,
|
|
209
|
+
)
|
|
210
|
+
return session_id, ttl_seconds
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _create_guest_session(request: Request, *, guest_session_generation: int) -> tuple[str, int]:
|
|
214
|
+
settings = await get_settings_cache().get()
|
|
215
|
+
ttl_seconds = resolve_dashboard_session_ttl_seconds(request, settings.dashboard_session_ttl_seconds)
|
|
216
|
+
session_id = get_dashboard_session_store().create_guest_session(
|
|
217
|
+
ttl_seconds=ttl_seconds,
|
|
218
|
+
guest_session_generation=guest_session_generation,
|
|
219
|
+
)
|
|
220
|
+
return session_id, ttl_seconds
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
#: Fields that describe a signed-in account; stripped whenever the response is
|
|
224
|
+
#: served to a caller that is not authenticated as one.
|
|
225
|
+
_UNAUTHENTICATED_ACCOUNT_FIELDS: dict[str, object] = {
|
|
226
|
+
"user": None,
|
|
227
|
+
"access_summary": None,
|
|
228
|
+
"assignable_role_ids": [],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _refused_company_sign_in(request: Request, login: DashboardLoginHint | None) -> DashboardLoginHint | None:
|
|
233
|
+
"""This browser's own refusal marker, projected onto the login hint, or ``None``.
|
|
234
|
+
|
|
235
|
+
The marker is read here and nowhere else, and only for a caller that holds
|
|
236
|
+
no session: what it carries is a lossy echo of an identity this very
|
|
237
|
+
browser presented a moment ago, so handing it back to that browser tells it
|
|
238
|
+
nothing it did not already know, and there is no request shape that gets it
|
|
239
|
+
for anybody else's address.
|
|
240
|
+
|
|
241
|
+
A marker naming a row that no longer exists yields nothing rather than a
|
|
242
|
+
guess. The label belongs to the row; an install that deleted it has no
|
|
243
|
+
label to give, and inventing one from the active providers would be the
|
|
244
|
+
screen asserting which provider refused somebody.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
if login is None:
|
|
248
|
+
return None
|
|
249
|
+
marker = get_oidc_pending_cookie_store().get(request.cookies.get(OIDC_PENDING_COOKIE))
|
|
250
|
+
if marker is None:
|
|
251
|
+
return None
|
|
252
|
+
row = next((row for row in await get_auth_provider_registry().rows() if row.id == marker.provider_id), None)
|
|
253
|
+
if row is None:
|
|
254
|
+
return None
|
|
255
|
+
return login.model_copy(
|
|
256
|
+
update={
|
|
257
|
+
"pending_identity": True,
|
|
258
|
+
"pending_arrival": DashboardPendingArrival(provider=row.label, reference=marker.reference),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def _decorate_session_response(
|
|
264
|
+
description: SessionDescription,
|
|
265
|
+
*,
|
|
266
|
+
request: Request,
|
|
267
|
+
context: DashboardAuthContext,
|
|
268
|
+
force_authenticated: bool = False,
|
|
269
|
+
) -> DashboardAuthSessionResponse:
|
|
270
|
+
response, resolved = description.response, description.resolved
|
|
271
|
+
request_auth = get_dashboard_request_auth(request)
|
|
272
|
+
auth_mode = get_settings().dashboard_auth_mode
|
|
273
|
+
has_pwd = resolved is not None and resolved.state.password_verified
|
|
274
|
+
totp_pending = (
|
|
275
|
+
has_pwd and resolved is not None and response.totp_required_on_login and not resolved.state.totp_verified
|
|
276
|
+
)
|
|
277
|
+
fully_authorized = has_pwd and not totp_pending and response.password_required
|
|
278
|
+
# Behind a reverse proxy the cookie only counts as a fallback while the
|
|
279
|
+
# local login policy admits the account -- the same call the session gate
|
|
280
|
+
# makes, so ``password_session_active`` never advertises a fallback the
|
|
281
|
+
# gate would refuse.
|
|
282
|
+
fallback_admitted = resolved is None or (
|
|
283
|
+
# An OIDC session is not the local fallback (it is the very thing the
|
|
284
|
+
# policy closes the local door against), so it never reports one.
|
|
285
|
+
is_local_password_session(resolved.state)
|
|
286
|
+
and local_login_admits(resolved.user, (await get_settings_cache().get()).local_login_policy)
|
|
287
|
+
)
|
|
288
|
+
fallback_authorized = fully_authorized and fallback_admitted
|
|
289
|
+
|
|
290
|
+
if request_auth is None:
|
|
291
|
+
update: dict[str, object] = {
|
|
292
|
+
"auth_mode": auth_mode,
|
|
293
|
+
"password_management_enabled": password_management_enabled(auth_mode),
|
|
294
|
+
"password_session_active": (
|
|
295
|
+
fallback_authorized if auth_mode == DashboardAuthMode.TRUSTED_HEADER else fully_authorized
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
# Without the header only a password session gets in. When no account
|
|
299
|
+
# holds a password (proxy-created accounts do not count) there is no
|
|
300
|
+
# form to show: the client renders the reverse-proxy notice.
|
|
301
|
+
if auth_mode == DashboardAuthMode.TRUSTED_HEADER and not fallback_authorized and not totp_pending:
|
|
302
|
+
local_password_users = (await get_dashboard_users_cache().local_auth_state()).active_local_password_users
|
|
303
|
+
if local_password_users == 0 or not response.password_required:
|
|
304
|
+
update["authenticated"] = False
|
|
305
|
+
update["password_required"] = False
|
|
306
|
+
update.update(_UNAUTHENTICATED_ACCOUNT_FIELDS)
|
|
307
|
+
# A company sign-in this install refused, described to the browser it
|
|
308
|
+
# refused -- and only while that browser has nothing better: a session
|
|
309
|
+
# is the answer to the same question and supersedes the marker.
|
|
310
|
+
if not bool(update.get("authenticated", response.authenticated)):
|
|
311
|
+
refused = await _refused_company_sign_in(request, response.login)
|
|
312
|
+
if refused is not None:
|
|
313
|
+
update["login"] = refused
|
|
314
|
+
return response.model_copy(update=update)
|
|
315
|
+
|
|
316
|
+
if request_auth.mode == DashboardAuthMode.TRUSTED_HEADER:
|
|
317
|
+
resolution = await resolve_trusted_header_request(request, request_auth)
|
|
318
|
+
if resolution is None:
|
|
319
|
+
# Header present but the provider is inactive: the dependency answers
|
|
320
|
+
# proxy_auth_required, so the session must not describe an admin.
|
|
321
|
+
return response.model_copy(
|
|
322
|
+
update={
|
|
323
|
+
"authenticated": False,
|
|
324
|
+
"password_required": False,
|
|
325
|
+
"auth_mode": DashboardAuthMode.TRUSTED_HEADER,
|
|
326
|
+
"password_session_active": fallback_authorized,
|
|
327
|
+
**_UNAUTHENTICATED_ACCOUNT_FIELDS,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
if resolution.user is None and has_pwd and fallback_admitted:
|
|
331
|
+
# Refused identity but a password cookie rides along: describe that
|
|
332
|
+
# session (the break-glass admin stays reachable behind the proxy).
|
|
333
|
+
return response.model_copy(
|
|
334
|
+
update={
|
|
335
|
+
"auth_mode": DashboardAuthMode.TRUSTED_HEADER,
|
|
336
|
+
"password_management_enabled": True,
|
|
337
|
+
"password_session_active": fallback_authorized,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
return await _trusted_header_session_response(
|
|
341
|
+
response, resolution, request=request, context=context, password_session_active=fallback_authorized
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Disabled auth: the implicit admin holds every permission.
|
|
345
|
+
return response.model_copy(
|
|
346
|
+
update={
|
|
347
|
+
"authenticated": force_authenticated or response.authenticated,
|
|
348
|
+
"totp_required_on_login": totp_pending,
|
|
349
|
+
"auth_mode": request_auth.mode,
|
|
350
|
+
"password_management_enabled": password_management_enabled(request_auth.mode),
|
|
351
|
+
"password_session_active": fully_authorized,
|
|
352
|
+
"role": DashboardRole.ADMIN,
|
|
353
|
+
"permissions": permission_strings(ADMIN_GRANTS),
|
|
354
|
+
"totp_enrollment_required": False,
|
|
355
|
+
"access_summary": await context.service.access_summary(),
|
|
356
|
+
"assignable_role_ids": assignable_role_ids(),
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _trusted_header_session_response(
|
|
362
|
+
response: DashboardAuthSessionResponse,
|
|
363
|
+
resolution: ExternalResolution,
|
|
364
|
+
*,
|
|
365
|
+
request: Request,
|
|
366
|
+
context: DashboardAuthContext,
|
|
367
|
+
password_session_active: bool,
|
|
368
|
+
) -> DashboardAuthSessionResponse:
|
|
369
|
+
"""The session as the proxy-asserted account sees it, or the "not ready" state for a refused identity.
|
|
370
|
+
|
|
371
|
+
``password_session_active`` reports whether a fallback password cookie also
|
|
372
|
+
rode along, so the settings page keeps gating password management on it.
|
|
373
|
+
The account's step-up rides in the step-up cookie (no session cookie exists).
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
user = resolution.user
|
|
377
|
+
if user is None:
|
|
378
|
+
return response.model_copy(
|
|
379
|
+
update={
|
|
380
|
+
"authenticated": False,
|
|
381
|
+
"auth_mode": DashboardAuthMode.TRUSTED_HEADER,
|
|
382
|
+
"totp_required_on_login": False,
|
|
383
|
+
"totp_configured": False,
|
|
384
|
+
"password_session_active": password_session_active,
|
|
385
|
+
"auth_method": None,
|
|
386
|
+
"totp_enrollment_required": False,
|
|
387
|
+
"login": response.login.model_copy(update={"pending_identity": True}) if response.login else None,
|
|
388
|
+
**_UNAUTHENTICATED_ACCOUNT_FIELDS,
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
grants = resolve_role_grants(user.role)
|
|
392
|
+
manages_users = Permission.USERS_MANAGE in grants
|
|
393
|
+
return response.model_copy(
|
|
394
|
+
update={
|
|
395
|
+
"authenticated": True,
|
|
396
|
+
# An account with an identity exists (this one), so sign-in is required
|
|
397
|
+
# even when the cached auth state predates its just-in-time creation.
|
|
398
|
+
"password_required": True,
|
|
399
|
+
"auth_mode": DashboardAuthMode.TRUSTED_HEADER,
|
|
400
|
+
"password_management_enabled": True,
|
|
401
|
+
"password_session_active": password_session_active,
|
|
402
|
+
"totp_required_on_login": False,
|
|
403
|
+
"totp_configured": user.totp_secret_encrypted is not None,
|
|
404
|
+
"role": DashboardRole.ADMIN,
|
|
405
|
+
"permissions": permission_strings(grants),
|
|
406
|
+
"user": session_user(user),
|
|
407
|
+
"auth_method": DashboardAuthMode.TRUSTED_HEADER.value,
|
|
408
|
+
"must_change_password": False,
|
|
409
|
+
"totp_enrollment_required": False,
|
|
410
|
+
"access_summary": await context.service.access_summary() if manages_users else None,
|
|
411
|
+
"assignable_role_ids": assignable_role_ids() if manages_users else [],
|
|
412
|
+
"step_up": await step_up_state(user, verified_at=recorded_step_up(request, user)),
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _guest_overrides() -> dict[str, object]:
|
|
418
|
+
return {
|
|
419
|
+
"bootstrap_required": False,
|
|
420
|
+
"bootstrap_token_configured": False,
|
|
421
|
+
"role": DashboardRole.GUEST,
|
|
422
|
+
"permissions": permission_strings(GUEST_GRANTS),
|
|
423
|
+
"guest_access_enabled": True,
|
|
424
|
+
"password_session_active": False,
|
|
425
|
+
"user": None,
|
|
426
|
+
"auth_method": None,
|
|
427
|
+
"must_change_password": False,
|
|
428
|
+
"totp_enrollment_required": False,
|
|
429
|
+
"access_summary": None,
|
|
430
|
+
"assignable_role_ids": [],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _public_guest_response(response: DashboardAuthSessionResponse) -> DashboardAuthSessionResponse:
|
|
435
|
+
return response.model_copy(update={**_guest_overrides(), "authenticated": True, "guest_password_required": False})
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _guest_login_required_response(response: DashboardAuthSessionResponse) -> DashboardAuthSessionResponse:
|
|
439
|
+
return response.model_copy(update={**_guest_overrides(), "authenticated": False, "guest_password_required": True})
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def _require_password_session(request: Request, context: DashboardAuthContext) -> ResolvedUserSession:
|
|
443
|
+
try:
|
|
444
|
+
return await context.service.require_password_session(request.cookies.get(DASHBOARD_SESSION_COOKIE))
|
|
445
|
+
except PasswordSessionRequiredError as exc:
|
|
446
|
+
raise DashboardAuthError("Authentication is required") from exc
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
async def _resolve_account_session(request: Request, context: DashboardAuthContext) -> ResolvedUserSession:
|
|
450
|
+
"""The signed-in account for the TOTP routes: the trusted-header account when the header is present,
|
|
451
|
+
else the password session — the same precedence as the session dependency, so the account that
|
|
452
|
+
enrols is the account the dashboard acts as.
|
|
453
|
+
|
|
454
|
+
A trusted-header account has no session cookie; it gets a stand-in state
|
|
455
|
+
(``password_verified=False``, ``auth_method=trusted_header``) so the same
|
|
456
|
+
routes can enrol its TOTP secret — the only step-up method such an account
|
|
457
|
+
can have without a local password.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
header_account = await _header_account_session(request)
|
|
461
|
+
if header_account is not None:
|
|
462
|
+
return header_account
|
|
463
|
+
try:
|
|
464
|
+
return await context.service.require_password_session(request.cookies.get(DASHBOARD_SESSION_COOKIE))
|
|
465
|
+
except PasswordSessionRequiredError as exc:
|
|
466
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def _header_account_session(request: Request) -> ResolvedUserSession | None:
|
|
470
|
+
request_auth = get_dashboard_request_auth(request)
|
|
471
|
+
if request_auth is None or request_auth.mode != DashboardAuthMode.TRUSTED_HEADER:
|
|
472
|
+
return None
|
|
473
|
+
resolution = await resolve_trusted_header_request(request, request_auth)
|
|
474
|
+
if resolution is None or resolution.user is None:
|
|
475
|
+
return None
|
|
476
|
+
now = session_clock()
|
|
477
|
+
state = DashboardSessionState(
|
|
478
|
+
expires_at=now,
|
|
479
|
+
issued_at=now,
|
|
480
|
+
kind="user",
|
|
481
|
+
user_id=resolution.user.id,
|
|
482
|
+
session_generation=resolution.user.session_generation,
|
|
483
|
+
auth_method=DashboardAuthMode.TRUSTED_HEADER.value,
|
|
484
|
+
)
|
|
485
|
+
return ResolvedUserSession(user=resolution.user, state=state)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _is_header_account(resolved: ResolvedUserSession) -> bool:
|
|
489
|
+
return not resolved.state.password_verified
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def _require_management_session(
|
|
493
|
+
request: Request, context: DashboardAuthContext, *, allow_unenrolled: bool = False
|
|
494
|
+
) -> ResolvedUserSession:
|
|
495
|
+
"""A password session that also passed TOTP when the install requires it.
|
|
496
|
+
|
|
497
|
+
Password/TOTP management is refused outright in the auth-bypass modes. An
|
|
498
|
+
account that still has to enrol a TOTP secret is refused with 403 unless the
|
|
499
|
+
route is one of the self-service routes (``allow_unenrolled``).
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
_ensure_password_management_enabled(request)
|
|
503
|
+
try:
|
|
504
|
+
return await context.service.require_management_session(
|
|
505
|
+
request.cookies.get(DASHBOARD_SESSION_COOKIE), allow_unenrolled=allow_unenrolled
|
|
506
|
+
)
|
|
507
|
+
except PasswordSessionRequiredError as exc:
|
|
508
|
+
raise DashboardAuthError("Authentication is required") from exc
|
|
509
|
+
except TotpVerificationRequiredError as exc:
|
|
510
|
+
raise DashboardAuthError(str(exc), code="totp_required") from exc
|
|
511
|
+
except TotpEnrollmentRequiredError as exc:
|
|
512
|
+
raise _enrollment_required(exc) from exc
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _enrollment_required(exc: TotpEnrollmentRequiredError) -> DashboardPermissionError:
|
|
516
|
+
return DashboardPermissionError(str(exc), code="totp_enrollment_required")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _ensure_password_management_enabled(request: Request) -> None:
|
|
520
|
+
request_auth = get_dashboard_request_auth(request)
|
|
521
|
+
if request_auth is not None and not password_management_enabled(request_auth.mode):
|
|
522
|
+
raise DashboardBadRequestError(
|
|
523
|
+
"Password and TOTP management is disabled while dashboard auth is bypassed",
|
|
524
|
+
code="password_management_disabled",
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def _invalidate_auth_caches() -> None:
|
|
529
|
+
await get_settings_cache().invalidate()
|
|
530
|
+
await get_dashboard_users_cache().invalidate()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# bcrypt enforces a hard 72-byte limit on the input password; anything longer
|
|
534
|
+
# raises ``ValueError`` from ``bcrypt.hashpw`` and surfaces as a 500 to the
|
|
535
|
+
# client. Validate the encoded length here so the API returns a clear 400.
|
|
536
|
+
_MAX_PASSWORD_BYTES = 72
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _validate_password_length(password: str) -> None:
|
|
540
|
+
if len(password) < 8:
|
|
541
|
+
raise DashboardValidationError("Password must be at least 8 characters")
|
|
542
|
+
if len(password.encode("utf-8")) > _MAX_PASSWORD_BYTES:
|
|
543
|
+
raise DashboardValidationError(
|
|
544
|
+
f"Password must be at most {_MAX_PASSWORD_BYTES} bytes when encoded as UTF-8. "
|
|
545
|
+
"Note that multi-byte characters (e.g. emoji, non-ASCII letters) count for more than one byte.",
|
|
546
|
+
code="password_too_long",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _rate_limit_error(exc: DashboardRateLimitError, *, code: str) -> DashboardRateLimitError:
|
|
551
|
+
return DashboardRateLimitError(
|
|
552
|
+
f"Too many attempts. Try again in {exc.retry_after} seconds.",
|
|
553
|
+
retry_after=exc.retry_after,
|
|
554
|
+
code=code,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@router.get("/session", response_model=DashboardAuthSessionResponse)
|
|
559
|
+
async def get_dashboard_auth_session(
|
|
560
|
+
request: Request,
|
|
561
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
562
|
+
) -> DashboardAuthSessionResponse:
|
|
563
|
+
session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
564
|
+
description = await context.service.describe_session(session_id)
|
|
565
|
+
decorated = await _decorate_session_response(
|
|
566
|
+
description, request=request, context=context, force_authenticated=True
|
|
567
|
+
)
|
|
568
|
+
if decorated.auth_mode != DashboardAuthMode.STANDARD:
|
|
569
|
+
return decorated
|
|
570
|
+
if decorated.password_required or is_local_request(request):
|
|
571
|
+
return decorated
|
|
572
|
+
current_settings = await context.repository.get_settings()
|
|
573
|
+
if current_settings.guest_access_enabled:
|
|
574
|
+
if current_settings.guest_password_hash is None:
|
|
575
|
+
return _public_guest_response(decorated)
|
|
576
|
+
if decorated.authenticated and decorated.role == DashboardRole.GUEST:
|
|
577
|
+
session_state = get_dashboard_session_store().get(session_id)
|
|
578
|
+
if (
|
|
579
|
+
session_state is not None
|
|
580
|
+
and session_state.is_guest
|
|
581
|
+
and session_state.guest_session_generation == current_settings.guest_session_generation
|
|
582
|
+
):
|
|
583
|
+
return decorated
|
|
584
|
+
return _guest_login_required_response(decorated)
|
|
585
|
+
bootstrap_token_configured = await has_active_bootstrap_token()
|
|
586
|
+
return decorated.model_copy(
|
|
587
|
+
update={
|
|
588
|
+
"authenticated": False,
|
|
589
|
+
"bootstrap_required": True,
|
|
590
|
+
"bootstrap_token_configured": bootstrap_token_configured,
|
|
591
|
+
**_UNAUTHENTICATED_ACCOUNT_FIELDS,
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@router.post("/password/setup", response_model=DashboardAuthSessionResponse)
|
|
597
|
+
async def setup_password(
|
|
598
|
+
request: Request,
|
|
599
|
+
payload: PasswordSetupRequest = Body(...),
|
|
600
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
601
|
+
) -> DashboardAuthSessionResponse | JSONResponse:
|
|
602
|
+
settings = get_settings()
|
|
603
|
+
auth_state = await context.repository.get_local_auth_state()
|
|
604
|
+
if settings.dashboard_auth_mode == DashboardAuthMode.DISABLED:
|
|
605
|
+
raise DashboardBadRequestError(
|
|
606
|
+
"Password management is disabled while dashboard auth is bypassed",
|
|
607
|
+
code="password_management_disabled",
|
|
608
|
+
)
|
|
609
|
+
if settings.dashboard_auth_mode == DashboardAuthMode.TRUSTED_HEADER:
|
|
610
|
+
# Behind the proxy the break-glass admin is created by an account that
|
|
611
|
+
# manages users (a header-resolved account or a password session), never
|
|
612
|
+
# by a bare request: without the header this is proxy_auth_required, with
|
|
613
|
+
# a refused identity identity_not_provisioned, as for every other route.
|
|
614
|
+
principal = await validate_dashboard_session(request)
|
|
615
|
+
ensure_dashboard_permission(principal, Permission.USERS_MANAGE)
|
|
616
|
+
elif not auth_state.requires_auth and not is_local_request(request):
|
|
617
|
+
submitted_bootstrap_token = (payload.bootstrap_token or "").strip()
|
|
618
|
+
validation_status = await get_bootstrap_validation_status(submitted_bootstrap_token)
|
|
619
|
+
if validation_status == "unavailable":
|
|
620
|
+
raise DashboardAuthError(
|
|
621
|
+
"Remote bootstrap is disabled until CODEX_LB_DASHBOARD_BOOTSTRAP_TOKEN is configured.",
|
|
622
|
+
code="bootstrap_unavailable",
|
|
623
|
+
)
|
|
624
|
+
if validation_status == "password_already_configured":
|
|
625
|
+
raise DashboardConflictError("Password is already configured", code="password_already_configured")
|
|
626
|
+
if validation_status != "valid":
|
|
627
|
+
raise DashboardAuthError("Invalid dashboard bootstrap token.", code="invalid_bootstrap_token")
|
|
628
|
+
password = payload.password.strip()
|
|
629
|
+
_validate_password_length(password)
|
|
630
|
+
try:
|
|
631
|
+
user = await context.service.setup_password(password)
|
|
632
|
+
except PasswordAlreadyConfiguredError as exc:
|
|
633
|
+
raise DashboardConflictError(str(exc), code="password_already_configured") from exc
|
|
634
|
+
|
|
635
|
+
await _invalidate_auth_caches()
|
|
636
|
+
return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
async def _issue_user_session_response(
|
|
640
|
+
request: Request,
|
|
641
|
+
context: DashboardAuthContext,
|
|
642
|
+
user: DashboardUser,
|
|
643
|
+
*,
|
|
644
|
+
totp_verified: bool,
|
|
645
|
+
auth_method: str,
|
|
646
|
+
) -> JSONResponse:
|
|
647
|
+
# Every caller just verified a password. That is a complete step-up only
|
|
648
|
+
# for an account without a TOTP secret; otherwise ``/totp/verify`` mints it.
|
|
649
|
+
session_id, session_ttl_seconds = await _create_user_session(
|
|
650
|
+
request,
|
|
651
|
+
user,
|
|
652
|
+
totp_verified=totp_verified,
|
|
653
|
+
auth_method=auth_method,
|
|
654
|
+
step_up_verified_at=session_clock() if user.totp_secret_encrypted is None else None,
|
|
655
|
+
)
|
|
656
|
+
response = await _decorate_session_response(
|
|
657
|
+
await context.service.describe_session(session_id), request=request, context=context
|
|
658
|
+
)
|
|
659
|
+
json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
|
|
660
|
+
_set_session_cookie(json_response, session_id, request, max_age_seconds=session_ttl_seconds)
|
|
661
|
+
return json_response
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@router.post("/guest/login", response_model=DashboardAuthSessionResponse)
|
|
665
|
+
async def login_guest(
|
|
666
|
+
request: Request,
|
|
667
|
+
payload: GuestLoginRequest | None = Body(default=None),
|
|
668
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
669
|
+
) -> DashboardAuthSessionResponse | JSONResponse:
|
|
670
|
+
settings = await get_settings_cache().get()
|
|
671
|
+
if not settings.guest_access_enabled:
|
|
672
|
+
raise DashboardBadRequestError("Guest access is disabled", code="guest_access_disabled")
|
|
673
|
+
if (
|
|
674
|
+
get_settings().dashboard_auth_mode == DashboardAuthMode.TRUSTED_HEADER
|
|
675
|
+
and get_dashboard_request_auth(request) is None
|
|
676
|
+
):
|
|
677
|
+
raise DashboardAuthError("Reverse proxy authentication is required", code="proxy_auth_required")
|
|
678
|
+
|
|
679
|
+
limiter = get_guest_password_rate_limiter()
|
|
680
|
+
rate_key = _session_client_key(request, prefix="guest_login")
|
|
681
|
+
if settings.guest_password_hash is not None:
|
|
682
|
+
try:
|
|
683
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
684
|
+
except DashboardRateLimitError as exc:
|
|
685
|
+
raise _rate_limit_error(exc, code="guest_password_rate_limited") from exc
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
verification = await context.service.verify_guest_password(
|
|
689
|
+
None if payload is None else payload.password,
|
|
690
|
+
actor_ip=_client_host(request),
|
|
691
|
+
)
|
|
692
|
+
except GuestAccessDisabledError as exc:
|
|
693
|
+
raise DashboardBadRequestError(str(exc), code="guest_access_disabled") from exc
|
|
694
|
+
except InvalidCredentialsError as exc:
|
|
695
|
+
raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
|
|
696
|
+
|
|
697
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
698
|
+
|
|
699
|
+
# Stamp the generation of the very settings row the credential was checked
|
|
700
|
+
# against (one database read, not the 5 s cache and not a second read): a
|
|
701
|
+
# guest password enabled between check and mint must invalidate this cookie.
|
|
702
|
+
session_id, session_ttl_seconds = await _create_guest_session(
|
|
703
|
+
request, guest_session_generation=verification.guest_session_generation
|
|
704
|
+
)
|
|
705
|
+
response = await _decorate_session_response(
|
|
706
|
+
await context.service.describe_session(session_id), request=request, context=context
|
|
707
|
+
)
|
|
708
|
+
json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
|
|
709
|
+
_set_session_cookie(json_response, session_id, request, max_age_seconds=session_ttl_seconds)
|
|
710
|
+
return json_response
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@router.post("/password/login", response_model=DashboardAuthSessionResponse)
|
|
714
|
+
async def login_password(
|
|
715
|
+
request: Request,
|
|
716
|
+
payload: PasswordLoginRequest = Body(...),
|
|
717
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
718
|
+
) -> DashboardAuthSessionResponse | JSONResponse:
|
|
719
|
+
if get_settings().dashboard_auth_mode == DashboardAuthMode.DISABLED:
|
|
720
|
+
raise DashboardBadRequestError(
|
|
721
|
+
"Password login is disabled while dashboard auth is bypassed",
|
|
722
|
+
code="password_management_disabled",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# Target resolution happens before any limiter so a missing username on a
|
|
726
|
+
# multi-user install (422) and an unconfigured install (400) never spend budget.
|
|
727
|
+
try:
|
|
728
|
+
target = await context.service.resolve_login_target(payload.username)
|
|
729
|
+
except PasswordNotConfiguredError as exc:
|
|
730
|
+
raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
|
|
731
|
+
except UsernameRequiredError as exc:
|
|
732
|
+
await _audit_username_required(request, context)
|
|
733
|
+
raise DashboardValidationError(str(exc), code="username_required") from exc
|
|
734
|
+
|
|
735
|
+
# Two buckets, both spent by every attempt: the coarse per-address ceiling
|
|
736
|
+
# that bounds the endpoint, and the per-(address, username) budget that
|
|
737
|
+
# keeps one account's failures from barring another. No account is exempt
|
|
738
|
+
# from either. PLAN §4.4 exempted the emergency account from a limit it
|
|
739
|
+
# assumed was keyed on the username alone, which an attacker could exhaust
|
|
740
|
+
# from anywhere; both keys carry the client address, so an attacker
|
|
741
|
+
# hammering that username from their own address cannot touch the operator
|
|
742
|
+
# signing in from a different one. T13/T15 ("no remotely triggerable
|
|
743
|
+
# account lockout") therefore hold without an exemption -- and the
|
|
744
|
+
# exemption itself was an oracle, because whether the ninth failure for a
|
|
745
|
+
# username answers 429 or 401 named the emergency account to an
|
|
746
|
+
# unauthenticated caller.
|
|
747
|
+
address_limiter, address_key = get_password_address_rate_limiter(), _password_login_address_key(request)
|
|
748
|
+
limiter = get_password_rate_limiter()
|
|
749
|
+
rate_key = _password_login_key(request, target.username)
|
|
750
|
+
try:
|
|
751
|
+
await address_limiter.check_and_increment(address_key, context.session)
|
|
752
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
753
|
+
except DashboardRateLimitError as exc:
|
|
754
|
+
raise _rate_limit_error(exc, code="password_rate_limited") from exc
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
user = await context.service.verify_user_password(target, payload.password, actor_ip=_client_host(request))
|
|
758
|
+
except InvalidCredentialsError as exc:
|
|
759
|
+
raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
|
|
760
|
+
|
|
761
|
+
# Only the per-account bucket is cleared. The coarse address bucket is the
|
|
762
|
+
# endpoint's only ceiling on *how many* usernames one address may try, and
|
|
763
|
+
# clearing it on success would hand that ceiling to anyone holding a single
|
|
764
|
+
# valid account: seven guesses at someone else's username, one sign-in of
|
|
765
|
+
# their own, seven more, for as long as they like. It is left to expire on
|
|
766
|
+
# its own window instead, which the person who just signed in never notices
|
|
767
|
+
# (60/60 s against one sign-in).
|
|
768
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
769
|
+
# Only last_login_at changed; nothing on the auth path reads it, so the users
|
|
770
|
+
# cache stays warm.
|
|
771
|
+
|
|
772
|
+
return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def _audit_username_required(request: Request, context: DashboardAuthContext) -> None:
|
|
776
|
+
"""Audit a ``username_required`` refusal without letting it become an unbounded row source.
|
|
777
|
+
|
|
778
|
+
The refusal itself never spends password budget, so a client that is
|
|
779
|
+
already at the password limit gets no row, and a dedicated per-client
|
|
780
|
+
budget (same 8/60 s shape, own counter) bounds the rows a client can add
|
|
781
|
+
without ever touching the password limiter's counter.
|
|
782
|
+
|
|
783
|
+
The budget it reads is the per-address one, which is the bucket ordinary
|
|
784
|
+
logins from this client actually increment; a per-username key would be a
|
|
785
|
+
counter nothing ever advances, so the guard would let every refusal
|
|
786
|
+
through.
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
try:
|
|
790
|
+
await get_password_address_rate_limiter().check(_password_login_address_key(request), context.session)
|
|
791
|
+
await get_login_failed_audit_rate_limiter().check_and_increment(
|
|
792
|
+
_session_client_key(request, prefix="login_failed_audit"), context.session
|
|
793
|
+
)
|
|
794
|
+
except DashboardRateLimitError:
|
|
795
|
+
return
|
|
796
|
+
log_login_failed(_client_host(request), "password", "username_required")
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@router.post("/password/change")
|
|
800
|
+
async def change_password(
|
|
801
|
+
request: Request,
|
|
802
|
+
payload: PasswordChangeRequest = Body(...),
|
|
803
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
804
|
+
) -> JSONResponse:
|
|
805
|
+
resolved = await _require_management_session(request, context, allow_unenrolled=True)
|
|
806
|
+
|
|
807
|
+
new_password = payload.new_password.strip()
|
|
808
|
+
_validate_password_length(new_password)
|
|
809
|
+
|
|
810
|
+
try:
|
|
811
|
+
await context.service.change_password(
|
|
812
|
+
resolved.user,
|
|
813
|
+
payload.current_password,
|
|
814
|
+
new_password,
|
|
815
|
+
actor_ip=_client_host(request),
|
|
816
|
+
auth_method=resolved.state.auth_method,
|
|
817
|
+
)
|
|
818
|
+
except PasswordNotConfiguredError as exc:
|
|
819
|
+
raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
|
|
820
|
+
except InvalidCredentialsError as exc:
|
|
821
|
+
raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
|
|
822
|
+
|
|
823
|
+
await _invalidate_auth_caches()
|
|
824
|
+
# Every other device is logged out by the generation bump; this caller stays
|
|
825
|
+
# signed in through a fresh cookie that keeps the old session's remaining life.
|
|
826
|
+
user = await context.repository.get_user_by_id(resolved.user.id)
|
|
827
|
+
if user is None:
|
|
828
|
+
raise DashboardAuthError("Authentication is required")
|
|
829
|
+
remaining = max(1, resolved.state.expires_at - int(time()))
|
|
830
|
+
session_id, session_ttl_seconds = await _create_user_session(
|
|
831
|
+
request,
|
|
832
|
+
user,
|
|
833
|
+
totp_verified=resolved.state.totp_verified,
|
|
834
|
+
auth_method=resolved.state.auth_method or "password",
|
|
835
|
+
max_ttl_seconds=remaining,
|
|
836
|
+
step_up_verified_at=resolved.state.step_up_verified_at,
|
|
837
|
+
)
|
|
838
|
+
response = JSONResponse(status_code=200, content={"status": "ok"})
|
|
839
|
+
_set_session_cookie(response, session_id, request, max_age_seconds=session_ttl_seconds)
|
|
840
|
+
return response
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@router.post("/guest/password")
|
|
844
|
+
async def set_guest_password(
|
|
845
|
+
payload: GuestPasswordSetRequest = Body(...),
|
|
846
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
847
|
+
_principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
|
|
848
|
+
) -> JSONResponse:
|
|
849
|
+
password = payload.password.strip()
|
|
850
|
+
_validate_password_length(password)
|
|
851
|
+
await context.service.set_guest_password(password)
|
|
852
|
+
await get_settings_cache().invalidate()
|
|
853
|
+
return JSONResponse(status_code=200, content={"status": "ok"})
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@router.delete("/guest/password")
|
|
857
|
+
async def remove_guest_password(
|
|
858
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
859
|
+
_principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
|
|
860
|
+
) -> JSONResponse:
|
|
861
|
+
await context.service.clear_guest_password()
|
|
862
|
+
await get_settings_cache().invalidate()
|
|
863
|
+
return JSONResponse(status_code=200, content={"status": "ok"})
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@router.post("/guest/logout-all")
|
|
867
|
+
async def revoke_guest_sessions(
|
|
868
|
+
request: Request,
|
|
869
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
870
|
+
principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
|
|
871
|
+
) -> JSONResponse:
|
|
872
|
+
"""Invalidate every outstanding guest session without touching guest settings."""
|
|
873
|
+
|
|
874
|
+
await context.service.revoke_guest_sessions()
|
|
875
|
+
await get_settings_cache().invalidate()
|
|
876
|
+
AuditService.log_async(
|
|
877
|
+
"guest_sessions_revoked",
|
|
878
|
+
actor_ip=_client_host(request),
|
|
879
|
+
actor=AuditActor.from_principal(principal),
|
|
880
|
+
target=AuditTarget("settings", "guest_access"),
|
|
881
|
+
)
|
|
882
|
+
return JSONResponse(status_code=200, content={"status": "ok"})
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@router.delete("/password")
|
|
886
|
+
async def remove_password(
|
|
887
|
+
request: Request,
|
|
888
|
+
payload: PasswordRemoveRequest = Body(...),
|
|
889
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
890
|
+
) -> JSONResponse:
|
|
891
|
+
resolved = await _require_management_session(request, context)
|
|
892
|
+
|
|
893
|
+
try:
|
|
894
|
+
await context.service.remove_password(
|
|
895
|
+
resolved.user,
|
|
896
|
+
payload.password,
|
|
897
|
+
actor_ip=_client_host(request),
|
|
898
|
+
auth_method=resolved.state.auth_method,
|
|
899
|
+
)
|
|
900
|
+
except PasswordNotConfiguredError as exc:
|
|
901
|
+
raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
|
|
902
|
+
except InvalidCredentialsError as exc:
|
|
903
|
+
raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
|
|
904
|
+
except OtherUsersExistError as exc:
|
|
905
|
+
raise DashboardConflictError(str(exc), code="other_users_exist") from exc
|
|
906
|
+
except CredentialRequiredError as exc:
|
|
907
|
+
raise DashboardConflictError(str(exc), code="credential_required") from exc
|
|
908
|
+
except LastBreakGlassProtectedError as exc:
|
|
909
|
+
raise DashboardConflictError(str(exc), code="last_break_glass_protected") from exc
|
|
910
|
+
|
|
911
|
+
await _invalidate_auth_caches()
|
|
912
|
+
bootstrap_token = await ensure_auto_bootstrap_token()
|
|
913
|
+
if bootstrap_token:
|
|
914
|
+
log_bootstrap_token(logger, bootstrap_token, reason="password-removed")
|
|
915
|
+
response = JSONResponse(status_code=200, content={"status": "ok"})
|
|
916
|
+
response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
|
|
917
|
+
return response
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@router.post("/logout-all")
|
|
921
|
+
async def logout_everywhere(
|
|
922
|
+
request: Request,
|
|
923
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
924
|
+
) -> JSONResponse:
|
|
925
|
+
"""Revoke every session of the signed-in account, including this one."""
|
|
926
|
+
|
|
927
|
+
resolved = await _require_password_session(request, context)
|
|
928
|
+
await context.service.revoke_user_sessions(
|
|
929
|
+
resolved.user, actor_ip=_client_host(request), auth_method=resolved.state.auth_method
|
|
930
|
+
)
|
|
931
|
+
await get_dashboard_users_cache().invalidate()
|
|
932
|
+
response = JSONResponse(status_code=200, content={"status": "ok"})
|
|
933
|
+
response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
|
|
934
|
+
return response
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@router.get("/me", response_model=DashboardMeResponse)
|
|
938
|
+
async def get_me(
|
|
939
|
+
request: Request,
|
|
940
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
941
|
+
) -> DashboardMeResponse:
|
|
942
|
+
"""The signed-in account. Guests and implicit admins have no account and get 401."""
|
|
943
|
+
|
|
944
|
+
try:
|
|
945
|
+
return await context.service.me(request.cookies.get(DASHBOARD_SESSION_COOKIE))
|
|
946
|
+
except PasswordSessionRequiredError as exc:
|
|
947
|
+
raise DashboardAuthError("A user account session is required", code="user_account_required") from exc
|
|
948
|
+
except TotpVerificationRequiredError as exc:
|
|
949
|
+
raise DashboardAuthError(str(exc), code="totp_required") from exc
|
|
950
|
+
except TotpEnrollmentRequiredError as exc:
|
|
951
|
+
raise _enrollment_required(exc) from exc
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
@router.patch("/me", response_model=DashboardMeResponse)
|
|
955
|
+
async def update_me(
|
|
956
|
+
request: Request,
|
|
957
|
+
payload: ProfileUpdateRequest = Body(...),
|
|
958
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
959
|
+
users: DashboardUsersContext = Depends(get_dashboard_users_context),
|
|
960
|
+
) -> DashboardMeResponse:
|
|
961
|
+
"""Self-service profile edit (display name, e-mail) for the signed-in account."""
|
|
962
|
+
|
|
963
|
+
resolved = await _require_management_session(request, context, allow_unenrolled=True)
|
|
964
|
+
with mapped_user_errors():
|
|
965
|
+
await users.service.update_profile(resolved.user, payload, actor_ip=_client_host(request))
|
|
966
|
+
return await context.service.me(request.cookies.get(DASHBOARD_SESSION_COOKIE))
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
@router.get("/invite/{token}", response_model=InviteDescriptionResponse)
|
|
970
|
+
async def describe_invite(
|
|
971
|
+
token: str,
|
|
972
|
+
request: Request,
|
|
973
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
974
|
+
users: DashboardUsersContext = Depends(get_dashboard_users_context),
|
|
975
|
+
) -> InviteDescriptionResponse:
|
|
976
|
+
"""Unauthenticated: what the acceptance screen shows. Every invalid token is the same 404."""
|
|
977
|
+
|
|
978
|
+
limiter = get_invite_lookup_rate_limiter()
|
|
979
|
+
try:
|
|
980
|
+
await limiter.check_and_increment(_session_client_key(request, prefix="invite_lookup"), context.session)
|
|
981
|
+
except DashboardRateLimitError as exc:
|
|
982
|
+
raise _rate_limit_error(exc, code="invite_rate_limited") from exc
|
|
983
|
+
try:
|
|
984
|
+
description = await users.service.describe_invite(token)
|
|
985
|
+
except InviteNotFoundError as exc:
|
|
986
|
+
raise DashboardNotFoundError(str(exc), code="invite_not_found") from exc
|
|
987
|
+
return InviteDescriptionResponse(
|
|
988
|
+
role_name=description.role_name,
|
|
989
|
+
inviter_display_name=description.inviter_display_name,
|
|
990
|
+
suggested_username=description.suggested_username,
|
|
991
|
+
username_locked=description.username_locked,
|
|
992
|
+
expires_at=description.expires_at,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
@router.post("/invite/accept", response_model=DashboardAuthSessionResponse)
|
|
997
|
+
async def accept_invite(
|
|
998
|
+
request: Request,
|
|
999
|
+
payload: InviteAcceptRequest = Body(...),
|
|
1000
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1001
|
+
users: DashboardUsersContext = Depends(get_dashboard_users_context),
|
|
1002
|
+
) -> DashboardAuthSessionResponse | JSONResponse:
|
|
1003
|
+
"""Unauthenticated: set the invited account's password, activate it, and sign it in."""
|
|
1004
|
+
|
|
1005
|
+
if get_settings().dashboard_auth_mode == DashboardAuthMode.DISABLED:
|
|
1006
|
+
raise DashboardBadRequestError(
|
|
1007
|
+
"Invites cannot be accepted while dashboard auth is bypassed", code="password_management_disabled"
|
|
1008
|
+
)
|
|
1009
|
+
if await context.service.resolve_user_session(request.cookies.get(DASHBOARD_SESSION_COOKIE)) is not None:
|
|
1010
|
+
raise DashboardConflictError("Sign out before accepting an invite", code="already_signed_in")
|
|
1011
|
+
password = payload.password.strip()
|
|
1012
|
+
_validate_password_length(password)
|
|
1013
|
+
|
|
1014
|
+
try:
|
|
1015
|
+
await get_invite_accept_rate_limiter().check_and_increment(
|
|
1016
|
+
_session_client_key(request, prefix="invite_accept"), context.session
|
|
1017
|
+
)
|
|
1018
|
+
await get_invite_accept_token_rate_limiter().check_and_increment(
|
|
1019
|
+
f"invite_accept_token:{invite_token_hash(payload.token).hex()}", context.session
|
|
1020
|
+
)
|
|
1021
|
+
except DashboardRateLimitError as exc:
|
|
1022
|
+
raise _rate_limit_error(exc, code="invite_rate_limited") from exc
|
|
1023
|
+
|
|
1024
|
+
try:
|
|
1025
|
+
with mapped_user_errors():
|
|
1026
|
+
user = await users.service.accept_invite(
|
|
1027
|
+
payload.token,
|
|
1028
|
+
username=payload.username,
|
|
1029
|
+
password_hash=hash_password(password),
|
|
1030
|
+
display_name=payload.display_name,
|
|
1031
|
+
actor_ip=_client_host(request),
|
|
1032
|
+
)
|
|
1033
|
+
except InviteNotFoundError as exc:
|
|
1034
|
+
raise DashboardNotFoundError(str(exc), code="invite_not_found") from exc
|
|
1035
|
+
except UsernameLockedError as exc:
|
|
1036
|
+
raise DashboardValidationError(str(exc), code="username_locked") from exc
|
|
1037
|
+
|
|
1038
|
+
await _invalidate_auth_caches()
|
|
1039
|
+
return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@router.post("/totp/setup/start", response_model=TotpSetupStartResponse)
|
|
1043
|
+
async def start_totp_setup(
|
|
1044
|
+
request: Request,
|
|
1045
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1046
|
+
) -> TotpSetupStartResponse:
|
|
1047
|
+
_ensure_password_management_enabled(request)
|
|
1048
|
+
resolved = await _resolve_account_session(request, context)
|
|
1049
|
+
session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
1050
|
+
try:
|
|
1051
|
+
return await context.service.start_totp_setup(session_id=session_id, resolved=resolved)
|
|
1052
|
+
except PasswordSessionRequiredError as exc:
|
|
1053
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
1054
|
+
except TotpAlreadyConfiguredError as exc:
|
|
1055
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
@router.post("/totp/setup/confirm")
|
|
1059
|
+
async def confirm_totp_setup(
|
|
1060
|
+
request: Request,
|
|
1061
|
+
payload: TotpSetupConfirmRequest = Body(...),
|
|
1062
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1063
|
+
) -> JSONResponse:
|
|
1064
|
+
_ensure_password_management_enabled(request)
|
|
1065
|
+
resolved = await _resolve_account_session(request, context)
|
|
1066
|
+
|
|
1067
|
+
limiter = get_totp_rate_limiter()
|
|
1068
|
+
rate_key = _session_client_key(request, prefix="totp_setup_confirm")
|
|
1069
|
+
try:
|
|
1070
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
1071
|
+
except DashboardRateLimitError as exc:
|
|
1072
|
+
raise _rate_limit_error(exc, code="totp_rate_limited") from exc
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
1076
|
+
await context.service.confirm_totp_setup(
|
|
1077
|
+
session_id=session_id,
|
|
1078
|
+
secret=payload.secret,
|
|
1079
|
+
code=payload.code,
|
|
1080
|
+
actor_ip=_client_host(request),
|
|
1081
|
+
resolved=resolved,
|
|
1082
|
+
)
|
|
1083
|
+
except PasswordSessionRequiredError as exc:
|
|
1084
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
1085
|
+
except TotpInvalidCodeError as exc:
|
|
1086
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
|
|
1087
|
+
except TotpInvalidSetupError as exc:
|
|
1088
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
|
|
1089
|
+
except TotpAlreadyConfiguredError as exc:
|
|
1090
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
|
|
1091
|
+
|
|
1092
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
1093
|
+
await _invalidate_auth_caches()
|
|
1094
|
+
# The enrolment itself does not mint a TOTP-verified session: whenever the
|
|
1095
|
+
# new secret makes the second factor mandatory the account presents it
|
|
1096
|
+
# once through ``/totp/verify``, which is the shape the enrolment gate
|
|
1097
|
+
# already has for the global toggle.
|
|
1098
|
+
return JSONResponse(status_code=200, content={"status": "ok"})
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@router.post("/totp/verify", response_model=DashboardAuthSessionResponse)
|
|
1102
|
+
async def verify_totp(
|
|
1103
|
+
request: Request,
|
|
1104
|
+
payload: TotpVerifyRequest = Body(...),
|
|
1105
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1106
|
+
) -> DashboardAuthSessionResponse | JSONResponse:
|
|
1107
|
+
_ensure_password_management_enabled(request)
|
|
1108
|
+
limiter = get_totp_rate_limiter()
|
|
1109
|
+
rate_key = _session_client_key(request, prefix="totp_verify")
|
|
1110
|
+
current_session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
1111
|
+
resolved = await _resolve_account_session(request, context)
|
|
1112
|
+
try:
|
|
1113
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
1114
|
+
except DashboardRateLimitError as exc:
|
|
1115
|
+
raise _rate_limit_error(exc, code="totp_rate_limited") from exc
|
|
1116
|
+
if _is_header_account(resolved):
|
|
1117
|
+
# No session cookie to upgrade: a valid code is a step-up for the header account.
|
|
1118
|
+
return await _step_up_header_account(request, context, resolved.user, limiter, rate_key, code=payload.code)
|
|
1119
|
+
try:
|
|
1120
|
+
configured_ttl_seconds = (await get_settings_cache().get()).dashboard_session_ttl_seconds
|
|
1121
|
+
session_ttl_seconds = _session_ttl_seconds(request, resolved.user, configured_ttl_seconds)
|
|
1122
|
+
session_id, applied_ttl_seconds = await context.service.verify_totp(
|
|
1123
|
+
session_id=current_session_id,
|
|
1124
|
+
code=payload.code,
|
|
1125
|
+
ttl_seconds=session_ttl_seconds,
|
|
1126
|
+
actor_ip=_client_host(request),
|
|
1127
|
+
)
|
|
1128
|
+
except PasswordSessionRequiredError as exc:
|
|
1129
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
1130
|
+
except TotpInvalidCodeError as exc:
|
|
1131
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
|
|
1132
|
+
except TotpNotConfiguredError as exc:
|
|
1133
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
|
|
1134
|
+
|
|
1135
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
1136
|
+
await get_dashboard_users_cache().invalidate()
|
|
1137
|
+
response = await _decorate_session_response(
|
|
1138
|
+
await context.service.describe_session(session_id), request=request, context=context
|
|
1139
|
+
)
|
|
1140
|
+
json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
|
|
1141
|
+
_set_session_cookie(json_response, session_id, request, max_age_seconds=applied_ttl_seconds)
|
|
1142
|
+
return json_response
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
@router.post("/totp/disable")
|
|
1146
|
+
async def disable_totp(
|
|
1147
|
+
request: Request,
|
|
1148
|
+
payload: TotpVerifyRequest = Body(...),
|
|
1149
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1150
|
+
) -> JSONResponse:
|
|
1151
|
+
_ensure_password_management_enabled(request)
|
|
1152
|
+
limiter = get_totp_rate_limiter()
|
|
1153
|
+
rate_key = _session_client_key(request, prefix="totp_disable")
|
|
1154
|
+
session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
1155
|
+
# A header account has no TOTP-verified cookie; its valid code is the verification.
|
|
1156
|
+
header_account = await _header_account_session(request)
|
|
1157
|
+
if header_account is None:
|
|
1158
|
+
try:
|
|
1159
|
+
await context.service.ensure_totp_verified_session(session_id)
|
|
1160
|
+
except PasswordSessionRequiredError as exc:
|
|
1161
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
1162
|
+
except TotpEnrollmentRequiredError as exc:
|
|
1163
|
+
raise _enrollment_required(exc) from exc
|
|
1164
|
+
try:
|
|
1165
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
1166
|
+
except DashboardRateLimitError as exc:
|
|
1167
|
+
raise _rate_limit_error(exc, code="totp_rate_limited") from exc
|
|
1168
|
+
try:
|
|
1169
|
+
user = await context.service.disable_totp(
|
|
1170
|
+
session_id=session_id,
|
|
1171
|
+
code=payload.code,
|
|
1172
|
+
actor_ip=_client_host(request),
|
|
1173
|
+
resolved=header_account,
|
|
1174
|
+
)
|
|
1175
|
+
except PasswordSessionRequiredError as exc:
|
|
1176
|
+
raise DashboardAuthError(str(exc)) from exc
|
|
1177
|
+
except TotpInvalidCodeError as exc:
|
|
1178
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
|
|
1179
|
+
except TotpNotConfiguredError as exc:
|
|
1180
|
+
raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
|
|
1181
|
+
except LastBreakGlassProtectedError as exc:
|
|
1182
|
+
raise DashboardConflictError(str(exc), code="last_break_glass_protected") from exc
|
|
1183
|
+
|
|
1184
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
1185
|
+
await _invalidate_auth_caches()
|
|
1186
|
+
# Disabling is allowed even when TOTP was the account's only step-up
|
|
1187
|
+
# method; the client is told so it can point at enrolling again.
|
|
1188
|
+
return JSONResponse(status_code=200, content={"status": "ok", "stepUpAvailable": user.password_hash is not None})
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
@router.post("/step-up", response_model=StepUpResponse)
|
|
1192
|
+
async def step_up(
|
|
1193
|
+
request: Request,
|
|
1194
|
+
payload: StepUpRequest = Body(...),
|
|
1195
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1196
|
+
) -> JSONResponse:
|
|
1197
|
+
"""Re-verify the signed-in account for sensitive changes (PLAN §5 H5).
|
|
1198
|
+
|
|
1199
|
+
Password accounts present their password (and their TOTP code when they
|
|
1200
|
+
have a secret); provider-only accounts present their TOTP code. Success
|
|
1201
|
+
stamps ``su`` into a re-issued session cookie, or — for a trusted-header
|
|
1202
|
+
account, which has no session cookie — into the short-lived step-up cookie.
|
|
1203
|
+
"""
|
|
1204
|
+
|
|
1205
|
+
principal = await validate_dashboard_session(request)
|
|
1206
|
+
if principal.user_id is None:
|
|
1207
|
+
raise DashboardAuthError("A user account session is required", code="user_account_required")
|
|
1208
|
+
user = await context.repository.get_user_by_id(principal.user_id)
|
|
1209
|
+
if user is None:
|
|
1210
|
+
raise DashboardAuthError("Authentication is required")
|
|
1211
|
+
limiter = get_password_rate_limiter()
|
|
1212
|
+
rate_key = _session_client_key(request, prefix="step_up")
|
|
1213
|
+
try:
|
|
1214
|
+
await limiter.check_and_increment(rate_key, context.session)
|
|
1215
|
+
except DashboardRateLimitError as exc:
|
|
1216
|
+
raise _rate_limit_error(exc, code="step_up_rate_limited") from exc
|
|
1217
|
+
try:
|
|
1218
|
+
await context.service.verify_step_up(
|
|
1219
|
+
user,
|
|
1220
|
+
password=payload.password,
|
|
1221
|
+
code=payload.code,
|
|
1222
|
+
actor_ip=_client_host(request),
|
|
1223
|
+
auth_method=principal.auth_method,
|
|
1224
|
+
)
|
|
1225
|
+
except StepUpUnavailableError as exc:
|
|
1226
|
+
raise DashboardPermissionError(STEP_UP_UNAVAILABLE_MESSAGE, code="step_up_unavailable") from exc
|
|
1227
|
+
except InvalidCredentialsError as exc:
|
|
1228
|
+
raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
|
|
1229
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
1230
|
+
await get_dashboard_users_cache().invalidate()
|
|
1231
|
+
|
|
1232
|
+
verified_at = session_clock()
|
|
1233
|
+
response = _step_up_response(verified_at)
|
|
1234
|
+
await record_step_up_on(response, request, user, verified_at=verified_at)
|
|
1235
|
+
return response
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
async def record_step_up_on(response: Response, request: Request, user: DashboardUser, *, verified_at: int) -> None:
|
|
1239
|
+
"""Write a completed step-up onto ``response``, by whichever of the two paths fits.
|
|
1240
|
+
|
|
1241
|
+
A cookie session carries the proof in its own ``su`` claim; a principal
|
|
1242
|
+
that has no session cookie (a trusted-header account) carries it in the
|
|
1243
|
+
generation-bound step-up cookie. Shared with the OIDC step-up completion so
|
|
1244
|
+
that flow mints the proof through these same two paths instead of adding a
|
|
1245
|
+
third: one function, one set of cookie attributes, one lifetime.
|
|
1246
|
+
"""
|
|
1247
|
+
|
|
1248
|
+
state = get_dashboard_session_store().get(request.cookies.get(DASHBOARD_SESSION_COOKIE))
|
|
1249
|
+
if state is not None and state.is_user and state.user_id == user.id and state.password_verified:
|
|
1250
|
+
# Keep the session exactly as it was (method, TOTP step, remaining life); only ``su`` changes.
|
|
1251
|
+
session_id, session_ttl_seconds = await _create_user_session(
|
|
1252
|
+
request,
|
|
1253
|
+
user,
|
|
1254
|
+
totp_verified=state.totp_verified,
|
|
1255
|
+
auth_method=state.auth_method or "password",
|
|
1256
|
+
max_ttl_seconds=max(1, state.expires_at - verified_at),
|
|
1257
|
+
step_up_verified_at=verified_at,
|
|
1258
|
+
)
|
|
1259
|
+
_set_session_cookie(response, session_id, request, max_age_seconds=session_ttl_seconds)
|
|
1260
|
+
else:
|
|
1261
|
+
_set_step_up_cookie(response, user, request, verified_at=verified_at)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
async def _step_up_header_account(
|
|
1265
|
+
request: Request,
|
|
1266
|
+
context: DashboardAuthContext,
|
|
1267
|
+
user: DashboardUser,
|
|
1268
|
+
limiter: DatabaseRateLimiter,
|
|
1269
|
+
rate_key: str,
|
|
1270
|
+
*,
|
|
1271
|
+
code: str,
|
|
1272
|
+
) -> JSONResponse:
|
|
1273
|
+
"""``/totp/verify`` for a trusted-header account: the code proves the account and mints the step-up cookie."""
|
|
1274
|
+
|
|
1275
|
+
try:
|
|
1276
|
+
await context.service.verify_step_up(
|
|
1277
|
+
user,
|
|
1278
|
+
password=None,
|
|
1279
|
+
code=code,
|
|
1280
|
+
actor_ip=_client_host(request),
|
|
1281
|
+
auth_method=DashboardAuthMode.TRUSTED_HEADER.value,
|
|
1282
|
+
)
|
|
1283
|
+
except (InvalidCredentialsError, StepUpUnavailableError) as exc:
|
|
1284
|
+
raise DashboardBadRequestError("Invalid TOTP code", code="invalid_totp_code") from exc
|
|
1285
|
+
await limiter.clear_for_key(rate_key, context.session)
|
|
1286
|
+
verified_at = session_clock()
|
|
1287
|
+
response = await _decorate_session_response(
|
|
1288
|
+
await context.service.describe_session(None), request=request, context=context
|
|
1289
|
+
)
|
|
1290
|
+
json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
|
|
1291
|
+
_set_step_up_cookie(json_response, user, request, verified_at=verified_at)
|
|
1292
|
+
return json_response
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _step_up_response(verified_at: int) -> JSONResponse:
|
|
1296
|
+
body = StepUpResponse(verified_at=verified_at, expires_at=step_up_expires_at(verified_at))
|
|
1297
|
+
return JSONResponse(status_code=200, content=body.model_dump(by_alias=True))
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _set_step_up_cookie(response: Response, user: DashboardUser, request: Request, *, verified_at: int) -> None:
|
|
1301
|
+
response.set_cookie(
|
|
1302
|
+
key=STEP_UP_COOKIE,
|
|
1303
|
+
value=get_step_up_cookie_store().create(
|
|
1304
|
+
user.id, session_generation=user.session_generation, verified_at=verified_at
|
|
1305
|
+
),
|
|
1306
|
+
httponly=True,
|
|
1307
|
+
secure=request.url.scheme == "https",
|
|
1308
|
+
samesite="lax",
|
|
1309
|
+
max_age=step_up_expires_at(verified_at) - verified_at,
|
|
1310
|
+
path="/",
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
@router.post("/logout")
|
|
1315
|
+
async def logout_dashboard(
|
|
1316
|
+
request: Request,
|
|
1317
|
+
context: DashboardAuthContext = Depends(get_dashboard_auth_context),
|
|
1318
|
+
) -> JSONResponse:
|
|
1319
|
+
session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
|
|
1320
|
+
context.service.logout(session_id)
|
|
1321
|
+
response = JSONResponse(status_code=200, content={"status": "ok"})
|
|
1322
|
+
response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
|
|
1323
|
+
# The pending screen's Logout is how a refused person leaves it, so the
|
|
1324
|
+
# refusal marker goes with the session; otherwise the screen comes back.
|
|
1325
|
+
clear_pending_marker(response)
|
|
1326
|
+
return response
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _set_session_cookie(response: Response, session_id: str, request: Request, *, max_age_seconds: int) -> None:
|
|
1330
|
+
response.set_cookie(
|
|
1331
|
+
key=DASHBOARD_SESSION_COOKIE,
|
|
1332
|
+
value=session_id,
|
|
1333
|
+
httponly=True,
|
|
1334
|
+
secure=request.url.scheme == "https",
|
|
1335
|
+
samesite="lax",
|
|
1336
|
+
max_age=max_age_seconds,
|
|
1337
|
+
path="/",
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
# The OIDC sign-in routes are written in their own module but mount on *this*
|
|
1342
|
+
# router: one prefix, one error format, one set of middleware exemptions, no
|
|
1343
|
+
# second ``include_router``. Imported last, when ``router`` exists.
|
|
1344
|
+
from app.modules.dashboard_auth import oidc_api as _oidc_api # noqa: E402,F401
|