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,725 @@
|
|
|
1
|
+
"""Queries and writes over ``dashboard_users`` and ``dashboard_user_invites``.
|
|
2
|
+
|
|
3
|
+
The read side is shared with the users cache and the auth module. Writes here
|
|
4
|
+
never touch credentials of the compat ``admin`` (those go through
|
|
5
|
+
``DashboardAuthRepository`` so the legacy mirror stays consistent); they cover
|
|
6
|
+
account lifecycle (invite, edit, disable, delete), the invite rows, and the
|
|
7
|
+
owner-driven API-key cascade, all inside the caller's session.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import ColumnElement, and_, delete, func, or_, select, text, update
|
|
18
|
+
from sqlalchemy.exc import OperationalError
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
from sqlalchemy.orm import aliased, selectinload
|
|
21
|
+
|
|
22
|
+
from app.core.auth.dashboard_access import PRESET_ROLE_IDS, PresetRoleSlug
|
|
23
|
+
from app.db.models import (
|
|
24
|
+
ApiKey,
|
|
25
|
+
ApiKeyDeactivatedReason,
|
|
26
|
+
DashboardIdentity,
|
|
27
|
+
DashboardRoleRecord,
|
|
28
|
+
DashboardUser,
|
|
29
|
+
DashboardUserInvite,
|
|
30
|
+
DashboardUserStatus,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_USERNAME_PATTERN = re.compile(r"^[a-z0-9._-]{1,64}$")
|
|
34
|
+
_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def utc_now() -> datetime:
|
|
38
|
+
"""The clock every invite-liveness decision reads (one seam for tests)."""
|
|
39
|
+
|
|
40
|
+
return datetime.now(UTC)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_username(value: str) -> str:
|
|
44
|
+
"""Usernames are compared case-insensitively and stored normalized."""
|
|
45
|
+
|
|
46
|
+
return value.strip().casefold()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_valid_username(value: str) -> bool:
|
|
50
|
+
return _USERNAME_PATTERN.fullmatch(value) is not None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_email(value: str | None) -> str | None:
|
|
54
|
+
"""E-mails are stored lower-cased; blank means "no e-mail"."""
|
|
55
|
+
|
|
56
|
+
normalized = (value or "").strip().casefold()
|
|
57
|
+
return normalized or None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_valid_email(value: str) -> bool:
|
|
61
|
+
return len(value) <= 320 and _EMAIL_PATTERN.fullmatch(value) is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class LocalAuthState:
|
|
66
|
+
"""What the users table says about whether this install requires sign-in.
|
|
67
|
+
|
|
68
|
+
``requires_auth`` is true when an active user can sign in at all (holds a
|
|
69
|
+
password or an external identity). ``sole_local_password_user_id`` is set
|
|
70
|
+
only when exactly one active user holds a password, which lets the login
|
|
71
|
+
form omit the username field on single-user installs.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
any_user: bool
|
|
75
|
+
active_users: int
|
|
76
|
+
active_local_password_users: int
|
|
77
|
+
requires_auth: bool
|
|
78
|
+
sole_local_password_user_id: str | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class DashboardUserCounts:
|
|
83
|
+
total: int
|
|
84
|
+
active: int
|
|
85
|
+
invited: int
|
|
86
|
+
disabled: int
|
|
87
|
+
non_admin: int
|
|
88
|
+
pending_invites: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _user_query():
|
|
92
|
+
return select(DashboardUser).options(
|
|
93
|
+
selectinload(DashboardUser.role).selectinload(DashboardRoleRecord.grants),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _invite_query():
|
|
98
|
+
return select(DashboardUserInvite).options(
|
|
99
|
+
selectinload(DashboardUserInvite.user).selectinload(DashboardUser.role).selectinload(DashboardRoleRecord.grants)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def live_invite_filter(now: datetime) -> ColumnElement[bool]:
|
|
104
|
+
"""An invite that can still be accepted: not consumed, not revoked, and not expired.
|
|
105
|
+
|
|
106
|
+
An SSO-only invite has no link to expire: the account waits for its first
|
|
107
|
+
provider sign-in for as long as the administrator leaves it in place.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return and_(
|
|
111
|
+
DashboardUserInvite.consumed_at.is_(None),
|
|
112
|
+
DashboardUserInvite.revoked_at.is_(None),
|
|
113
|
+
or_(DashboardUserInvite.sso_only.is_(True), DashboardUserInvite.expires_at > now),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DashboardUsersRepository:
|
|
118
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
119
|
+
self._session = session
|
|
120
|
+
|
|
121
|
+
# --- reads ---
|
|
122
|
+
|
|
123
|
+
async def get_by_id(self, user_id: str) -> DashboardUser | None:
|
|
124
|
+
stmt = _user_query().where(DashboardUser.id == user_id)
|
|
125
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
126
|
+
|
|
127
|
+
async def get_by_username(self, normalized_username: str) -> DashboardUser | None:
|
|
128
|
+
stmt = _user_query().where(DashboardUser.username == normalized_username)
|
|
129
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
130
|
+
|
|
131
|
+
async def get_by_email(self, normalized_email: str) -> DashboardUser | None:
|
|
132
|
+
stmt = _user_query().where(DashboardUser.email == normalized_email)
|
|
133
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
134
|
+
|
|
135
|
+
async def get_identity(self, provider: str, provider_key: str, subject: str) -> DashboardIdentity | None:
|
|
136
|
+
"""The identity row for a provider triple, with its account and role loaded."""
|
|
137
|
+
|
|
138
|
+
stmt = (
|
|
139
|
+
select(DashboardIdentity)
|
|
140
|
+
.options(
|
|
141
|
+
selectinload(DashboardIdentity.user)
|
|
142
|
+
.selectinload(DashboardUser.role)
|
|
143
|
+
.selectinload(DashboardRoleRecord.grants)
|
|
144
|
+
)
|
|
145
|
+
.where(DashboardIdentity.provider == provider)
|
|
146
|
+
.where(DashboardIdentity.provider_key == provider_key)
|
|
147
|
+
.where(DashboardIdentity.subject == subject)
|
|
148
|
+
)
|
|
149
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
150
|
+
|
|
151
|
+
async def has_identity(self, user_id: str, *, provider: str, provider_key: str) -> bool:
|
|
152
|
+
"""Whether this account holds any identity on that provider.
|
|
153
|
+
|
|
154
|
+
The other direction of :meth:`get_identity`, and deliberately a bare
|
|
155
|
+
existence check: the caller (step-up availability) needs to know that
|
|
156
|
+
the provider can vouch for the account, not which subject it uses.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
stmt = (
|
|
160
|
+
select(DashboardIdentity.id)
|
|
161
|
+
.where(DashboardIdentity.user_id == user_id)
|
|
162
|
+
.where(DashboardIdentity.provider == provider)
|
|
163
|
+
.where(DashboardIdentity.provider_key == provider_key)
|
|
164
|
+
.limit(1)
|
|
165
|
+
)
|
|
166
|
+
return (await self._session.execute(stmt)).first() is not None
|
|
167
|
+
|
|
168
|
+
async def list_users(self) -> Sequence[DashboardUser]:
|
|
169
|
+
stmt = _user_query().order_by(DashboardUser.created_at.asc(), DashboardUser.id.asc())
|
|
170
|
+
return (await self._session.execute(stmt)).scalars().all()
|
|
171
|
+
|
|
172
|
+
async def list_active_local_password_users(self) -> Sequence[DashboardUser]:
|
|
173
|
+
stmt = (
|
|
174
|
+
_user_query()
|
|
175
|
+
.where(DashboardUser.status == DashboardUserStatus.ACTIVE.value)
|
|
176
|
+
.where(DashboardUser.password_hash.is_not(None))
|
|
177
|
+
.order_by(DashboardUser.created_at.asc(), DashboardUser.id.asc())
|
|
178
|
+
)
|
|
179
|
+
return (await self._session.execute(stmt)).scalars().all()
|
|
180
|
+
|
|
181
|
+
async def count_identities(self, user_id: str) -> int:
|
|
182
|
+
stmt = select(func.count()).select_from(DashboardIdentity).where(DashboardIdentity.user_id == user_id)
|
|
183
|
+
return int((await self._session.execute(stmt)).scalar_one())
|
|
184
|
+
|
|
185
|
+
async def primary_identity_provider(self, user_id: str) -> str | None:
|
|
186
|
+
"""The provider of the account's oldest identity (which sign-in method manages it)."""
|
|
187
|
+
|
|
188
|
+
stmt = (
|
|
189
|
+
select(DashboardIdentity.provider)
|
|
190
|
+
.where(DashboardIdentity.user_id == user_id)
|
|
191
|
+
.order_by(DashboardIdentity.created_at.asc(), DashboardIdentity.id.asc())
|
|
192
|
+
.limit(1)
|
|
193
|
+
)
|
|
194
|
+
return (await self._session.execute(stmt)).scalars().first()
|
|
195
|
+
|
|
196
|
+
async def count_active_admins(self, *, exclude_user_id: str | None = None) -> int:
|
|
197
|
+
"""Active accounts holding the admin *preset* (custom roles never count, however wide)."""
|
|
198
|
+
|
|
199
|
+
stmt = (
|
|
200
|
+
select(func.count())
|
|
201
|
+
.select_from(DashboardUser)
|
|
202
|
+
.where(DashboardUser.status == DashboardUserStatus.ACTIVE.value)
|
|
203
|
+
.where(DashboardUser.role_id == PRESET_ROLE_IDS[PresetRoleSlug.ADMIN])
|
|
204
|
+
)
|
|
205
|
+
if exclude_user_id is not None:
|
|
206
|
+
stmt = stmt.where(DashboardUser.id != exclude_user_id)
|
|
207
|
+
return int((await self._session.execute(stmt)).scalar_one())
|
|
208
|
+
|
|
209
|
+
def _qualifying_break_glass_filter(self) -> ColumnElement[bool]:
|
|
210
|
+
"""The five facts of a qualifying break-glass account, as a WHERE clause.
|
|
211
|
+
|
|
212
|
+
Kept in step with :func:`app.modules.dashboard_users.break_glass.qualifies`;
|
|
213
|
+
the password term is what stops a proxy-provisioned admin with no local
|
|
214
|
+
credential from counting as a way back in.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
return and_(
|
|
218
|
+
DashboardUser.is_break_glass.is_(True),
|
|
219
|
+
DashboardUser.status == DashboardUserStatus.ACTIVE.value,
|
|
220
|
+
DashboardUser.role_id == PRESET_ROLE_IDS[PresetRoleSlug.ADMIN],
|
|
221
|
+
DashboardUser.totp_secret_encrypted.is_not(None),
|
|
222
|
+
DashboardUser.password_hash.is_not(None),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def count_qualifying_break_glass(self, *, exclude_user_id: str | None = None) -> int:
|
|
226
|
+
"""Accounts that could still open the door while local sign-in is restricted."""
|
|
227
|
+
|
|
228
|
+
stmt = select(func.count()).select_from(DashboardUser).where(self._qualifying_break_glass_filter())
|
|
229
|
+
if exclude_user_id is not None:
|
|
230
|
+
stmt = stmt.where(DashboardUser.id != exclude_user_id)
|
|
231
|
+
return int((await self._session.execute(stmt)).scalar_one())
|
|
232
|
+
|
|
233
|
+
async def list_break_glass_designations(self) -> Sequence[DashboardUser]:
|
|
234
|
+
"""Every designated account, qualifying or not, so a refusal can name the one that would fix it."""
|
|
235
|
+
|
|
236
|
+
stmt = (
|
|
237
|
+
_user_query()
|
|
238
|
+
.where(DashboardUser.is_break_glass.is_(True))
|
|
239
|
+
.order_by(DashboardUser.created_at.asc(), DashboardUser.id.asc())
|
|
240
|
+
)
|
|
241
|
+
return (await self._session.execute(stmt)).scalars().all()
|
|
242
|
+
|
|
243
|
+
async def local_auth_state(self) -> LocalAuthState:
|
|
244
|
+
active = DashboardUser.status == DashboardUserStatus.ACTIVE.value
|
|
245
|
+
totals = (
|
|
246
|
+
await self._session.execute(
|
|
247
|
+
select(
|
|
248
|
+
func.count(DashboardUser.id),
|
|
249
|
+
func.count(DashboardUser.id).filter(active),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
).one()
|
|
253
|
+
total_users, active_users = int(totals[0]), int(totals[1])
|
|
254
|
+
password_user_ids = (
|
|
255
|
+
(
|
|
256
|
+
await self._session.execute(
|
|
257
|
+
select(DashboardUser.id).where(active).where(DashboardUser.password_hash.is_not(None))
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
.scalars()
|
|
261
|
+
.all()
|
|
262
|
+
)
|
|
263
|
+
identity_user_exists = (
|
|
264
|
+
await self._session.execute(
|
|
265
|
+
select(DashboardIdentity.id)
|
|
266
|
+
.join(DashboardUser, DashboardUser.id == DashboardIdentity.user_id)
|
|
267
|
+
.where(active)
|
|
268
|
+
.limit(1)
|
|
269
|
+
)
|
|
270
|
+
).first() is not None
|
|
271
|
+
return LocalAuthState(
|
|
272
|
+
any_user=total_users > 0,
|
|
273
|
+
active_users=active_users,
|
|
274
|
+
active_local_password_users=len(password_user_ids),
|
|
275
|
+
requires_auth=bool(password_user_ids) or identity_user_exists,
|
|
276
|
+
sole_local_password_user_id=password_user_ids[0] if len(password_user_ids) == 1 else None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def counts(self, *, now: datetime | None = None) -> DashboardUserCounts:
|
|
280
|
+
"""Team-size facts. Invited accounts whose invite is no longer live are
|
|
281
|
+
zombies awaiting the lazy purge and are not counted anywhere."""
|
|
282
|
+
|
|
283
|
+
status = DashboardUser.status
|
|
284
|
+
live = self._live_account_filter(now or utc_now())
|
|
285
|
+
row = (
|
|
286
|
+
await self._session.execute(
|
|
287
|
+
select(
|
|
288
|
+
func.count(DashboardUser.id),
|
|
289
|
+
func.count(DashboardUser.id).filter(status == DashboardUserStatus.ACTIVE.value),
|
|
290
|
+
func.count(DashboardUser.id).filter(status == DashboardUserStatus.INVITED.value),
|
|
291
|
+
func.count(DashboardUser.id).filter(status == DashboardUserStatus.DISABLED.value),
|
|
292
|
+
func.count(DashboardUser.id).filter(DashboardUser.role_id != PRESET_ROLE_IDS[PresetRoleSlug.ADMIN]),
|
|
293
|
+
).where(live)
|
|
294
|
+
)
|
|
295
|
+
).one()
|
|
296
|
+
return DashboardUserCounts(
|
|
297
|
+
total=int(row[0]),
|
|
298
|
+
active=int(row[1]),
|
|
299
|
+
invited=int(row[2]),
|
|
300
|
+
disabled=int(row[3]),
|
|
301
|
+
non_admin=int(row[4]),
|
|
302
|
+
pending_invites=int(row[2]),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _live_account_filter(now: datetime) -> ColumnElement[bool]:
|
|
307
|
+
live_invite = (
|
|
308
|
+
select(DashboardUserInvite.id)
|
|
309
|
+
.where(DashboardUserInvite.user_id == DashboardUser.id)
|
|
310
|
+
.where(live_invite_filter(now))
|
|
311
|
+
.exists()
|
|
312
|
+
)
|
|
313
|
+
return or_(DashboardUser.status != DashboardUserStatus.INVITED.value, live_invite)
|
|
314
|
+
|
|
315
|
+
# --- write serialisation and guarded writes ---
|
|
316
|
+
|
|
317
|
+
async def acquire_write_intent(self) -> None:
|
|
318
|
+
"""Serialise concurrent account mutations before any invariant is read.
|
|
319
|
+
|
|
320
|
+
SQLite: ``BEGIN IMMEDIATE`` takes the database write lock up front (the
|
|
321
|
+
same pattern the accounts repository uses), so two admins disabling
|
|
322
|
+
each other queue instead of both passing the last-admin check.
|
|
323
|
+
PostgreSQL: lock the active admin rows ``FOR UPDATE``; every mutation
|
|
324
|
+
that could change who counts as an admin must go through them.
|
|
325
|
+
|
|
326
|
+
``BEGIN IMMEDIATE`` cannot run inside an open transaction, so a caller
|
|
327
|
+
that has already read something falls back to a row-less ``UPDATE``.
|
|
328
|
+
That statement is not a no-op for locking: SQLite opens the table for
|
|
329
|
+
writing and takes the RESERVED lock before evaluating the predicate,
|
|
330
|
+
which is the whole point of the fallback. Callers should still acquire
|
|
331
|
+
before their first read, so the two dialects take the lock at the same
|
|
332
|
+
moment and neither depends on that subtlety.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
if self._session.get_bind().dialect.name == "sqlite":
|
|
336
|
+
try:
|
|
337
|
+
await self._session.execute(text("BEGIN IMMEDIATE"))
|
|
338
|
+
except OperationalError as exc:
|
|
339
|
+
if "within a transaction" not in str(exc).lower():
|
|
340
|
+
raise
|
|
341
|
+
await self._session.execute(text("UPDATE dashboard_users SET id = id WHERE 1 = 0"))
|
|
342
|
+
return
|
|
343
|
+
await self._session.execute(
|
|
344
|
+
select(DashboardUser.id)
|
|
345
|
+
.where(DashboardUser.status == DashboardUserStatus.ACTIVE.value)
|
|
346
|
+
.where(DashboardUser.role_id == PRESET_ROLE_IDS[PresetRoleSlug.ADMIN])
|
|
347
|
+
.with_for_update()
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _other_active_admin_exists(user_id: str) -> ColumnElement[bool]:
|
|
352
|
+
other = aliased(DashboardUser)
|
|
353
|
+
return (
|
|
354
|
+
select(other.id)
|
|
355
|
+
.where(other.id != user_id)
|
|
356
|
+
.where(other.status == DashboardUserStatus.ACTIVE.value)
|
|
357
|
+
.where(other.role_id == PRESET_ROLE_IDS[PresetRoleSlug.ADMIN])
|
|
358
|
+
.exists()
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _other_qualifying_break_glass_exists(self, user_id: str) -> ColumnElement[bool]:
|
|
362
|
+
other = aliased(DashboardUser)
|
|
363
|
+
return (
|
|
364
|
+
select(other.id)
|
|
365
|
+
.where(other.id != user_id)
|
|
366
|
+
.where(other.is_break_glass.is_(True))
|
|
367
|
+
.where(other.status == DashboardUserStatus.ACTIVE.value)
|
|
368
|
+
.where(other.role_id == PRESET_ROLE_IDS[PresetRoleSlug.ADMIN])
|
|
369
|
+
.where(other.totp_secret_encrypted.is_not(None))
|
|
370
|
+
.where(other.password_hash.is_not(None))
|
|
371
|
+
.exists()
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
async def update_role_status_guarded(
|
|
375
|
+
self,
|
|
376
|
+
user_id: str,
|
|
377
|
+
*,
|
|
378
|
+
role_id: str,
|
|
379
|
+
status: str,
|
|
380
|
+
is_break_glass: bool | None = None,
|
|
381
|
+
require_other_admin: bool = True,
|
|
382
|
+
require_other_break_glass: bool = False,
|
|
383
|
+
) -> bool:
|
|
384
|
+
"""Write role/status (and the designation) only while the invariants still hold (no commit).
|
|
385
|
+
|
|
386
|
+
``False`` means the conditional UPDATE matched no row: another writer
|
|
387
|
+
removed the last other admin, or the last other qualifying break-glass
|
|
388
|
+
account, between the read and this statement.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
stmt = update(DashboardUser).where(DashboardUser.id == user_id)
|
|
392
|
+
if require_other_admin:
|
|
393
|
+
stmt = stmt.where(self._other_active_admin_exists(user_id))
|
|
394
|
+
if require_other_break_glass:
|
|
395
|
+
stmt = stmt.where(self._other_qualifying_break_glass_exists(user_id))
|
|
396
|
+
values: dict[str, object] = {"role_id": role_id, "status": status}
|
|
397
|
+
if is_break_glass is not None:
|
|
398
|
+
values["is_break_glass"] = is_break_glass
|
|
399
|
+
result = await self._session.execute(
|
|
400
|
+
stmt.values(**values).returning(DashboardUser.id).execution_options(synchronize_session=False)
|
|
401
|
+
)
|
|
402
|
+
return result.scalar_one_or_none() is not None
|
|
403
|
+
|
|
404
|
+
# --- invites ---
|
|
405
|
+
|
|
406
|
+
async def list_live_invites(self, now: datetime) -> Sequence[DashboardUserInvite]:
|
|
407
|
+
stmt = (
|
|
408
|
+
_invite_query()
|
|
409
|
+
.join(DashboardUser, DashboardUser.id == DashboardUserInvite.user_id)
|
|
410
|
+
.where(live_invite_filter(now))
|
|
411
|
+
.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
412
|
+
.order_by(DashboardUserInvite.created_at.asc(), DashboardUserInvite.id.asc())
|
|
413
|
+
)
|
|
414
|
+
return (await self._session.execute(stmt)).scalars().all()
|
|
415
|
+
|
|
416
|
+
async def get_invite_by_token_hash(self, token_hash: bytes) -> DashboardUserInvite | None:
|
|
417
|
+
stmt = _invite_query().where(DashboardUserInvite.token_hash == token_hash)
|
|
418
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
419
|
+
|
|
420
|
+
async def get_invite_for_user(self, user_id: str) -> DashboardUserInvite | None:
|
|
421
|
+
stmt = _invite_query().where(DashboardUserInvite.user_id == user_id)
|
|
422
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
423
|
+
|
|
424
|
+
async def consume_invite(self, invite_id: str, *, token_hash: bytes, now: datetime) -> bool:
|
|
425
|
+
"""Compare-and-set the invite as consumed; ``False`` when it is no longer live (no commit).
|
|
426
|
+
|
|
427
|
+
The presented token hash is part of the predicate so a resend that
|
|
428
|
+
rotated the token between lookup and consume fails the accept.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
# The invite row is already in the identity map (loaded with a naive
|
|
432
|
+
# SQLite timestamp), so the ORM must not try to evaluate the aware
|
|
433
|
+
# ``expires_at > now`` criterion in Python; the database decides.
|
|
434
|
+
result = await self._session.execute(
|
|
435
|
+
update(DashboardUserInvite)
|
|
436
|
+
.where(DashboardUserInvite.id == invite_id)
|
|
437
|
+
.where(DashboardUserInvite.token_hash == token_hash)
|
|
438
|
+
.where(live_invite_filter(now))
|
|
439
|
+
.values(consumed_at=now)
|
|
440
|
+
.returning(DashboardUserInvite.id)
|
|
441
|
+
.execution_options(synchronize_session=False)
|
|
442
|
+
)
|
|
443
|
+
return result.scalar_one_or_none() is not None
|
|
444
|
+
|
|
445
|
+
async def find_invite_expecting_identity(
|
|
446
|
+
self, provider: str, provider_key: str, subject: str, now: datetime
|
|
447
|
+
) -> DashboardUserInvite | None:
|
|
448
|
+
"""The live invite of an ``invited`` account pre-created for exactly this identity triple."""
|
|
449
|
+
|
|
450
|
+
stmt = (
|
|
451
|
+
_invite_query()
|
|
452
|
+
.join(DashboardUser, DashboardUser.id == DashboardUserInvite.user_id)
|
|
453
|
+
.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
454
|
+
.where(live_invite_filter(now))
|
|
455
|
+
.where(DashboardUserInvite.expected_provider == provider)
|
|
456
|
+
.where(DashboardUserInvite.expected_provider_key == provider_key)
|
|
457
|
+
.where(DashboardUserInvite.expected_subject == subject)
|
|
458
|
+
.order_by(DashboardUserInvite.created_at.asc(), DashboardUserInvite.id.asc())
|
|
459
|
+
)
|
|
460
|
+
return (await self._session.execute(stmt)).scalars().first()
|
|
461
|
+
|
|
462
|
+
async def consume_invite_by_identity(self, invite_id: str, *, now: datetime) -> bool:
|
|
463
|
+
"""Consume the invite via its expected identity (compare-and-set); ``False`` = no longer live (no commit)."""
|
|
464
|
+
|
|
465
|
+
result = await self._session.execute(
|
|
466
|
+
update(DashboardUserInvite)
|
|
467
|
+
.where(DashboardUserInvite.id == invite_id)
|
|
468
|
+
.where(live_invite_filter(now))
|
|
469
|
+
.values(consumed_at=now)
|
|
470
|
+
.returning(DashboardUserInvite.id)
|
|
471
|
+
.execution_options(synchronize_session=False)
|
|
472
|
+
)
|
|
473
|
+
return result.scalar_one_or_none() is not None
|
|
474
|
+
|
|
475
|
+
async def live_invite_for_user(self, user_id: str, now: datetime) -> DashboardUserInvite | None:
|
|
476
|
+
"""The live invite of a still-``invited`` account, or ``None``."""
|
|
477
|
+
|
|
478
|
+
stmt = (
|
|
479
|
+
_invite_query()
|
|
480
|
+
.join(DashboardUser, DashboardUser.id == DashboardUserInvite.user_id)
|
|
481
|
+
.where(DashboardUserInvite.user_id == user_id)
|
|
482
|
+
.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
483
|
+
.where(live_invite_filter(now))
|
|
484
|
+
)
|
|
485
|
+
return (await self._session.execute(stmt)).scalar_one_or_none()
|
|
486
|
+
|
|
487
|
+
async def conflicting_field(self, username: str, email: str | None) -> str | None:
|
|
488
|
+
"""After a unique violation: which of the two candidate values already exists."""
|
|
489
|
+
|
|
490
|
+
taken = select(DashboardUser.id).where(DashboardUser.username == username)
|
|
491
|
+
if (await self._session.execute(taken)).first() is not None:
|
|
492
|
+
return "username"
|
|
493
|
+
if email is not None:
|
|
494
|
+
taken = select(DashboardUser.id).where(DashboardUser.email == email)
|
|
495
|
+
if (await self._session.execute(taken)).first() is not None:
|
|
496
|
+
return "email"
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
async def delete_invite(self, user_id: str) -> bool:
|
|
500
|
+
"""Delete the account's invite row and keep the account (no commit); ``False`` = there was none.
|
|
501
|
+
|
|
502
|
+
The one primitive nothing else offers. A human revoke on an account
|
|
503
|
+
that never accepted deletes the account too, and the lazy purge does
|
|
504
|
+
the same; a back-channel deactivation must leave the row behind,
|
|
505
|
+
because the caller has to keep finding that person afterwards. The
|
|
506
|
+
DELETE is not conditional on the invite still being live: an account
|
|
507
|
+
moving out of ``invited`` has no use for a spent one either, and
|
|
508
|
+
leaving it would strand a row the purge — which only looks at
|
|
509
|
+
``invited`` accounts — would never reach again.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
deleted = await self._session.execute(
|
|
513
|
+
delete(DashboardUserInvite)
|
|
514
|
+
.where(DashboardUserInvite.user_id == user_id)
|
|
515
|
+
.returning(DashboardUserInvite.id)
|
|
516
|
+
.execution_options(synchronize_session=False)
|
|
517
|
+
)
|
|
518
|
+
return deleted.scalar_one_or_none() is not None
|
|
519
|
+
|
|
520
|
+
async def purge_expired_invited_users(self, now: datetime) -> int:
|
|
521
|
+
"""Delete ``invited`` accounts whose invite expired (row + invite); returns how many.
|
|
522
|
+
|
|
523
|
+
One conditional DELETE evaluates ``status = 'invited'`` and "no live
|
|
524
|
+
invite" in the deleting transaction itself, so an acceptance that
|
|
525
|
+
committed in the meantime (status now ``active``) is never swept up.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
expired_invite = (
|
|
529
|
+
select(DashboardUserInvite.id)
|
|
530
|
+
.where(DashboardUserInvite.user_id == DashboardUser.id)
|
|
531
|
+
.where(DashboardUserInvite.consumed_at.is_(None))
|
|
532
|
+
.where(DashboardUserInvite.sso_only.is_(False))
|
|
533
|
+
.where(DashboardUserInvite.expires_at <= now)
|
|
534
|
+
.exists()
|
|
535
|
+
)
|
|
536
|
+
live_invite = (
|
|
537
|
+
select(DashboardUserInvite.id)
|
|
538
|
+
.where(DashboardUserInvite.user_id == DashboardUser.id)
|
|
539
|
+
.where(live_invite_filter(now))
|
|
540
|
+
.exists()
|
|
541
|
+
)
|
|
542
|
+
deleted = (
|
|
543
|
+
await self._session.execute(
|
|
544
|
+
delete(DashboardUser)
|
|
545
|
+
.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
546
|
+
.where(expired_invite)
|
|
547
|
+
.where(~live_invite)
|
|
548
|
+
.returning(DashboardUser.id)
|
|
549
|
+
.execution_options(synchronize_session=False)
|
|
550
|
+
)
|
|
551
|
+
).scalars()
|
|
552
|
+
purged = list(deleted.all())
|
|
553
|
+
if not purged:
|
|
554
|
+
await self._session.rollback()
|
|
555
|
+
return 0
|
|
556
|
+
# The FK cascades on both dialects; delete explicitly so no ORM state lingers.
|
|
557
|
+
await self._session.execute(delete(DashboardUserInvite).where(DashboardUserInvite.user_id.in_(purged)))
|
|
558
|
+
await self._session.commit()
|
|
559
|
+
return len(purged)
|
|
560
|
+
|
|
561
|
+
async def rotate_invite(self, user_id: str, *, token_hash: bytes, expires_at: datetime) -> bool:
|
|
562
|
+
"""Rotate the invite only while the account is still ``invited`` (no commit); ``False`` = refused."""
|
|
563
|
+
|
|
564
|
+
still_invited = (
|
|
565
|
+
select(DashboardUser.id)
|
|
566
|
+
.where(DashboardUser.id == DashboardUserInvite.user_id)
|
|
567
|
+
.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
568
|
+
.exists()
|
|
569
|
+
)
|
|
570
|
+
result = await self._session.execute(
|
|
571
|
+
update(DashboardUserInvite)
|
|
572
|
+
.where(DashboardUserInvite.user_id == user_id)
|
|
573
|
+
.where(still_invited)
|
|
574
|
+
.values(token_hash=token_hash, expires_at=expires_at, consumed_at=None, revoked_at=None)
|
|
575
|
+
.returning(DashboardUserInvite.id)
|
|
576
|
+
.execution_options(synchronize_session=False)
|
|
577
|
+
)
|
|
578
|
+
return result.scalar_one_or_none() is not None
|
|
579
|
+
|
|
580
|
+
# --- writes ---
|
|
581
|
+
|
|
582
|
+
def add(self, *rows: DashboardUser | DashboardUserInvite | DashboardIdentity) -> None:
|
|
583
|
+
self._session.add_all(rows)
|
|
584
|
+
|
|
585
|
+
async def commit_user(self, user_id: str, *, bump_generation: bool = False) -> DashboardUser:
|
|
586
|
+
"""Commit pending changes, optionally bumping ``session_generation`` atomically
|
|
587
|
+
(``SET x = x + 1`` in the same transaction, never from a stale ORM value),
|
|
588
|
+
and return the freshly loaded account. Any failure rolls the whole write back."""
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
await self._session.flush()
|
|
592
|
+
if bump_generation:
|
|
593
|
+
await self._session.execute(
|
|
594
|
+
update(DashboardUser)
|
|
595
|
+
.where(DashboardUser.id == user_id)
|
|
596
|
+
.values(session_generation=DashboardUser.session_generation + 1)
|
|
597
|
+
)
|
|
598
|
+
await self._session.commit()
|
|
599
|
+
except Exception:
|
|
600
|
+
await self._session.rollback()
|
|
601
|
+
raise
|
|
602
|
+
self._session.expire_all()
|
|
603
|
+
user = await self.get_by_id(user_id)
|
|
604
|
+
if user is None: # pragma: no cover - the row was committed a statement ago
|
|
605
|
+
raise RuntimeError(f"dashboard user {user_id} vanished after commit")
|
|
606
|
+
return user
|
|
607
|
+
|
|
608
|
+
async def rollback(self) -> None:
|
|
609
|
+
await self._session.rollback()
|
|
610
|
+
|
|
611
|
+
async def _lock_owner(self, user_id: str) -> None:
|
|
612
|
+
"""Take the owner's row before reading which of its keys the cascade must move.
|
|
613
|
+
|
|
614
|
+
Both directions of the cascade read ``api_keys`` and then write it,
|
|
615
|
+
while the API-key page's ``isActive: true`` reads the owner's status
|
|
616
|
+
(``FOR UPDATE`` on this same row) and then writes the key. Under
|
|
617
|
+
PostgreSQL's READ COMMITTED neither read is repeated, so with the two
|
|
618
|
+
rows taken in opposite orders a key switched on after the cascade's
|
|
619
|
+
scan — but committed before the disable — survives active on a
|
|
620
|
+
disabled owner. Taking the owner row first puts both sides in one
|
|
621
|
+
order: whoever loses the row reads the winner's committed state.
|
|
622
|
+
|
|
623
|
+
SQLite ignores ``FOR UPDATE`` and needs nothing here: its writers are
|
|
624
|
+
serialised by the database write lock the callers already hold.
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
await self._session.execute(select(DashboardUser.id).where(DashboardUser.id == user_id).with_for_update())
|
|
628
|
+
|
|
629
|
+
async def deactivate_owned_keys(self, user_id: str) -> list[str]:
|
|
630
|
+
"""Turn off every active key the account owns, recording why (no commit); returns their hashes.
|
|
631
|
+
|
|
632
|
+
The owner's row is taken first so "which keys are active" is read under
|
|
633
|
+
the lock the API-key page holds while it re-enables one.
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
await self._lock_owner(user_id)
|
|
637
|
+
result = await self._session.execute(
|
|
638
|
+
update(ApiKey)
|
|
639
|
+
.where(ApiKey.owner_user_id == user_id)
|
|
640
|
+
.where(ApiKey.is_active.is_(True))
|
|
641
|
+
.values(is_active=False, deactivated_reason=ApiKeyDeactivatedReason.OWNER_DISABLED.value)
|
|
642
|
+
.returning(ApiKey.key_hash)
|
|
643
|
+
)
|
|
644
|
+
return list(result.scalars().all())
|
|
645
|
+
|
|
646
|
+
async def reactivate_owner_disabled_keys(self, user_id: str) -> list[str] | None:
|
|
647
|
+
"""Restore only the keys the owner cascade turned off; manual blocks stay off.
|
|
648
|
+
|
|
649
|
+
The owner's row is taken first (see :meth:`_lock_owner`) and the UPDATE
|
|
650
|
+
is conditional on the owner being active at that moment; a disable that
|
|
651
|
+
is already in flight therefore either commits first — and this reads
|
|
652
|
+
its ``disabled`` — or waits for this and then finds the restored keys
|
|
653
|
+
active. ``None`` means the owner is not active (nothing was written).
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
await self._lock_owner(user_id)
|
|
657
|
+
owner_active = (
|
|
658
|
+
select(DashboardUser.id)
|
|
659
|
+
.where(DashboardUser.id == ApiKey.owner_user_id)
|
|
660
|
+
.where(DashboardUser.status == DashboardUserStatus.ACTIVE.value)
|
|
661
|
+
.exists()
|
|
662
|
+
)
|
|
663
|
+
result = await self._session.execute(
|
|
664
|
+
update(ApiKey)
|
|
665
|
+
.where(ApiKey.owner_user_id == user_id)
|
|
666
|
+
.where(ApiKey.is_active.is_(False))
|
|
667
|
+
.where(ApiKey.deactivated_reason == ApiKeyDeactivatedReason.OWNER_DISABLED.value)
|
|
668
|
+
.where(owner_active)
|
|
669
|
+
.values(is_active=True, deactivated_reason=None)
|
|
670
|
+
.returning(ApiKey.key_hash)
|
|
671
|
+
.execution_options(synchronize_session=False)
|
|
672
|
+
)
|
|
673
|
+
hashes = list(result.scalars().all())
|
|
674
|
+
if not hashes:
|
|
675
|
+
status = (
|
|
676
|
+
await self._session.execute(select(DashboardUser.status).where(DashboardUser.id == user_id))
|
|
677
|
+
).scalar_one_or_none()
|
|
678
|
+
if status != DashboardUserStatus.ACTIVE.value:
|
|
679
|
+
await self._session.rollback()
|
|
680
|
+
return None
|
|
681
|
+
await self._session.commit()
|
|
682
|
+
return hashes
|
|
683
|
+
|
|
684
|
+
async def delete_user(
|
|
685
|
+
self,
|
|
686
|
+
user: DashboardUser,
|
|
687
|
+
*,
|
|
688
|
+
require_other_admin: bool = False,
|
|
689
|
+
require_other_break_glass: bool = False,
|
|
690
|
+
only_while_invited: bool = False,
|
|
691
|
+
) -> list[str] | None:
|
|
692
|
+
"""Delete the account; its keys stay (owner cleared) but are inactive with the owner reason.
|
|
693
|
+
|
|
694
|
+
With ``require_other_admin`` the DELETE only applies while another
|
|
695
|
+
active admin exists; with ``only_while_invited`` only while the account
|
|
696
|
+
is still ``invited`` (an acceptance that committed meanwhile survives).
|
|
697
|
+
``None`` means it was refused and everything (including the key
|
|
698
|
+
cascade) was rolled back.
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
hashes = await self.deactivate_owned_keys(user.id)
|
|
703
|
+
await self._session.execute(
|
|
704
|
+
update(ApiKey).where(ApiKey.owner_user_id == user.id).values(owner_user_id=None)
|
|
705
|
+
)
|
|
706
|
+
await self._session.execute(delete(DashboardUserInvite).where(DashboardUserInvite.user_id == user.id))
|
|
707
|
+
await self._session.execute(delete(DashboardIdentity).where(DashboardIdentity.user_id == user.id))
|
|
708
|
+
stmt = delete(DashboardUser).where(DashboardUser.id == user.id)
|
|
709
|
+
if require_other_admin:
|
|
710
|
+
stmt = stmt.where(self._other_active_admin_exists(user.id))
|
|
711
|
+
if require_other_break_glass:
|
|
712
|
+
stmt = stmt.where(self._other_qualifying_break_glass_exists(user.id))
|
|
713
|
+
if only_while_invited:
|
|
714
|
+
stmt = stmt.where(DashboardUser.status == DashboardUserStatus.INVITED.value)
|
|
715
|
+
deleted = await self._session.execute(
|
|
716
|
+
stmt.returning(DashboardUser.id).execution_options(synchronize_session=False)
|
|
717
|
+
)
|
|
718
|
+
if deleted.scalar_one_or_none() is None:
|
|
719
|
+
await self._session.rollback()
|
|
720
|
+
return None
|
|
721
|
+
await self._session.commit()
|
|
722
|
+
except Exception:
|
|
723
|
+
await self._session.rollback()
|
|
724
|
+
raise
|
|
725
|
+
return hashes
|