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,1384 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from time import time
|
|
11
|
+
from typing import Literal, Protocol, cast
|
|
12
|
+
|
|
13
|
+
import bcrypt
|
|
14
|
+
import segno
|
|
15
|
+
|
|
16
|
+
from app.core.audit.service import AuditActor, AuditAuthMethod, AuditService, AuditSeverity, AuditTarget
|
|
17
|
+
from app.core.auth.dashboard_access import (
|
|
18
|
+
ADMIN_GRANTS,
|
|
19
|
+
ASSIGNABLE_PRESET_ROLES,
|
|
20
|
+
GUEST_GRANTS,
|
|
21
|
+
PRESET_ROLE_IDS,
|
|
22
|
+
DashboardRole,
|
|
23
|
+
Grants,
|
|
24
|
+
Permission,
|
|
25
|
+
PresetRoleSlug,
|
|
26
|
+
permission_strings,
|
|
27
|
+
totp_policy_applies,
|
|
28
|
+
)
|
|
29
|
+
from app.core.auth.dashboard_mode import DashboardAuthMode
|
|
30
|
+
from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
|
|
31
|
+
from app.core.auth.providers.registry import ActiveProvider, get_auth_provider_registry
|
|
32
|
+
from app.core.auth.step_up import (
|
|
33
|
+
StepUpMethod,
|
|
34
|
+
account_step_up_methods,
|
|
35
|
+
is_step_up_fresh,
|
|
36
|
+
step_up_expires_at,
|
|
37
|
+
step_up_methods,
|
|
38
|
+
)
|
|
39
|
+
from app.core.auth.totp import build_otpauth_uri, generate_totp_secret, verify_totp_code
|
|
40
|
+
from app.core.config.settings import get_settings
|
|
41
|
+
from app.core.crypto import TokenEncryptor
|
|
42
|
+
from app.core.rate_limiter.db_rate_limiter import DatabaseRateLimiter
|
|
43
|
+
from app.db.models import AuthProviderKind, DashboardUser, DashboardUserStatus
|
|
44
|
+
from app.modules.dashboard_auth.oidc_flows import OIDC_LOGIN_START_PATH
|
|
45
|
+
from app.modules.dashboard_auth.schemas import (
|
|
46
|
+
DashboardAccessSummary,
|
|
47
|
+
DashboardAuthSessionResponse,
|
|
48
|
+
DashboardLoginHint,
|
|
49
|
+
DashboardLoginProvider,
|
|
50
|
+
DashboardMeResponse,
|
|
51
|
+
DashboardSessionUser,
|
|
52
|
+
DashboardStepUpState,
|
|
53
|
+
DashboardUserRoleSummary,
|
|
54
|
+
LocalLoginPolicyValue,
|
|
55
|
+
LoginProviderKind,
|
|
56
|
+
TotpSetupStartResponse,
|
|
57
|
+
)
|
|
58
|
+
from app.modules.dashboard_roles.service import resolve_role_grants
|
|
59
|
+
from app.modules.dashboard_users.break_glass import (
|
|
60
|
+
assert_break_glass_remains,
|
|
61
|
+
break_glass_second_factor_required,
|
|
62
|
+
local_login_admits,
|
|
63
|
+
)
|
|
64
|
+
from app.modules.dashboard_users.credentials import assert_credential_remains
|
|
65
|
+
from app.modules.dashboard_users.repository import (
|
|
66
|
+
DashboardUserCounts,
|
|
67
|
+
LocalAuthState,
|
|
68
|
+
is_valid_username,
|
|
69
|
+
normalize_username,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
DASHBOARD_SESSION_COOKIE = "codex_lb_dashboard_session"
|
|
73
|
+
#: Cookie payload format. Version 1 carried ``pw``/``tv``/``role``/``gv`` and no
|
|
74
|
+
#: user id; it is rejected outright so a stale cookie can never resolve to a
|
|
75
|
+
#: user it was not issued for.
|
|
76
|
+
SESSION_PAYLOAD_VERSION = 2
|
|
77
|
+
_TOTP_ISSUER = "codex-lb"
|
|
78
|
+
AUTH_METHOD_PASSWORD = "password"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DashboardAuthSettingsProtocol(Protocol):
|
|
82
|
+
guest_access_enabled: bool
|
|
83
|
+
guest_password_hash: str | None
|
|
84
|
+
guest_session_generation: int
|
|
85
|
+
totp_required_on_login: bool
|
|
86
|
+
totp_required_for_admin_role: bool
|
|
87
|
+
local_login_policy: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DashboardAuthRepositoryProtocol(Protocol):
|
|
91
|
+
async def get_settings(self) -> DashboardAuthSettingsProtocol: ...
|
|
92
|
+
|
|
93
|
+
async def get_local_auth_state(self) -> LocalAuthState: ...
|
|
94
|
+
|
|
95
|
+
async def get_user_by_id(self, user_id: str) -> DashboardUser | None: ...
|
|
96
|
+
|
|
97
|
+
async def get_user_by_username(self, normalized_username: str) -> DashboardUser | None: ...
|
|
98
|
+
|
|
99
|
+
async def list_active_local_password_users(self) -> Sequence[DashboardUser]: ...
|
|
100
|
+
|
|
101
|
+
async def count_user_identities(self, user_id: str) -> int: ...
|
|
102
|
+
|
|
103
|
+
async def count_live_invites(self) -> int: ...
|
|
104
|
+
|
|
105
|
+
async def acquire_write_intent(self) -> None: ...
|
|
106
|
+
|
|
107
|
+
async def get_user_counts(self) -> DashboardUserCounts: ...
|
|
108
|
+
|
|
109
|
+
async def count_qualifying_break_glass(self, *, exclude_user_id: str | None = None) -> int: ...
|
|
110
|
+
|
|
111
|
+
async def count_custom_roles(self) -> int: ...
|
|
112
|
+
|
|
113
|
+
async def count_role_mappings(self) -> int: ...
|
|
114
|
+
|
|
115
|
+
async def count_scim_tokens(self) -> int: ...
|
|
116
|
+
|
|
117
|
+
async def create_first_admin(self, password_hash: str) -> DashboardUser | None: ...
|
|
118
|
+
|
|
119
|
+
async def rotate_user_password(self, user_id: str, password_hash: str) -> DashboardUser: ...
|
|
120
|
+
|
|
121
|
+
async def set_user_totp_secret(
|
|
122
|
+
self,
|
|
123
|
+
user_id: str,
|
|
124
|
+
secret_encrypted: bytes | None,
|
|
125
|
+
*,
|
|
126
|
+
bump_generation: bool = False,
|
|
127
|
+
preserve_policy: bool = False,
|
|
128
|
+
) -> DashboardUser: ...
|
|
129
|
+
|
|
130
|
+
async def try_advance_user_totp_step(self, user_id: str, step: int) -> bool: ...
|
|
131
|
+
|
|
132
|
+
async def bump_session_generation(self, user_id: str) -> int: ...
|
|
133
|
+
|
|
134
|
+
async def clear_user_credentials(self, user_id: str) -> DashboardUser: ...
|
|
135
|
+
|
|
136
|
+
async def touch_last_login(self, user_id: str) -> None: ...
|
|
137
|
+
|
|
138
|
+
async def set_guest_password_hash(self, password_hash: str) -> DashboardAuthSettingsProtocol: ...
|
|
139
|
+
|
|
140
|
+
async def clear_guest_password_hash(self) -> DashboardAuthSettingsProtocol: ...
|
|
141
|
+
|
|
142
|
+
async def bump_guest_session_generation(self) -> DashboardAuthSettingsProtocol: ...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TotpAlreadyConfiguredError(ValueError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TotpNotConfiguredError(ValueError):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TotpInvalidCodeError(ValueError):
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TotpInvalidSetupError(ValueError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TotpVerificationRequiredError(ValueError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TotpEnrollmentRequiredError(ValueError):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PasswordAlreadyConfiguredError(ValueError):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PasswordNotConfiguredError(ValueError):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class InvalidCredentialsError(ValueError):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class UsernameRequiredError(ValueError):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class OtherUsersExistError(ValueError):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GuestAccessDisabledError(ValueError):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PasswordSessionRequiredError(ValueError):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class StepUpUnavailableError(ValueError):
|
|
198
|
+
"""The account holds neither a password nor a TOTP secret, so nothing can be re-verified."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
SessionKind = Literal["user", "guest"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(slots=True, frozen=True)
|
|
205
|
+
class DashboardSessionState:
|
|
206
|
+
"""Decoded session cookie (payload version 2).
|
|
207
|
+
|
|
208
|
+
``kind == "user"`` carries the account id and the ``session_generation`` the
|
|
209
|
+
cookie was minted under; the request path re-reads the user row and rejects
|
|
210
|
+
the cookie when the account is gone, disabled, or has revoked its sessions.
|
|
211
|
+
``kind == "guest"`` carries only the guest generation. ``step_up_verified_at``
|
|
212
|
+
(``su``) is when the account last re-verified a credential for a sensitive
|
|
213
|
+
change; absent until it does. ``break_glass`` (``bg``) marks an emergency
|
|
214
|
+
session so the dashboard can say so; it is optional and its absence means
|
|
215
|
+
false, which is why the payload version does not change for it.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
expires_at: int
|
|
219
|
+
issued_at: int
|
|
220
|
+
kind: SessionKind
|
|
221
|
+
user_id: str | None = None
|
|
222
|
+
session_generation: int | None = None
|
|
223
|
+
password_verified: bool = False
|
|
224
|
+
totp_verified: bool = False
|
|
225
|
+
auth_method: str | None = None
|
|
226
|
+
guest_session_generation: int | None = None
|
|
227
|
+
step_up_verified_at: int | None = None
|
|
228
|
+
break_glass: bool = False
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def is_user(self) -> bool:
|
|
232
|
+
return self.kind == "user"
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def is_guest(self) -> bool:
|
|
236
|
+
return self.kind == "guest"
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def role(self) -> DashboardRole:
|
|
240
|
+
"""Coarse wire role: every user session is ``admin`` on the wire this release."""
|
|
241
|
+
|
|
242
|
+
return DashboardRole.GUEST if self.kind == "guest" else DashboardRole.ADMIN
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _as_int(value: object) -> int | None:
|
|
246
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
247
|
+
return None
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class DashboardSessionStore:
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
self._encryptor: TokenEncryptor | None = None
|
|
254
|
+
|
|
255
|
+
def _get_encryptor(self) -> TokenEncryptor:
|
|
256
|
+
if self._encryptor is None:
|
|
257
|
+
self._encryptor = TokenEncryptor()
|
|
258
|
+
return self._encryptor
|
|
259
|
+
|
|
260
|
+
def _seal(self, data: dict[str, object]) -> str:
|
|
261
|
+
payload = json.dumps(data, separators=(",", ":"))
|
|
262
|
+
return self._get_encryptor().encrypt(payload).decode("ascii")
|
|
263
|
+
|
|
264
|
+
def create_user_session(
|
|
265
|
+
self,
|
|
266
|
+
user_id: str,
|
|
267
|
+
session_generation: int,
|
|
268
|
+
*,
|
|
269
|
+
password_verified: bool,
|
|
270
|
+
totp_verified: bool,
|
|
271
|
+
ttl_seconds: int,
|
|
272
|
+
auth_method: str = AUTH_METHOD_PASSWORD,
|
|
273
|
+
step_up_verified_at: int | None = None,
|
|
274
|
+
break_glass: bool = False,
|
|
275
|
+
) -> str:
|
|
276
|
+
now = int(time())
|
|
277
|
+
payload: dict[str, object] = {
|
|
278
|
+
"v": SESSION_PAYLOAD_VERSION,
|
|
279
|
+
"exp": now + ttl_seconds,
|
|
280
|
+
"iat": now,
|
|
281
|
+
"uid": user_id,
|
|
282
|
+
"sg": session_generation,
|
|
283
|
+
"pv": password_verified,
|
|
284
|
+
"tp": totp_verified,
|
|
285
|
+
"am": auth_method,
|
|
286
|
+
}
|
|
287
|
+
if step_up_verified_at is not None:
|
|
288
|
+
payload["su"] = step_up_verified_at
|
|
289
|
+
if break_glass:
|
|
290
|
+
payload["bg"] = True
|
|
291
|
+
return self._seal(payload)
|
|
292
|
+
|
|
293
|
+
def create_guest_session(self, *, ttl_seconds: int, guest_session_generation: int) -> str:
|
|
294
|
+
now = int(time())
|
|
295
|
+
return self._seal(
|
|
296
|
+
{
|
|
297
|
+
"v": SESSION_PAYLOAD_VERSION,
|
|
298
|
+
"exp": now + ttl_seconds,
|
|
299
|
+
"iat": now,
|
|
300
|
+
"guest": True,
|
|
301
|
+
"gg": guest_session_generation,
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def get(self, session_id: str | None) -> DashboardSessionState | None:
|
|
306
|
+
if not session_id:
|
|
307
|
+
return None
|
|
308
|
+
token = session_id.strip()
|
|
309
|
+
if not token:
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
raw = self._get_encryptor().decrypt(token.encode("ascii"))
|
|
313
|
+
except Exception:
|
|
314
|
+
return None
|
|
315
|
+
try:
|
|
316
|
+
data = json.loads(raw)
|
|
317
|
+
except Exception:
|
|
318
|
+
return None
|
|
319
|
+
if not isinstance(data, dict) or data.get("v") != SESSION_PAYLOAD_VERSION:
|
|
320
|
+
return None
|
|
321
|
+
exp = _as_int(data.get("exp"))
|
|
322
|
+
iat = _as_int(data.get("iat"))
|
|
323
|
+
if exp is None or iat is None or exp < int(time()):
|
|
324
|
+
return None
|
|
325
|
+
if data.get("guest") is True:
|
|
326
|
+
gg = _as_int(data.get("gg"))
|
|
327
|
+
if gg is None:
|
|
328
|
+
return None
|
|
329
|
+
return DashboardSessionState(expires_at=exp, issued_at=iat, kind="guest", guest_session_generation=gg)
|
|
330
|
+
uid = data.get("uid")
|
|
331
|
+
sg = _as_int(data.get("sg"))
|
|
332
|
+
pv = data.get("pv")
|
|
333
|
+
tp = data.get("tp")
|
|
334
|
+
am = data.get("am")
|
|
335
|
+
if not isinstance(uid, str) or not uid or sg is None:
|
|
336
|
+
return None
|
|
337
|
+
if not isinstance(pv, bool) or not isinstance(tp, bool) or not isinstance(am, str):
|
|
338
|
+
return None
|
|
339
|
+
return DashboardSessionState(
|
|
340
|
+
expires_at=exp,
|
|
341
|
+
issued_at=iat,
|
|
342
|
+
kind="user",
|
|
343
|
+
user_id=uid,
|
|
344
|
+
session_generation=sg,
|
|
345
|
+
password_verified=pv,
|
|
346
|
+
totp_verified=tp,
|
|
347
|
+
auth_method=am,
|
|
348
|
+
step_up_verified_at=_as_int(data.get("su")),
|
|
349
|
+
break_glass=data.get("bg") is True,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def delete(self, session_id: str | None) -> None:
|
|
353
|
+
# Stateless: deletion is handled by clearing the cookie client-side.
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def session_clock() -> int:
|
|
358
|
+
"""The clock sessions and step-ups are minted and checked against (one clock, one truth)."""
|
|
359
|
+
|
|
360
|
+
return int(time())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def is_local_password_session(state: DashboardSessionState) -> bool:
|
|
364
|
+
"""Whether this cookie stands for *a local password*, which is what ``local_login_policy`` governs.
|
|
365
|
+
|
|
366
|
+
``password_verified`` is the flag that admits a cookie at all, so every
|
|
367
|
+
account session sets it -- including one minted by the identity provider.
|
|
368
|
+
The local login policy is about the door that does not depend on that
|
|
369
|
+
provider, so it keys on the method too: without this an OIDC session would
|
|
370
|
+
count as the local fallback and single sign-on would silently defeat
|
|
371
|
+
``admins_only`` and ``break_glass_only``, the two policies that exist to
|
|
372
|
+
close the local door once single sign-on is on. One function, so the gate
|
|
373
|
+
and the session response that advertises the fallback cannot disagree.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
return state.password_verified and state.auth_method == AUTH_METHOD_PASSWORD
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class StepUpCookieStore:
|
|
380
|
+
"""The ``codex_lb_step_up`` cookie: a step-up for principals without a session cookie.
|
|
381
|
+
|
|
382
|
+
Trusted-header accounts prove who they are on every request through the
|
|
383
|
+
proxy header and carry no session cookie, so their re-verification rides
|
|
384
|
+
in this separate short-lived cookie (``{v, uid, sg, su, exp}``). It is
|
|
385
|
+
only honoured for the account it was minted for, and only while that
|
|
386
|
+
account's ``session_generation`` is unchanged: resetting its TOTP or
|
|
387
|
+
revoking its sessions must void the proof, not leave it usable for the
|
|
388
|
+
rest of the window.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
PAYLOAD_VERSION = 1
|
|
392
|
+
|
|
393
|
+
def __init__(self) -> None:
|
|
394
|
+
self._encryptor: TokenEncryptor | None = None
|
|
395
|
+
|
|
396
|
+
def _get_encryptor(self) -> TokenEncryptor:
|
|
397
|
+
if self._encryptor is None:
|
|
398
|
+
self._encryptor = TokenEncryptor()
|
|
399
|
+
return self._encryptor
|
|
400
|
+
|
|
401
|
+
def create(self, user_id: str, *, session_generation: int, verified_at: int) -> str:
|
|
402
|
+
payload = {
|
|
403
|
+
"v": self.PAYLOAD_VERSION,
|
|
404
|
+
"uid": user_id,
|
|
405
|
+
"sg": session_generation,
|
|
406
|
+
"su": verified_at,
|
|
407
|
+
"exp": step_up_expires_at(verified_at),
|
|
408
|
+
}
|
|
409
|
+
return self._get_encryptor().encrypt(json.dumps(payload, separators=(",", ":"))).decode("ascii")
|
|
410
|
+
|
|
411
|
+
def get(self, token: str | None, *, user_id: str, session_generation: int) -> int | None:
|
|
412
|
+
"""The verification time the cookie records for this account, or ``None``.
|
|
413
|
+
|
|
414
|
+
A generation bump (TOTP reset, sessions revoked) voids the proof.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
if not token or not token.strip():
|
|
418
|
+
return None
|
|
419
|
+
try:
|
|
420
|
+
data = json.loads(self._get_encryptor().decrypt(token.strip().encode("ascii")))
|
|
421
|
+
except Exception:
|
|
422
|
+
return None
|
|
423
|
+
if not isinstance(data, dict) or data.get("v") != self.PAYLOAD_VERSION or data.get("uid") != user_id:
|
|
424
|
+
return None
|
|
425
|
+
if _as_int(data.get("sg")) != session_generation:
|
|
426
|
+
return None
|
|
427
|
+
verified_at, exp = _as_int(data.get("su")), _as_int(data.get("exp"))
|
|
428
|
+
if verified_at is None or exp is None or exp < session_clock():
|
|
429
|
+
return None
|
|
430
|
+
return verified_at
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(slots=True, frozen=True)
|
|
434
|
+
class ResolvedUserSession:
|
|
435
|
+
"""A decoded user cookie whose account is still active and whose generation still matches."""
|
|
436
|
+
|
|
437
|
+
user: DashboardUser
|
|
438
|
+
state: DashboardSessionState
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@dataclass(slots=True, frozen=True)
|
|
442
|
+
class SessionDescription:
|
|
443
|
+
"""The session response together with the account it was resolved for (reused by the API layer)."""
|
|
444
|
+
|
|
445
|
+
response: DashboardAuthSessionResponse
|
|
446
|
+
resolved: ResolvedUserSession | None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
AuthStateProvider = Callable[[], Awaitable[LocalAuthState]]
|
|
450
|
+
ActiveProvidersProvider = Callable[[], Awaitable[list[ActiveProvider]]]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@dataclass(slots=True, frozen=True)
|
|
454
|
+
class GuestVerification:
|
|
455
|
+
"""Outcome of a guest credential check plus the generation of the very row it was checked against.
|
|
456
|
+
|
|
457
|
+
The cookie must be stamped with this generation: re-reading settings after
|
|
458
|
+
the check could pick up a bump (guest password just enabled) and mint a
|
|
459
|
+
cookie that outlives the credential it never satisfied.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
password_verified: bool
|
|
463
|
+
guest_session_generation: int
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass(slots=True, frozen=True)
|
|
467
|
+
class LoginTarget:
|
|
468
|
+
"""Who a password login attempt is for, before the password is checked.
|
|
469
|
+
|
|
470
|
+
``username`` is the normalized submitted (or resolved) username and is
|
|
471
|
+
``None`` only when no username could be resolved; ``user`` is ``None``
|
|
472
|
+
when no active account with that username exists. Callers must not leak
|
|
473
|
+
the difference to the client.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
username: str | None
|
|
477
|
+
user: DashboardUser | None
|
|
478
|
+
#: Why no account was resolved (audit ``login_failed.reason``); ``None`` when ``user`` is set.
|
|
479
|
+
refusal_reason: str | None = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _user_is_active(user: DashboardUser | None) -> bool:
|
|
483
|
+
return user is not None and user.status == DashboardUserStatus.ACTIVE.value
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _user_actor(user: DashboardUser, auth_method: str) -> AuditActor:
|
|
487
|
+
return AuditActor(user_id=user.id, username=user.username, role_slug=user.role.slug, auth_method=auth_method)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _user_target(user: DashboardUser) -> AuditTarget:
|
|
491
|
+
return AuditTarget("user", user.id)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
_GUEST_ACTOR = AuditActor(
|
|
495
|
+
user_id=None,
|
|
496
|
+
username=None,
|
|
497
|
+
role_slug=PresetRoleSlug.GUEST.value,
|
|
498
|
+
auth_method=AuditAuthMethod.GUEST.value,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def log_login_failed(actor_ip: str | None, method: str, reason: str, *, username: str | None = None) -> None:
|
|
503
|
+
"""Audit a refused sign-in: no actor (nobody authenticated), warning severity, a fixed reason."""
|
|
504
|
+
|
|
505
|
+
AuditService.log_async(
|
|
506
|
+
"login_failed",
|
|
507
|
+
actor_ip=actor_ip,
|
|
508
|
+
details={"method": method, "username": username, "reason": reason},
|
|
509
|
+
severity=AuditSeverity.WARNING,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def role_summary(user: DashboardUser) -> DashboardUserRoleSummary:
|
|
514
|
+
return DashboardUserRoleSummary(id=user.role.id, slug=user.role.slug, name=user.role.name, kind=user.role.kind)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def session_user(user: DashboardUser) -> DashboardSessionUser:
|
|
518
|
+
return DashboardSessionUser(
|
|
519
|
+
id=user.id,
|
|
520
|
+
username=user.username,
|
|
521
|
+
display_name=user.display_name,
|
|
522
|
+
role=role_summary(user),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class DashboardAuthService:
|
|
527
|
+
def __init__(
|
|
528
|
+
self,
|
|
529
|
+
repository: DashboardAuthRepositoryProtocol,
|
|
530
|
+
session_store: DashboardSessionStore,
|
|
531
|
+
*,
|
|
532
|
+
auth_state_provider: AuthStateProvider | None = None,
|
|
533
|
+
active_providers_provider: ActiveProvidersProvider | None = None,
|
|
534
|
+
) -> None:
|
|
535
|
+
self._repository = repository
|
|
536
|
+
self._session_store = session_store
|
|
537
|
+
self._encryptor = TokenEncryptor()
|
|
538
|
+
# The session response is served on every page load; it reads the derived
|
|
539
|
+
# auth state and the active providers through the process caches. Login
|
|
540
|
+
# and setup decisions keep reading the repository directly.
|
|
541
|
+
self._auth_state = auth_state_provider or _cached_local_auth_state
|
|
542
|
+
self._active_providers = active_providers_provider or _cached_active_providers
|
|
543
|
+
|
|
544
|
+
# --- session resolution ---
|
|
545
|
+
|
|
546
|
+
async def resolve_user_session(self, session_id: str | None) -> ResolvedUserSession | None:
|
|
547
|
+
state = self._session_store.get(session_id)
|
|
548
|
+
return await self._resolve_state(state)
|
|
549
|
+
|
|
550
|
+
async def _resolve_state(self, state: DashboardSessionState | None) -> ResolvedUserSession | None:
|
|
551
|
+
if state is None or not state.is_user or state.user_id is None:
|
|
552
|
+
return None
|
|
553
|
+
user = await self._repository.get_user_by_id(state.user_id)
|
|
554
|
+
if not _user_is_active(user) or user is None:
|
|
555
|
+
return None
|
|
556
|
+
if user.session_generation != state.session_generation:
|
|
557
|
+
return None
|
|
558
|
+
return ResolvedUserSession(user=user, state=state)
|
|
559
|
+
|
|
560
|
+
async def require_password_session(self, session_id: str | None) -> ResolvedUserSession:
|
|
561
|
+
resolved = await self.resolve_user_session(session_id)
|
|
562
|
+
if resolved is None or not resolved.state.password_verified:
|
|
563
|
+
raise PasswordSessionRequiredError("Password-authenticated session is required")
|
|
564
|
+
return resolved
|
|
565
|
+
|
|
566
|
+
async def require_management_session(
|
|
567
|
+
self, session_id: str | None, *, allow_unenrolled: bool = False
|
|
568
|
+
) -> ResolvedUserSession:
|
|
569
|
+
"""A password session that has also passed TOTP when the install requires it.
|
|
570
|
+
|
|
571
|
+
With the TOTP policy on, an account that has no secret yet is refused
|
|
572
|
+
(``TotpEnrollmentRequiredError``) unless ``allow_unenrolled`` marks the
|
|
573
|
+
caller as one of the self-service routes an unenrolled account may use.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
resolved = await self.require_password_session(session_id)
|
|
577
|
+
if await self._totp_required_for(resolved.user):
|
|
578
|
+
if resolved.user.totp_secret_encrypted is None:
|
|
579
|
+
if not allow_unenrolled:
|
|
580
|
+
raise TotpEnrollmentRequiredError("TOTP enrollment is required before dashboard access")
|
|
581
|
+
elif not resolved.state.totp_verified:
|
|
582
|
+
raise TotpVerificationRequiredError("TOTP verification is required for dashboard access")
|
|
583
|
+
return resolved
|
|
584
|
+
|
|
585
|
+
async def _totp_required_for(self, user: DashboardUser) -> bool:
|
|
586
|
+
"""The TOTP policy as it binds ``user``.
|
|
587
|
+
|
|
588
|
+
The global toggle, the admin-role toggle for admin-level roles, and --
|
|
589
|
+
whatever either says -- an emergency account that holds a secret.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
settings = await self._repository.get_settings()
|
|
593
|
+
return totp_policy_applies(
|
|
594
|
+
required_on_login=settings.totp_required_on_login,
|
|
595
|
+
required_for_admin_role=settings.totp_required_for_admin_role,
|
|
596
|
+
grants=resolve_role_grants(user.role),
|
|
597
|
+
) or break_glass_second_factor_required(user)
|
|
598
|
+
|
|
599
|
+
async def _require_totp_verified_session(self, session_id: str | None) -> ResolvedUserSession:
|
|
600
|
+
resolved = await self.require_password_session(session_id)
|
|
601
|
+
if resolved.user.totp_secret_encrypted is None and await self._totp_required_for(resolved.user):
|
|
602
|
+
raise TotpEnrollmentRequiredError("TOTP enrollment is required before dashboard access")
|
|
603
|
+
if not resolved.state.totp_verified:
|
|
604
|
+
raise PasswordSessionRequiredError("TOTP-verified session is required")
|
|
605
|
+
return resolved
|
|
606
|
+
|
|
607
|
+
async def ensure_active_password_session(self, session_id: str | None) -> None:
|
|
608
|
+
await self.require_password_session(session_id)
|
|
609
|
+
|
|
610
|
+
async def ensure_totp_verified_session(self, session_id: str | None) -> None:
|
|
611
|
+
await self._require_totp_verified_session(session_id)
|
|
612
|
+
|
|
613
|
+
# --- session response ---
|
|
614
|
+
|
|
615
|
+
async def get_session_state(self, session_id: str | None) -> DashboardAuthSessionResponse:
|
|
616
|
+
return (await self.describe_session(session_id)).response
|
|
617
|
+
|
|
618
|
+
async def describe_session(self, session_id: str | None) -> SessionDescription:
|
|
619
|
+
settings = await self._repository.get_settings()
|
|
620
|
+
auth_state = await self._auth_state()
|
|
621
|
+
password_required = auth_state.requires_auth
|
|
622
|
+
guest_access_enabled = settings.guest_access_enabled
|
|
623
|
+
guest_password_required = guest_access_enabled and settings.guest_password_hash is not None
|
|
624
|
+
state = self._session_store.get(session_id) if password_required or guest_access_enabled else None
|
|
625
|
+
resolved = await self._resolve_state(state)
|
|
626
|
+
public_guest_authenticated = bool(
|
|
627
|
+
guest_access_enabled
|
|
628
|
+
and not guest_password_required
|
|
629
|
+
and password_required
|
|
630
|
+
and get_settings().dashboard_auth_mode == DashboardAuthMode.STANDARD
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
user: DashboardUser | None = None
|
|
634
|
+
grants: Grants
|
|
635
|
+
step_up: DashboardStepUpState | None = None
|
|
636
|
+
totp_configured = False
|
|
637
|
+
totp_pending = False
|
|
638
|
+
totp_enrollment_required = False
|
|
639
|
+
auth_method: str | None = None
|
|
640
|
+
if (
|
|
641
|
+
state is not None
|
|
642
|
+
and state.is_guest
|
|
643
|
+
and guest_access_enabled
|
|
644
|
+
and state.guest_session_generation == settings.guest_session_generation
|
|
645
|
+
):
|
|
646
|
+
authenticated = True
|
|
647
|
+
role = DashboardRole.GUEST
|
|
648
|
+
grants = GUEST_GRANTS
|
|
649
|
+
elif resolved is not None and resolved.state.password_verified:
|
|
650
|
+
user = resolved.user
|
|
651
|
+
grants = resolve_role_grants(user.role)
|
|
652
|
+
totp_policy = totp_policy_applies(
|
|
653
|
+
required_on_login=settings.totp_required_on_login,
|
|
654
|
+
required_for_admin_role=settings.totp_required_for_admin_role,
|
|
655
|
+
grants=grants,
|
|
656
|
+
) or break_glass_second_factor_required(user)
|
|
657
|
+
totp_configured = user.totp_secret_encrypted is not None
|
|
658
|
+
totp_pending = totp_policy and totp_configured and not resolved.state.totp_verified
|
|
659
|
+
totp_enrollment_required = totp_policy and not totp_configured
|
|
660
|
+
authenticated = not totp_pending
|
|
661
|
+
role = DashboardRole.ADMIN
|
|
662
|
+
auth_method = resolved.state.auth_method
|
|
663
|
+
step_up = await step_up_state(user, verified_at=resolved.state.step_up_verified_at)
|
|
664
|
+
elif not password_required:
|
|
665
|
+
authenticated = True
|
|
666
|
+
role = DashboardRole.ADMIN
|
|
667
|
+
grants = ADMIN_GRANTS
|
|
668
|
+
elif public_guest_authenticated:
|
|
669
|
+
authenticated = True
|
|
670
|
+
role = DashboardRole.GUEST
|
|
671
|
+
grants = GUEST_GRANTS
|
|
672
|
+
else:
|
|
673
|
+
authenticated = False
|
|
674
|
+
role = DashboardRole.ADMIN
|
|
675
|
+
grants = ADMIN_GRANTS
|
|
676
|
+
|
|
677
|
+
# Team facts and assignable roles are for signed-in accounts holding
|
|
678
|
+
# users:manage only; the implicit admin has no account and gets none.
|
|
679
|
+
manages_users = bool(
|
|
680
|
+
authenticated and user is not None and not totp_enrollment_required and Permission.USERS_MANAGE in grants
|
|
681
|
+
)
|
|
682
|
+
response = DashboardAuthSessionResponse(
|
|
683
|
+
authenticated=authenticated,
|
|
684
|
+
password_required=password_required,
|
|
685
|
+
local_password_configured=auth_state.active_local_password_users > 0,
|
|
686
|
+
totp_required_on_login=totp_pending,
|
|
687
|
+
totp_configured=totp_configured,
|
|
688
|
+
role=role,
|
|
689
|
+
permissions=permission_strings(grants),
|
|
690
|
+
guest_access_enabled=guest_access_enabled,
|
|
691
|
+
guest_password_required=guest_password_required,
|
|
692
|
+
user=session_user(user) if user is not None else None,
|
|
693
|
+
auth_method=auth_method,
|
|
694
|
+
must_change_password=bool(user is not None and user.must_change_password),
|
|
695
|
+
totp_enrollment_required=totp_enrollment_required,
|
|
696
|
+
login=await self.login_hint(auth_state, settings.local_login_policy),
|
|
697
|
+
access_summary=await self.access_summary() if manages_users else None,
|
|
698
|
+
assignable_role_ids=assignable_role_ids() if manages_users else [],
|
|
699
|
+
step_up=step_up,
|
|
700
|
+
break_glass_session=bool(resolved is not None and resolved.state.break_glass and user is not None),
|
|
701
|
+
)
|
|
702
|
+
return SessionDescription(response=response, resolved=resolved)
|
|
703
|
+
|
|
704
|
+
async def login_hint(self, auth_state: LocalAuthState, policy: str) -> DashboardLoginHint:
|
|
705
|
+
"""The login screen's facts, served to unauthenticated clients too — never a username."""
|
|
706
|
+
|
|
707
|
+
return DashboardLoginHint(
|
|
708
|
+
username_field="hidden" if auth_state.active_local_password_users == 1 else "shown",
|
|
709
|
+
providers=[
|
|
710
|
+
DashboardLoginProvider(
|
|
711
|
+
# The registry only activates kinds with an implementation, which are the wire kinds.
|
|
712
|
+
kind=cast(LoginProviderKind, item.row.kind),
|
|
713
|
+
provider_key=item.row.provider_key,
|
|
714
|
+
label=item.row.label,
|
|
715
|
+
# A redirect-style provider names where its sign-in starts;
|
|
716
|
+
# the others have no URL of their own. The client that does
|
|
717
|
+
# not know the kind simply ignores the entry.
|
|
718
|
+
login_url=OIDC_LOGIN_START_PATH if item.row.kind == AuthProviderKind.OIDC.value else None,
|
|
719
|
+
)
|
|
720
|
+
for item in await self._active_providers()
|
|
721
|
+
],
|
|
722
|
+
local_login=cast(LocalLoginPolicyValue, policy),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
async def access_summary(self) -> DashboardAccessSummary:
|
|
726
|
+
counts = await self._repository.get_user_counts()
|
|
727
|
+
return DashboardAccessSummary(
|
|
728
|
+
users_total=counts.total,
|
|
729
|
+
users_active=counts.active,
|
|
730
|
+
users_invited=counts.invited,
|
|
731
|
+
users_disabled=counts.disabled,
|
|
732
|
+
pending_invites=counts.pending_invites,
|
|
733
|
+
non_admin_users=counts.non_admin,
|
|
734
|
+
custom_roles=await self._repository.count_custom_roles(),
|
|
735
|
+
providers_enabled=[item.row.kind for item in await self._active_providers()],
|
|
736
|
+
role_mappings=await self._repository.count_role_mappings(),
|
|
737
|
+
scim_tokens=await self._repository.count_scim_tokens(),
|
|
738
|
+
audit_sinks=0,
|
|
739
|
+
local_login_policy=cast(LocalLoginPolicyValue, (await self._repository.get_settings()).local_login_policy),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
async def me(self, session_id: str | None) -> DashboardMeResponse:
|
|
743
|
+
resolved = await self.require_management_session(session_id, allow_unenrolled=True)
|
|
744
|
+
user = resolved.user
|
|
745
|
+
return DashboardMeResponse(
|
|
746
|
+
id=user.id,
|
|
747
|
+
username=user.username,
|
|
748
|
+
display_name=user.display_name,
|
|
749
|
+
email=user.email,
|
|
750
|
+
role=role_summary(user),
|
|
751
|
+
auth_method=resolved.state.auth_method,
|
|
752
|
+
totp_configured=user.totp_secret_encrypted is not None,
|
|
753
|
+
must_change_password=user.must_change_password,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# --- password ---
|
|
757
|
+
|
|
758
|
+
async def setup_password(self, password: str) -> DashboardUser:
|
|
759
|
+
user = await self._repository.create_first_admin(hash_password(password))
|
|
760
|
+
if user is None:
|
|
761
|
+
raise PasswordAlreadyConfiguredError("Password is already configured")
|
|
762
|
+
return user
|
|
763
|
+
|
|
764
|
+
async def resolve_login_target(self, username: str | None) -> LoginTarget:
|
|
765
|
+
"""Pick the account a login attempt is for.
|
|
766
|
+
|
|
767
|
+
A single-user install accepts a login without a username. With more
|
|
768
|
+
than one active local password user the username is mandatory
|
|
769
|
+
(``UsernameRequiredError``); that refusal must not spend any rate-limit
|
|
770
|
+
budget, and the route decides whether to audit it. Why a username did
|
|
771
|
+
not resolve is kept on the target for the audit row only; the client
|
|
772
|
+
never learns it -- including when ``local_login_policy`` is what
|
|
773
|
+
refused the account.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
auth_state = await self._repository.get_local_auth_state()
|
|
777
|
+
if auth_state.active_local_password_users == 0:
|
|
778
|
+
raise PasswordNotConfiguredError("Password is not configured")
|
|
779
|
+
policy = (await self._repository.get_settings()).local_login_policy
|
|
780
|
+
if username is None:
|
|
781
|
+
if auth_state.sole_local_password_user_id is None:
|
|
782
|
+
raise UsernameRequiredError("Username is required")
|
|
783
|
+
user = await self._repository.get_user_by_id(auth_state.sole_local_password_user_id)
|
|
784
|
+
if user is not None and not local_login_admits(user, policy):
|
|
785
|
+
return LoginTarget(username=user.username, user=None, refusal_reason="login_policy")
|
|
786
|
+
return LoginTarget(username=user.username if user is not None else None, user=user)
|
|
787
|
+
normalized = normalize_username(username)
|
|
788
|
+
if not is_valid_username(normalized):
|
|
789
|
+
return LoginTarget(username=normalized, user=None, refusal_reason="invalid_username")
|
|
790
|
+
user = await self._repository.get_user_by_username(normalized)
|
|
791
|
+
if user is None:
|
|
792
|
+
return LoginTarget(username=normalized, user=None, refusal_reason="unknown_identity")
|
|
793
|
+
if not _user_is_active(user):
|
|
794
|
+
return LoginTarget(username=normalized, user=None, refusal_reason="disabled_user")
|
|
795
|
+
# A policy refusal drops the account exactly like an unknown username
|
|
796
|
+
# does: ``verify_user_password`` still runs its one hash comparison, so
|
|
797
|
+
# the answer, its body and its timing are the same.
|
|
798
|
+
if not local_login_admits(user, policy):
|
|
799
|
+
return LoginTarget(username=normalized, user=None, refusal_reason="login_policy")
|
|
800
|
+
return LoginTarget(username=normalized, user=user)
|
|
801
|
+
|
|
802
|
+
async def verify_user_password(
|
|
803
|
+
self,
|
|
804
|
+
target: LoginTarget,
|
|
805
|
+
password: str,
|
|
806
|
+
*,
|
|
807
|
+
actor_ip: str | None = None,
|
|
808
|
+
) -> DashboardUser:
|
|
809
|
+
"""Check ``password`` for ``target``; unknown accounts take the same path and time.
|
|
810
|
+
|
|
811
|
+
Exactly one bcrypt verification runs whether or not the account exists
|
|
812
|
+
or holds a password (a memoised dummy hash stands in), so response
|
|
813
|
+
timing cannot reveal which usernames are real. The audit record only
|
|
814
|
+
keeps well-formed usernames.
|
|
815
|
+
"""
|
|
816
|
+
|
|
817
|
+
user = target.user
|
|
818
|
+
stored_hash = user.password_hash if user is not None else None
|
|
819
|
+
matched = _check_password(password, stored_hash if stored_hash is not None else _dummy_password_hash())
|
|
820
|
+
if user is None or stored_hash is None or not matched:
|
|
821
|
+
username_is_valid = target.username is not None and is_valid_username(target.username)
|
|
822
|
+
log_login_failed(
|
|
823
|
+
actor_ip,
|
|
824
|
+
AUTH_METHOD_PASSWORD,
|
|
825
|
+
target.refusal_reason or "bad_password",
|
|
826
|
+
username=target.username if username_is_valid else None,
|
|
827
|
+
)
|
|
828
|
+
raise InvalidCredentialsError("Invalid credentials")
|
|
829
|
+
await self._repository.touch_last_login(user.id)
|
|
830
|
+
if user.totp_secret_encrypted is None or not await self._totp_required_for(user):
|
|
831
|
+
AuditService.log_async(
|
|
832
|
+
"login_success",
|
|
833
|
+
actor_ip=actor_ip,
|
|
834
|
+
details={"method": AUTH_METHOD_PASSWORD, "username": user.username},
|
|
835
|
+
actor=_user_actor(user, AUTH_METHOD_PASSWORD),
|
|
836
|
+
target=_user_target(user),
|
|
837
|
+
)
|
|
838
|
+
return user
|
|
839
|
+
|
|
840
|
+
async def verify_password(self, password: str, *, actor_ip: str | None = None) -> DashboardUser:
|
|
841
|
+
"""Single-user convenience used by tests and callers without a username."""
|
|
842
|
+
|
|
843
|
+
return await self.verify_user_password(await self.resolve_login_target(None), password, actor_ip=actor_ip)
|
|
844
|
+
|
|
845
|
+
async def verify_guest_password(self, password: str | None, *, actor_ip: str | None = None) -> GuestVerification:
|
|
846
|
+
"""Check the guest credential against one settings row read from the database.
|
|
847
|
+
|
|
848
|
+
The returned generation belongs to that same row; callers stamp it into
|
|
849
|
+
the cookie instead of reading settings again.
|
|
850
|
+
"""
|
|
851
|
+
|
|
852
|
+
settings = await self._repository.get_settings()
|
|
853
|
+
if not settings.guest_access_enabled:
|
|
854
|
+
raise GuestAccessDisabledError("Guest access is disabled")
|
|
855
|
+
generation = settings.guest_session_generation
|
|
856
|
+
current = settings.guest_password_hash
|
|
857
|
+
if current is None:
|
|
858
|
+
AuditService.log_async("login_success", actor_ip=actor_ip, details={"method": "guest"}, actor=_GUEST_ACTOR)
|
|
859
|
+
return GuestVerification(password_verified=False, guest_session_generation=generation)
|
|
860
|
+
if password is None or not _check_password(password, current):
|
|
861
|
+
log_login_failed(actor_ip, AuditAuthMethod.GUEST.value, "bad_password")
|
|
862
|
+
raise InvalidCredentialsError("Invalid credentials")
|
|
863
|
+
AuditService.log_async("login_success", actor_ip=actor_ip, details={"method": "guest"}, actor=_GUEST_ACTOR)
|
|
864
|
+
return GuestVerification(password_verified=True, guest_session_generation=generation)
|
|
865
|
+
|
|
866
|
+
async def change_password(
|
|
867
|
+
self,
|
|
868
|
+
user: DashboardUser,
|
|
869
|
+
current_password: str,
|
|
870
|
+
new_password: str,
|
|
871
|
+
*,
|
|
872
|
+
actor_ip: str | None = None,
|
|
873
|
+
auth_method: str | None = None,
|
|
874
|
+
) -> int:
|
|
875
|
+
"""Rotate the password and revoke every other session; returns the new generation.
|
|
876
|
+
|
|
877
|
+
``auth_method`` is how the calling session authenticated (audit actor).
|
|
878
|
+
"""
|
|
879
|
+
|
|
880
|
+
if user.password_hash is None:
|
|
881
|
+
raise PasswordNotConfiguredError("Password is not configured")
|
|
882
|
+
if not _check_password(current_password, user.password_hash):
|
|
883
|
+
raise InvalidCredentialsError("Invalid credentials")
|
|
884
|
+
rotated = await self._repository.rotate_user_password(user.id, hash_password(new_password))
|
|
885
|
+
AuditService.log_async(
|
|
886
|
+
"password_changed",
|
|
887
|
+
actor_ip=actor_ip,
|
|
888
|
+
details={"username": user.username},
|
|
889
|
+
actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
|
|
890
|
+
target=_user_target(user),
|
|
891
|
+
)
|
|
892
|
+
return rotated.session_generation
|
|
893
|
+
|
|
894
|
+
async def remove_password(
|
|
895
|
+
self,
|
|
896
|
+
user: DashboardUser,
|
|
897
|
+
password: str,
|
|
898
|
+
*,
|
|
899
|
+
actor_ip: str | None = None,
|
|
900
|
+
auth_method: str | None = None,
|
|
901
|
+
) -> None:
|
|
902
|
+
"""Solo-install only: drop the user's credentials so the install is passwordless again.
|
|
903
|
+
|
|
904
|
+
"Solo" is every account the install holds, in any status -- not just
|
|
905
|
+
the active ones. A disabled account is still an account: it keeps its
|
|
906
|
+
role, its owned keys and (if it ever had one) its password hash, and
|
|
907
|
+
only an account with ``users:manage`` can bring it back. Removing the
|
|
908
|
+
last *active* password while one exists would hand the install to the
|
|
909
|
+
implicit local admin, which holds no account and therefore cannot
|
|
910
|
+
manage users at all, stranding the disabled row with no way to enable,
|
|
911
|
+
delete or sign in as it -- while the install-wide TOTP requirements
|
|
912
|
+
this write also clears were only ever justified by "this account *is*
|
|
913
|
+
the install". Deleting the other accounts first is the way through.
|
|
914
|
+
"""
|
|
915
|
+
|
|
916
|
+
if user.password_hash is None:
|
|
917
|
+
raise PasswordNotConfiguredError("Password is not configured")
|
|
918
|
+
if not _check_password(password, user.password_hash):
|
|
919
|
+
raise InvalidCredentialsError("Invalid credentials")
|
|
920
|
+
# Serialised with account mutations and invite acceptance: a pending
|
|
921
|
+
# invite would otherwise turn a passwordless install into one where
|
|
922
|
+
# authentication is mandatory again but no admin holds a password.
|
|
923
|
+
await self._repository.acquire_write_intent()
|
|
924
|
+
counts = await self._repository.get_user_counts()
|
|
925
|
+
solo_install = counts.total == 1 and counts.active == 1
|
|
926
|
+
identities = await self._repository.count_user_identities(user.id)
|
|
927
|
+
if not solo_install or identities or await self._repository.count_live_invites():
|
|
928
|
+
raise OtherUsersExistError(
|
|
929
|
+
"Other accounts exist or invites are pending; delete the other accounts "
|
|
930
|
+
"or revoke pending invites instead"
|
|
931
|
+
)
|
|
932
|
+
assert_credential_remains(password_hash=None, identity_count=identities, solo_install=solo_install)
|
|
933
|
+
# Dropping the password is one more way to lose the last qualifying
|
|
934
|
+
# emergency account: a restricted policy would survive the removal, and
|
|
935
|
+
# the re-bootstrapped ``admin`` is designated but never qualifying, so
|
|
936
|
+
# the next sign-in would meet a door nothing can open. Same guard, same
|
|
937
|
+
# refusal, still under the write intent taken above.
|
|
938
|
+
await self._assert_break_glass_remains(user, has_password=False, has_totp=False)
|
|
939
|
+
await self._repository.clear_user_credentials(user.id)
|
|
940
|
+
AuditService.log_async(
|
|
941
|
+
"password_removed",
|
|
942
|
+
actor_ip=actor_ip,
|
|
943
|
+
details={"username": user.username},
|
|
944
|
+
actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
|
|
945
|
+
target=_user_target(user),
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
async def revoke_user_sessions(
|
|
949
|
+
self,
|
|
950
|
+
user: DashboardUser,
|
|
951
|
+
*,
|
|
952
|
+
actor_ip: str | None = None,
|
|
953
|
+
auth_method: str | None = None,
|
|
954
|
+
) -> int:
|
|
955
|
+
generation = await self._repository.bump_session_generation(user.id)
|
|
956
|
+
AuditService.log_async(
|
|
957
|
+
"user_sessions_revoked",
|
|
958
|
+
actor_ip=actor_ip,
|
|
959
|
+
details={"username": user.username, "scope": "self"},
|
|
960
|
+
actor=_user_actor(user, auth_method or AUTH_METHOD_PASSWORD),
|
|
961
|
+
target=_user_target(user),
|
|
962
|
+
)
|
|
963
|
+
return generation
|
|
964
|
+
|
|
965
|
+
async def set_guest_password(self, password: str) -> None:
|
|
966
|
+
await self._repository.set_guest_password_hash(hash_password(password))
|
|
967
|
+
|
|
968
|
+
async def clear_guest_password(self) -> None:
|
|
969
|
+
await self._repository.clear_guest_password_hash()
|
|
970
|
+
|
|
971
|
+
async def revoke_guest_sessions(self) -> None:
|
|
972
|
+
"""Invalidate every outstanding guest session cookie."""
|
|
973
|
+
|
|
974
|
+
await self._repository.bump_guest_session_generation()
|
|
975
|
+
|
|
976
|
+
# --- TOTP (per user) ---
|
|
977
|
+
|
|
978
|
+
async def _account_for_totp(
|
|
979
|
+
self, session_id: str | None, resolved: ResolvedUserSession | None
|
|
980
|
+
) -> ResolvedUserSession:
|
|
981
|
+
"""The account a TOTP route acts on: the password session, or the account the route already resolved.
|
|
982
|
+
|
|
983
|
+
Routes pass ``resolved`` for a trusted-header account, which has no
|
|
984
|
+
session cookie but may still enrol TOTP (it is its only step-up method).
|
|
985
|
+
"""
|
|
986
|
+
|
|
987
|
+
return resolved if resolved is not None else await self.require_password_session(session_id)
|
|
988
|
+
|
|
989
|
+
async def start_totp_setup(
|
|
990
|
+
self, *, session_id: str | None, resolved: ResolvedUserSession | None = None
|
|
991
|
+
) -> TotpSetupStartResponse:
|
|
992
|
+
resolved = await self._account_for_totp(session_id, resolved)
|
|
993
|
+
if resolved.user.totp_secret_encrypted is not None:
|
|
994
|
+
raise TotpAlreadyConfiguredError("TOTP is already configured. Disable it before setting a new secret")
|
|
995
|
+
secret = generate_totp_secret()
|
|
996
|
+
otpauth_uri = build_otpauth_uri(secret, issuer=_TOTP_ISSUER, account_name=resolved.user.username)
|
|
997
|
+
return TotpSetupStartResponse(
|
|
998
|
+
secret=secret,
|
|
999
|
+
otpauth_uri=otpauth_uri,
|
|
1000
|
+
qr_svg_data_uri=_qr_svg_data_uri(otpauth_uri),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
async def confirm_totp_setup(
|
|
1004
|
+
self,
|
|
1005
|
+
*,
|
|
1006
|
+
session_id: str | None,
|
|
1007
|
+
secret: str,
|
|
1008
|
+
code: str,
|
|
1009
|
+
actor_ip: str | None = None,
|
|
1010
|
+
resolved: ResolvedUserSession | None = None,
|
|
1011
|
+
) -> None:
|
|
1012
|
+
resolved = await self._account_for_totp(session_id, resolved)
|
|
1013
|
+
if resolved.user.totp_secret_encrypted is not None:
|
|
1014
|
+
raise TotpAlreadyConfiguredError("TOTP is already configured. Disable it before setting a new secret")
|
|
1015
|
+
try:
|
|
1016
|
+
verification = verify_totp_code(secret, code, window=1)
|
|
1017
|
+
except ValueError as exc:
|
|
1018
|
+
raise TotpInvalidSetupError("Invalid TOTP setup payload") from exc
|
|
1019
|
+
if not verification.is_valid:
|
|
1020
|
+
raise TotpInvalidCodeError("Invalid TOTP code")
|
|
1021
|
+
await self._repository.set_user_totp_secret(resolved.user.id, self._encryptor.encrypt(secret))
|
|
1022
|
+
AuditService.log_async(
|
|
1023
|
+
"totp_enabled",
|
|
1024
|
+
actor_ip=actor_ip,
|
|
1025
|
+
details={"username": resolved.user.username},
|
|
1026
|
+
actor=_user_actor(resolved.user, resolved.state.auth_method or AUTH_METHOD_PASSWORD),
|
|
1027
|
+
target=_user_target(resolved.user),
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
async def verify_totp(
|
|
1031
|
+
self,
|
|
1032
|
+
*,
|
|
1033
|
+
session_id: str | None,
|
|
1034
|
+
code: str,
|
|
1035
|
+
ttl_seconds: int,
|
|
1036
|
+
actor_ip: str | None = None,
|
|
1037
|
+
) -> tuple[str, int]:
|
|
1038
|
+
resolved = await self.require_password_session(session_id)
|
|
1039
|
+
user, existing_state = resolved.user, resolved.state
|
|
1040
|
+
secret_encrypted = user.totp_secret_encrypted
|
|
1041
|
+
if secret_encrypted is None:
|
|
1042
|
+
raise TotpNotConfiguredError("TOTP is not configured")
|
|
1043
|
+
secret = self._encryptor.decrypt(secret_encrypted)
|
|
1044
|
+
verification = verify_totp_code(
|
|
1045
|
+
secret,
|
|
1046
|
+
code,
|
|
1047
|
+
window=1,
|
|
1048
|
+
last_verified_step=user.totp_last_verified_step,
|
|
1049
|
+
)
|
|
1050
|
+
# Snapshot the attribution before the counter write: a refused replay
|
|
1051
|
+
# rolls the session back, which expires ``user`` and would turn a later
|
|
1052
|
+
# attribute read into a lazy load outside the async context.
|
|
1053
|
+
username = user.username
|
|
1054
|
+
actor = _user_actor(user, AuditAuthMethod.TOTP.value)
|
|
1055
|
+
target = _user_target(user)
|
|
1056
|
+
if not verification.is_valid or verification.matched_step is None:
|
|
1057
|
+
log_login_failed(actor_ip, AuditAuthMethod.TOTP.value, "bad_totp", username=username)
|
|
1058
|
+
raise TotpInvalidCodeError("Invalid TOTP code")
|
|
1059
|
+
updated = await self._repository.try_advance_user_totp_step(user.id, verification.matched_step)
|
|
1060
|
+
if not updated:
|
|
1061
|
+
log_login_failed(actor_ip, AuditAuthMethod.TOTP.value, "bad_totp", username=username)
|
|
1062
|
+
raise TotpInvalidCodeError("Invalid TOTP code")
|
|
1063
|
+
AuditService.log_async(
|
|
1064
|
+
"login_success",
|
|
1065
|
+
actor_ip=actor_ip,
|
|
1066
|
+
details={"method": "totp", "username": username},
|
|
1067
|
+
actor=actor,
|
|
1068
|
+
target=target,
|
|
1069
|
+
)
|
|
1070
|
+
if user.is_break_glass:
|
|
1071
|
+
# The emergency door was used. This is the first (and, with the CLI,
|
|
1072
|
+
# one of two) critical-severity events the product writes: an
|
|
1073
|
+
# operator reading the audit log must never have to infer it.
|
|
1074
|
+
AuditService.log_async(
|
|
1075
|
+
"break_glass_login",
|
|
1076
|
+
actor_ip=actor_ip,
|
|
1077
|
+
details={"method": "totp", "username": username},
|
|
1078
|
+
actor=actor,
|
|
1079
|
+
target=target,
|
|
1080
|
+
severity=AuditSeverity.CRITICAL,
|
|
1081
|
+
)
|
|
1082
|
+
# Honor the existing password-session expiry so that a TTL change
|
|
1083
|
+
# mid-flow (between password login and TOTP submission) cannot extend
|
|
1084
|
+
# an already-issued session, while still applying the TTL cap resolved
|
|
1085
|
+
# for the current TOTP request. The state captured by
|
|
1086
|
+
# require_password_session is reused so a second store.get() race
|
|
1087
|
+
# cannot turn a near-expiry password session into a full-length one.
|
|
1088
|
+
now = int(time())
|
|
1089
|
+
inherited_ttl = max(1, existing_state.expires_at - now)
|
|
1090
|
+
applied_ttl = min(inherited_ttl, ttl_seconds)
|
|
1091
|
+
# Completing the second factor of a sign-in that just happened is a
|
|
1092
|
+
# step-up: both credentials were presented within the window. Verifying
|
|
1093
|
+
# against an old session is not — the password behind it was proven
|
|
1094
|
+
# long ago (and whoever holds the cookie could have enrolled the secret
|
|
1095
|
+
# themselves), so that path keeps whatever step-up the session already
|
|
1096
|
+
# carried and leaves /step-up to ask for the password.
|
|
1097
|
+
#
|
|
1098
|
+
# "A sign-in that just happened" means a *local password* sign-in. A
|
|
1099
|
+
# session minted by the identity provider is fresh too, and its
|
|
1100
|
+
# ``password_verified`` flag is set because that is the flag the gate
|
|
1101
|
+
# reads — but no password was presented to obtain it. Without the
|
|
1102
|
+
# method test, an account holding a password could sign in through
|
|
1103
|
+
# single sign-on, enrol a TOTP secret, verify a code and hold a step-up
|
|
1104
|
+
# covering every security mutation, having proven one of the two
|
|
1105
|
+
# factors it is required to present. The OIDC step-up flow is the path
|
|
1106
|
+
# for that account, and it refuses an account that holds a local factor.
|
|
1107
|
+
completing_fresh_login = is_local_password_session(existing_state) and is_step_up_fresh(
|
|
1108
|
+
existing_state.issued_at, now=now
|
|
1109
|
+
)
|
|
1110
|
+
step_up_verified_at = now if completing_fresh_login else existing_state.step_up_verified_at
|
|
1111
|
+
new_session_id = self._session_store.create_user_session(
|
|
1112
|
+
user.id,
|
|
1113
|
+
user.session_generation,
|
|
1114
|
+
password_verified=True,
|
|
1115
|
+
totp_verified=True,
|
|
1116
|
+
ttl_seconds=applied_ttl,
|
|
1117
|
+
auth_method=existing_state.auth_method or AUTH_METHOD_PASSWORD,
|
|
1118
|
+
step_up_verified_at=step_up_verified_at,
|
|
1119
|
+
break_glass=user.is_break_glass,
|
|
1120
|
+
)
|
|
1121
|
+
return new_session_id, applied_ttl
|
|
1122
|
+
|
|
1123
|
+
async def _consume_totp_code(self, user: DashboardUser, code: str) -> None:
|
|
1124
|
+
"""Check ``code`` against the account's secret and advance its replay counter, or raise."""
|
|
1125
|
+
|
|
1126
|
+
secret_encrypted = user.totp_secret_encrypted
|
|
1127
|
+
if secret_encrypted is None:
|
|
1128
|
+
raise TotpNotConfiguredError("TOTP is not configured")
|
|
1129
|
+
secret = self._encryptor.decrypt(secret_encrypted)
|
|
1130
|
+
verification = verify_totp_code(secret, code, window=1, last_verified_step=user.totp_last_verified_step)
|
|
1131
|
+
if not verification.is_valid or verification.matched_step is None:
|
|
1132
|
+
raise TotpInvalidCodeError("Invalid TOTP code")
|
|
1133
|
+
if not await self._repository.try_advance_user_totp_step(user.id, verification.matched_step):
|
|
1134
|
+
raise TotpInvalidCodeError("Invalid TOTP code")
|
|
1135
|
+
|
|
1136
|
+
async def disable_totp(
|
|
1137
|
+
self,
|
|
1138
|
+
*,
|
|
1139
|
+
session_id: str | None,
|
|
1140
|
+
code: str,
|
|
1141
|
+
actor_ip: str | None = None,
|
|
1142
|
+
resolved: ResolvedUserSession | None = None,
|
|
1143
|
+
) -> DashboardUser:
|
|
1144
|
+
"""Drop the account's secret after a valid code; returns the account.
|
|
1145
|
+
|
|
1146
|
+
A header account never carries a TOTP-verified cookie; its valid
|
|
1147
|
+
``code`` is the verification (a password session still needs ``tp``).
|
|
1148
|
+
"""
|
|
1149
|
+
|
|
1150
|
+
resolved = resolved if resolved is not None else await self._require_totp_verified_session(session_id)
|
|
1151
|
+
user = resolved.user
|
|
1152
|
+
# The code is consumed *first* because advancing the replay counter
|
|
1153
|
+
# commits, and a commit between the guard's count and the secret write
|
|
1154
|
+
# would drop the write-intent lock the guard took -- two concurrent
|
|
1155
|
+
# disables would then both pass the count and clear both secrets. The
|
|
1156
|
+
# cost is that a refused removal still spends the code the caller
|
|
1157
|
+
# typed; the guard is what must not be racy.
|
|
1158
|
+
await self._consume_totp_code(user, code)
|
|
1159
|
+
await self._assert_break_glass_remains(user, has_totp=False)
|
|
1160
|
+
await self._repository.set_user_totp_secret(user.id, None)
|
|
1161
|
+
AuditService.log_async(
|
|
1162
|
+
"totp_disabled",
|
|
1163
|
+
actor_ip=actor_ip,
|
|
1164
|
+
details={"username": user.username},
|
|
1165
|
+
actor=_user_actor(user, resolved.state.auth_method or AUTH_METHOD_PASSWORD),
|
|
1166
|
+
target=_user_target(user),
|
|
1167
|
+
)
|
|
1168
|
+
return user
|
|
1169
|
+
|
|
1170
|
+
async def _assert_break_glass_remains(
|
|
1171
|
+
self, user: DashboardUser, *, has_totp: bool | None = None, has_password: bool | None = None
|
|
1172
|
+
) -> None:
|
|
1173
|
+
"""The shared break-glass guard, reached from the self-service side.
|
|
1174
|
+
|
|
1175
|
+
Same function, same refusal as the management paths: an account that
|
|
1176
|
+
is the install's only qualifying emergency account cannot remove its
|
|
1177
|
+
own second factor (or its own password) while local sign-in is
|
|
1178
|
+
restricted.
|
|
1179
|
+
|
|
1180
|
+
The caller must not commit between this call and its write: the write
|
|
1181
|
+
intent taken here is what keeps two simultaneous removals from both
|
|
1182
|
+
passing the count, and a commit releases it.
|
|
1183
|
+
"""
|
|
1184
|
+
|
|
1185
|
+
# Serialised with the management paths *before the policy is read*: the
|
|
1186
|
+
# secret write has no conditional form, so the lock is what keeps two
|
|
1187
|
+
# simultaneous removals from both passing the count -- and a policy read
|
|
1188
|
+
# that happens outside it can be a tightening older than the guard, which
|
|
1189
|
+
# would let this removal take the last qualifying account away.
|
|
1190
|
+
await self._repository.acquire_write_intent()
|
|
1191
|
+
settings = await self._repository.get_settings()
|
|
1192
|
+
await assert_break_glass_remains(
|
|
1193
|
+
user,
|
|
1194
|
+
policy=settings.local_login_policy,
|
|
1195
|
+
count_other_qualifying=lambda: self._repository.count_qualifying_break_glass(exclude_user_id=user.id),
|
|
1196
|
+
has_totp=has_totp,
|
|
1197
|
+
has_password=has_password,
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
# --- step-up (per user) ---
|
|
1201
|
+
|
|
1202
|
+
async def verify_step_up(
|
|
1203
|
+
self,
|
|
1204
|
+
user: DashboardUser,
|
|
1205
|
+
*,
|
|
1206
|
+
password: str | None,
|
|
1207
|
+
code: str | None,
|
|
1208
|
+
actor_ip: str | None = None,
|
|
1209
|
+
auth_method: str | None = None,
|
|
1210
|
+
) -> list[StepUpMethod]:
|
|
1211
|
+
"""Re-verify ``user`` with every factor it holds; returns the methods that were checked.
|
|
1212
|
+
|
|
1213
|
+
Password accounts present the password (and a TOTP code when the account
|
|
1214
|
+
has a secret); provider-only accounts present a TOTP code. Every refusal
|
|
1215
|
+
is the same ``InvalidCredentialsError``. An account with no factor at all
|
|
1216
|
+
raises ``StepUpUnavailableError`` instead — the client is told to enrol.
|
|
1217
|
+
"""
|
|
1218
|
+
|
|
1219
|
+
methods = step_up_methods(user)
|
|
1220
|
+
if not methods:
|
|
1221
|
+
raise StepUpUnavailableError("The account holds no credential to re-verify")
|
|
1222
|
+
username = user.username
|
|
1223
|
+
actor, target = _user_actor(user, auth_method or AUTH_METHOD_PASSWORD), _user_target(user)
|
|
1224
|
+
if "password" in methods and (
|
|
1225
|
+
user.password_hash is None or not _check_password(password or "", user.password_hash)
|
|
1226
|
+
):
|
|
1227
|
+
log_login_failed(actor_ip, "step_up", "bad_password", username=username)
|
|
1228
|
+
raise InvalidCredentialsError("Invalid credentials")
|
|
1229
|
+
if "totp" in methods:
|
|
1230
|
+
try:
|
|
1231
|
+
await self._consume_totp_code(user, code or "")
|
|
1232
|
+
except (TotpInvalidCodeError, TotpNotConfiguredError) as exc:
|
|
1233
|
+
log_login_failed(actor_ip, "step_up", "bad_totp", username=username)
|
|
1234
|
+
raise InvalidCredentialsError("Invalid credentials") from exc
|
|
1235
|
+
AuditService.log_async(
|
|
1236
|
+
"step_up_verified",
|
|
1237
|
+
actor_ip=actor_ip,
|
|
1238
|
+
details={"username": username, "methods": list(methods)},
|
|
1239
|
+
actor=actor,
|
|
1240
|
+
target=target,
|
|
1241
|
+
)
|
|
1242
|
+
return methods
|
|
1243
|
+
|
|
1244
|
+
def logout(self, session_id: str | None) -> None:
|
|
1245
|
+
self._session_store.delete(session_id)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
async def step_up_state(user: DashboardUser, *, verified_at: int | None) -> DashboardStepUpState:
|
|
1249
|
+
"""The session response's ``step_up`` block: a still-fresh verification and the account's methods.
|
|
1250
|
+
|
|
1251
|
+
Async because the methods come from :func:`account_step_up_methods`: what
|
|
1252
|
+
the dashboard shows and what the gate accepts have to be the same list, and
|
|
1253
|
+
for an account whose only factor is its identity provider that list is not
|
|
1254
|
+
a property of the row alone.
|
|
1255
|
+
"""
|
|
1256
|
+
|
|
1257
|
+
fresh = verified_at if is_step_up_fresh(verified_at, now=session_clock()) else None
|
|
1258
|
+
return DashboardStepUpState(
|
|
1259
|
+
verified_at=fresh,
|
|
1260
|
+
expires_at=step_up_expires_at(fresh) if fresh is not None else None,
|
|
1261
|
+
methods=await account_step_up_methods(user),
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def assignable_role_ids() -> list[str]:
|
|
1266
|
+
return [PRESET_ROLE_IDS[slug] for slug in sorted(ASSIGNABLE_PRESET_ROLES, key=lambda slug: slug.value)]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
async def _cached_local_auth_state() -> LocalAuthState:
|
|
1270
|
+
return await get_dashboard_users_cache().local_auth_state()
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
async def _cached_active_providers() -> list[ActiveProvider]:
|
|
1274
|
+
return await get_auth_provider_registry().get_active_providers(get_settings().dashboard_auth_mode)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
_dashboard_session_store = DashboardSessionStore()
|
|
1278
|
+
_step_up_cookie_store = StepUpCookieStore()
|
|
1279
|
+
_totp_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="totp")
|
|
1280
|
+
_password_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="password")
|
|
1281
|
+
#: The ceiling on failed password logins from one address, whatever usernames
|
|
1282
|
+
#: they name. The per-(address, username) bucket above keeps one account's
|
|
1283
|
+
#: failures from barring another, which also means one address can mint a fresh
|
|
1284
|
+
#: bucket per username it invents; without a coarse address bucket the endpoint
|
|
1285
|
+
#: has no ceiling at all and every attempt costs a blocking password-hash
|
|
1286
|
+
#: comparison on the event loop that serves the proxy.
|
|
1287
|
+
#:
|
|
1288
|
+
#: 60/60 s is seven and a half times the per-account allowance: a NATed office
|
|
1289
|
+
#: whose people are all mistyping at once never reaches it (one sign-in form
|
|
1290
|
+
#: submission is one request), while a sprayer behind one address is held to a
|
|
1291
|
+
#: bounded number of hash comparisons per minute. It is deliberately *not* the
|
|
1292
|
+
#: per-account limit, so the 8/60 s semantics and the tests that pin them are
|
|
1293
|
+
#: untouched.
|
|
1294
|
+
_password_address_rate_limiter = DatabaseRateLimiter(max_attempts=60, window_seconds=60, type="password_address")
|
|
1295
|
+
_guest_password_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="guest_password")
|
|
1296
|
+
#: Bounds the anonymous ``login_failed`` rows a client can append from refusals
|
|
1297
|
+
#: that by design spend no password budget (``username_required``).
|
|
1298
|
+
_login_failed_audit_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="login_failed_audit")
|
|
1299
|
+
#: The ceiling on the two public OIDC routes, per client address. One sign-in
|
|
1300
|
+
#: is two requests (start, callback), so 30/60 s is generous for a person and
|
|
1301
|
+
#: bounded for a client replaying a captured callback URL. It is deliberately
|
|
1302
|
+
#: never cleared on success: it is the endpoints' only ceiling.
|
|
1303
|
+
_oidc_address_rate_limiter = DatabaseRateLimiter(max_attempts=30, window_seconds=60, type="oidc_address")
|
|
1304
|
+
#: The per-``state`` budget on the callback, keyed on the hash so the table
|
|
1305
|
+
#: never holds a live state value. Same shape as ``invite_accept_token``: a
|
|
1306
|
+
#: legitimate flow spends one, a replay of one URL spends the rest.
|
|
1307
|
+
_oidc_state_rate_limiter = DatabaseRateLimiter(max_attempts=5, window_seconds=60, type="oidc_callback_state")
|
|
1308
|
+
_invite_lookup_rate_limiter = DatabaseRateLimiter(max_attempts=30, window_seconds=60, type="invite_lookup")
|
|
1309
|
+
_invite_accept_rate_limiter = DatabaseRateLimiter(max_attempts=8, window_seconds=60, type="invite_accept")
|
|
1310
|
+
_invite_accept_token_rate_limiter = DatabaseRateLimiter(max_attempts=5, window_seconds=60, type="invite_accept_token")
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def get_dashboard_session_store() -> DashboardSessionStore:
|
|
1314
|
+
return _dashboard_session_store
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def get_step_up_cookie_store() -> StepUpCookieStore:
|
|
1318
|
+
return _step_up_cookie_store
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def get_totp_rate_limiter() -> DatabaseRateLimiter:
|
|
1322
|
+
return _totp_rate_limiter
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def get_password_rate_limiter() -> DatabaseRateLimiter:
|
|
1326
|
+
return _password_rate_limiter
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def get_password_address_rate_limiter() -> DatabaseRateLimiter:
|
|
1330
|
+
return _password_address_rate_limiter
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def get_guest_password_rate_limiter() -> DatabaseRateLimiter:
|
|
1334
|
+
return _guest_password_rate_limiter
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def get_login_failed_audit_rate_limiter() -> DatabaseRateLimiter:
|
|
1338
|
+
return _login_failed_audit_rate_limiter
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def get_oidc_address_rate_limiter() -> DatabaseRateLimiter:
|
|
1342
|
+
return _oidc_address_rate_limiter
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def get_oidc_state_rate_limiter() -> DatabaseRateLimiter:
|
|
1346
|
+
return _oidc_state_rate_limiter
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def get_invite_lookup_rate_limiter() -> DatabaseRateLimiter:
|
|
1350
|
+
return _invite_lookup_rate_limiter
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def get_invite_accept_rate_limiter() -> DatabaseRateLimiter:
|
|
1354
|
+
return _invite_accept_rate_limiter
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def get_invite_accept_token_rate_limiter() -> DatabaseRateLimiter:
|
|
1358
|
+
return _invite_accept_token_rate_limiter
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _qr_svg_data_uri(payload: str) -> str:
|
|
1362
|
+
qr = segno.make(payload)
|
|
1363
|
+
buffer = BytesIO()
|
|
1364
|
+
qr.save(buffer, kind="svg", xmldecl=False, scale=6, border=2)
|
|
1365
|
+
raw = buffer.getvalue()
|
|
1366
|
+
return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def hash_password(password: str) -> str:
|
|
1370
|
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
@lru_cache(maxsize=1)
|
|
1374
|
+
def _dummy_password_hash() -> str:
|
|
1375
|
+
"""A throwaway hash verified against when no real one exists (built on first use, not at import)."""
|
|
1376
|
+
|
|
1377
|
+
return hash_password(secrets.token_urlsafe(32))
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _check_password(password: str, password_hash: str) -> bool:
|
|
1381
|
+
try:
|
|
1382
|
+
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
|
1383
|
+
except ValueError:
|
|
1384
|
+
return False
|