From d75530a0caf5069804aff7f2bb378f655de52fe4 Mon Sep 17 00:00:00 2001 From: Shibayan95 Date: Thu, 23 Apr 2026 22:20:48 +0530 Subject: [PATCH] Resolve conflict in cherry-pick of 6e0b1e4c659cb780b8e9d4aa33756d6eee919f8b and change the commit message --- server/app/models/agents/component.rb | 23 +++ server/app/models/agents/knowledge_base.rb | 51 +++++++ server/app/models/agents/tool.rb | 58 +++++++ server/app/models/connector.rb | 11 +- server/app/models/model.rb | 10 ++ .../models/configuration_unstructured.json | 3 +- .../models/configuration_vector_search.json | 3 +- .../syncs/configuration_embedding.json | 22 +++ server/app/models/sync.rb | 8 + server/app/serializers/model_serializer.rb | 6 +- server/app/serializers/sync_serializer.rb | 8 + server/lib/utils/secret_masking.rb | 62 ++++++++ server/spec/lib/utils/secret_masking_spec.rb | 144 ++++++++++++++++++ server/spec/models/connector_spec.rb | 75 +-------- server/spec/models/model_spec.rb | 86 +++++++++++ server/spec/models/sync_spec.rb | 63 ++++++++ .../requests/api/v1/models_controller_spec.rb | 35 +++++ .../requests/api/v1/syncs_controller_spec.rb | 2 +- .../spec/serializers/model_serializer_spec.rb | 58 +++++++ 19 files changed, 652 insertions(+), 76 deletions(-) create mode 100644 server/app/models/agents/knowledge_base.rb create mode 100644 server/app/models/agents/tool.rb create mode 100644 server/app/models/schema_validations/syncs/configuration_embedding.json create mode 100644 server/lib/utils/secret_masking.rb create mode 100644 server/spec/lib/utils/secret_masking_spec.rb create mode 100644 server/spec/serializers/model_serializer_spec.rb diff --git a/server/app/models/agents/component.rb b/server/app/models/agents/component.rb index d890b358f..11b5656dc 100644 --- a/server/app/models/agents/component.rb +++ b/server/app/models/agents/component.rb @@ -17,5 +17,28 @@ class Component < ApplicationRecord validates :component_type, presence: true store :position, coder: JSON +<<<<<<< HEAD +======= + + # Returns configuration with auth_config values masked for API responses. + # Currently applies to :a2a_agent components and masks all non-blank strings under auth_config. + def masked_configuration + return configuration if configuration.blank? + return configuration unless a2a_agent? + + mask_a2a_secrets(configuration.deep_dup) + end + + private + + def mask_a2a_secrets(config) + config["api_key"] = Utils::SecretMasking::MASKED_VALUE if config["api_key"].present? + if config["auth_config"].present? + config["auth_config"] = + Utils::SecretMasking.mask_nested_values(config["auth_config"]) + end + config + end +>>>>>>> 6e0b1e4c6 (fix(CE): return masked configuration for model (#1840)) end end diff --git a/server/app/models/agents/knowledge_base.rb b/server/app/models/agents/knowledge_base.rb new file mode 100644 index 000000000..8150e9b5e --- /dev/null +++ b/server/app/models/agents/knowledge_base.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Agents + class KnowledgeBase < ApplicationRecord + EMBEDDING_CONFIG_JSON_SCHEMA = Rails.root.join( + "app/models/schema_validations/knowledge_bases/configuration_embedding.json" + ) + STORAGE_CONFIG_JSON_SCHEMA = Rails.root.join( + "app/models/schema_validations/knowledge_bases/configuration_storage.json" + ) + + belongs_to :workspace + belongs_to :hosted_data_store, optional: true + belongs_to :source_connector, class_name: "Connector", optional: true + belongs_to :destination_connector, class_name: "Connector", optional: true + has_many :knowledge_base_files, class_name: "Agents::KnowledgeBaseFile", dependent: :destroy + + enum :knowledge_base_type, { vector_store: 0, semantic_data_model: 1 } + + validates :name, presence: true + validates :knowledge_base_type, presence: true + validates :size, presence: true + validates :embedding_config, presence: true, json: { schema: lambda { + embedding_config_schema_validation + } } + validates :storage_config, presence: true, json: { schema: lambda { + storage_config_schema_validation + } } + + def masked_embedding_config + return embedding_config if embedding_config.blank? + + mask_secret_values(embedding_config.deep_dup) + end + + private + + def embedding_config_schema_validation + EMBEDDING_CONFIG_JSON_SCHEMA + end + + def storage_config_schema_validation + STORAGE_CONFIG_JSON_SCHEMA + end + + def mask_secret_values(config) + config["api_key"] = Utils::SecretMasking.mask_nested_values(config["api_key"]) if config["api_key"].present? + config + end + end +end diff --git a/server/app/models/agents/tool.rb b/server/app/models/agents/tool.rb new file mode 100644 index 000000000..30678ab28 --- /dev/null +++ b/server/app/models/agents/tool.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Agents + class Tool < ApplicationRecord + MCP_CONFIG_JSON_SCHEMA = Rails.root.join( + "app/models/schema_validations/tools/mcp.json" + ) + + belongs_to :workspace + + enum :tool_type, { mcp: 0 } + + validates :name, presence: true, uniqueness: { scope: :workspace_id, case_sensitive: false } + validates :tool_type, presence: true + validates :configuration, presence: true, json: { schema: -> { configuration_schema } } + + scope :recently_updated, -> { order(updated_at: :desc) } + + scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{sanitize_sql_like(query)}%") } + + # Returns connection config for MCP tools + # Used by MCP client to establish connection + def connection_config + return {} unless mcp? && configuration.present? + + cfg = configuration.with_indifferent_access + { + url: cfg["url"], + transport: cfg["transport"], + auth_type: cfg["auth_type"], + auth_config: cfg["auth_config"] || {}, + headers: cfg["headers"] || {}, + timeout: cfg["timeout"] || 30 + }.compact + end + + # Mask sensitive configuration values for API responses + def masked_configuration + return configuration if configuration.blank? + + mask_mcp_secrets(configuration.deep_dup) + end + + private + + def configuration_schema + MCP_CONFIG_JSON_SCHEMA + end + + def mask_mcp_secrets(config) + if config["auth_config"].present? + config["auth_config"] = + Utils::SecretMasking.mask_nested_values(config["auth_config"]) + end + config + end + end +end diff --git a/server/app/models/connector.rb b/server/app/models/connector.rb index a6f230be7..63635caff 100644 --- a/server/app/models/connector.rb +++ b/server/app/models/connector.rb @@ -214,12 +214,12 @@ def resolved_configuration def masked_configuration spec = connector_client.new.connector_spec[:connection_specification].with_indifferent_access - secret_keys = extract_secret_keys(spec) - mask_secret_values(configuration.deep_dup, secret_keys) + Utils::SecretMasking.mask_by_keys(configuration.deep_dup, spec) end private +<<<<<<< HEAD def extract_secret_keys(schema, keys = []) return keys unless schema.is_a?(Hash) @@ -249,6 +249,13 @@ def mask_secret_values(config, secret_keys) config.map { |item| mask_secret_values(item, secret_keys) } else config +======= + def format_llm_payload(payload) + if connector_name == "Anthropic" + payload.to_json + else + payload +>>>>>>> 6e0b1e4c6 (fix(CE): return masked configuration for model (#1840)) end end end diff --git a/server/app/models/model.rb b/server/app/models/model.rb index e1e544a77..3637ba0de 100644 --- a/server/app/models/model.rb +++ b/server/app/models/model.rb @@ -73,6 +73,16 @@ def json_schema configuration["json_schema"] end + def masked_configuration + return configuration if configuration.blank? + + schema_path = configuration_schema_validation + return configuration if schema_path.nil? + + schema = JSON.parse(File.read(schema_path)).with_indifferent_access + Utils::SecretMasking.mask_by_keys(configuration.deep_dup, schema) + end + private def configuration_schema_validation diff --git a/server/app/models/schema_validations/models/configuration_unstructured.json b/server/app/models/schema_validations/models/configuration_unstructured.json index 0e43035ed..93c0ab28c 100644 --- a/server/app/models/schema_validations/models/configuration_unstructured.json +++ b/server/app/models/schema_validations/models/configuration_unstructured.json @@ -8,7 +8,8 @@ "properties": { "api_key": { "type": "string", - "minLength": 1 + "minLength": 1, + "multiwoven_secret": true }, "model": { "type": "string", diff --git a/server/app/models/schema_validations/models/configuration_vector_search.json b/server/app/models/schema_validations/models/configuration_vector_search.json index 4240ffb9b..87fbfac43 100644 --- a/server/app/models/schema_validations/models/configuration_vector_search.json +++ b/server/app/models/schema_validations/models/configuration_vector_search.json @@ -9,7 +9,8 @@ "properties": { "api_key": { "type": "string", - "minLength": 1 + "minLength": 1, + "multiwoven_secret": true }, "model": { "type": "string", diff --git a/server/app/models/schema_validations/syncs/configuration_embedding.json b/server/app/models/schema_validations/syncs/configuration_embedding.json new file mode 100644 index 000000000..d25c7a5f7 --- /dev/null +++ b/server/app/models/schema_validations/syncs/configuration_embedding.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" }, + "embedding_config": { + "type": "object", + "properties": { + "mode": { "type": "string" }, + "model": { "type": "string" }, + "api_key": { + "type": "string", + "multiwoven_secret": true + } + } + } + } + } +} diff --git a/server/app/models/sync.rb b/server/app/models/sync.rb index fc32623d4..51e7cf552 100644 --- a/server/app/models/sync.rb +++ b/server/app/models/sync.rb @@ -22,6 +22,7 @@ # updated_at :datetime not null # class Sync < ApplicationRecord # rubocop:disable Metrics/ClassLength + SYNC_CONFIG_JSON_SCHEMA = Rails.root.join("app/models/schema_validations/syncs/configuration_embedding.json") include AASM include Discard::Model @@ -80,6 +81,13 @@ class Sync < ApplicationRecord # rubocop:disable Metrics/ClassLength end end + def masked_configuration + return configuration if configuration.blank? + + schema = JSON.parse(File.read(SYNC_CONFIG_JSON_SCHEMA)).with_indifferent_access + Utils::SecretMasking.mask_by_keys(configuration.deep_dup, schema) + end + def to_protocol catalog = destination.catalog Multiwoven::Integrations::Protocol::SyncConfig.new( diff --git a/server/app/serializers/model_serializer.rb b/server/app/serializers/model_serializer.rb index 071e85f15..de018fc83 100644 --- a/server/app/serializers/model_serializer.rb +++ b/server/app/serializers/model_serializer.rb @@ -11,11 +11,9 @@ def configuration if object.ai_ml? connector = object.connector json_schema = connector.catalog.json_schema(connector.connector_name) - existing_config = object.configuration - - existing_config.merge({ json_schema: }) + object.masked_configuration.merge({ json_schema: }) else - object.configuration + object.masked_configuration end end end diff --git a/server/app/serializers/sync_serializer.rb b/server/app/serializers/sync_serializer.rb index e9f33a50e..c8d3a3b08 100644 --- a/server/app/serializers/sync_serializer.rb +++ b/server/app/serializers/sync_serializer.rb @@ -1,11 +1,19 @@ # frozen_string_literal: true class SyncSerializer < ActiveModel::Serializer +<<<<<<< HEAD attributes :id, :source_id, :destination_id, :model_id, :configuration, +======= + attributes :id, :name, :source_id, :destination_id, :model_id, +>>>>>>> 6e0b1e4c6 (fix(CE): return masked configuration for model (#1840)) :schedule_type, :sync_mode, :sync_interval, :sync_interval_unit, :cron_expression, :stream_name, :status, :cursor_field, :current_cursor_field, :updated_at, :created_at + attribute :configuration do + object.masked_configuration + end + attribute :source do ConnectorSerializer.new(object.source).attributes.except(:configuration) end diff --git a/server/lib/utils/secret_masking.rb b/server/lib/utils/secret_masking.rb new file mode 100644 index 000000000..e525aa6dd --- /dev/null +++ b/server/lib/utils/secret_masking.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Utils + module SecretMasking + MASKED_VALUE = "*************" + + module_function + + def mask_by_keys(config, schema) + secret_keys = extract_secret_keys(schema) + return config if secret_keys.empty? + + mask_values(config, secret_keys) + end + + def mask_nested_values(obj) + case obj + when Hash + obj.transform_values { |v| mask_nested_values(v) } + when Array + obj.map { |v| mask_nested_values(v) } + when String + obj.present? ? MASKED_VALUE : obj + else + obj + end + end + + def mask_values(config, secret_keys) + case config + when Hash + config.each_with_object({}) do |(key, value), result| + result[key] = if secret_keys.include?(key.to_s) + MASKED_VALUE + else + mask_values(value, secret_keys) + end + end + when Array + config.map { |item| mask_values(item, secret_keys) } + else + config + end + end + + def extract_secret_keys(schema, keys = []) + return keys unless schema.is_a?(Hash) + + schema = schema.with_indifferent_access + (schema["properties"] || {}).each do |key, subschema| + keys << key.to_s if subschema["multiwoven_secret"] + extract_secret_keys(subschema, keys) + end + + extract_secret_keys(schema["items"], keys) if schema["items"] + + keys + end + + private_class_method :extract_secret_keys, :mask_values + end +end diff --git a/server/spec/lib/utils/secret_masking_spec.rb b/server/spec/lib/utils/secret_masking_spec.rb new file mode 100644 index 000000000..c6549c538 --- /dev/null +++ b/server/spec/lib/utils/secret_masking_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::SecretMasking do + describe "MASKED_VALUE" do + it "equals the expected mask string" do + expect(described_class::MASKED_VALUE).to eq("*************") + end + end + + describe ".mask_by_keys" do + let(:schema) do + { + properties: { + api_key: { type: "string", multiwoven_secret: true }, + password: { type: "string", multiwoven_secret: true }, + host: { type: "string" } + } + } + end + + it "returns a non-hash value unchanged" do + expect(described_class.mask_by_keys("plaintext", schema)).to eq("plaintext") + end + + it "returns nil unchanged" do + expect(described_class.mask_by_keys(nil, schema)).to be_nil + end + + it "masks values whose keys are marked multiwoven_secret in schema" do + config = { "api_key" => "real-key", "password" => "hunter2" } + result = described_class.mask_by_keys(config, schema) + expect(result["api_key"]).to eq("*************") + expect(result["password"]).to eq("*************") + end + + it "leaves values whose keys are not marked as secrets unchanged" do + config = { "host" => "localhost", "port" => 5432 } + result = described_class.mask_by_keys(config, schema) + expect(result["host"]).to eq("localhost") + expect(result["port"]).to eq(5432) + end + + it "recurses into nested hashes" do + nested_schema = { + properties: { + credentials: { + type: "object", + properties: { + api_key: { type: "string", multiwoven_secret: true }, + user: { type: "string" } + } + } + } + } + config = { "credentials" => { "api_key" => "secret", "user" => "admin" } } + result = described_class.mask_by_keys(config, nested_schema) + expect(result["credentials"]["api_key"]).to eq("*************") + expect(result["credentials"]["user"]).to eq("admin") + end + + it "recurses into arrays using schema items" do + array_schema = { + type: "array", + items: { + type: "object", + properties: { + api_key: { type: "string", multiwoven_secret: true }, + host: { type: "string" } + } + } + } + config = [{ "api_key" => "key1", "host" => "example.com" }, { "api_key" => "key2" }] + result = described_class.mask_by_keys(config, array_schema) + expect(result[0]["api_key"]).to eq("*************") + expect(result[0]["host"]).to eq("example.com") + expect(result[1]["api_key"]).to eq("*************") + end + + it "returns config unchanged when schema has no multiwoven_secret fields" do + empty_schema = { properties: { host: { type: "string" } } } + config = { "host" => "localhost", "api_key" => "real-key" } + result = described_class.mask_by_keys(config, empty_schema) + expect(result).to eq(config) + end + + it "does not mutate the original config" do + config = { "api_key" => "original" } + described_class.mask_by_keys(config, schema) + expect(config["api_key"]).to eq("original") + end + + it "does not expose extract_secret_keys publicly" do + expect { described_class.extract_secret_keys({}) }.to raise_error(NoMethodError) + end + end + + describe ".mask_nested_values" do + it "masks a non-blank string" do + expect(described_class.mask_nested_values("sensitive")).to eq("*************") + end + + it "leaves a blank string unchanged" do + expect(described_class.mask_nested_values("")).to eq("") + end + + it "returns nil unchanged" do + expect(described_class.mask_nested_values(nil)).to be_nil + end + + it "returns a numeric value unchanged" do + expect(described_class.mask_nested_values(42)).to eq(42) + end + + it "masks all string values in a hash" do + obj = { "token" => "abc", "label" => "public" } + result = described_class.mask_nested_values(obj) + expect(result["token"]).to eq("*************") + expect(result["label"]).to eq("*************") + end + + it "recursively masks values in nested hashes" do + obj = { "auth" => { "bearer" => "secret", "scheme" => "Bearer" } } + result = described_class.mask_nested_values(obj) + expect(result["auth"]["bearer"]).to eq("*************") + expect(result["auth"]["scheme"]).to eq("*************") + end + + it "recursively masks strings in arrays" do + obj = ["token1", "token2", ""] + result = described_class.mask_nested_values(obj) + expect(result).to eq(["*************", "*************", ""]) + end + + it "handles mixed nested structures" do + obj = { "keys" => %w[key1 key2], "meta" => { "id" => 1, "label" => "x" } } + result = described_class.mask_nested_values(obj) + expect(result["keys"]).to eq(["*************", "*************"]) + expect(result["meta"]["id"]).to eq(1) + expect(result["meta"]["label"]).to eq("*************") + end + end +end diff --git a/server/spec/models/connector_spec.rb b/server/spec/models/connector_spec.rb index 28f2a9c99..1b706b2ff 100644 --- a/server/spec/models/connector_spec.rb +++ b/server/spec/models/connector_spec.rb @@ -555,75 +555,16 @@ end end - describe "#extract_secret_keys" do + describe "#mask_by_keys" do let(:schema) do { - properties: { - host: { type: "string" }, - credentials: { - type: "object", - properties: { - username: { type: "string" }, - password: { type: "string", multiwoven_secret: true } - } - }, - api_key: { type: "string", multiwoven_secret: true }, - nested: { - type: "object", - properties: { - secret: { type: "string", multiwoven_secret: true }, - normal: { type: "string" } - } - } + "properties" => { + "password" => { "multiwoven_secret" => true }, + "api_key" => { "multiwoven_secret" => true } } } end - it "extracts all secret keys from schema" do - result = connector.send(:extract_secret_keys, schema) - - expect(result).to contain_exactly("password", "api_key", "secret") - end - - context "when schema has no secrets" do - let(:schema) do - { - properties: { - host: { type: "string" }, - port: { type: "number" } - } - } - end - - it "returns empty array" do - result = connector.send(:extract_secret_keys, schema) - - expect(result).to be_empty - end - end - - context "when schema is not a hash" do - it "returns empty array" do - result = connector.send(:extract_secret_keys, "not a hash") - - expect(result).to be_empty - end - end - - context "when schema has no properties" do - let(:schema) { { type: "object" } } - - it "returns empty array" do - result = connector.send(:extract_secret_keys, schema) - - expect(result).to be_empty - end - end - end - - describe "#mask_secret_values" do - let(:secret_keys) { %w[password api_key] } - context "when config is a hash" do let(:config) do { @@ -637,7 +578,7 @@ end it "masks secret values and preserves structure" do - result = connector.send(:mask_secret_values, config, secret_keys) + result = Utils::SecretMasking.mask_by_keys(config, schema) expect(result["host"]).to eq("example.com") expect(result["password"]).to eq("*************") @@ -655,7 +596,7 @@ end it "masks secrets in array items" do - result = connector.send(:mask_secret_values, config, secret_keys) + result = Utils::SecretMasking.mask_by_keys(config, schema) expect(result[0]["name"]).to eq("item1") expect(result[0]["password"]).to eq("*************") @@ -666,7 +607,7 @@ context "when config is not a hash or array" do it "returns config unchanged" do - result = connector.send(:mask_secret_values, "string_value", secret_keys) + result = Utils::SecretMasking.mask_by_keys("string_value", schema) expect(result).to eq("string_value") end @@ -681,7 +622,7 @@ end it "returns config unchanged" do - result = connector.send(:mask_secret_values, config, secret_keys) + result = Utils::SecretMasking.mask_by_keys(config, schema) expect(result).to eq(config) end diff --git a/server/spec/models/model_spec.rb b/server/spec/models/model_spec.rb index 38700241a..1b3df4720 100644 --- a/server/spec/models/model_spec.rb +++ b/server/spec/models/model_spec.rb @@ -273,6 +273,92 @@ end end + describe "#masked_configuration" do + let(:source) { create(:connector, connector_type: "source", connector_name: "Snowflake") } + + context "when configuration is blank" do + it "returns configuration as-is" do + model = Model.new(query_type: :raw_sql, connector_id: source.id, workspace_id: source.workspace_id) + model.configuration = nil + expect(model.masked_configuration).to be_nil + end + end + + context "when query_type has no schema (raw_sql)" do + it "returns configuration unchanged" do + model = Model.new( + query_type: :raw_sql, + connector_id: source.id, + workspace_id: source.workspace_id, + configuration: { "host" => "localhost" } + ) + expect(model.masked_configuration).to eq({ "host" => "localhost" }) + end + end + + context "when query_type is vector_search" do + let(:config) do + { + "harvesters" => [], + "json_schema" => {}, + "embedding_config" => { "api_key" => "secret-key", "model" => "text-embedding-ada-002" } + } + end + + it "masks api_key in embedding_config" do + model = Model.new( + query_type: :vector_search, + connector_id: source.id, + workspace_id: source.workspace_id, + configuration: config + ) + result = model.masked_configuration + expect(result.dig("embedding_config", "api_key")).to eq("*************") + expect(result.dig("embedding_config", "model")).to eq("text-embedding-ada-002") + end + + it "does not mutate the original configuration" do + model = Model.new( + query_type: :vector_search, + connector_id: source.id, + workspace_id: source.workspace_id, + configuration: config + ) + model.masked_configuration + expect(model.configuration.dig("embedding_config", "api_key")).to eq("secret-key") + end + end + + context "when query_type is unstructured" do + it "masks api_key in embedding_config" do + model = Model.new( + query_type: :unstructured, + connector_id: source.id, + workspace_id: source.workspace_id, + configuration: { + "embedding_config" => { "api_key" => "secret-key", "model" => "text-embedding-ada-002" }, + "chunk_config" => { "chunk_size" => 500, "chunk_overlap" => 50 } + } + ) + result = model.masked_configuration + expect(result.dig("embedding_config", "api_key")).to eq("*************") + expect(result.dig("embedding_config", "model")).to eq("text-embedding-ada-002") + end + end + + context "when query_type is ai_ml" do + it "returns configuration unchanged (no multiwoven_secret fields in schema)" do + model = Model.new( + query_type: :ai_ml, + connector_id: source.id, + workspace_id: source.workspace_id, + configuration: { "harvesters" => [] } + ) + expect(model.masked_configuration).to eq({ "harvesters" => [] }) + end + end + end + describe "#to_protocol" do it "returns a protocol model with correct attributes" do model = Model.new( diff --git a/server/spec/models/sync_spec.rb b/server/spec/models/sync_spec.rb index 184f95350..0d35c33f6 100644 --- a/server/spec/models/sync_spec.rb +++ b/server/spec/models/sync_spec.rb @@ -53,6 +53,69 @@ it { should validate_presence_of(:cron_expression) } end + describe "#masked_configuration" do + let(:source) { create(:connector, connector_type: "source", connector_name: "Snowflake") } + let(:destination) { create(:connector, connector_type: "destination") } + let!(:catalog) do + create(:catalog, connector: destination, + catalog: { + "request_rate_limit" => 60, + "request_rate_limit_unit" => "minute", + "request_rate_concurrency" => 2, + "streams" => [{ "name" => "profile", "batch_support" => false, + "batch_size" => 1, "json_schema" => {} }] + }) + end + let(:sync) { create(:sync, source:, destination:) } + + context "when configuration is blank" do + it "returns configuration as-is" do + sync.configuration = {} + expect(sync.masked_configuration).to eq({}) + end + end + + context "when configuration has no embedding_config" do + it "returns configuration unchanged" do + sync.configuration = [{ "from" => "email", "to" => "customer_email" }] + result = sync.masked_configuration + expect(result[0]["from"]).to eq("email") + expect(result[0]["to"]).to eq("customer_email") + end + end + + context "when configuration contains embedding_config with api_key" do + let(:config) do + [ + { + "from" => "content", + "to" => "embedding", + "embedding_config" => { "mode" => "automatic", "model" => "ada", "api_key" => "secret-key" } + } + ] + end + + before { sync.configuration = config } + + it "masks api_key inside embedding_config" do + result = sync.masked_configuration + expect(result[0].dig("embedding_config", "api_key")).to eq("*************") + end + + it "leaves non-secret fields unchanged" do + result = sync.masked_configuration + expect(result[0]["from"]).to eq("content") + expect(result[0].dig("embedding_config", "mode")).to eq("automatic") + expect(result[0].dig("embedding_config", "model")).to eq("ada") + end + + it "does not mutate the original configuration" do + sync.masked_configuration + expect(sync.configuration[0].dig("embedding_config", "api_key")).to eq("secret-key") + end + end + end + describe "#to_protocol" do let(:streams) do [ diff --git a/server/spec/requests/api/v1/models_controller_spec.rb b/server/spec/requests/api/v1/models_controller_spec.rb index bb29242ab..7574873b0 100644 --- a/server/spec/requests/api/v1/models_controller_spec.rb +++ b/server/spec/requests/api/v1/models_controller_spec.rb @@ -178,6 +178,41 @@ expect(response_hash.dig(:data, :attributes, :configuration)).to eq(expected_configuration) end + it "does not mask configuration for raw_sql models (no schema)" do + model_with_secrets = create( + :model, + connector:, + workspace:, + query_type: :raw_sql, + query: "SELECT 1", + configuration: { "api_key" => "real-api-key", "host" => "localhost" } + ) + get "/api/v1/models/#{model_with_secrets.id}", headers: auth_headers(user, workspace_id) + expect(response).to have_http_status(:ok) + config = JSON.parse(response.body).dig("data", "attributes", "configuration") + expect(config["api_key"]).to eq("real-api-key") + expect(config["host"]).to eq("localhost") + end + + it "masks api_key for vector_search models via schema" do + vector_search_model = create( + :model, + connector:, + workspace:, + query_type: :vector_search, + configuration: { + "harvesters" => [], + "json_schema" => {}, + "embedding_config" => { "api_key" => "real-api-key", "model" => "text-embedding-ada-002" } + } + ) + get "/api/v1/models/#{vector_search_model.id}", headers: auth_headers(user, workspace_id) + expect(response).to have_http_status(:ok) + config = JSON.parse(response.body).dig("data", "attributes", "configuration") + expect(config.dig("embedding_config", "api_key")).to eq("*************") + expect(config.dig("embedding_config", "model")).to eq("text-embedding-ada-002") + end + it "returns success and fetch model for viewer role" do workspace.workspace_users.first.update(role: viewer_role) get "/api/v1/models/#{models.first.id}", headers: auth_headers(user, workspace_id) diff --git a/server/spec/requests/api/v1/syncs_controller_spec.rb b/server/spec/requests/api/v1/syncs_controller_spec.rb index 3df502b97..ba42a78fd 100644 --- a/server/spec/requests/api/v1/syncs_controller_spec.rb +++ b/server/spec/requests/api/v1/syncs_controller_spec.rb @@ -439,7 +439,7 @@ expect(response_hash.dig(:data, :attributes, :configuration, :embedding_config, :model)) .to eq(request_body.dig(:sync, :configuration, :embedding_config, :model)) expect(response_hash.dig(:data, :attributes, :configuration, :embedding_config, :api_key)) - .to eq(request_body.dig(:sync, :configuration, :embedding_config, :api_key)) + .to eq("*************") audit_log = AuditLog.last expect(audit_log).not_to be_nil diff --git a/server/spec/serializers/model_serializer_spec.rb b/server/spec/serializers/model_serializer_spec.rb new file mode 100644 index 000000000..409ea4a76 --- /dev/null +++ b/server/spec/serializers/model_serializer_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ModelSerializer, type: :serializer do + let(:workspace) { create(:workspace) } + let(:source) { create(:connector, connector_type: "source", connector_name: "Snowflake", workspace:) } + + describe "#configuration" do + context "when model is not ai_ml" do + let(:model) do + create(:model, + connector: source, + workspace:, + query_type: :raw_sql, + query: "SELECT 1", + configuration: nil) + end + + it "returns masked_configuration" do + model.configuration = { "host" => "localhost" } + allow(model).to receive(:masked_configuration).and_return({ "host" => "localhost" }) + serializer = described_class.new(model) + expect(serializer.attributes[:configuration]).to eq({ "host" => "localhost" }) + end + end + + context "when model is ai_ml" do + let(:ai_ml_connector) do + create(:connector, connector_type: "source", connector_name: "OpenAI", workspace:) + end + let(:json_schema) { { "type" => "object", "properties" => { "prompt" => { "type" => "string" } } } } + let(:model) do + Model.new( + connector: ai_ml_connector, + workspace:, + query_type: :ai_ml, + name: "test ai model", + configuration: { "harvesters" => [] } + ) + end + let(:mock_catalog) { instance_double(Catalog) } + + before do + allow(ai_ml_connector).to receive(:catalog).and_return(mock_catalog) + allow(mock_catalog).to receive(:json_schema).and_return(json_schema) + allow(model).to receive(:masked_configuration).and_return({ "harvesters" => [] }) + end + + it "merges json_schema into masked_configuration" do + serializer = described_class.new(model) + result = serializer.attributes[:configuration] + expect(result["harvesters"]).to eq([]) + expect(result[:json_schema]).to eq(json_schema) + end + end + end +end