diff --git a/server/enterprise/app/interactors/agents/workflows/update_workflow_components.rb b/server/enterprise/app/interactors/agents/workflows/update_workflow_components.rb new file mode 100644 index 000000000..3dd832a00 --- /dev/null +++ b/server/enterprise/app/interactors/agents/workflows/update_workflow_components.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Agents + module Workflows + class UpdateWorkflowComponents + include Interactor + + delegate :workflow, :components_params, to: :context + + def call + # Get IDs of components in the update params + updated_component_ids = components_params.map { |params| params[:id] } + + # Delete components that are not in the update params + workflow.components.where.not(id: updated_component_ids).find_each do |component| + set_knowledge_base_component(component, enabled: false) if component.component_type == "knowledge_base" + delete_file_input_files if component.component_type == "file_input" + component.destroy! + end + + # Update or create components from params + components_params.each do |component_params| + update_component(component_params) + end + end + + private + + def update_component(params) + existing_component = Agents::Component.find_by(id: params[:id]) + if existing_component && existing_component.workflow_id != workflow.id + raise StandardError, "Component with ID '#{params[:id]}' already exists in another workflow" + end + + component = workflow.components.find_or_initialize_by(id: params[:id]) + configuration = params[:configuration] + masked_keys = Utils::SecretMasking.masked_attribute_keys(params[:configuration]) + unless masked_keys.empty? + configuration = configuration.respond_to?(:to_unsafe_h) ? configuration.to_unsafe_h : configuration + configuration = configuration.except(*masked_keys).merge(component.configuration.slice(*masked_keys)) + end + old_component = component.dup + component.update!( + workspace: workflow.workspace, + name: params[:name], + component_type: params[:component_type], + component_category: params[:component_category], + data: params[:data], + configuration:, + position: params[:position] + ) + return unless component.component_type == "knowledge_base" + + set_knowledge_base_component(old_component, enabled: false) + set_knowledge_base_component(component, enabled: true) + end + + def set_knowledge_base_component(component, enabled:) + knowledge_base_id = component.configuration&.dig("knowledge_base") + return if knowledge_base_id.blank? + + knowledge_base = Agents::KnowledgeBase.find_by(id: knowledge_base_id) + return if knowledge_base.nil? + + # rubocop:disable Rails/SkipsModelValidations + knowledge_base.knowledge_base_files.update_all(workflow_enabled: enabled) + # rubocop:enable Rails/SkipsModelValidations + end + + def delete_file_input_files + workflow.workflow_files.destroy_all + end + end + end +end diff --git a/server/lib/utils/secret_masking.rb b/server/lib/utils/secret_masking.rb new file mode 100644 index 000000000..90f0c7dbc --- /dev/null +++ b/server/lib/utils/secret_masking.rb @@ -0,0 +1,71 @@ +# 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 masked_attribute_keys(obj) + return [] if obj.blank? + + hash = obj.respond_to?(:to_unsafe_h) ? obj.to_unsafe_h : obj + return [] unless hash.respond_to?(:keys) + + hash.keys.select { |key| hash[key] == MASKED_VALUE } + 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/enterprise/interactors/agents/workflows/update_workflow_components_spec.rb b/server/spec/enterprise/interactors/agents/workflows/update_workflow_components_spec.rb new file mode 100644 index 000000000..74cd4d507 --- /dev/null +++ b/server/spec/enterprise/interactors/agents/workflows/update_workflow_components_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Agents::Workflows::UpdateWorkflowComponents do + describe ".call" do + let(:workflow) { create(:workflow) } + let(:existing_component) { create(:component, workflow:, name: "Old Component") } + let(:components_params) do + [ + { + id: existing_component.id, + name: "Updated Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: { "key" => "value" }, + data: { "category" => "input_output", + "component" => "chat_input", + "label" => "Chat Input" }, + position: { "x" => 100, "y" => 200 } + }, + { + id: "new-component-1", + name: "New Component", + component_type: "chat_output", + component_category: "generic_component", + configuration: { "key" => "value" }, + data: { "category" => "input_output", + "component" => "chat_input", + "label" => "Chat Input" }, + position: { "x" => 300, "y" => 400 } + } + ] + end + + let(:context) { { workflow:, components_params: } } + let(:knowledge_base) { create(:knowledge_base) } + let(:new_knowledge_base) { create(:knowledge_base) } + let(:knowledge_base_file) { create(:knowledge_base_file, knowledge_base:) } + let(:new_knowledge_base_file) { create(:knowledge_base_file, knowledge_base: new_knowledge_base) } + let(:knowledge_base_context) do + { + workflow:, + components_params: [ + { + id: existing_component.id, + component_type: "knowledge_base", + component_category: "generic_component", + data: { "category" => "input_output", + "component" => "chat_input", + "label" => "Chat Input" }, + position: { "x" => 100, "y" => 200 }, + configuration: { + "knowledge_base" => knowledge_base.id + } + } + ] + } + end + + before do + existing_component # Create the existing component + end + + it "updates existing components and creates new ones" do + result = described_class.call(context) + + expect(result).to be_success + + # Check updated component + updated_component = workflow.components.find(existing_component.id) + expect(updated_component.name).to eq("Updated Component") + expect(updated_component.component_type).to eq("chat_input") + expect(updated_component.configuration).to eq({ "key" => "value" }) + expect(updated_component.position).to eq({ "x" => 100, "y" => 200 }) + + # Check new component + new_component = workflow.components.find_by(id: "new-component-1") + expect(new_component).to be_present + expect(new_component.name).to eq("New Component") + expect(new_component.component_type).to eq("chat_output") + expect(new_component.configuration).to eq({ "key" => "value" }) + expect(new_component.position).to eq({ "x" => 300, "y" => 400 }) + end + + context "when components_params is blank" do + let(:components_params) { [] } + + it "removes all existing components" do + expect { described_class.call(context) }.to change { workflow.components.count }.to(0) + end + end + + context "when component update fails" do + let(:components_params) do + [ + { + id: existing_component.id, + name: "", # Invalid empty name + component_type: "chat_input", + configuration: { "key" => "value" } + } + ] + end + + it "raises an error" do + expect { described_class.call(context) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context "when component ID already exists in another workflow" do + let(:other_workflow) { create(:workflow) } + let!(:other_component) { create(:component, workflow: other_workflow, id: "shared-component-id") } + + let(:components_params) do + [ + { + id: "shared-component-id", # Same ID as component in other workflow + name: "New Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: { "key" => "value" }, + data: { "category" => "input_output", + "component" => "chat_input", + "label" => "Chat Input" }, + position: { "x" => 100, "y" => 200 } + } + ] + end + + it "raises StandardError with appropriate error message" do + expect do + described_class.call(context) + end.to raise_error(StandardError, + "Component with ID 'shared-component-id' already exists in another workflow") + end + end + + context "when component ID already exists in the same workflow" do + let(:components_params) do + [ + { + id: existing_component.id, # Same ID as existing component in same workflow + name: "Updated Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: { "key" => "value" }, + data: { "category" => "input_output", + "component" => "chat_input", + "label" => "Chat Input" }, + position: { "x" => 100, "y" => 200 } + } + ] + end + + it "allows updating the existing component" do + result = described_class.call(context) + + expect(result).to be_success + updated_component = workflow.components.find(existing_component.id) + expect(updated_component.name).to eq("Updated Component") + end + end + + context "when a knowledge base component is updated" do + before do + knowledge_base.knowledge_base_files << knowledge_base_file + new_knowledge_base.knowledge_base_files << new_knowledge_base_file + knowledge_base.save! + new_knowledge_base.save! + end + + it "enables the knowledge base file for the workflow" do + result = described_class.call(knowledge_base_context) + expect(result).to be_success + expect(knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_truthy + expect(new_knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_falsey + end + + it "disables the knowledge base file for the workflow" do + result = described_class.call(knowledge_base_context) + expect(result).to be_success + expect(knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_truthy + expect(new_knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_falsey + + knowledge_base_context[:components_params][0][:configuration]["knowledge_base"] = new_knowledge_base.id + result = described_class.call(knowledge_base_context) + expect(result).to be_success + expect(knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_falsey + expect(new_knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_truthy + end + end + + context "when a knowledge base component is deleted" do + before do + knowledge_base.knowledge_base_files << knowledge_base_file + knowledge_base.save! + end + + it "disables the knowledge base file for the workflow" do + described_class.call(knowledge_base_context) + expect(knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_truthy + knowledge_base_context[:components_params] = [] + result = described_class.call(knowledge_base_context) + expect(result).to be_success + expect(knowledge_base.reload.knowledge_base_files.first.workflow_enabled).to be_falsey + end + end + + context "when a component configuration contains a masked value" do + let(:masked) { Utils::SecretMasking::MASKED_VALUE } + let(:original_api_key) { "real-secret-key" } + + before do + existing_component.update!(configuration: { "api_key" => original_api_key, "limit" => 5 }) + end + + let(:components_params) do + [ + { + id: existing_component.id, + name: "Updated Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: { "api_key" => masked, "limit" => 10 }, + data: { "category" => "input_output", "component" => "chat_input", "label" => "Chat Input" }, + position: { "x" => 0, "y" => 0 } + } + ] + end + + it "updates non-masked fields and preserves the masked key's existing value" do + result = described_class.call(context) + + expect(result).to be_success + updated = workflow.components.find(existing_component.id) + expect(updated.name).to eq("Updated Component") + expect(updated.configuration["api_key"]).to eq(original_api_key) + expect(updated.configuration["limit"]).to eq(10) + end + end + + context "when some components have masked values and others do not" do + let(:masked) { Utils::SecretMasking::MASKED_VALUE } + let(:original_api_key) { "original-secret" } + + before do + existing_component.update!(configuration: { "api_key" => original_api_key }) + end + + let(:components_params) do + [ + { + id: existing_component.id, + name: "Updated Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: { "api_key" => masked }, + data: { "category" => "input_output", "component" => "chat_input", "label" => "Chat Input" }, + position: { "x" => 0, "y" => 0 } + }, + { + id: "new-clean-component", + name: "Clean Component", + component_type: "chat_output", + component_category: "generic_component", + configuration: { "key" => "real_value" }, + data: { "category" => "input_output", "component" => "chat_output", "label" => "Chat Output" }, + position: { "x" => 100, "y" => 0 } + } + ] + end + + it "updates the masked component preserving secret, and creates the clean component" do + result = described_class.call(context) + + expect(result).to be_success + updated = workflow.components.find(existing_component.id) + expect(updated.name).to eq("Updated Component") + expect(updated.configuration["api_key"]).to eq(original_api_key) + expect(workflow.components.find_by(id: "new-clean-component")).to be_present + end + end + + context "when a file_input component is removed" do + let!(:file_input_component) { create(:component, workflow:, component_type: :file_input) } + let!(:workflow_file) { create(:workflow_file, workflow:) } + + let(:components_params) do + [ + { + id: existing_component.id, + name: "Updated Component", + component_type: "chat_input", + component_category: "generic_component", + configuration: {}, + data: { "category" => "input_output", "component" => "chat_input" } + } + ] + end + + it "destroys all workflow files for the workflow" do + expect { described_class.call(context) }.to change { Agents::WorkflowFile.count }.by(-1) + expect(Agents::WorkflowFile.exists?(workflow_file.id)).to be false + end + + it "deletes the file_input component" do + described_class.call(context) + expect(Agents::Component.exists?(file_input_component.id)).to be false + end + end + 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..dda9d2c1c --- /dev/null +++ b/server/spec/lib/utils/secret_masking_spec.rb @@ -0,0 +1,166 @@ +# 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 + + describe ".masked_attribute_keys" do + let(:masked) { described_class::MASKED_VALUE } + + it "returns empty array for nil" do + expect(described_class.masked_attribute_keys(nil)).to eq([]) + end + + it "returns empty array when no values are masked" do + expect(described_class.masked_attribute_keys({ "api_key" => "real", "limit" => 10 })).to eq([]) + end + + it "returns the masked key" do + expect(described_class.masked_attribute_keys({ "api_key" => masked, + "limit" => 10 })).to contain_exactly("api_key") + end + + it "returns multiple masked keys" do + expect(described_class.masked_attribute_keys({ "api_key" => masked, "secret" => masked, + "limit" => 10 })).to contain_exactly("api_key", "secret") + end + end +end