diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 9505d864d76..1d0fb834d4d 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -68,41 +68,42 @@ func (c *CompatConfig) UnmarshalJSON(data []byte) error { // ClientConfig represents the core configuration for Bifrost HTTP transport and the Bifrost Client. // It includes settings for excess request handling, Prometheus metrics, and initial pool size. type ClientConfig struct { - DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full - InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client - PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics - EnableLogging *bool `json:"enable_logging"` // Enable logging of requests and responses - DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content - AllowPerRequestContentStorageOverride bool `json:"allow_per_request_content_storage_override"` // Allow per-request override of content storage via x-bf-disable-content-logging header/context - AllowPerRequestRawOverride bool `json:"allow_per_request_raw_override"` // Allow per-request override of raw request/response visibility via x-bf-send-back-raw-request and x-bf-send-back-raw-response headers - AllowDirectKeys bool `json:"allow_direct_keys"` // Allow callers to bypass the registered key pool via x-bf-direct-key: true header - DisableDBPingsInHealth bool `json:"disable_db_pings_in_health"` - LogRetentionDays int `json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) - EnforceAuthOnInference bool `json:"enforce_auth_on_inference"` // Require auth (VK, API key, or user token) on inference endpoints - EnforceGovernanceHeader bool `json:"enforce_governance_header,omitempty"` // Deprecated: use EnforceAuthOnInference - EnforceSCIMAuth bool `json:"enforce_scim_auth,omitempty"` // Deprecated: use EnforceAuthOnInference - AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) - AllowedHeaders []string `json:"allowed_headers,omitempty"` // Additional allowed headers for CORS and WebSocket - MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB - Compat CompatConfig `json:"compat"` // Compat plugin configuration - MCPAgentDepth int `json:"mcp_agent_depth"` // The maximum depth for MCP agent mode tool execution - MCPToolExecutionTimeout int `json:"mcp_tool_execution_timeout"` // The timeout for individual tool execution in seconds - MCPCodeModeBindingLevel string `json:"mcp_code_mode_binding_level"` // Code mode binding level: "server" or "tool" - MCPToolSyncInterval int `json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) - MCPDisableAutoToolInject bool `json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default - MCPEnableTempTokenAuth bool `json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth and per-user-headers auth pages. User-mode flows never mint regardless. - HeaderFilterConfig *tables.GlobalHeaderFilterConfig `json:"header_filter_config,omitempty"` // Global header filtering configuration for x-bf-eh-* headers - AsyncJobResultTTL int `json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) - RequiredHeaders []string `json:"required_headers,omitempty"` // Headers that must be present on every request (case-insensitive) - LoggingHeaders []string `json:"logging_headers,omitempty"` // Headers to capture in log metadata - WhitelistedRoutes []string `json:"whitelisted_routes,omitempty"` // Routes that bypass auth middleware - HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data - RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) - MCPExternalClientURL *schemas.SecretVar `json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers. Supports env var syntax ("env.MY_VAR") - MCPServerAuthMode tables.MCPServerAuthMode `json:"mcp_server_auth_mode,omitempty"` // How /mcp authenticates inbound clients: headers (default), both, or oauth. - OAuth2ServerConfig *tables.OAuth2ServerConfig `json:"oauth2_server_config,omitempty"` // OAuth2 AS-specific settings (IssuerURL, token TTLs). Only relevant when MCPServerAuthMode is both or oauth. - ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) - DumpErrorsInConsoleLogs bool `json:"dump_errors_in_console_logs"` // Dump error details in console logs + DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full + InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client + PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics + EnableLogging *bool `json:"enable_logging"` // Enable logging of requests and responses + DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content + AllowPerRequestContentStorageOverride bool `json:"allow_per_request_content_storage_override"` // Allow per-request override of content storage via x-bf-disable-content-logging header/context + AllowPerRequestRawOverride bool `json:"allow_per_request_raw_override"` // Allow per-request override of raw request/response visibility via x-bf-send-back-raw-request and x-bf-send-back-raw-response headers + AllowDirectKeys bool `json:"allow_direct_keys"` // Allow callers to bypass the registered key pool via x-bf-direct-key: true header + DisableDBPingsInHealth bool `json:"disable_db_pings_in_health"` + LogRetentionDays int `json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) + EnforceAuthOnInference bool `json:"enforce_auth_on_inference"` // Require auth (VK, API key, or user token) on inference endpoints + DualCredentialConflictBehavior tables.DualCredentialConflictBehavior `json:"dual_credential_conflict_behavior,omitempty"` // Behavior when both an IDP token and a VK are present on an inference request + EnforceGovernanceHeader bool `json:"enforce_governance_header,omitempty"` // Deprecated: use EnforceAuthOnInference + EnforceSCIMAuth bool `json:"enforce_scim_auth,omitempty"` // Deprecated: use EnforceAuthOnInference + AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) + AllowedHeaders []string `json:"allowed_headers,omitempty"` // Additional allowed headers for CORS and WebSocket + MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB + Compat CompatConfig `json:"compat"` // Compat plugin configuration + MCPAgentDepth int `json:"mcp_agent_depth"` // The maximum depth for MCP agent mode tool execution + MCPToolExecutionTimeout int `json:"mcp_tool_execution_timeout"` // The timeout for individual tool execution in seconds + MCPCodeModeBindingLevel string `json:"mcp_code_mode_binding_level"` // Code mode binding level: "server" or "tool" + MCPToolSyncInterval int `json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) + MCPDisableAutoToolInject bool `json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default + MCPEnableTempTokenAuth bool `json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth and per-user-headers auth pages. User-mode flows never mint regardless. + HeaderFilterConfig *tables.GlobalHeaderFilterConfig `json:"header_filter_config,omitempty"` // Global header filtering configuration for x-bf-eh-* headers + AsyncJobResultTTL int `json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) + RequiredHeaders []string `json:"required_headers,omitempty"` // Headers that must be present on every request (case-insensitive) + LoggingHeaders []string `json:"logging_headers,omitempty"` // Headers to capture in log metadata + WhitelistedRoutes []string `json:"whitelisted_routes,omitempty"` // Routes that bypass auth middleware + HideDeletedVirtualKeysInFilters bool `json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys from logs/MCP filter data + RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) + MCPExternalClientURL *schemas.SecretVar `json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers. Supports env var syntax ("env.MY_VAR") + MCPServerAuthMode tables.MCPServerAuthMode `json:"mcp_server_auth_mode,omitempty"` // How /mcp authenticates inbound clients: headers (default), both, or oauth. + OAuth2ServerConfig *tables.OAuth2ServerConfig `json:"oauth2_server_config,omitempty"` // OAuth2 AS-specific settings (IssuerURL, token TTLs). Only relevant when MCPServerAuthMode is both or oauth. + ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) + DumpErrorsInConsoleLogs bool `json:"dump_errors_in_console_logs"` // Dump error details in console logs } // IsMCPOAuthDiscoveryEnabled reports whether the well-known OAuth discovery @@ -167,6 +168,10 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { hash.Write([]byte("enforceAuthOnInference:false")) } + if c.DualCredentialConflictBehavior != "" && c.DualCredentialConflictBehavior != tables.DualCredentialConflictBehaviorPreferIDP { + hash.Write([]byte("dualCredentialConflictBehavior:" + string(c.DualCredentialConflictBehavior))) + } + if c.Compat.ConvertTextToChat { hash.Write([]byte("compatConvertTextToChat:true")) } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index ca25ea02d79..0f55e32082a 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -446,6 +446,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"add_inference_geo_multiplier_column"}, run: migrationAddInferenceGeoMultiplierColumn}, {IDs: []string{"repair_bare_wildcard_allowed_models"}, run: migrationRepairBareWildcardAllowedModels}, {IDs: []string{"add_bedrock_project_id_columns"}, run: migrationAddBedrockProjectIDColumns}, + {IDs: []string{"add_dual_credential_conflict_behavior_column"}, run: migrationAddDualCredentialConflictBehaviorColumn}, } // quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes. @@ -5395,6 +5396,36 @@ func migrationAddEnforceAuthOnInferenceColumn(ctx context.Context, db *gorm.DB, return nil } +// migrationAddDualCredentialConflictBehaviorColumn adds the dual_credential_conflict_behavior +// column to the config_client table. The column is added with its gorm-defined +// NOT NULL default ('prefer_idp'), so existing rows retain the pre-feature behavior. +func migrationAddDualCredentialConflictBehaviorColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_dual_credential_conflict_behavior_column" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := addColumnIfNotExists(tx, logger, &tables.TableClientConfig{}, "dual_credential_conflict_behavior"); err != nil { + return err + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := dropColumnIfExists(tx, logger, &tables.TableClientConfig{}, "dual_credential_conflict_behavior"); err != nil { + return err + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running dual credential conflict behavior column migration: %s", err.Error()) + } + return nil +} + func migrationReconcilePricingOverridesTable(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { migrationName := "reconcile_pricing_overrides_table" logger.Info("[configstore] starting migration %s", migrationName) diff --git a/framework/configstore/migrations_test.go b/framework/configstore/migrations_test.go index 60f23ad9ab9..ba00d311303 100644 --- a/framework/configstore/migrations_test.go +++ b/framework/configstore/migrations_test.go @@ -2767,3 +2767,39 @@ func TestFullMigration_UpgradeFromPreDumpErrorsSchema(t *testing.T) { assert.NotEmpty(t, gotHash) assert.NotEqual(t, "stale-hash", gotHash, "config_hash should have been recomputed by the chain") } + +// TestMigrationAddDualCredentialConflictBehaviorColumn verifies that upgrading a +// config_client that predates dual_credential_conflict_behavior re-adds the column +// and backfills existing rows with the NOT NULL default ('prefer_idp'). Without the +// migration, an upgraded deployment would carry a struct column the physical table +// lacks and fail the config save/sync path. +func TestMigrationAddDualCredentialConflictBehaviorColumn(t *testing.T) { + db := setupTestDB(t) + ctx := context.Background() + + // Build the current schema, seed a row, then drop the column to simulate a + // pre-feature config_client. + require.NoError(t, db.AutoMigrate(&tables.TableClientConfig{})) + seed := &tables.TableClientConfig{ConfigHash: "seed-hash"} + require.NoError(t, db.Create(seed).Error) + + require.NoError(t, db.Migrator().DropColumn(&tables.TableClientConfig{}, "dual_credential_conflict_behavior")) + require.False(t, db.Migrator().HasColumn(&tables.TableClientConfig{}, "dual_credential_conflict_behavior"), + "precondition: dual_credential_conflict_behavior must be absent to reproduce the upgrade path") + + require.NoError(t, migrationAddDualCredentialConflictBehaviorColumn(ctx, db, testMigrationLogger), + "migration should re-add the column") + + require.True(t, db.Migrator().HasColumn(&tables.TableClientConfig{}, "dual_credential_conflict_behavior"), + "migration should have added dual_credential_conflict_behavior") + + // The pre-existing row must be backfilled with the prefer_idp default so upgraded + // deployments retain the pre-feature behavior. + var got string + require.NoError(t, db.Raw("SELECT dual_credential_conflict_behavior FROM config_client WHERE id = ?", seed.ID).Scan(&got).Error) + assert.Equal(t, "prefer_idp", got, "existing rows should default to prefer_idp after migration") + + // Idempotency: re-running the migration is a no-op and must not error. + require.NoError(t, migrationAddDualCredentialConflictBehaviorColumn(ctx, db, testMigrationLogger), + "re-running the migration should be idempotent") +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index c00f3bc0b39..f12686f7256 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -252,6 +252,7 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC DumpErrorsInConsoleLogs: config.DumpErrorsInConsoleLogs, LogRetentionDays: config.LogRetentionDays, EnforceAuthOnInference: config.EnforceAuthOnInference, + DualCredentialConflictBehavior: config.DualCredentialConflictBehavior, EnforceGovernanceHeader: config.EnforceGovernanceHeader, EnforceSCIMAuth: config.EnforceSCIMAuth, PrometheusLabels: config.PrometheusLabels, @@ -509,20 +510,21 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er return nil, err } return &ClientConfig{ - DropExcessRequests: dbConfig.DropExcessRequests, - InitialPoolSize: dbConfig.InitialPoolSize, - PrometheusLabels: dbConfig.PrometheusLabels, - EnableLogging: dbConfig.EnableLogging, - DisableContentLogging: dbConfig.DisableContentLogging, - DisableDBPingsInHealth: dbConfig.DisableDBPingsInHealth, - DumpErrorsInConsoleLogs: dbConfig.DumpErrorsInConsoleLogs, - LogRetentionDays: dbConfig.LogRetentionDays, - EnforceAuthOnInference: dbConfig.EnforceAuthOnInference, - EnforceGovernanceHeader: dbConfig.EnforceGovernanceHeader, - EnforceSCIMAuth: dbConfig.EnforceSCIMAuth, - AllowedOrigins: dbConfig.AllowedOrigins, - AllowedHeaders: dbConfig.AllowedHeaders, - MaxRequestBodySizeMB: dbConfig.MaxRequestBodySizeMB, + DropExcessRequests: dbConfig.DropExcessRequests, + InitialPoolSize: dbConfig.InitialPoolSize, + PrometheusLabels: dbConfig.PrometheusLabels, + EnableLogging: dbConfig.EnableLogging, + DisableContentLogging: dbConfig.DisableContentLogging, + DisableDBPingsInHealth: dbConfig.DisableDBPingsInHealth, + DumpErrorsInConsoleLogs: dbConfig.DumpErrorsInConsoleLogs, + LogRetentionDays: dbConfig.LogRetentionDays, + EnforceAuthOnInference: dbConfig.EnforceAuthOnInference, + DualCredentialConflictBehavior: dbConfig.DualCredentialConflictBehavior, + EnforceGovernanceHeader: dbConfig.EnforceGovernanceHeader, + EnforceSCIMAuth: dbConfig.EnforceSCIMAuth, + AllowedOrigins: dbConfig.AllowedOrigins, + AllowedHeaders: dbConfig.AllowedHeaders, + MaxRequestBodySizeMB: dbConfig.MaxRequestBodySizeMB, Compat: CompatConfig{ ConvertTextToChat: dbConfig.CompatConvertTextToChat, ConvertChatToResponses: dbConfig.CompatConvertChatToResponses, diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 3f1a6cf6ac0..1805504b1e9 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -7,41 +7,55 @@ import ( "gorm.io/gorm" ) +// DualCredentialConflictBehavior controls what happens on inference requests that +// carry both an IDP access token (Authorization: Bearer ) and a virtual key (x-bf-vk). +type DualCredentialConflictBehavior string + +const ( + // DualCredentialConflictBehaviorError rejects the request with 400. + DualCredentialConflictBehaviorError DualCredentialConflictBehavior = "error" + // DualCredentialConflictBehaviorPreferVK drops the IDP token and authenticates via the virtual key. + DualCredentialConflictBehaviorPreferVK DualCredentialConflictBehavior = "prefer_vk" + // DualCredentialConflictBehaviorPreferIDP uses the IDP token for identity (default when unset). + DualCredentialConflictBehaviorPreferIDP DualCredentialConflictBehavior = "prefer_idp" +) + // TableClientConfig represents global client configuration in the database type TableClientConfig struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - DropExcessRequests bool `gorm:"default:false" json:"drop_excess_requests"` - PrometheusLabelsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - AllowedOriginsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - AllowedHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - HeaderFilterConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized GlobalHeaderFilterConfig - MetadataJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]any for UI/admin preferences (e.g. onboarding_dismissed). Bypasses config.json sync. - InitialPoolSize int `gorm:"default:300" json:"initial_pool_size"` - EnableLogging *bool `gorm:"default:true" json:"enable_logging"` - DisableContentLogging bool `gorm:"default:false" json:"disable_content_logging"` // DisableContentLogging controls whether sensitive content (inputs, outputs, embeddings, etc.) is logged - DisableDBPingsInHealth bool `gorm:"default:false" json:"disable_db_pings_in_health"` - DumpErrorsInConsoleLogs bool `gorm:"default:false" json:"dump_errors_in_console_logs"` // Dump full error details to the server console logs - LogRetentionDays int `gorm:"default:365" json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) - EnforceAuthOnInference bool `gorm:"default:false" json:"enforce_auth_on_inference"` - EnforceGovernanceHeader bool `gorm:"" json:"enforce_governance_header"` - EnforceSCIMAuth bool `gorm:"default:false" json:"enforce_scim_auth"` - MaxRequestBodySizeMB int `gorm:"default:100" json:"max_request_body_size_mb"` - MCPAgentDepth int `gorm:"default:10" json:"mcp_agent_depth"` - MCPToolExecutionTimeout int `gorm:"default:30" json:"mcp_tool_execution_timeout"` // Timeout for individual tool execution in seconds (default: 30) - MCPCodeModeBindingLevel string `gorm:"default:server" json:"mcp_code_mode_binding_level"` // How tools are exposed in VFS: "server" or "tool" - MCPToolSyncInterval int `gorm:"default:10" json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) - MCPDisableAutoToolInject bool `gorm:"default:false" json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default - MCPEnableTempTokenAuth bool `gorm:"default:false" json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth and per-user-headers auth pages. User-mode flows never mint regardless. - AsyncJobResultTTL int `gorm:"default:3600" json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) - RequiredHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - LoggingHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - HideDeletedVirtualKeysInFilters bool `gorm:"default:false" json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys in logs filter dropdowns - RoutingChainMaxDepth int `gorm:"default:10" json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) - MCPExternalClientURL string `gorm:"type:varchar(512)" json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers - WhitelistedRoutesJSON string `gorm:"type:text" json:"-"` // JSON serialized []string - AllowPerRequestContentStorageOverride bool `gorm:"default:false" json:"allow_per_request_content_storage_override"` // Allow per-request override for content storage (e.g. long-term vs ephemeral) - AllowPerRequestRawOverride bool `gorm:"default:false" json:"allow_per_request_raw_override"` // Allow per-request override for raw request/response storage - AllowDirectKeys bool `gorm:"default:false" json:"allow_direct_keys"` // Allow callers to bypass the registered key pool via x-bf-direct-key header + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DropExcessRequests bool `gorm:"default:false" json:"drop_excess_requests"` + PrometheusLabelsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + AllowedOriginsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + AllowedHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + HeaderFilterConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized GlobalHeaderFilterConfig + MetadataJSON string `gorm:"type:text" json:"-"` // JSON serialized map[string]any for UI/admin preferences (e.g. onboarding_dismissed). Bypasses config.json sync. + InitialPoolSize int `gorm:"default:300" json:"initial_pool_size"` + EnableLogging *bool `gorm:"default:true" json:"enable_logging"` + DisableContentLogging bool `gorm:"default:false" json:"disable_content_logging"` // DisableContentLogging controls whether sensitive content (inputs, outputs, embeddings, etc.) is logged + DisableDBPingsInHealth bool `gorm:"default:false" json:"disable_db_pings_in_health"` + DumpErrorsInConsoleLogs bool `gorm:"default:false" json:"dump_errors_in_console_logs"` // Dump full error details to the server console logs + LogRetentionDays int `gorm:"default:365" json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) + EnforceAuthOnInference bool `gorm:"default:false" json:"enforce_auth_on_inference"` + EnforceGovernanceHeader bool `gorm:"" json:"enforce_governance_header"` + EnforceSCIMAuth bool `gorm:"default:false" json:"enforce_scim_auth"` + DualCredentialConflictBehavior DualCredentialConflictBehavior `gorm:"column:dual_credential_conflict_behavior;type:varchar(20);not null;default:'prefer_idp'" json:"dual_credential_conflict_behavior"` + MaxRequestBodySizeMB int `gorm:"default:100" json:"max_request_body_size_mb"` + MCPAgentDepth int `gorm:"default:10" json:"mcp_agent_depth"` + MCPToolExecutionTimeout int `gorm:"default:30" json:"mcp_tool_execution_timeout"` // Timeout for individual tool execution in seconds (default: 30) + MCPCodeModeBindingLevel string `gorm:"default:server" json:"mcp_code_mode_binding_level"` // How tools are exposed in VFS: "server" or "tool" + MCPToolSyncInterval int `gorm:"default:10" json:"mcp_tool_sync_interval"` // Global tool sync interval in minutes (default: 10, 0 = disabled) + MCPDisableAutoToolInject bool `gorm:"default:false" json:"mcp_disable_auto_tool_inject"` // When true, MCP tools are not injected into requests by default + MCPEnableTempTokenAuth bool `gorm:"default:false" json:"mcp_enable_temp_token_auth"` // When true, scoped temp tokens can authorize MCP per-user OAuth and per-user-headers auth pages. User-mode flows never mint regardless. + AsyncJobResultTTL int `gorm:"default:3600" json:"async_job_result_ttl"` // Default TTL for async job results in seconds (default: 3600 = 1 hour) + RequiredHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + LoggingHeadersJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + HideDeletedVirtualKeysInFilters bool `gorm:"default:false" json:"hide_deleted_virtual_keys_in_filters"` // Hide deleted virtual keys in logs filter dropdowns + RoutingChainMaxDepth int `gorm:"default:10" json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) + MCPExternalClientURL string `gorm:"type:varchar(512)" json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers + WhitelistedRoutesJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + AllowPerRequestContentStorageOverride bool `gorm:"default:false" json:"allow_per_request_content_storage_override"` // Allow per-request override for content storage (e.g. long-term vs ephemeral) + AllowPerRequestRawOverride bool `gorm:"default:false" json:"allow_per_request_raw_override"` // Allow per-request override for raw request/response storage + AllowDirectKeys bool `gorm:"default:false" json:"allow_direct_keys"` // Allow callers to bypass the registered key pool via x-bf-direct-key header // Compat plugin feature flags CompatConvertTextToChat bool `gorm:"column:compat_convert_text_to_chat;default:false" json:"-"` @@ -67,14 +81,14 @@ type TableClientConfig struct { UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` // Virtual fields for runtime use (not stored in DB) - PrometheusLabels []string `gorm:"-" json:"prometheus_labels"` - AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"` - AllowedHeaders []string `gorm:"-" json:"allowed_headers,omitempty"` - RequiredHeaders []string `gorm:"-" json:"required_headers,omitempty"` - LoggingHeaders []string `gorm:"-" json:"logging_headers,omitempty"` - WhitelistedRoutes []string `gorm:"-" json:"whitelisted_routes,omitempty"` - HeaderFilterConfig *GlobalHeaderFilterConfig `gorm:"-" json:"header_filter_config,omitempty"` - Metadata map[string]any `gorm:"-" json:"metadata,omitempty"` + PrometheusLabels []string `gorm:"-" json:"prometheus_labels"` + AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"` + AllowedHeaders []string `gorm:"-" json:"allowed_headers,omitempty"` + RequiredHeaders []string `gorm:"-" json:"required_headers,omitempty"` + LoggingHeaders []string `gorm:"-" json:"logging_headers,omitempty"` + WhitelistedRoutes []string `gorm:"-" json:"whitelisted_routes,omitempty"` + HeaderFilterConfig *GlobalHeaderFilterConfig `gorm:"-" json:"header_filter_config,omitempty"` + Metadata map[string]any `gorm:"-" json:"metadata,omitempty"` OAuth2ServerConfig *OAuth2ServerConfig `gorm:"-" json:"oauth2_server_config,omitempty"` } diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index e934f3c9026..d198fefdbf4 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -504,6 +504,11 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { updatedConfig.EnforceGovernanceHeader = payload.ClientConfig.EnforceAuthOnInference updatedConfig.EnforceSCIMAuth = payload.ClientConfig.EnforceAuthOnInference + // Only update when explicitly provided to avoid clearing the stored default (prefer_idp) + if payload.ClientConfig.DualCredentialConflictBehavior != "" { + updatedConfig.DualCredentialConflictBehavior = payload.ClientConfig.DualCredentialConflictBehavior + } + // Only update MaxRequestBodySizeMB if explicitly provided (> 0) to avoid clearing stored value if payload.ClientConfig.MaxRequestBodySizeMB > 0 { if payload.ClientConfig.MaxRequestBodySizeMB != currentConfig.MaxRequestBodySizeMB { @@ -626,14 +631,14 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { } updatedConfig.LogRetentionDays = payload.ClientConfig.LogRetentionDays - // Update the store with the new config - h.store.ClientConfig = updatedConfig - if err := h.store.ConfigStore.UpdateClientConfig(ctx, updatedConfig); err != nil { logger.Warn("failed to save configuration: %v", err) SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to save configuration: %v", err)) return } + + // Apply the in-memory change only after persistence succeeds. + h.store.ClientConfig = updatedConfig // Reloading client config from config store if err := h.configManager.ReloadClientConfigFromConfigStore(ctx); err != nil { logger.Warn("failed to reload client config from config store: %v", err) diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go index 5eea426aab8..19ac43b6095 100644 --- a/transports/bifrost-http/handlers/mcpserver.go +++ b/transports/bifrost-http/handlers/mcpserver.go @@ -643,11 +643,9 @@ func (h *MCPServerHandler) getMCPServerForRequest(ctx *fasthttp.RequestCtx) (*mc if h.identityResolver != nil && (authMode == tables.MCPServerAuthModeHeaders || authMode == tables.MCPServerAuthModeBoth) { if userID, _ := ctx.UserValue(schemas.BifrostContextKeyUserID).(string); userID != "" { - // The user identity is the sole credential; reject a stray virtual key - // header so it is not also attributed to the request. - if headerVK := getVKFromRequest(ctx); headerVK != "" { - return nil, fmt.Errorf("conflicting credentials: a user token and a virtual key header were both provided; send only one") - } + // Dual-credential conflict (IDP token + VK) is handled upstream in the SCIM + // InferenceMiddleware before identity is stamped, respecting the operator's + // dual_credential_conflict_behavior config. No check needed here. vkID, err := h.identityResolver.ResolveUserVirtualKey(ctx, userID) if err != nil { return nil, err diff --git a/transports/config.schema.json b/transports/config.schema.json index e4d0bfece00..586632d1481 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -124,6 +124,11 @@ "type": "boolean", "description": "Require auth (VK, API key, or user token) on inference endpoints" }, + "dual_credential_conflict_behavior": { + "type": "string", + "enum": ["error", "prefer_vk", "prefer_idp"], + "description": "Controls behavior when an inference request carries both an IDP access token (Authorization: Bearer ) and a virtual key (x-bf-vk header). 'error' rejects with 400. 'prefer_vk' drops the IDP token and authenticates via the VK. 'prefer_idp' (default) uses the IDP token for identity." + }, "enforce_scim_auth": { "type": "boolean", "description": "Deprecated: use enforce_auth_on_inference" diff --git a/ui/app/workspace/config/views/securityView.tsx b/ui/app/workspace/config/views/securityView.tsx index 71beba66673..1f704cffa3d 100644 --- a/ui/app/workspace/config/views/securityView.tsx +++ b/ui/app/workspace/config/views/securityView.tsx @@ -3,6 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { SecretVarInput } from "@/components/ui/secretVarInput"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { IS_ENTERPRISE } from "@/lib/constants/config"; @@ -106,6 +107,8 @@ export default function SecurityView() { const enforceAuthOnInferenceChanged = localConfig.enforce_auth_on_inference !== config.enforce_auth_on_inference; const allowDirectKeysChanged = localConfig.allow_direct_keys !== config.allow_direct_keys; + const dualCredentialConflictBehaviorChanged = + (localConfig.dual_credential_conflict_behavior || "prefer_idp") !== (config.dual_credential_conflict_behavior || "prefer_idp"); return ( originsChanged || @@ -114,7 +117,8 @@ export default function SecurityView() { whitelistedRoutesChanged || authChanged || enforceAuthOnInferenceChanged || - allowDirectKeysChanged + allowDirectKeysChanged || + dualCredentialConflictBehaviorChanged ); }, [config, localConfig, authConfig, bifrostConfig, showPasswordSection]); @@ -317,6 +321,36 @@ export default function SecurityView() { onCheckedChange={(checked) => handleConfigChange("enforce_auth_on_inference", checked)} /> + {/* Dual Credential Conflict Behavior */} + {IS_ENTERPRISE && ( +
+
+ +

+ How to handle inference requests that present both an identity provider access token (Authorization: Bearer) and a + virtual key (x-bf-vk). Prefer IDP token uses the user token for identity, Prefer virtual key drops the + IDP token and authenticates via the virtual key, and Reject request returns a 400 error. +

+
+ +
+ )} {/* Allow Direct API Keys */}
diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index 31a19e37400..b23aba3e8d3 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -581,6 +581,7 @@ export interface CoreConfig { dump_errors_in_console_logs: boolean; log_retention_days: number; enforce_auth_on_inference: boolean; + dual_credential_conflict_behavior?: "error" | "prefer_vk" | "prefer_idp"; allowed_origins: string[]; allowed_headers: string[]; max_request_body_size_mb: number; @@ -621,6 +622,7 @@ export const DefaultCoreConfig: CoreConfig = { dump_errors_in_console_logs: false, log_retention_days: 365, enforce_auth_on_inference: false, + dual_credential_conflict_behavior: "prefer_idp", allowed_origins: [], max_request_body_size_mb: 100, compat: { convert_text_to_chat: false, convert_chat_to_responses: false, should_drop_params: false, should_convert_params: false },