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,786 @@
|
|
|
1
|
+
"""What a SCIM push means in this product's own terms.
|
|
2
|
+
|
|
3
|
+
Three rules shape everything below. An account is resolved by its identity
|
|
4
|
+
triple and never by a ``userName`` — the pushed name is an input to naming, not
|
|
5
|
+
a claim about which account is meant. Every read and write is bound to the
|
|
6
|
+
calling token's ``provider_key``, which is the principal-free stand-in for
|
|
7
|
+
``assert_can_act_on``. And a pushed role is bounded in code below admin level,
|
|
8
|
+
because a bearer token holds no grants and could therefore never satisfy the
|
|
9
|
+
delegation check every other role-assignment path in this product passes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field, replace
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any, NoReturn
|
|
18
|
+
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from app.core.audit.service import AuditActor, AuditDetails, AuditService, AuditSeverity, AuditTarget
|
|
22
|
+
from app.core.audit.types import AuditAuthMethod
|
|
23
|
+
from app.core.auth.dashboard_access import PresetRoleSlug, is_admin_level
|
|
24
|
+
from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
|
|
25
|
+
from app.core.auth.providers import ExternalIdentity
|
|
26
|
+
from app.core.utils.request_id import get_request_id
|
|
27
|
+
from app.core.utils.time import utcnow
|
|
28
|
+
from app.db.models import (
|
|
29
|
+
DashboardIdentity,
|
|
30
|
+
DashboardRoleRecord,
|
|
31
|
+
DashboardUser,
|
|
32
|
+
DashboardUserRoleSource,
|
|
33
|
+
DashboardUserStatus,
|
|
34
|
+
)
|
|
35
|
+
from app.modules.dashboard_roles.repository import DashboardRolesRepository
|
|
36
|
+
from app.modules.dashboard_roles.service import (
|
|
37
|
+
RoleNotAssignableError,
|
|
38
|
+
resolve_assignable_role,
|
|
39
|
+
resolve_role_grants,
|
|
40
|
+
role_assignable_to_users,
|
|
41
|
+
)
|
|
42
|
+
from app.modules.dashboard_users.break_glass import LastBreakGlassProtectedError
|
|
43
|
+
from app.modules.dashboard_users.identity_resolver import IdentityResolver, InviteLinkFailure
|
|
44
|
+
from app.modules.dashboard_users.repository import DashboardUsersRepository, normalize_email, utc_now
|
|
45
|
+
from app.modules.dashboard_users.service import (
|
|
46
|
+
DashboardUsersService,
|
|
47
|
+
InvitePendingError,
|
|
48
|
+
LastAdminProtectedError,
|
|
49
|
+
UserNotFoundError,
|
|
50
|
+
)
|
|
51
|
+
from app.modules.scim import errors
|
|
52
|
+
from app.modules.scim.repository import MAX_LIST_COUNT, SCIM_PROVIDER, ScimUsersRepository
|
|
53
|
+
from app.modules.scim.schemas import (
|
|
54
|
+
LIST_RESPONSE_SCHEMA,
|
|
55
|
+
USER_SCHEMA,
|
|
56
|
+
ScimMultiValue,
|
|
57
|
+
ScimPatchRequest,
|
|
58
|
+
ScimUserRequest,
|
|
59
|
+
coerce_active,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: Where ``source`` lands in the audit rows the shared lifecycle functions write.
|
|
63
|
+
AUDIT_SOURCE = "scim"
|
|
64
|
+
#: A ``userName`` that slugifies to nothing becomes this plus a digest, so a
|
|
65
|
+
#: local account name says which path provisioned it.
|
|
66
|
+
SLUG_PREFIX = "scim-"
|
|
67
|
+
#: The role a create with no ``roles`` attribute gets. A stated default for an
|
|
68
|
+
#: *absent* attribute — never a fallback for a refused one, which is the
|
|
69
|
+
#: failure mode this surface exists not to repeat.
|
|
70
|
+
DEFAULT_ROLE_SLUG = PresetRoleSlug.VIEWER.value
|
|
71
|
+
|
|
72
|
+
#: RFC 7644 §3.4.2.2 defines a whole filter grammar. This surface answers the
|
|
73
|
+
#: one expression an identity provider needs to reconcile the resources it
|
|
74
|
+
#: created and refuses the rest by name rather than half-implementing it.
|
|
75
|
+
_USER_NAME_FILTER = re.compile(r'^\s*userName\s+eq\s+(?P<quote>["\'])(?P<value>[^"\']*)(?P=quote)\s*$', re.IGNORECASE)
|
|
76
|
+
_SUPPORTED_FILTER = 'userName eq "<value>"'
|
|
77
|
+
_PATCH_OPS = frozenset({"add", "replace"})
|
|
78
|
+
#: Which resource attribute a ``path`` may name, keyed case-folded. Operations
|
|
79
|
+
#: without a path (Okta's spelling) carry the same names as keys of an object
|
|
80
|
+
#: instead, and go through the very same table.
|
|
81
|
+
_PATCH_ATTRIBUTES: dict[str, str] = {
|
|
82
|
+
"active": "active",
|
|
83
|
+
"username": "userName",
|
|
84
|
+
"displayname": "displayName",
|
|
85
|
+
"externalid": "externalId",
|
|
86
|
+
"emails": "emails",
|
|
87
|
+
"roles": "roles",
|
|
88
|
+
"name": "name",
|
|
89
|
+
}
|
|
90
|
+
#: The attributes a request may name. ``externalId`` is in the set because a
|
|
91
|
+
#: ``PUT`` carries it, but it names no change: it is re-read as an immutability
|
|
92
|
+
#: guard and never written.
|
|
93
|
+
_ATTRIBUTES = frozenset({"userName", "displayName", "emails", "active", "roles", "externalId", "name"})
|
|
94
|
+
#: The two whose value is a list of ``{"value": …}`` entries on the wire.
|
|
95
|
+
_MULTI_VALUED = frozenset({"emails", "roles"})
|
|
96
|
+
|
|
97
|
+
_ROLE_REFUSAL_DETAILS: dict[str, str] = {
|
|
98
|
+
"unknown_role": "No role named '{value}' exists on this install.",
|
|
99
|
+
"role_not_assignable": (
|
|
100
|
+
"The role '{value}' exists but is not one this release assigns to an account, so it cannot be provisioned."
|
|
101
|
+
),
|
|
102
|
+
"role_not_provisionable": (
|
|
103
|
+
"The role '{value}' is administrative; an administrator assigns it by hand and a provisioning push cannot."
|
|
104
|
+
),
|
|
105
|
+
}
|
|
106
|
+
#: The audit action written when a pushed role is deliberately not applied.
|
|
107
|
+
#: The push still succeeds; the row exists so an operator can see that the
|
|
108
|
+
#: identity provider's role did not win, and it is read back by the next push
|
|
109
|
+
#: so that it is written once per account rather than on every sync.
|
|
110
|
+
ROLE_KEPT_ACTION = "scim_role_push_ignored"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scim_actor() -> AuditActor:
|
|
114
|
+
"""Who a push is attributed to. There is no account, so nothing pretends there is."""
|
|
115
|
+
|
|
116
|
+
return AuditActor(user_id=None, username=None, role_slug=None, auth_method=AuditAuthMethod.SCIM.value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class ScimChanges:
|
|
121
|
+
"""What one request asks to change, whichever verb asked.
|
|
122
|
+
|
|
123
|
+
Membership of ``touched`` is the difference between "set this to nothing"
|
|
124
|
+
and "leave this alone", and it is the *only* thing the two verbs disagree
|
|
125
|
+
about: a ``PUT`` replaces the resource, so an attribute it omits is
|
|
126
|
+
cleared, while a ``PATCH`` names the attributes it means and every other
|
|
127
|
+
one survives untouched. Both arrive here through one reading of one
|
|
128
|
+
request model, so no other difference between them is possible.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
touched: frozenset[str] = field(default_factory=frozenset)
|
|
132
|
+
user_name: str | None = None
|
|
133
|
+
display_name: str | None = None
|
|
134
|
+
email: str | None = None
|
|
135
|
+
active: bool | None = None
|
|
136
|
+
role_slug: str | None = None
|
|
137
|
+
|
|
138
|
+
def has(self, attribute: str) -> bool:
|
|
139
|
+
return attribute in self.touched
|
|
140
|
+
|
|
141
|
+
def with_value(self, attribute: str, **values: Any) -> ScimChanges:
|
|
142
|
+
return replace(self, touched=self.touched | {attribute}, **values)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _primary(values: list[ScimMultiValue]) -> str | None:
|
|
146
|
+
for entry in values:
|
|
147
|
+
if entry.primary and entry.value:
|
|
148
|
+
return entry.value
|
|
149
|
+
for entry in values:
|
|
150
|
+
if entry.value:
|
|
151
|
+
return entry.value
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _display_name(payload: ScimUserRequest) -> str | None:
|
|
156
|
+
if payload.display_name:
|
|
157
|
+
return payload.display_name
|
|
158
|
+
if payload.name is None:
|
|
159
|
+
return None
|
|
160
|
+
if payload.name.formatted:
|
|
161
|
+
return payload.name.formatted
|
|
162
|
+
parts = [part for part in (payload.name.given_name, payload.name.family_name) if part]
|
|
163
|
+
return " ".join(parts) or None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pushed_role(values: list[ScimMultiValue]) -> str | None:
|
|
167
|
+
"""At most one role travels on a push; two would be an ambiguity, not a set."""
|
|
168
|
+
|
|
169
|
+
if not values:
|
|
170
|
+
return None
|
|
171
|
+
if len(values) > 1:
|
|
172
|
+
raise errors.invalid_value("Exactly one role may be provisioned for an account.")
|
|
173
|
+
return values[0].value if values[0].value is not None else ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _as_multi_valued(value: Any) -> Any:
|
|
177
|
+
"""A patched multi-valued attribute in the one shape the request model reads.
|
|
178
|
+
|
|
179
|
+
Identity providers spell ``emails`` and ``roles`` three ways in a patch —
|
|
180
|
+
a bare string, one object, or the list the resource schema actually
|
|
181
|
+
declares — and only the third is what a ``PUT`` body carries. Widening the
|
|
182
|
+
first two here rather than parsing them separately is what lets both verbs
|
|
183
|
+
share one model, one set of length caps and one ``primary`` rule.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
return [{"value": value}]
|
|
188
|
+
if isinstance(value, dict):
|
|
189
|
+
return [value]
|
|
190
|
+
if isinstance(value, list):
|
|
191
|
+
return [{"value": entry} if isinstance(entry, str) else entry for entry in value]
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _record(attributes: dict[str, Any], path: str, value: Any) -> None:
|
|
196
|
+
"""Fold one patch operation into the resource attributes it names."""
|
|
197
|
+
|
|
198
|
+
attribute = _PATCH_ATTRIBUTES.get(path.strip().casefold())
|
|
199
|
+
if attribute is None:
|
|
200
|
+
# Ignored, not refused — the rule a replace body already follows.
|
|
201
|
+
# `title`, `locale`, phone numbers and an enterprise extension ride on
|
|
202
|
+
# every routine Okta or Entra push, and refusing them would turn that
|
|
203
|
+
# push into a 400 while protecting nothing the byte bound does not.
|
|
204
|
+
return
|
|
205
|
+
if attribute in attributes:
|
|
206
|
+
raise errors.invalid_syntax(f"The attribute '{attribute}' is named more than once in one request.")
|
|
207
|
+
attributes[attribute] = _as_multi_valued(value) if attribute in _MULTI_VALUED else value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validated(attributes: dict[str, Any]) -> ScimUserRequest:
|
|
211
|
+
"""The folded patch, read by the model a ``PUT`` body is read by.
|
|
212
|
+
|
|
213
|
+
This is the whole of "the two verbs agree": a patch is turned into the
|
|
214
|
+
attributes of a user resource and then validated exactly once, by the same
|
|
215
|
+
model, so the length caps, the cardinality caps and the ``primary`` rule
|
|
216
|
+
cannot be present on one verb and missing on the other. A refusal is
|
|
217
|
+
spelled the way the same over-long value is spelled on a ``PUT``.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
return ScimUserRequest.model_validate(attributes)
|
|
222
|
+
except ValidationError as exc:
|
|
223
|
+
raise errors.invalid_syntax("The request body is not a valid SCIM resource.") from exc
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _stamp(value: datetime | None) -> str | None:
|
|
227
|
+
"""A naive-UTC column as an RFC 3339 instant, the way the rest of the API renders them."""
|
|
228
|
+
|
|
229
|
+
if value is None:
|
|
230
|
+
return None
|
|
231
|
+
return value.isoformat() + "Z" if value.tzinfo is None else value.isoformat().replace("+00:00", "Z")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ScimUsersService:
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
scim_repository: ScimUsersRepository,
|
|
238
|
+
users_repository: DashboardUsersRepository,
|
|
239
|
+
roles: DashboardRolesRepository,
|
|
240
|
+
resolver: IdentityResolver,
|
|
241
|
+
users_service: DashboardUsersService,
|
|
242
|
+
) -> None:
|
|
243
|
+
self._scim = scim_repository
|
|
244
|
+
self._users = users_repository
|
|
245
|
+
self._roles = roles
|
|
246
|
+
self._resolver = resolver
|
|
247
|
+
self._service = users_service
|
|
248
|
+
|
|
249
|
+
# --- projection ---
|
|
250
|
+
|
|
251
|
+
def resource(self, identity: DashboardIdentity, *, base_path: str) -> dict[str, Any]:
|
|
252
|
+
"""The SCIM user resource.
|
|
253
|
+
|
|
254
|
+
``userName`` is what the identity provider pushed, not the local
|
|
255
|
+
account name: the two differ by the slug rules, and the identity
|
|
256
|
+
provider reconciles on its own spelling.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
user = identity.user
|
|
260
|
+
role_slug = user.role.slug if self._provisionable(user.role) else None
|
|
261
|
+
return {
|
|
262
|
+
"schemas": [USER_SCHEMA],
|
|
263
|
+
"id": user.id,
|
|
264
|
+
"externalId": identity.subject,
|
|
265
|
+
"userName": identity.user_name or user.username,
|
|
266
|
+
"displayName": user.display_name,
|
|
267
|
+
"active": user.status == DashboardUserStatus.ACTIVE.value,
|
|
268
|
+
"emails": [{"value": identity.email, "primary": True}] if identity.email else [],
|
|
269
|
+
"roles": [{"value": role_slug}] if role_slug is not None else [],
|
|
270
|
+
"meta": {
|
|
271
|
+
"resourceType": "User",
|
|
272
|
+
"created": _stamp(user.created_at),
|
|
273
|
+
"lastModified": _stamp(user.updated_at),
|
|
274
|
+
# Root-relative on purpose: an absolute URI would have to be
|
|
275
|
+
# built from a caller-supplied ``Host``, and this install has
|
|
276
|
+
# no configured public base URL to build one from honestly.
|
|
277
|
+
"location": f"{base_path}/Users/{user.id}",
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def list_response(
|
|
282
|
+
self, identities: list[DashboardIdentity], *, total: int, start_index: int, base_path: str
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
return {
|
|
285
|
+
"schemas": [LIST_RESPONSE_SCHEMA],
|
|
286
|
+
"totalResults": total,
|
|
287
|
+
"startIndex": start_index,
|
|
288
|
+
"itemsPerPage": len(identities),
|
|
289
|
+
"Resources": [self.resource(identity, base_path=base_path) for identity in identities],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _provisionable(role: DashboardRoleRecord) -> bool:
|
|
294
|
+
"""Whether this surface would hand that role out; it reports no other."""
|
|
295
|
+
|
|
296
|
+
return role_assignable_to_users(role) and not is_admin_level(resolve_role_grants(role))
|
|
297
|
+
|
|
298
|
+
# --- reads ---
|
|
299
|
+
|
|
300
|
+
async def require_identity(self, provider_key: str, user_id: str) -> DashboardIdentity:
|
|
301
|
+
"""The resource a SCIM ``id`` names, inside this token's namespace only.
|
|
302
|
+
|
|
303
|
+
An account with no ``scim`` identity here answers ``404`` whether it
|
|
304
|
+
does not exist or belongs to another namespace: distinguishing the two
|
|
305
|
+
would turn this surface into an account enumerator, which is the
|
|
306
|
+
``users:manage`` read the token is denied.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
identity = await self._scim.get_identity_by_user(provider_key, user_id)
|
|
310
|
+
if identity is None:
|
|
311
|
+
raise errors.not_found()
|
|
312
|
+
return identity
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def parse_filter(raw: str | None) -> str | None:
|
|
316
|
+
if raw is None or not raw.strip():
|
|
317
|
+
return None
|
|
318
|
+
match = _USER_NAME_FILTER.match(raw)
|
|
319
|
+
if match is None:
|
|
320
|
+
raise errors.invalid_filter(f"The only supported filter is {_SUPPORTED_FILTER}.")
|
|
321
|
+
return match.group("value")
|
|
322
|
+
|
|
323
|
+
async def list_users(
|
|
324
|
+
self, provider_key: str, *, user_name: str | None, start_index: int, count: int
|
|
325
|
+
) -> tuple[list[DashboardIdentity], int]:
|
|
326
|
+
rows, total = await self._scim.list_identities(
|
|
327
|
+
provider_key,
|
|
328
|
+
user_name=user_name,
|
|
329
|
+
offset=max(start_index, 1) - 1,
|
|
330
|
+
limit=min(max(count, 0), MAX_LIST_COUNT),
|
|
331
|
+
)
|
|
332
|
+
return list(rows), total
|
|
333
|
+
|
|
334
|
+
# --- the role gate ---
|
|
335
|
+
|
|
336
|
+
async def resolve_pushed_role(
|
|
337
|
+
self,
|
|
338
|
+
raw: str | None,
|
|
339
|
+
*,
|
|
340
|
+
token_id: str,
|
|
341
|
+
external_id: str,
|
|
342
|
+
actor_ip: str | None,
|
|
343
|
+
) -> DashboardRoleRecord:
|
|
344
|
+
"""The role a push may hand out, or a refusal naming the value it refused.
|
|
345
|
+
|
|
346
|
+
A SCIM bearer token is not a principal and holds no grants, so
|
|
347
|
+
``assert_can_delegate`` — the gate every other role-assignment path in
|
|
348
|
+
this product passes — can never be satisfied here. The bound is
|
|
349
|
+
therefore in code: an assignable role that is not admin-level. An
|
|
350
|
+
administrative slug is refused with a reason of its own rather than
|
|
351
|
+
quietly downgraded, because an operator told "admin is not
|
|
352
|
+
provisionable" can act, and one whose admins silently became viewers
|
|
353
|
+
cannot.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
slug = DEFAULT_ROLE_SLUG if raw is None else raw.strip().casefold()
|
|
357
|
+
if not slug:
|
|
358
|
+
self._refuse_role("unknown_role", raw or "", token_id, external_id, actor_ip)
|
|
359
|
+
found = await self._roles.get_role_by_slug(slug)
|
|
360
|
+
if found is None:
|
|
361
|
+
self._refuse_role("unknown_role", raw or slug, token_id, external_id, actor_ip)
|
|
362
|
+
try:
|
|
363
|
+
role = await resolve_assignable_role(self._roles, found.id)
|
|
364
|
+
except RoleNotAssignableError:
|
|
365
|
+
self._refuse_role("role_not_assignable", raw or slug, token_id, external_id, actor_ip)
|
|
366
|
+
if is_admin_level(resolve_role_grants(role)):
|
|
367
|
+
self._refuse_role("role_not_provisionable", raw or slug, token_id, external_id, actor_ip)
|
|
368
|
+
return role
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _refuse_role(reason: str, value: str, token_id: str, external_id: str, actor_ip: str | None) -> NoReturn:
|
|
372
|
+
details: AuditDetails = {
|
|
373
|
+
"reason": reason,
|
|
374
|
+
"role": value,
|
|
375
|
+
"external_id": external_id,
|
|
376
|
+
"scim_token_id": token_id,
|
|
377
|
+
}
|
|
378
|
+
AuditService.log_async(
|
|
379
|
+
"scim_provision_refused",
|
|
380
|
+
actor_ip=actor_ip,
|
|
381
|
+
details=details,
|
|
382
|
+
actor=scim_actor(),
|
|
383
|
+
severity=AuditSeverity.WARNING,
|
|
384
|
+
)
|
|
385
|
+
raise errors.invalid_value(_ROLE_REFUSAL_DETAILS[reason].format(value=value))
|
|
386
|
+
|
|
387
|
+
# --- create ---
|
|
388
|
+
|
|
389
|
+
async def create_user(
|
|
390
|
+
self,
|
|
391
|
+
payload: ScimUserRequest,
|
|
392
|
+
*,
|
|
393
|
+
provider_key: str,
|
|
394
|
+
token_id: str,
|
|
395
|
+
actor_ip: str | None,
|
|
396
|
+
) -> DashboardIdentity:
|
|
397
|
+
user_name = (payload.user_name or "").strip()
|
|
398
|
+
if not user_name:
|
|
399
|
+
raise errors.invalid_value("userName is required.")
|
|
400
|
+
external_id = (payload.external_id or "").strip()
|
|
401
|
+
if not external_id:
|
|
402
|
+
raise errors.invalid_value("externalId is required: it is the subject the account is keyed by.")
|
|
403
|
+
active = True if payload.active is None else coerce_active(payload.active)
|
|
404
|
+
if active is None:
|
|
405
|
+
raise errors.invalid_value("active must be a boolean.")
|
|
406
|
+
|
|
407
|
+
existing = await self._scim.get_identity_by_subject(provider_key, external_id)
|
|
408
|
+
if existing is not None:
|
|
409
|
+
raise errors.already_exists(existing.user_id)
|
|
410
|
+
role = await self.resolve_pushed_role(
|
|
411
|
+
_pushed_role(payload.roles), token_id=token_id, external_id=external_id, actor_ip=actor_ip
|
|
412
|
+
)
|
|
413
|
+
identity = ExternalIdentity(
|
|
414
|
+
provider=SCIM_PROVIDER,
|
|
415
|
+
provider_key=provider_key,
|
|
416
|
+
subject=external_id,
|
|
417
|
+
email=normalize_email(_primary(payload.emails)),
|
|
418
|
+
display_name=_display_name(payload),
|
|
419
|
+
)
|
|
420
|
+
user = await self._provision(identity, role, user_name=user_name, actor_ip=actor_ip)
|
|
421
|
+
AuditService.log_async(
|
|
422
|
+
"scim_user_provisioned",
|
|
423
|
+
actor_ip=actor_ip,
|
|
424
|
+
details={
|
|
425
|
+
"username": user.username,
|
|
426
|
+
"external_id": external_id,
|
|
427
|
+
"role": role.slug,
|
|
428
|
+
"scim_token_id": token_id,
|
|
429
|
+
},
|
|
430
|
+
actor=scim_actor(),
|
|
431
|
+
target=AuditTarget("user", user.id),
|
|
432
|
+
)
|
|
433
|
+
if not active:
|
|
434
|
+
await self._set_active(user.id, False, token_id=token_id, actor_ip=actor_ip)
|
|
435
|
+
return await self.require_identity(provider_key, user.id)
|
|
436
|
+
|
|
437
|
+
async def _provision(
|
|
438
|
+
self, identity: ExternalIdentity, role: DashboardRoleRecord, *, user_name: str, actor_ip: str | None
|
|
439
|
+
) -> DashboardUser:
|
|
440
|
+
"""Reuse the resolver's rules rather than keep a second copy of them.
|
|
441
|
+
|
|
442
|
+
An invite pre-created for exactly this triple is honoured first, the
|
|
443
|
+
way a first sign-in would honour it, so a push cannot create a second
|
|
444
|
+
account beside one an administrator already prepared.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
now = utc_now()
|
|
448
|
+
invite = await self._users.find_invite_expecting_identity(
|
|
449
|
+
identity.provider, identity.provider_key, identity.subject, now
|
|
450
|
+
)
|
|
451
|
+
if invite is not None:
|
|
452
|
+
linked = await self._resolver.link_expected_invite(
|
|
453
|
+
invite, identity, now=now, actor_ip=actor_ip, user_name=user_name, record_login=False
|
|
454
|
+
)
|
|
455
|
+
if not isinstance(linked, InviteLinkFailure):
|
|
456
|
+
return linked
|
|
457
|
+
if linked.reason == "raced":
|
|
458
|
+
raced = await self._scim.get_identity_by_subject(identity.provider_key, identity.subject)
|
|
459
|
+
if raced is not None:
|
|
460
|
+
raise errors.already_exists(raced.user_id)
|
|
461
|
+
provisioned = await self._resolver.provision_account(
|
|
462
|
+
identity,
|
|
463
|
+
role_id=role.id,
|
|
464
|
+
role_slug=role.slug,
|
|
465
|
+
role_source=DashboardUserRoleSource.SCIM.value,
|
|
466
|
+
actor_ip=actor_ip,
|
|
467
|
+
user_name=user_name,
|
|
468
|
+
username_source=user_name,
|
|
469
|
+
empty_slug_prefix=SLUG_PREFIX,
|
|
470
|
+
record_login=False,
|
|
471
|
+
via=SCIM_PROVIDER,
|
|
472
|
+
)
|
|
473
|
+
if isinstance(provisioned, DashboardIdentity):
|
|
474
|
+
raise errors.already_exists(provisioned.user_id)
|
|
475
|
+
return provisioned
|
|
476
|
+
|
|
477
|
+
# --- update ---
|
|
478
|
+
|
|
479
|
+
def changes_from_replace(self, identity: DashboardIdentity, payload: ScimUserRequest) -> ScimChanges:
|
|
480
|
+
"""A ``PUT`` replaces the resource, so it names every attribute.
|
|
481
|
+
|
|
482
|
+
What it omits is cleared rather than kept, which is the whole of the
|
|
483
|
+
difference between the two verbs: the reading of each named attribute
|
|
484
|
+
is one function they share.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
return self._changes(identity, payload, _ATTRIBUTES)
|
|
488
|
+
|
|
489
|
+
def changes_from_patch(self, identity: DashboardIdentity, payload: ScimPatchRequest) -> ScimChanges:
|
|
490
|
+
"""Fold a PatchOp into a user resource and read it as a replace body.
|
|
491
|
+
|
|
492
|
+
Identity providers spell the same intent two ways — a ``path`` naming
|
|
493
|
+
one attribute, or no path and an object of attributes — so the fold
|
|
494
|
+
accepts both and then hands the result to the one parser. A patch is
|
|
495
|
+
therefore not a second, weaker reading of the same wire format: it
|
|
496
|
+
cannot forget a length cap, a ``primary`` flag or the one-role rule,
|
|
497
|
+
because it does not read any of them itself.
|
|
498
|
+
|
|
499
|
+
Two rules make the fold honest. An attribute this resource does not
|
|
500
|
+
model is ignored exactly as a ``PUT`` body ignores it — every identity
|
|
501
|
+
provider sends more than a core user — and an attribute named twice in
|
|
502
|
+
one request is refused, because folding two writes of one attribute
|
|
503
|
+
would have to discard one of them, and the discarded one could be the
|
|
504
|
+
value that should have been refused.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
attributes: dict[str, Any] = {}
|
|
508
|
+
for operation in payload.operations:
|
|
509
|
+
if operation.op.strip().casefold() not in _PATCH_OPS:
|
|
510
|
+
raise errors.invalid_syntax(f"The operation '{operation.op}' is not supported.")
|
|
511
|
+
if operation.path is not None:
|
|
512
|
+
_record(attributes, operation.path, operation.value)
|
|
513
|
+
continue
|
|
514
|
+
if not isinstance(operation.value, dict):
|
|
515
|
+
raise errors.invalid_path("An operation without a path must carry an object of attributes.")
|
|
516
|
+
for attribute, value in operation.value.items():
|
|
517
|
+
_record(attributes, attribute, value)
|
|
518
|
+
return self._changes(identity, _validated(attributes), frozenset(attributes))
|
|
519
|
+
|
|
520
|
+
def _changes(self, identity: DashboardIdentity, payload: ScimUserRequest, named: frozenset[str]) -> ScimChanges:
|
|
521
|
+
"""The one reading of a user resource, for both verbs.
|
|
522
|
+
|
|
523
|
+
``named`` is the only thing the two verbs disagree about: a ``PUT``
|
|
524
|
+
names every attribute, a ``PATCH`` names the ones it carries. An
|
|
525
|
+
attribute that is named but carries nothing — a null ``active``, an
|
|
526
|
+
empty ``roles`` array — states no intent and is not a change, which is
|
|
527
|
+
what makes a ``PUT`` that omits ``roles`` and a ``PATCH`` that does not
|
|
528
|
+
mention it behave the same way.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
external_id = (payload.external_id or "").strip()
|
|
532
|
+
if external_id and external_id != identity.subject:
|
|
533
|
+
raise errors.immutable("externalId identifies the account and cannot be changed.")
|
|
534
|
+
changes = ScimChanges()
|
|
535
|
+
if "userName" in named:
|
|
536
|
+
user_name = (payload.user_name or "").strip()
|
|
537
|
+
if not user_name:
|
|
538
|
+
raise errors.invalid_value("userName is required and cannot be cleared.")
|
|
539
|
+
changes = changes.with_value("userName", user_name=user_name)
|
|
540
|
+
if named & {"displayName", "name"}:
|
|
541
|
+
changes = changes.with_value("displayName", display_name=_display_name(payload))
|
|
542
|
+
if "emails" in named:
|
|
543
|
+
changes = changes.with_value("emails", email=normalize_email(_primary(payload.emails)))
|
|
544
|
+
if "active" in named and payload.active is not None:
|
|
545
|
+
active = coerce_active(payload.active)
|
|
546
|
+
if active is None:
|
|
547
|
+
raise errors.invalid_value("active must be a boolean.")
|
|
548
|
+
changes = changes.with_value("active", active=active)
|
|
549
|
+
if "roles" in named and payload.roles:
|
|
550
|
+
changes = changes.with_value("roles", role_slug=_pushed_role(payload.roles))
|
|
551
|
+
return changes
|
|
552
|
+
|
|
553
|
+
async def apply(
|
|
554
|
+
self,
|
|
555
|
+
identity: DashboardIdentity,
|
|
556
|
+
changes: ScimChanges,
|
|
557
|
+
*,
|
|
558
|
+
provider_key: str,
|
|
559
|
+
token_id: str,
|
|
560
|
+
actor_ip: str | None,
|
|
561
|
+
) -> DashboardIdentity:
|
|
562
|
+
"""Refuse first, write only what changes, commit once.
|
|
563
|
+
|
|
564
|
+
The three phases are the shape of this method and each exists because
|
|
565
|
+
mixing it into the others produced a defect.
|
|
566
|
+
|
|
567
|
+
*Refuse first.* Every guard a push can fail — the pushed role, and the
|
|
568
|
+
one direction that grants access — is evaluated before a single
|
|
569
|
+
attribute is written, off a row no commit has expired yet.
|
|
570
|
+
|
|
571
|
+
*Write only what changes.* A redelivered push whose values already
|
|
572
|
+
hold writes nothing at all, so the idempotency the lifecycle functions
|
|
573
|
+
promise is not undone by an attribute update committed beside them.
|
|
574
|
+
|
|
575
|
+
*Commit once.* The attribute writes stay pending and are carried by
|
|
576
|
+
the lifecycle function's own commit, so a deactivation refused by the
|
|
577
|
+
break-glass or last-admin invariant takes the rest of the request back
|
|
578
|
+
with it instead of leaving half of it applied.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
user = identity.user
|
|
582
|
+
# Read off the rows while they are still loaded: the commit below
|
|
583
|
+
# expires every attribute, and reading one back then is synchronous IO
|
|
584
|
+
# in an async context.
|
|
585
|
+
user_id = user.id
|
|
586
|
+
username = user.username
|
|
587
|
+
subject = identity.subject
|
|
588
|
+
|
|
589
|
+
# --- 1. every refusal, before anything is written ---
|
|
590
|
+
moved: DashboardRoleRecord | None = None
|
|
591
|
+
kept: str | None = None
|
|
592
|
+
pushed_slug: str | None = None
|
|
593
|
+
if changes.has("roles"):
|
|
594
|
+
pushed = await self.resolve_pushed_role(
|
|
595
|
+
changes.role_slug, token_id=token_id, external_id=subject, actor_ip=actor_ip
|
|
596
|
+
)
|
|
597
|
+
pushed_slug = pushed.slug
|
|
598
|
+
# A role a person pinned by hand is theirs, and a role this surface
|
|
599
|
+
# would not hand out is not one it may take away either: it moves
|
|
600
|
+
# only on an account this surface both manages and could itself
|
|
601
|
+
# have provisioned. The rest of the push applies either way.
|
|
602
|
+
if user.role_id == pushed.id:
|
|
603
|
+
pushed_slug = None
|
|
604
|
+
elif user.role_source != DashboardUserRoleSource.SCIM.value:
|
|
605
|
+
kept = "role_source_manual"
|
|
606
|
+
elif not self._provisionable(user.role):
|
|
607
|
+
kept = "role_not_provisionable"
|
|
608
|
+
else:
|
|
609
|
+
moved = pushed
|
|
610
|
+
if changes.active is True and user.status != DashboardUserStatus.ACTIVE.value:
|
|
611
|
+
# The asymmetry this closes: deprovision re-checks break-glass and
|
|
612
|
+
# last-admin, a role push re-checks assignability and role_source,
|
|
613
|
+
# and the one direction that *grants* access used to re-check
|
|
614
|
+
# nothing — so ``active: true`` re-enabled an administrator a human
|
|
615
|
+
# had disabled, and revived every key the disable had cascaded.
|
|
616
|
+
# Read here rather than after the write, because the commit below
|
|
617
|
+
# expires every attribute of the row this predicate reads.
|
|
618
|
+
if not self._provisionable(moved if moved is not None else user.role):
|
|
619
|
+
self._refuse_enable(user, token_id=token_id, external_id=subject, actor_ip=actor_ip)
|
|
620
|
+
|
|
621
|
+
# --- 2. only what actually changes ---
|
|
622
|
+
written = False
|
|
623
|
+
if moved is not None:
|
|
624
|
+
user.role_id = moved.id
|
|
625
|
+
written = True
|
|
626
|
+
if changes.has("displayName"):
|
|
627
|
+
if user.display_name != changes.display_name:
|
|
628
|
+
user.display_name = changes.display_name
|
|
629
|
+
written = True
|
|
630
|
+
if identity.display_name != changes.display_name:
|
|
631
|
+
identity.display_name = changes.display_name
|
|
632
|
+
written = True
|
|
633
|
+
if changes.has("userName") and identity.user_name != changes.user_name:
|
|
634
|
+
# The local account name is assigned once. A rename at the identity
|
|
635
|
+
# provider changes what it calls the person, never who the account
|
|
636
|
+
# is here: renaming from a machine push would race this install's
|
|
637
|
+
# own uniqueness rules and could move another account's name.
|
|
638
|
+
identity.user_name = changes.user_name
|
|
639
|
+
written = True
|
|
640
|
+
if changes.has("emails") and identity.email != changes.email:
|
|
641
|
+
# On the identity row only. ``dashboard_users.email`` is UNIQUE
|
|
642
|
+
# across the install, so writing a pushed address there would let
|
|
643
|
+
# one identity provider's update fail — or, worse, contend — over
|
|
644
|
+
# an address a local account already holds. The resource reports
|
|
645
|
+
# the identity's address, so the push still round-trips.
|
|
646
|
+
identity.email = changes.email
|
|
647
|
+
written = True
|
|
648
|
+
if written:
|
|
649
|
+
# Naive UTC: the identity timestamp columns are naive on both
|
|
650
|
+
# dialects. Stamped only beside a real write, so "this push
|
|
651
|
+
# changed nothing" stays literally true.
|
|
652
|
+
identity.last_seen_at = utcnow()
|
|
653
|
+
|
|
654
|
+
# --- 3. one transaction ---
|
|
655
|
+
committed = False
|
|
656
|
+
active = changes.active
|
|
657
|
+
if active is not None:
|
|
658
|
+
committed = await self._set_active(user_id, active, token_id=token_id, actor_ip=actor_ip)
|
|
659
|
+
if written and not committed:
|
|
660
|
+
await self._users.commit_user(user_id)
|
|
661
|
+
elif not (written or committed):
|
|
662
|
+
# Nothing to write. Release the accounts write intent a no-op
|
|
663
|
+
# lifecycle call took, rather than holding the single writer slot
|
|
664
|
+
# until the response has been built.
|
|
665
|
+
await self._users.rollback()
|
|
666
|
+
if moved is not None:
|
|
667
|
+
await get_dashboard_users_cache().invalidate()
|
|
668
|
+
if kept is not None and pushed_slug is not None:
|
|
669
|
+
await self._audit_role_kept(
|
|
670
|
+
user_id,
|
|
671
|
+
username,
|
|
672
|
+
kept,
|
|
673
|
+
pushed_slug,
|
|
674
|
+
token_id=token_id,
|
|
675
|
+
external_id=subject,
|
|
676
|
+
actor_ip=actor_ip,
|
|
677
|
+
)
|
|
678
|
+
return await self.require_identity(provider_key, user_id)
|
|
679
|
+
|
|
680
|
+
def _refuse_enable(self, user: DashboardUser, *, token_id: str, external_id: str, actor_ip: str | None) -> NoReturn:
|
|
681
|
+
"""The enable direction, bounded by the predicate the role push uses.
|
|
682
|
+
|
|
683
|
+
An account holding a role this surface does not provision is not an
|
|
684
|
+
account it may switch back on: enabling it would restore both the
|
|
685
|
+
account and every key its disable cascaded, and a bearer token holds no
|
|
686
|
+
grant that could justify either. The detail names the account and not
|
|
687
|
+
the role, because the resource deliberately reports no ``roles`` for
|
|
688
|
+
exactly these accounts; the administrator-facing reason is in the
|
|
689
|
+
audit row, which is where an administrator looks.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
details: AuditDetails = {
|
|
693
|
+
"reason": "account_not_provisionable",
|
|
694
|
+
"username": user.username,
|
|
695
|
+
"role": user.role.slug,
|
|
696
|
+
"external_id": external_id,
|
|
697
|
+
"scim_token_id": token_id,
|
|
698
|
+
}
|
|
699
|
+
AuditService.log_async(
|
|
700
|
+
"scim_provision_refused",
|
|
701
|
+
actor_ip=actor_ip,
|
|
702
|
+
details=details,
|
|
703
|
+
actor=scim_actor(),
|
|
704
|
+
target=AuditTarget("user", user.id),
|
|
705
|
+
severity=AuditSeverity.WARNING,
|
|
706
|
+
)
|
|
707
|
+
raise errors.conflict(
|
|
708
|
+
f"The account '{user.username}' holds a role this surface does not provision, "
|
|
709
|
+
"so a provisioning push cannot re-enable it; an administrator enables it by hand."
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
async def _audit_role_kept(
|
|
713
|
+
self,
|
|
714
|
+
user_id: str,
|
|
715
|
+
username: str,
|
|
716
|
+
reason: str,
|
|
717
|
+
slug: str,
|
|
718
|
+
*,
|
|
719
|
+
token_id: str,
|
|
720
|
+
external_id: str,
|
|
721
|
+
actor_ip: str | None,
|
|
722
|
+
) -> None:
|
|
723
|
+
"""One row the first time a pushed role is ignored, and none after it.
|
|
724
|
+
|
|
725
|
+
An identity provider re-pushes its whole directory on a schedule, so a
|
|
726
|
+
row per push would bury the one an operator needs. The row already
|
|
727
|
+
written is the marker, which is why this one event is awaited rather
|
|
728
|
+
than fired and forgotten: the next push reads it back to decide whether
|
|
729
|
+
to write, so it has to have committed before this one answers. It is
|
|
730
|
+
written outside the request's own transaction, after the commit above,
|
|
731
|
+
for the same reason.
|
|
732
|
+
"""
|
|
733
|
+
|
|
734
|
+
if await self._scim.audit_row_exists(ROLE_KEPT_ACTION, user_id):
|
|
735
|
+
return
|
|
736
|
+
details: AuditDetails = {
|
|
737
|
+
"reason": reason,
|
|
738
|
+
"username": username,
|
|
739
|
+
"role": slug,
|
|
740
|
+
"external_id": external_id,
|
|
741
|
+
"scim_token_id": token_id,
|
|
742
|
+
}
|
|
743
|
+
await AuditService.log(
|
|
744
|
+
ROLE_KEPT_ACTION,
|
|
745
|
+
actor_ip=actor_ip,
|
|
746
|
+
details=details,
|
|
747
|
+
request_id=get_request_id(),
|
|
748
|
+
actor=scim_actor(),
|
|
749
|
+
target=AuditTarget("user", user_id),
|
|
750
|
+
severity=AuditSeverity.WARNING,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# --- the lifecycle, through the shared functions ---
|
|
754
|
+
|
|
755
|
+
async def _set_active(self, user_id: str, active: bool, *, token_id: str, actor_ip: str | None) -> bool:
|
|
756
|
+
"""One cascade, whoever asked for it; ``True`` when it wrote and committed.
|
|
757
|
+
|
|
758
|
+
``deactivate_user`` already emits ``scim_deprovision_refused`` next to
|
|
759
|
+
raising, so this maps the refusal to a ``409`` and deliberately does
|
|
760
|
+
**not** write a second row for it. Both functions return ``False``
|
|
761
|
+
without writing or committing when the account is already in the state
|
|
762
|
+
asked for, and the caller carries its own pending writes accordingly.
|
|
763
|
+
"""
|
|
764
|
+
|
|
765
|
+
actor = scim_actor()
|
|
766
|
+
try:
|
|
767
|
+
if active:
|
|
768
|
+
return await self._service.reactivate_user(user_id, actor=actor, actor_ip=actor_ip, source=AUDIT_SOURCE)
|
|
769
|
+
changed = await self._service.deactivate_user(user_id, actor=actor, actor_ip=actor_ip, source=AUDIT_SOURCE)
|
|
770
|
+
except LastBreakGlassProtectedError as exc:
|
|
771
|
+
raise errors.conflict(str(exc)) from exc
|
|
772
|
+
except LastAdminProtectedError as exc:
|
|
773
|
+
raise errors.conflict(str(exc)) from exc
|
|
774
|
+
except InvitePendingError as exc:
|
|
775
|
+
raise errors.conflict(str(exc)) from exc
|
|
776
|
+
except UserNotFoundError as exc:
|
|
777
|
+
raise errors.not_found() from exc
|
|
778
|
+
if changed:
|
|
779
|
+
AuditService.log_async(
|
|
780
|
+
"scim_user_deprovisioned",
|
|
781
|
+
actor_ip=actor_ip,
|
|
782
|
+
details={"scim_token_id": token_id},
|
|
783
|
+
actor=actor,
|
|
784
|
+
target=AuditTarget("user", user_id),
|
|
785
|
+
)
|
|
786
|
+
return changed
|