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,665 @@
|
|
|
1
|
+
"""Identity -> account: the one path every sign-in provider ends in.
|
|
2
|
+
|
|
3
|
+
Order of resolution (PLAN.md 4.6): an existing identity row; an ``invited``
|
|
4
|
+
account pre-created for exactly this identity; an active account with the
|
|
5
|
+
same e-mail when the provider allows e-mail linking; and finally just-in-time
|
|
6
|
+
provisioning with the role its highest-priority matching rule names, falling
|
|
7
|
+
back to the provider's ``unknown_identity_role_id`` (``NULL`` means refuse).
|
|
8
|
+
Accounts are never resolved by username. With no role mappings on the
|
|
9
|
+
provider, existing accounts are never re-evaluated (D10: an upgraded
|
|
10
|
+
trusted-header install keeps its admins); once a provider has rules, the
|
|
11
|
+
accounts it created (``role_source=mapping``) are re-evaluated on the same run
|
|
12
|
+
and only written when something actually changed.
|
|
13
|
+
|
|
14
|
+
A header-style provider presents the identity on every request, so results
|
|
15
|
+
are cached per identity for the users-cache TTL: the database path (and its
|
|
16
|
+
writes) runs once per TTL per identity, and status or role changes reach the
|
|
17
|
+
request path through the users cache as usual.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Iterator, Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
import anyio
|
|
34
|
+
from sqlalchemy.exc import IntegrityError
|
|
35
|
+
|
|
36
|
+
import app.modules.dashboard_users.repository as users_repository
|
|
37
|
+
from app.core.audit.service import AuditActor, AuditDetails, AuditService, AuditTarget
|
|
38
|
+
from app.core.audit.types import AuditSeverity
|
|
39
|
+
from app.core.auth.dashboard_access import PRESET_ROLE_IDS, PresetRoleSlug
|
|
40
|
+
from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
|
|
41
|
+
from app.core.auth.providers import ExternalIdentity
|
|
42
|
+
from app.core.utils.time import utcnow
|
|
43
|
+
from app.db.models import (
|
|
44
|
+
COMPAT_ADMIN_USERNAME,
|
|
45
|
+
DashboardAuthProvider,
|
|
46
|
+
DashboardIdentity,
|
|
47
|
+
DashboardRoleMapping,
|
|
48
|
+
DashboardRoleRecord,
|
|
49
|
+
DashboardUser,
|
|
50
|
+
DashboardUserInvite,
|
|
51
|
+
DashboardUserRoleSource,
|
|
52
|
+
DashboardUserStatus,
|
|
53
|
+
)
|
|
54
|
+
from app.db.session import SessionLocal
|
|
55
|
+
from app.modules.dashboard_roles.repository import DashboardRolesRepository
|
|
56
|
+
from app.modules.dashboard_users.repository import DashboardUsersRepository, normalize_email
|
|
57
|
+
from app.modules.role_mappings.matching import match_role_id
|
|
58
|
+
from app.modules.role_mappings.repository import RoleMappingsRepository
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
DenialCode = Literal["identity_not_provisioned", "account_disabled"]
|
|
63
|
+
|
|
64
|
+
_SLUG_MAX_LENGTH = 56
|
|
65
|
+
_SLUG_DISALLOWED = re.compile(r"[^a-z0-9._-]")
|
|
66
|
+
_JIT_ATTEMPTS = 3
|
|
67
|
+
#: What a name becomes when the value slugifies to nothing. One marker per
|
|
68
|
+
#: provisioning path, so a name says which path made it; the sign-in one is the
|
|
69
|
+
#: default because changing it would rename accounts that already exist.
|
|
70
|
+
TRUSTED_HEADER_SLUG_PREFIX = "th-"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class ResolvedAccount:
|
|
75
|
+
user_id: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Denied:
|
|
80
|
+
code: DenialCode
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
Resolution = ResolvedAccount | Denied
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class InviteLinkFailure:
|
|
88
|
+
"""Why :meth:`IdentityResolver.link_expected_invite` wrote nothing.
|
|
89
|
+
|
|
90
|
+
``stale`` means the invite stopped being live between the lookup and the
|
|
91
|
+
compare-and-set; ``raced`` means a concurrent request landed the same
|
|
92
|
+
identity triple first, so the caller re-reads it and that request's answer
|
|
93
|
+
stands.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
reason: Literal["stale", "raced"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def slugify_subject(subject: str, *, empty_prefix: str = TRUSTED_HEADER_SLUG_PREFIX) -> str:
|
|
100
|
+
"""The username stem of a just-in-time account (PLAN.md 4.6 JIT rules).
|
|
101
|
+
|
|
102
|
+
Lower-cased, ``@`` becomes ``.``, everything outside ``[a-z0-9._-]`` is
|
|
103
|
+
dropped, cut to 56 characters (leaving room for a collision suffix). An
|
|
104
|
+
empty result falls back to ``<prefix><sha256 8hex>`` of the value so any
|
|
105
|
+
value yields a valid, stable name. The prefix names the path that
|
|
106
|
+
provisioned the account and defaults to the sign-in one, so no existing
|
|
107
|
+
account name changes when a second path starts using these rules.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
lowered = subject.casefold().replace("@", ".")
|
|
111
|
+
slug = _SLUG_DISALLOWED.sub("", lowered)[:_SLUG_MAX_LENGTH]
|
|
112
|
+
if not slug:
|
|
113
|
+
return f"{empty_prefix}{hashlib.sha256(subject.encode('utf-8')).hexdigest()[:8]}"
|
|
114
|
+
return slug
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def jit_username_candidates(stem: str) -> Iterator[str]:
|
|
118
|
+
"""``stem``, then ``stem-2``, ``stem-3``, ... for UNIQUE collisions."""
|
|
119
|
+
|
|
120
|
+
yield stem
|
|
121
|
+
suffix = 2
|
|
122
|
+
while True:
|
|
123
|
+
yield f"{stem}-{suffix}"
|
|
124
|
+
suffix += 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _now() -> datetime:
|
|
128
|
+
"""Aware clock for the tz-aware invite columns (``expires_at`` comparisons)."""
|
|
129
|
+
|
|
130
|
+
return users_repository.utc_now()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _naive_now() -> datetime:
|
|
134
|
+
"""Naive UTC for the naive account/identity timestamp columns (PostgreSQL rejects aware values there)."""
|
|
135
|
+
|
|
136
|
+
return utcnow()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _user_actor(user: DashboardUser, auth_method: str) -> AuditActor:
|
|
140
|
+
return AuditActor(user_id=user.id, username=user.username, role_slug=user.role.slug, auth_method=auth_method)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _groups_snapshot(groups: tuple[str, ...]) -> str | None:
|
|
144
|
+
"""The identity's group set as stored on the identity row; ``None`` when it has none."""
|
|
145
|
+
|
|
146
|
+
return json.dumps(list(groups)) if groups else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _identity_details(identity: ExternalIdentity) -> AuditDetails:
|
|
150
|
+
return {
|
|
151
|
+
"provider": identity.provider,
|
|
152
|
+
"provider_key": identity.provider_key,
|
|
153
|
+
"subject": identity.subject,
|
|
154
|
+
"email": identity.email,
|
|
155
|
+
"groups": list(identity.groups),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class IdentityResolver:
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
repository: DashboardUsersRepository,
|
|
163
|
+
roles: DashboardRolesRepository,
|
|
164
|
+
mappings: RoleMappingsRepository,
|
|
165
|
+
) -> None:
|
|
166
|
+
self._repo = repository
|
|
167
|
+
self._roles = roles
|
|
168
|
+
self._mappings = mappings
|
|
169
|
+
|
|
170
|
+
async def resolve(
|
|
171
|
+
self, identity: ExternalIdentity, provider: DashboardAuthProvider, *, actor_ip: str | None
|
|
172
|
+
) -> Resolution:
|
|
173
|
+
now = _now()
|
|
174
|
+
existing = await self._repo.get_identity(identity.provider, identity.provider_key, identity.subject)
|
|
175
|
+
if existing is not None:
|
|
176
|
+
return await self._seen(existing, identity, provider, now)
|
|
177
|
+
invite = await self._repo.find_invite_expecting_identity(
|
|
178
|
+
identity.provider, identity.provider_key, identity.subject, now
|
|
179
|
+
)
|
|
180
|
+
if invite is not None:
|
|
181
|
+
return await self._link_invited(invite, identity, provider, now, actor_ip)
|
|
182
|
+
if provider.link_by_email and identity.email is not None:
|
|
183
|
+
candidate = await self._repo.get_by_email(normalize_email(identity.email) or "")
|
|
184
|
+
if candidate is not None and candidate.status == DashboardUserStatus.ACTIVE.value:
|
|
185
|
+
return await self._link(candidate, identity, provider, now, actor_ip, via="email")
|
|
186
|
+
return await self._provision(identity, provider, now, actor_ip)
|
|
187
|
+
|
|
188
|
+
# --- step 1: known identity ---
|
|
189
|
+
|
|
190
|
+
async def _seen(
|
|
191
|
+
self, row: DashboardIdentity, identity: ExternalIdentity, provider: DashboardAuthProvider, now: datetime
|
|
192
|
+
) -> Resolution:
|
|
193
|
+
user = row.user
|
|
194
|
+
if user.status == DashboardUserStatus.DISABLED.value:
|
|
195
|
+
return Denied("account_disabled")
|
|
196
|
+
if user.status != DashboardUserStatus.ACTIVE.value:
|
|
197
|
+
return Denied("identity_not_provisioned")
|
|
198
|
+
# One write per TTL: this is the trusted-header "login".
|
|
199
|
+
if row.email != identity.email:
|
|
200
|
+
row.email = identity.email
|
|
201
|
+
if row.display_name != identity.display_name:
|
|
202
|
+
row.display_name = identity.display_name
|
|
203
|
+
snapshot = _groups_snapshot(identity.groups)
|
|
204
|
+
if row.groups_json != snapshot:
|
|
205
|
+
row.groups_json = snapshot
|
|
206
|
+
seen = _naive_now()
|
|
207
|
+
row.last_seen_at = seen
|
|
208
|
+
user.last_login_at = seen
|
|
209
|
+
await self._repo.commit_user(user.id)
|
|
210
|
+
return await self._reevaluate(user, identity, provider) or ResolvedAccount(user_id=user.id)
|
|
211
|
+
|
|
212
|
+
# --- re-evaluation of an account the provider manages (PLAN.md 4.6) ---
|
|
213
|
+
|
|
214
|
+
async def _reevaluate(
|
|
215
|
+
self, user: DashboardUser, identity: ExternalIdentity, provider: DashboardAuthProvider
|
|
216
|
+
) -> Resolution | None:
|
|
217
|
+
"""Move a managed account onto the role its rules name; ``None`` = nothing to do.
|
|
218
|
+
|
|
219
|
+
Skipped for an account a person decided (``role_source=manual``), for a
|
|
220
|
+
provider whose role sync is off, and — the D10 guard — for a provider
|
|
221
|
+
with no rules at all: an upgraded install that adds no rule keeps every
|
|
222
|
+
account exactly as it was.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if provider.skip_role_sync or user.role_source != DashboardUserRoleSource.MAPPING.value:
|
|
226
|
+
return None
|
|
227
|
+
rows = await self._mappings.list_for_provider(identity.provider, identity.provider_key)
|
|
228
|
+
if not rows:
|
|
229
|
+
return None
|
|
230
|
+
winner = match_role_id(rows, groups=identity.groups, email=identity.email)
|
|
231
|
+
if winner is not None:
|
|
232
|
+
await self._apply_mapped_role(user, identity, winner)
|
|
233
|
+
return None
|
|
234
|
+
return await self._demote(user, identity, provider)
|
|
235
|
+
|
|
236
|
+
async def _apply_mapped_role(self, user: DashboardUser, identity: ExternalIdentity, role_id: str) -> None:
|
|
237
|
+
if user.role_id == role_id:
|
|
238
|
+
return
|
|
239
|
+
if await self._pin_last_admin(user, identity, role_id=role_id, status=user.status):
|
|
240
|
+
return
|
|
241
|
+
role = await self._roles.get_role(role_id)
|
|
242
|
+
if role is None: # pragma: no cover - RESTRICT keeps a rule's role alive
|
|
243
|
+
logger.warning("role_mapping_role_missing provider=%s role_id=%s", identity.provider, role_id)
|
|
244
|
+
return
|
|
245
|
+
previous_slug, new_slug = user.role.slug, role.slug
|
|
246
|
+
user.role_id = role_id
|
|
247
|
+
user = await self._repo.commit_user(user.id, bump_generation=True)
|
|
248
|
+
await get_dashboard_users_cache().invalidate()
|
|
249
|
+
AuditService.log_async(
|
|
250
|
+
"user_role_changed",
|
|
251
|
+
actor_ip=None,
|
|
252
|
+
details={"username": user.username, "from": previous_slug, "to": new_slug, "source": "mapping"},
|
|
253
|
+
actor=_user_actor(user, identity.provider),
|
|
254
|
+
target=AuditTarget("user", user.id),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def _demote(
|
|
258
|
+
self, user: DashboardUser, identity: ExternalIdentity, provider: DashboardAuthProvider
|
|
259
|
+
) -> Resolution | None:
|
|
260
|
+
"""No rule matches: park the account on ``no_match_role_id``, or disable it when that is NULL."""
|
|
261
|
+
|
|
262
|
+
fallback = provider.no_match_role_id
|
|
263
|
+
if fallback is not None and fallback == user.role_id:
|
|
264
|
+
return None
|
|
265
|
+
status = user.status if fallback is not None else DashboardUserStatus.DISABLED.value
|
|
266
|
+
role_id = fallback if fallback is not None else user.role_id
|
|
267
|
+
if await self._pin_last_admin(user, identity, role_id=role_id, status=status):
|
|
268
|
+
return None
|
|
269
|
+
previous_slug = user.role.slug
|
|
270
|
+
new_slug: str | None = None
|
|
271
|
+
if fallback is not None:
|
|
272
|
+
role = await self._roles.get_role(fallback)
|
|
273
|
+
if role is None:
|
|
274
|
+
logger.warning("auth_provider_no_match_role_missing provider=%s role_id=%s", provider.kind, fallback)
|
|
275
|
+
return None
|
|
276
|
+
new_slug = role.slug
|
|
277
|
+
user.role_id = fallback
|
|
278
|
+
else:
|
|
279
|
+
user.status = DashboardUserStatus.DISABLED.value
|
|
280
|
+
user = await self._repo.commit_user(user.id, bump_generation=True)
|
|
281
|
+
await get_dashboard_users_cache().invalidate()
|
|
282
|
+
AuditService.log_async(
|
|
283
|
+
"role_demoted_no_mapping",
|
|
284
|
+
actor_ip=None,
|
|
285
|
+
details={
|
|
286
|
+
"username": user.username,
|
|
287
|
+
"from": previous_slug,
|
|
288
|
+
"to": new_slug,
|
|
289
|
+
"provider": provider.kind,
|
|
290
|
+
"provider_key": provider.provider_key,
|
|
291
|
+
},
|
|
292
|
+
actor=_user_actor(user, identity.provider),
|
|
293
|
+
target=AuditTarget("user", user.id),
|
|
294
|
+
severity=AuditSeverity.WARNING,
|
|
295
|
+
)
|
|
296
|
+
return None if fallback is not None else Denied("account_disabled")
|
|
297
|
+
|
|
298
|
+
async def _pin_last_admin(
|
|
299
|
+
self, user: DashboardUser, identity: ExternalIdentity, *, role_id: str, status: str
|
|
300
|
+
) -> bool:
|
|
301
|
+
"""Refuse a change that would remove the last admin, once and for good.
|
|
302
|
+
|
|
303
|
+
The account keeps its role and becomes ``manual``, so the next run does
|
|
304
|
+
not retry the same refusal (and does not write the same audit row every
|
|
305
|
+
TTL). Locking an install out of its own reverse proxy is worse than a
|
|
306
|
+
rule that does not apply to one account. The count is read under the
|
|
307
|
+
accounts write intent, so the answer cannot be stale by the time the
|
|
308
|
+
demotion commits.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
admin_preset = PRESET_ROLE_IDS[PresetRoleSlug.ADMIN]
|
|
312
|
+
leaves_admin = (
|
|
313
|
+
user.status == DashboardUserStatus.ACTIVE.value
|
|
314
|
+
and user.role_id == admin_preset
|
|
315
|
+
and not (role_id == admin_preset and status == DashboardUserStatus.ACTIVE.value)
|
|
316
|
+
)
|
|
317
|
+
if not leaves_admin:
|
|
318
|
+
return False
|
|
319
|
+
# Serialise before reading the invariant, exactly like the admin path:
|
|
320
|
+
# two replicas demoting two mapping-managed admins at once would
|
|
321
|
+
# otherwise each see the other survive and both commit, leaving an
|
|
322
|
+
# install with no admin at all. The lock is taken on this resolver's
|
|
323
|
+
# own session, which is also the session ``commit_user`` commits, so
|
|
324
|
+
# it is still held when the demotion is written.
|
|
325
|
+
await self._repo.acquire_write_intent()
|
|
326
|
+
if await self._repo.count_active_admins(exclude_user_id=user.id) > 0:
|
|
327
|
+
return False
|
|
328
|
+
previous_source = user.role_source
|
|
329
|
+
user.role_source = DashboardUserRoleSource.MANUAL.value
|
|
330
|
+
user = await self._repo.commit_user(user.id)
|
|
331
|
+
await get_dashboard_users_cache().invalidate()
|
|
332
|
+
AuditService.log_async(
|
|
333
|
+
"role_source_overridden",
|
|
334
|
+
actor_ip=None,
|
|
335
|
+
details={
|
|
336
|
+
"username": user.username,
|
|
337
|
+
"from_source": previous_source,
|
|
338
|
+
"to_source": DashboardUserRoleSource.MANUAL.value,
|
|
339
|
+
"reason": "last_admin_protected",
|
|
340
|
+
"provider": identity.provider,
|
|
341
|
+
},
|
|
342
|
+
actor=_user_actor(user, identity.provider),
|
|
343
|
+
target=AuditTarget("user", user.id),
|
|
344
|
+
severity=AuditSeverity.WARNING,
|
|
345
|
+
)
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
# --- step 1.5: pre-created account waiting for this identity ---
|
|
349
|
+
|
|
350
|
+
async def link_expected_invite(
|
|
351
|
+
self,
|
|
352
|
+
invite: DashboardUserInvite,
|
|
353
|
+
identity: ExternalIdentity,
|
|
354
|
+
*,
|
|
355
|
+
now: datetime,
|
|
356
|
+
actor_ip: str | None,
|
|
357
|
+
user_name: str | None = None,
|
|
358
|
+
record_login: bool = True,
|
|
359
|
+
) -> DashboardUser | InviteLinkFailure:
|
|
360
|
+
"""Attach this identity to the account an invite pre-created for it, and activate it.
|
|
361
|
+
|
|
362
|
+
The narrow entry point of resolution step 1.5, shared by the sign-in
|
|
363
|
+
path and by SCIM: a provisioning push finds the same pre-created
|
|
364
|
+
account a first sign-in would have, by the same exact triple, and must
|
|
365
|
+
not create a second one beside it. ``record_login`` is off for SCIM —
|
|
366
|
+
a push is not a sign-in and must not fake a last-login timestamp.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
user = invite.user
|
|
370
|
+
self._repo.add(self._identity_row(user, identity, user_name=user_name))
|
|
371
|
+
user.status = DashboardUserStatus.ACTIVE.value
|
|
372
|
+
if record_login:
|
|
373
|
+
user.last_login_at = _naive_now()
|
|
374
|
+
try:
|
|
375
|
+
if not await self._repo.consume_invite_by_identity(invite.id, now=now):
|
|
376
|
+
await self._repo.rollback()
|
|
377
|
+
return InviteLinkFailure("stale")
|
|
378
|
+
user = await self._repo.commit_user(user.id, bump_generation=True)
|
|
379
|
+
except IntegrityError:
|
|
380
|
+
# The identity row landed from a concurrent request; that request's answer stands.
|
|
381
|
+
await self._repo.rollback()
|
|
382
|
+
return InviteLinkFailure("raced")
|
|
383
|
+
await get_dashboard_users_cache().invalidate()
|
|
384
|
+
self._audit_linked(user, identity, actor_ip, via="invite")
|
|
385
|
+
return user
|
|
386
|
+
|
|
387
|
+
async def _link_invited(
|
|
388
|
+
self,
|
|
389
|
+
invite: DashboardUserInvite,
|
|
390
|
+
identity: ExternalIdentity,
|
|
391
|
+
provider: DashboardAuthProvider,
|
|
392
|
+
now: datetime,
|
|
393
|
+
actor_ip: str | None,
|
|
394
|
+
) -> Resolution:
|
|
395
|
+
linked = await self.link_expected_invite(invite, identity, now=now, actor_ip=actor_ip)
|
|
396
|
+
if isinstance(linked, InviteLinkFailure):
|
|
397
|
+
if linked.reason == "stale":
|
|
398
|
+
return Denied("identity_not_provisioned")
|
|
399
|
+
return await self._retry_seen(identity, provider, now)
|
|
400
|
+
return ResolvedAccount(user_id=linked.id)
|
|
401
|
+
|
|
402
|
+
# --- step 2: e-mail link ---
|
|
403
|
+
|
|
404
|
+
async def _link(
|
|
405
|
+
self,
|
|
406
|
+
user: DashboardUser,
|
|
407
|
+
identity: ExternalIdentity,
|
|
408
|
+
provider: DashboardAuthProvider,
|
|
409
|
+
now: datetime,
|
|
410
|
+
actor_ip: str | None,
|
|
411
|
+
*,
|
|
412
|
+
via: str,
|
|
413
|
+
) -> Resolution:
|
|
414
|
+
self._repo.add(self._identity_row(user, identity))
|
|
415
|
+
user.last_login_at = _naive_now()
|
|
416
|
+
try:
|
|
417
|
+
user = await self._repo.commit_user(user.id)
|
|
418
|
+
except IntegrityError:
|
|
419
|
+
await self._repo.rollback()
|
|
420
|
+
return await self._retry_seen(identity, provider, now)
|
|
421
|
+
await get_dashboard_users_cache().invalidate()
|
|
422
|
+
self._audit_linked(user, identity, actor_ip, via=via)
|
|
423
|
+
return ResolvedAccount(user_id=user.id)
|
|
424
|
+
|
|
425
|
+
# --- step 3: just-in-time provisioning ---
|
|
426
|
+
|
|
427
|
+
async def provision_account(
|
|
428
|
+
self,
|
|
429
|
+
identity: ExternalIdentity,
|
|
430
|
+
*,
|
|
431
|
+
role_id: str,
|
|
432
|
+
role_slug: str,
|
|
433
|
+
role_source: str,
|
|
434
|
+
actor_ip: str | None,
|
|
435
|
+
user_name: str | None = None,
|
|
436
|
+
username_source: str | None = None,
|
|
437
|
+
empty_slug_prefix: str = TRUSTED_HEADER_SLUG_PREFIX,
|
|
438
|
+
record_login: bool = True,
|
|
439
|
+
via: str = "jit",
|
|
440
|
+
) -> DashboardUser | DashboardIdentity:
|
|
441
|
+
"""Create the account an identity names, on an explicitly chosen role.
|
|
442
|
+
|
|
443
|
+
The one copy of the provisioning rules, parameterised by where the role
|
|
444
|
+
came from: the sign-in path passes what its mappings resolved and
|
|
445
|
+
``role_source=mapping``; SCIM passes the slug the push named and
|
|
446
|
+
``role_source=scim``. Everything else is shared — the
|
|
447
|
+
e-mail-already-taken rule, the username slug walk that reserves
|
|
448
|
+
``admin``, the bounded ``IntegrityError`` retry that re-checks whether
|
|
449
|
+
the triple landed concurrently and re-reads the address a concurrent
|
|
450
|
+
write may have claimed, the identity row, and the ``user_created`` /
|
|
451
|
+
``identity_linked`` audit pair.
|
|
452
|
+
|
|
453
|
+
Returns the new account, or the identity row a concurrent request wrote
|
|
454
|
+
for the same triple — in which case nothing was created and that
|
|
455
|
+
request's account is the answer.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
# Scalars only: a rollback below expires the ORM row and a lazy reload
|
|
459
|
+
# outside the session's greenlet would fail (MissingGreenlet).
|
|
460
|
+
email = identity.email
|
|
461
|
+
if email is not None and await self._repo.get_by_email(email) is not None:
|
|
462
|
+
# Same e-mail, linking off: the address stays on the identity row only.
|
|
463
|
+
email = None
|
|
464
|
+
for attempt in range(_JIT_ATTEMPTS):
|
|
465
|
+
username = await self._free_username(
|
|
466
|
+
username_source if username_source is not None else identity.subject,
|
|
467
|
+
empty_prefix=empty_slug_prefix,
|
|
468
|
+
)
|
|
469
|
+
user = DashboardUser(
|
|
470
|
+
id=str(uuid.uuid4()),
|
|
471
|
+
username=username,
|
|
472
|
+
display_name=identity.display_name,
|
|
473
|
+
email=email,
|
|
474
|
+
role_id=role_id,
|
|
475
|
+
role_source=role_source,
|
|
476
|
+
status=DashboardUserStatus.ACTIVE.value,
|
|
477
|
+
last_login_at=_naive_now() if record_login else None,
|
|
478
|
+
)
|
|
479
|
+
self._repo.add(user, self._identity_row(user, identity, user_name=user_name))
|
|
480
|
+
try:
|
|
481
|
+
user = await self._repo.commit_user(user.id)
|
|
482
|
+
except IntegrityError:
|
|
483
|
+
await self._repo.rollback()
|
|
484
|
+
existing = await self._repo.get_identity(identity.provider, identity.provider_key, identity.subject)
|
|
485
|
+
if existing is not None:
|
|
486
|
+
return existing
|
|
487
|
+
if attempt == _JIT_ATTEMPTS - 1:
|
|
488
|
+
raise
|
|
489
|
+
if email is not None and await self._repo.get_by_email(email) is not None:
|
|
490
|
+
# The check above this loop was stale: a concurrent write
|
|
491
|
+
# claimed the address between it and this commit. Re-reading
|
|
492
|
+
# it is what makes the retry differ from the attempt that
|
|
493
|
+
# just failed — carrying the same conflicting address
|
|
494
|
+
# forward would spend every remaining attempt on the same
|
|
495
|
+
# violation and end in a 500 for a case the rule above
|
|
496
|
+
# already has an answer for. The address stays on the
|
|
497
|
+
# identity row either way.
|
|
498
|
+
email = None
|
|
499
|
+
continue
|
|
500
|
+
await get_dashboard_users_cache().invalidate()
|
|
501
|
+
AuditService.log_async(
|
|
502
|
+
"user_created",
|
|
503
|
+
actor_ip=actor_ip,
|
|
504
|
+
details={
|
|
505
|
+
"username": user.username,
|
|
506
|
+
"role": role_slug,
|
|
507
|
+
"jit": True,
|
|
508
|
+
**_identity_details(identity),
|
|
509
|
+
},
|
|
510
|
+
actor=AuditActor(user_id=None, username=None, role_slug=None, auth_method=identity.provider),
|
|
511
|
+
target=AuditTarget("user", user.id),
|
|
512
|
+
)
|
|
513
|
+
self._audit_linked(user, identity, actor_ip, via=via)
|
|
514
|
+
return user
|
|
515
|
+
raise RuntimeError("unreachable") # pragma: no cover
|
|
516
|
+
|
|
517
|
+
async def _provision(
|
|
518
|
+
self, identity: ExternalIdentity, provider: DashboardAuthProvider, now: datetime, actor_ip: str | None
|
|
519
|
+
) -> Resolution:
|
|
520
|
+
role = await self._jit_role(provider, identity)
|
|
521
|
+
if role is None:
|
|
522
|
+
AuditService.log_async(
|
|
523
|
+
"login_failed",
|
|
524
|
+
actor_ip=actor_ip,
|
|
525
|
+
details={"method": identity.provider, "reason": "unknown_identity", **_identity_details(identity)},
|
|
526
|
+
severity=AuditSeverity.WARNING,
|
|
527
|
+
)
|
|
528
|
+
return Denied("identity_not_provisioned")
|
|
529
|
+
provisioned = await self.provision_account(
|
|
530
|
+
identity,
|
|
531
|
+
role_id=role.id,
|
|
532
|
+
role_slug=role.slug,
|
|
533
|
+
role_source=DashboardUserRoleSource.MAPPING.value,
|
|
534
|
+
actor_ip=actor_ip,
|
|
535
|
+
)
|
|
536
|
+
if isinstance(provisioned, DashboardIdentity):
|
|
537
|
+
return await self._seen(provisioned, identity, provider, now)
|
|
538
|
+
return ResolvedAccount(user_id=provisioned.id)
|
|
539
|
+
|
|
540
|
+
async def _jit_role(
|
|
541
|
+
self, provider: DashboardAuthProvider, identity: ExternalIdentity
|
|
542
|
+
) -> DashboardRoleRecord | None:
|
|
543
|
+
"""The role a new account gets: its highest-priority matching rule, else the provider's default.
|
|
544
|
+
|
|
545
|
+
The rules are consulted even when role sync is off — ``skip_role_sync``
|
|
546
|
+
stops the resolver from moving accounts that already exist, not from
|
|
547
|
+
giving a new one the role it is entitled to.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
rows: Sequence[DashboardRoleMapping] = await self._mappings.list_for_provider(
|
|
551
|
+
identity.provider, identity.provider_key
|
|
552
|
+
)
|
|
553
|
+
role_id = match_role_id(rows, groups=identity.groups, email=identity.email)
|
|
554
|
+
if role_id is None:
|
|
555
|
+
role_id = provider.unknown_identity_role_id
|
|
556
|
+
if role_id is None:
|
|
557
|
+
return None
|
|
558
|
+
role = await self._roles.get_role(role_id)
|
|
559
|
+
if role is None:
|
|
560
|
+
logger.warning(
|
|
561
|
+
"auth_provider_jit_role_missing provider=%s role_id=%s",
|
|
562
|
+
provider.kind,
|
|
563
|
+
role_id,
|
|
564
|
+
)
|
|
565
|
+
return role
|
|
566
|
+
|
|
567
|
+
async def _free_username(self, value: str, *, empty_prefix: str = TRUSTED_HEADER_SLUG_PREFIX) -> str:
|
|
568
|
+
"""First candidate no account holds. ``admin`` is reserved for the local
|
|
569
|
+
break-glass account even before it exists -- and stays reserved after that
|
|
570
|
+
account is renamed away from it -- so a proxy user named admin becomes
|
|
571
|
+
``admin-2`` and the name the recovery runbooks use never means someone else."""
|
|
572
|
+
|
|
573
|
+
for candidate in jit_username_candidates(slugify_subject(value, empty_prefix=empty_prefix)):
|
|
574
|
+
if candidate == COMPAT_ADMIN_USERNAME:
|
|
575
|
+
continue
|
|
576
|
+
if await self._repo.get_by_username(candidate) is None:
|
|
577
|
+
return candidate
|
|
578
|
+
raise RuntimeError("unreachable") # pragma: no cover
|
|
579
|
+
|
|
580
|
+
# --- helpers ---
|
|
581
|
+
|
|
582
|
+
async def _retry_seen(
|
|
583
|
+
self, identity: ExternalIdentity, provider: DashboardAuthProvider, now: datetime
|
|
584
|
+
) -> Resolution:
|
|
585
|
+
existing = await self._repo.get_identity(identity.provider, identity.provider_key, identity.subject)
|
|
586
|
+
if existing is None:
|
|
587
|
+
return Denied("identity_not_provisioned")
|
|
588
|
+
return await self._seen(existing, identity, provider, now)
|
|
589
|
+
|
|
590
|
+
@staticmethod
|
|
591
|
+
def _identity_row(
|
|
592
|
+
user: DashboardUser, identity: ExternalIdentity, *, user_name: str | None = None
|
|
593
|
+
) -> DashboardIdentity:
|
|
594
|
+
return DashboardIdentity(
|
|
595
|
+
id=str(uuid.uuid4()),
|
|
596
|
+
user_id=user.id,
|
|
597
|
+
provider=identity.provider,
|
|
598
|
+
provider_key=identity.provider_key,
|
|
599
|
+
subject=identity.subject,
|
|
600
|
+
email=identity.email,
|
|
601
|
+
display_name=identity.display_name,
|
|
602
|
+
groups_json=_groups_snapshot(identity.groups),
|
|
603
|
+
user_name=user_name,
|
|
604
|
+
last_seen_at=_naive_now(),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _audit_linked(user: DashboardUser, identity: ExternalIdentity, actor_ip: str | None, *, via: str) -> None:
|
|
609
|
+
AuditService.log_async(
|
|
610
|
+
"identity_linked",
|
|
611
|
+
actor_ip=actor_ip,
|
|
612
|
+
details={"username": user.username, "via": via, **_identity_details(identity)},
|
|
613
|
+
actor=_user_actor(user, identity.provider),
|
|
614
|
+
target=AuditTarget("user", user.id),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class IdentityResolutionCache:
|
|
619
|
+
"""Per-identity resolution results, valid for the users-cache TTL.
|
|
620
|
+
|
|
621
|
+
A cached :class:`ResolvedAccount` only names the account; the request path
|
|
622
|
+
re-reads the account through the users cache, so a disable or role change
|
|
623
|
+
is honoured within that TTL. A cached :class:`Denied` keeps one refused
|
|
624
|
+
identity from spending a database round-trip and an audit row per request.
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
def __init__(self, *, ttl_seconds: float = 5.0) -> None:
|
|
628
|
+
if ttl_seconds <= 0:
|
|
629
|
+
raise ValueError("ttl_seconds must be positive")
|
|
630
|
+
self._ttl_seconds = ttl_seconds
|
|
631
|
+
self._entries: dict[tuple[str, str, str], tuple[Resolution, float]] = {}
|
|
632
|
+
self._lock = anyio.Lock()
|
|
633
|
+
|
|
634
|
+
async def resolve(
|
|
635
|
+
self, identity: ExternalIdentity, provider: DashboardAuthProvider, *, actor_ip: str | None
|
|
636
|
+
) -> Resolution:
|
|
637
|
+
key = (identity.provider, identity.provider_key, identity.subject)
|
|
638
|
+
now = time.monotonic()
|
|
639
|
+
entry = self._entries.get(key)
|
|
640
|
+
if entry is not None and now - entry[1] < self._ttl_seconds:
|
|
641
|
+
return entry[0]
|
|
642
|
+
async with self._lock:
|
|
643
|
+
now = time.monotonic()
|
|
644
|
+
entry = self._entries.get(key)
|
|
645
|
+
if entry is not None and now - entry[1] < self._ttl_seconds:
|
|
646
|
+
return entry[0]
|
|
647
|
+
async with SessionLocal() as session:
|
|
648
|
+
resolver = IdentityResolver(
|
|
649
|
+
DashboardUsersRepository(session),
|
|
650
|
+
DashboardRolesRepository(session),
|
|
651
|
+
RoleMappingsRepository(session),
|
|
652
|
+
)
|
|
653
|
+
result = await resolver.resolve(identity, provider, actor_ip=actor_ip)
|
|
654
|
+
self._entries[key] = (result, now)
|
|
655
|
+
return result
|
|
656
|
+
|
|
657
|
+
def clear(self) -> None:
|
|
658
|
+
self._entries.clear()
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
_identity_resolution_cache = IdentityResolutionCache()
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def get_identity_resolution_cache() -> IdentityResolutionCache:
|
|
665
|
+
return _identity_resolution_cache
|