Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ and this project adheres to

### Fixed

- Ensure that credentials are properly transferred when merging a sandbox. This
fixes a validation error which can occur on merge
[#4831](https://github.com/OpenFn/lightning/issues/4831)

## [2.16.7] - 2026-06-04

### Changed
Expand Down
72 changes: 61 additions & 11 deletions lib/lightning/projects/merge_projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,26 @@ defmodule Lightning.Projects.MergeProjects do
opts
) do
source_project =
Repo.preload(source_project, workflows: [:jobs, :triggers, :edges])
Repo.preload(source_project,
workflows: [:jobs, :triggers, :edges],
project_credentials: []
)

target_project =
Repo.preload(target_project, workflows: [:jobs, :triggers, :edges])
Repo.preload(target_project,
workflows: [:jobs, :triggers, :edges],
project_credentials: []
)

merge_project(
Map.from_struct(source_project),
Map.from_struct(target_project),
opts
)
credential_map =
build_credential_remap(
source_project.project_credentials,
target_project.project_credentials
)

Map.from_struct(source_project)
|> merge_project(Map.from_struct(target_project), opts)
|> remap_document_credentials(credential_map)
end

def merge_project(source_project, target_project, opts) do
Expand Down Expand Up @@ -552,10 +562,6 @@ defmodule Lightning.Projects.MergeProjects do
|> then(fn job_attrs ->
if target_job do
job_attrs
|> Map.put(
:project_credential_id,
target_job.project_credential_id
)
|> Map.put(
:keychain_credential_id,
target_job.keychain_credential_id
Expand Down Expand Up @@ -585,6 +591,50 @@ defmodule Lightning.Projects.MergeProjects do
{new_mapping, merged_from_source ++ deleted_targets}
end

# Builds a map of `source_project_credential_id => target_project_credential_id`
# by matching on the shared underlying `credential_id`
defp build_credential_remap(
source_project_credentials,
target_project_credentials
) do
target_by_credential =
Map.new(target_project_credentials, &{&1.credential_id, &1.id})

Map.new(source_project_credentials, fn pc ->
{pc.id, Map.get(target_by_credential, pc.credential_id)}
end)
end

defp remap_document_credentials(document, credential_map)
when map_size(credential_map) == 0,
do: document

defp remap_document_credentials(document, credential_map) do
Map.update(document, "workflows", [], fn workflows ->
Enum.map(workflows, &remap_workflow_credentials(&1, credential_map))
end)
end

defp remap_workflow_credentials(%{"jobs" => jobs} = workflow, credential_map) do
Map.put(
workflow,
"jobs",
Enum.map(jobs, &remap_job_credential(&1, credential_map))
)
end

defp remap_workflow_credentials(workflow, _credential_map), do: workflow

defp remap_job_credential(
%{"project_credential_id" => pc_id} = job,
credential_map
)
when not is_nil(pc_id) do
Map.put(job, "project_credential_id", Map.get(credential_map, pc_id, pc_id))
end

defp remap_job_credential(job, _credential_map), do: job

defp build_merged_triggers(source_triggers, target_triggers, trigger_mappings) do
# Process source triggers (matched and new)
{new_mapping, merged_from_source} =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,9 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do
def handle_event("show_modal", %{"target" => "new_credential"}, socket) do
if socket.assigns.can_create_project_credential do
project_credentials =
if socket.assigns.project do
[
%Lightning.Projects.ProjectCredential{
project_id: socket.assigns.project.id
}
]
else
[]
end
LightningWeb.CredentialLive.Helpers.default_project_credentials(
socket.assigns.project
)

{:noreply,
assign(socket,
Expand Down
18 changes: 18 additions & 0 deletions lib/lightning_web/live/credential_live/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,24 @@ defmodule LightningWeb.CredentialLive.Helpers do
}
end

@doc """
Builds the `project_credentials` to pre-select when creating a credential in
a project context: the active project plus its root parent project (when the
active project is a sandbox), deduplicated and order-preserving.

Returns an empty list when there is no project context.
"""
@spec default_project_credentials(Lightning.Projects.Project.t() | nil) ::
[Lightning.Projects.ProjectCredential.t()]
def default_project_credentials(nil), do: []

def default_project_credentials(%Lightning.Projects.Project{} = project) do
[project, Lightning.Projects.root_of(project)]
|> Enum.map(& &1.id)
|> Enum.uniq()
|> Enum.map(&%Lightning.Projects.ProjectCredential{project_id: &1})
end

def handle_save_response(socket, credential) do
if socket.assigns[:on_save] do
socket.assigns[:on_save].(credential)
Expand Down
9 changes: 4 additions & 5 deletions lib/lightning_web/live/workflow_live/collaborate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule LightningWeb.WorkflowLive.Collaborate do
alias Lightning.Workflows.WebhookAuthMethod
alias Lightning.Workflows.Workflow
alias LightningWeb.Channels.WorkflowJSON
alias LightningWeb.CredentialLive

on_mount({LightningWeb.Hooks, :project_scope})
on_mount({LightningWeb.Hooks, :check_limits})
Expand Down Expand Up @@ -45,6 +46,8 @@ defmodule LightningWeb.WorkflowLive.Collaborate do
show_credential_modal: false,
credential_schema: nil,
credential_to_edit: nil,
new_credential_project_credentials:
CredentialLive.Helpers.default_project_credentials(project),
show_webhook_auth_modal: false,
webhook_auth_method: nil,
ai_assistant_enabled: AiAssistant.enabled?()
Expand Down Expand Up @@ -223,11 +226,7 @@ defmodule LightningWeb.WorkflowLive.Collaborate do
%Lightning.Credentials.Credential{
schema: @credential_schema,
user_id: @current_user.id,
project_credentials: [
%Lightning.Projects.ProjectCredential{
project_id: @project.id
}
]
project_credentials: @new_credential_project_credentials
}
}
on_save={
Expand Down
10 changes: 5 additions & 5 deletions lib/lightning_web/live/workflow_live/edit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,7 @@ defmodule LightningWeb.WorkflowLive.Edit do
credential={
%Lightning.Credentials.Credential{
user_id: @current_user.id,
project_credentials: [
%Lightning.Projects.ProjectCredential{
project_id: @project.id
}
]
project_credentials: @new_credential_project_credentials
}
}
current_user={@current_user}
Expand Down Expand Up @@ -1462,6 +1458,10 @@ defmodule LightningWeb.WorkflowLive.Edit do
show_canvas_placeholder: assigns.live_action == :new,
show_workflow_ai_chat: false,
show_job_credential_modal: false,
new_credential_project_credentials:
LightningWeb.CredentialLive.Helpers.default_project_credentials(
assigns.project
),
active_modal: nil,
active_modal_assigns: nil,
admin_contacts: Projects.list_project_admin_emails(assigns.project.id),
Expand Down
117 changes: 117 additions & 0 deletions test/lightning/sandboxes_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,123 @@ defmodule Lightning.Projects.SandboxesTest do
end
end

describe "merge/4 workflows and credentials" do
test "merges a new workflow from a sandbox into the parent" do
%{actor: actor, parent: parent} = build_parent_fixture!(:owner)

{:ok, sandbox} = Sandboxes.provision(parent, actor, %{name: "sandbox-x"})

# Add a brand-new workflow to the sandbox only.
insert(:simple_workflow, project: sandbox, name: "NewFlow")

refute Repo.exists?(
from(w in Workflow,
where: w.project_id == ^parent.id and w.name == "NewFlow"
)
)

assert {:ok, _updated} = Sandboxes.merge(sandbox, parent, actor)

new_workflow =
Workflow
|> Repo.get_by!(project_id: parent.id, name: "NewFlow")
|> Repo.preload([:jobs, :triggers, :edges])

assert length(new_workflow.jobs) == 1
assert length(new_workflow.triggers) == 1
assert length(new_workflow.edges) == 1
end

test "merges a credential added to an existing step in the sandbox" do
%{actor: actor, parent: parent, pc: pc, nodes: %{j2: parent_a2}} =
build_parent_fixture!(:owner)

# Start with A2 (an existing parent step) having no credential.
Repo.update!(Ecto.Changeset.change(parent_a2, project_credential_id: nil))

{:ok, sandbox} = Sandboxes.provision(parent, actor, %{name: "sandbox-x"})

# The sandbox clones the parent's credential as its own project_credential
# referencing the same underlying credential.
sandbox_pc =
Repo.get_by!(ProjectCredential,
project_id: sandbox.id,
credential_id: pc.credential_id
)

# In the sandbox, add that credential to the previously-uncredentialed
# step.
sandbox
|> find_sandbox_job!("Alpha", "A2")
|> Ecto.Changeset.change(project_credential_id: sandbox_pc.id)
|> Repo.update!()

assert {:ok, _updated} = Sandboxes.merge(sandbox, parent, actor)

# The credential added in the sandbox propagates to the parent, mapped to
# the parent's project_credential for the same underlying credential.
assert Repo.reload!(parent_a2).project_credential_id == pc.id
end

test "merges a new workflow in the sandbox with a step that references a credential" do
%{actor: actor, parent: parent, pc: pc} = build_parent_fixture!(:owner)

{:ok, sandbox} = Sandboxes.provision(parent, actor, %{name: "sandbox-x"})

sandbox_pc =
Repo.get_by!(ProjectCredential,
project_id: sandbox.id,
credential_id: pc.credential_id
)

# Add a brand-new workflow to the sandbox whose step references the
# (sandbox-scoped) credential.
new_wf = insert(:simple_workflow, project: sandbox, name: "NewFlow")
[new_job] = Repo.preload(new_wf, :jobs).jobs

new_job
|> Ecto.Changeset.change(project_credential_id: sandbox_pc.id)
|> Repo.update!()

# The new workflow merges into the parent, mapping the (sandbox-scoped)
# credential to the parent's project_credential for the same underlying
# credential. Without remapping, import would reject the new job's
# sandbox-scoped project_credential_id ("credential doesnt exist or isn't
# available in this project").
assert {:ok, _updated} = Sandboxes.merge(sandbox, parent, actor)

parent_new_wf =
Repo.get_by!(Workflow, project_id: parent.id, name: "NewFlow")

merged_job =
Repo.get_by!(Job, workflow_id: parent_new_wf.id, name: new_job.name)

assert merged_job.project_credential_id == pc.id
end

test "merges a credential removed from an existing step in the sandbox" do
# A1 references the credential in the parent (per build_parent_fixture!).
%{actor: actor, parent: parent, pc: pc, nodes: %{j1: parent_a1}} =
build_parent_fixture!(:owner)

assert parent_a1.project_credential_id == pc.id

{:ok, sandbox} = Sandboxes.provision(parent, actor, %{name: "sandbox-x"})

# In the sandbox, remove the credential from the step.
sandbox
|> find_sandbox_job!("Alpha", "A1")
|> Ecto.Changeset.change(project_credential_id: nil)
|> Repo.update!()

assert {:ok, _updated} = Sandboxes.merge(sandbox, parent, actor)

# The removal propagates to the parent: the step no longer references a
# credential.
assert Repo.reload!(parent_a1).project_credential_id == nil
end
end

describe "keychains" do
test "clones only used keychains and rewires jobs to cloned keychains" do
%{
Expand Down
29 changes: 29 additions & 0 deletions test/lightning_web/live/credential_live_helpers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -247,4 +247,33 @@ defmodule LightningWeb.CredentialLive.HelpersTest do
assert updated.available_projects == projects
end
end

describe "default_project_credentials/1" do
test "returns an empty list without a project context" do
assert Helpers.default_project_credentials(nil) == []
end

test "returns only the project itself for a root project" do
project = insert(:project)

assert [%{project_id: project_id}] =
Helpers.default_project_credentials(project)

assert project_id == project.id
end

test "returns the sandbox and its root parent for a sandbox" do
root = insert(:project)
child = insert(:project, parent_id: root.id)
grandchild = insert(:project, parent_id: child.id)

project_ids =
grandchild
|> Helpers.default_project_credentials()
|> Enum.map(& &1.project_id)

# The active (sandbox) project and the root parent, not the intermediate.
assert project_ids == [grandchild.id, root.id]
end
end
end
Loading