diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c1c2133ae..a7b3200acec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ and this project adheres to is ambiguous across projects, the API returns 409 with guidance to add `?project_id=`. Existing unscoped calls keep working for unambiguous names. [#3548](https://github.com/OpenFn/lightning/issues/3548) +- Sandbox-aware Project Settings page. Each tab shows a banner explaining how + changes will (or will not) flow on merge: Local (sandbox-only), Editable + (syncs on merge), or Inherited (read-only, managed in the parent). The Sandbox + Identity panel links back to the parent project, the MFA toggle is read-only, + webhook authentication methods are managed from the parent project, and parent + project admins cannot be removed from a sandbox. The danger zone inside a + sandbox now deletes the sandbox through `Sandboxes.delete_sandbox/2` (matching + the Sandboxes page behaviour). + [#3398](https://github.com/OpenFn/lightning/issues/3398) ### Changed diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index 32d663ac7e5..1cde89e49f8 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -319,7 +319,10 @@ defmodule Lightning.Projects do ** (Ecto.NoResultsError) """ - def get_project_user!(id), do: Repo.get!(ProjectUser, id) + def get_project_user!(id, opts \\ []) do + include = Keyword.get(opts, :include, []) + ProjectUser |> Repo.get!(id) |> Repo.preload(include) + end @spec get_project_user(Ecto.UUID.t()) :: ProjectUser.t() | nil def get_project_user(id) when is_binary(id), do: Repo.get(ProjectUser, id) @@ -574,6 +577,15 @@ defmodule Lightning.Projects do %{user_id: user_id, project_id: project_id} = Repo.preload(project_user, [:user, :project]) + if Project.sandbox?(project_user.project) and + Lightning.Projects.Sandboxes.parent_admin?( + project_user.project, + project_user.user + ) do + raise ArgumentError, + "Cannot remove a parent project admin from a sandbox" + end + Repo.transaction(fn -> from(pc in Lightning.Projects.ProjectCredential, join: c in Lightning.Credentials.Credential, diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex index c7d1192e15e..c427dc57e72 100644 --- a/lib/lightning/projects/sandboxes.ex +++ b/lib/lightning/projects/sandboxes.ex @@ -214,6 +214,35 @@ defmodule Lightning.Projects.Sandboxes do end end + @doc """ + Returns `true` when `user` has an `:admin` or `:owner` role on any ancestor + of `project`, walking the parent chain. + + Used to enforce the parent-admin floor rule: a user who is admin/owner on + any ancestor project cannot be removed from, or downgraded within, a + sandbox descended from that project. + """ + @spec parent_admin?(Project.t(), User.t()) :: boolean() + def parent_admin?(%Project{} = project, %User{} = user) do + project + |> ancestors() + |> Enum.any?(fn ancestor -> + Lightning.Projects.get_project_user_role(user, ancestor) in [ + :admin, + :owner + ] + end) + end + + defp ancestors(%Project{parent_id: nil}), do: [] + + defp ancestors(%Project{parent_id: parent_id}) do + case Lightning.Projects.get_project(parent_id) do + nil -> [] + %Project{} = parent -> [parent | ancestors(parent)] + end + end + @doc """ Deletes a sandbox and all its descendant projects. diff --git a/lib/lightning_web/components/layout_components.ex b/lib/lightning_web/components/layout_components.ex index cc5c9024022..df608976857 100644 --- a/lib/lightning_web/components/layout_components.ex +++ b/lib/lightning_web/components/layout_components.ex @@ -388,7 +388,7 @@ defmodule LightningWeb.LayoutComponents do attr :title, :string, required: true attr :subtitle, :string, required: true - attr :permissions_message, :string, required: true + attr :permissions_message, :string, default: nil attr :can_perform_action, :boolean, default: true attr :action_button_text, :string, default: nil attr :action_button_click, :any, default: nil @@ -409,7 +409,7 @@ defmodule LightningWeb.LayoutComponents do {@subtitle} - <%= if !@can_perform_action do %> + <%= if !@can_perform_action and @permissions_message do %> <.permissions_message section={@permissions_message} /> <% end %> diff --git a/lib/lightning_web/components/sandbox_settings_banner.ex b/lib/lightning_web/components/sandbox_settings_banner.ex new file mode 100644 index 00000000000..3e90807e48f --- /dev/null +++ b/lib/lightning_web/components/sandbox_settings_banner.ex @@ -0,0 +1,71 @@ +defmodule LightningWeb.Components.SandboxSettingsBanner do + @moduledoc """ + Banner shown at the top of a Project Settings tab when the project is a + sandbox, communicating how changes on that tab will (or will not) flow + to the parent project on merge. + + Three variants: + + * `:local` — changes apply only to this sandbox and do not sync on merge + * `:editable` — changes will sync to the parent on merge + * `:inherited` — settings are read-only, managed in the parent project + + ## Examples + + <.sandbox_settings_banner variant={:local} /> + + <.sandbox_settings_banner + variant={:inherited} + parent_project={@parent_project} + /> + """ + use LightningWeb, :component + + alias LightningWeb.Components.Common + + attr :variant, :atom, required: true, values: [:local, :editable, :inherited] + attr :id, :string, required: true + attr :parent_project, :map, default: nil + + def sandbox_settings_banner(%{variant: :local} = assigns) do + ~H""" + + <:message> + Changes you make here only apply to this sandbox and do not sync to the parent project on merge. + + + """ + end + + def sandbox_settings_banner(%{variant: :editable} = assigns) do + ~H""" + + <:message> + Changes you make here will sync to the parent project on merge. + + + """ + end + + def sandbox_settings_banner(%{variant: :inherited} = assigns) do + ~H""" + + <:message> + These settings are inherited from the parent project + <.parent_link :if={@parent_project} project={@parent_project} />and cannot be changed here. + + + """ + end + + attr :project, :map, required: true + + defp parent_link(assigns) do + ~H""" + (<.link + navigate={~p"/projects/#{@project.id}/settings"} + class="font-medium underline" + >{@project.name}) + """ + end +end diff --git a/lib/lightning_web/live/project_live/collections_component.ex b/lib/lightning_web/live/project_live/collections_component.ex index 8374aacb335..4ab955009a6 100644 --- a/lib/lightning_web/live/project_live/collections_component.ex +++ b/lib/lightning_web/live/project_live/collections_component.ex @@ -3,6 +3,7 @@ defmodule LightningWeb.ProjectLive.CollectionsComponent do use LightningWeb, :live_component + import LightningWeb.Components.SandboxSettingsBanner import LightningWeb.LayoutComponents alias Lightning.Collections @@ -30,9 +31,9 @@ defmodule LightningWeb.ProjectLive.CollectionsComponent do collections: _, return_to: _, project: _, + sandbox?: _, current_user: _ - } = - assigns, + } = assigns, socket ) do {:ok, @@ -267,6 +268,11 @@ defmodule LightningWeb.ProjectLive.CollectionsComponent do action_button_disabled={!@can_create_collection} action_button_id="open-create-collection-modal-button" /> + <.sandbox_settings_banner + :if={@sandbox?} + id="sandbox-banner-collections" + variant={:editable} + /> <.form_modal_component :if={@action == :new} diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 4f4b134e635..d4a4bdbafad 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -4,8 +4,10 @@ defmodule LightningWeb.ProjectLive.Settings do """ use LightningWeb, :live_view + import LightningWeb.Components.SandboxSettingsBanner import LightningWeb.LayoutComponents + alias Lightning.Accounts.User alias Lightning.Collections alias Lightning.Credentials alias Lightning.Helpers @@ -14,6 +16,7 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.Projects.Project alias Lightning.Projects.ProjectLimiter alias Lightning.Projects.ProjectUser + alias Lightning.Projects.Sandboxes alias Lightning.VersionControl alias Lightning.WebhookAuthMethods alias LightningWeb.Components.GithubComponents @@ -34,6 +37,11 @@ defmodule LightningWeb.ProjectLive.Settings do VersionControl.subscribe(current_user) end + project = Lightning.Repo.preload(project, :parent) + sandbox? = Project.sandbox?(project) + parent_project = if sandbox?, do: project.parent + root_project = if sandbox?, do: Projects.root_of(project) + project_user = Projects.get_project_user(project, current_user) project_files = Projects.list_project_files(project) @@ -126,6 +134,9 @@ defmodule LightningWeb.ProjectLive.Settings do current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), name: project.name, + parent_project: parent_project, + root_project: root_project, + project: project, project_changeset: Project.form_changeset(project, %{raw_name: project.name}), project_files: project_files, @@ -133,6 +144,7 @@ defmodule LightningWeb.ProjectLive.Settings do project_user: project_user, project_users: [], projects: projects, + sandbox?: sandbox?, selected_credential_type: nil, show_collaborators_modal: false, show_invite_collaborators_modal: false, @@ -179,15 +191,41 @@ defmodule LightningWeb.ProjectLive.Settings do end defp apply_action(socket, :delete, %{"project_id" => id}) do - if socket.assigns.can_delete_project do - socket |> assign(:page_title, "Project settings") - else - socket - |> put_flash(:error, "You are not authorize to perform this action") - |> push_patch(to: ~p"/projects/#{id}/settings") + cond do + not socket.assigns.can_delete_project -> + socket + |> put_flash(:error, "You are not authorize to perform this action") + |> push_patch(to: ~p"/projects/#{id}/settings") + + socket.assigns.sandbox? -> + socket + |> assign(:page_title, "Project settings") + |> assign( + :confirm_delete_changeset, + sandbox_confirm_changeset(socket.assigns.project) + ) + |> assign(:confirm_delete_input, "") + + true -> + assign(socket, :page_title, "Project settings") end end + defp sandbox_confirm_changeset(sandbox, params \\ %{}) do + types = %{name: :string} + + {%{name: ""}, types} + |> Ecto.Changeset.cast(params, Map.keys(types)) + |> Ecto.Changeset.validate_required([:name]) + |> Ecto.Changeset.validate_change(:name, fn :name, value -> + if value == sandbox.name do + [] + else + [name: "does not match the sandbox name"] + end + end) + end + @impl true def handle_event("validate", %{"project" => params}, socket) do # The retention and concurrency forms don't include raw_name, @@ -338,6 +376,75 @@ defmodule LightningWeb.ProjectLive.Settings do |> noreply() end + def handle_event("close-delete-modal", _params, socket) do + socket + |> push_navigate(to: ~p"/projects/#{socket.assigns.project.id}/settings") + |> noreply() + end + + def handle_event("confirm-delete-validate", params, socket) do + confirm_params = params["confirm"] || %{} + + changeset = + sandbox_confirm_changeset(socket.assigns.project, confirm_params) + |> Map.put(:action, :validate) + + socket + |> assign(:confirm_delete_changeset, changeset) + |> assign(:confirm_delete_input, String.trim(confirm_params["name"] || "")) + |> noreply() + end + + def handle_event("confirm-delete", params, socket) do + confirm_params = params["confirm"] || %{} + + changeset = + sandbox_confirm_changeset(socket.assigns.project, confirm_params) + |> Map.put(:action, :validate) + + if changeset.valid? do + case Lightning.Projects.Sandboxes.delete_sandbox( + socket.assigns.project, + socket.assigns.current_user + ) do + {:ok, deleted} -> + socket + |> put_flash( + :info, + "Sandbox #{deleted.name} and all its associated descendants deleted" + ) + |> push_navigate(to: ~p"/projects/#{socket.assigns.root_project.id}/w") + |> noreply() + + {:error, :unauthorized} -> + socket + |> put_flash( + :error, + "You don't have permission to delete this sandbox" + ) + |> push_navigate( + to: ~p"/projects/#{socket.assigns.project.id}/settings" + ) + |> noreply() + + {:error, _reason} -> + socket + |> put_flash( + :error, + "Could not delete sandbox. Please try again later." + ) + |> push_navigate( + to: ~p"/projects/#{socket.assigns.project.id}/settings" + ) + |> noreply() + end + else + socket + |> assign(:confirm_delete_changeset, changeset) + |> noreply() + end + end + def handle_event( "show_modal", %{"target" => "new_webhook_auth_method"}, @@ -458,12 +565,14 @@ defmodule LightningWeb.ProjectLive.Settings do %{"project_user_id" => project_user_id}, %{assigns: assigns} = socket ) do - project_user = Projects.get_project_user!(project_user_id) + project_user = Projects.get_project_user!(project_user_id, include: :user) if user_removable?( project_user, assigns.current_user, - assigns.can_remove_project_user + assigns.can_remove_project_user, + assigns.project, + assigns.sandbox? ) do Projects.delete_project_user!(project_user) @@ -637,7 +746,13 @@ defmodule LightningWeb.ProjectLive.Settings do """ end - defp remove_user_tooltip(project_user, current_user, can_remove_project_user) do + defp remove_user_tooltip( + project_user, + current_user, + can_remove_project_user, + project, + sandbox? + ) do cond do !can_remove_project_user -> "You do not have permission to remove a user" @@ -648,16 +763,29 @@ defmodule LightningWeb.ProjectLive.Settings do project_user.role == :owner -> "You cannot remove an owner" + sandbox? and parent_admin?(project, project_user) -> + "Cannot remove a user who is admin or owner on the parent project" + true -> "" end end - defp user_removable?(project_user, current_user, can_remove_project_user) do + defp user_removable?( + project_user, + current_user, + can_remove_project_user, + project, + sandbox? + ) do can_remove_project_user and project_user.role != :owner and - project_user.user_id != current_user.id + project_user.user_id != current_user.id and + not (sandbox? and parent_admin?(project, project_user)) end + defp parent_admin?(project, %{user: %User{} = user}), + do: Sandboxes.parent_admin?(project, user) + defp user_has_valid_oauth_token(user) do VersionControl.oauth_token_valid?(user.github_oauth_token) end diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index 5653d3b2a22..891fffd18a5 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -63,19 +63,41 @@ <:panel hash="project" class="space-y-4"> <.section_header - title="Project setup" - subtitle="Projects are isolated workspaces that contain workflows, accessible to certain users." + title={if @sandbox?, do: "Sandbox setup", else: "Project setup"} + subtitle={ + if @sandbox?, + do: + "Sandboxes are isolated copies of a project for safe experimentation.", + else: + "Projects are isolated workspaces that contain workflows, accessible to certain users." + } permissions_message="basic settings, but you can export a copy." can_perform_action={@can_edit_project} /> + <.sandbox_settings_banner + :if={@sandbox?} + id="sandbox-banner-project" + variant={:local} + />
- Project Identity + {if @sandbox?, do: "Sandbox Identity", else: "Project Identity"}
- This metadata helps you identify the types of workflows managed in this project and the people that have access. + <%= if @sandbox? do %> + Identifies this sandbox within its parent: + <.link + :if={@parent_project} + navigate={~p"/projects/#{@parent_project.id}/settings"} + class="font-medium text-indigo-600 hover:text-indigo-900" + > + {@parent_project.name} + + <% else %> + This metadata helps you identify the types of workflows managed in this project and the people that have access. + <% end %>
<.form @@ -238,7 +260,13 @@
The danger zone
- Deleting your project is irreversible + <%= if @sandbox? do %> + Deleting this sandbox is irreversible. It removes every + workflow, trigger, version, keychain clone, and dataclip + that lives only inside it. + <% else %> + Deleting your project is irreversible + <% end %>
<.button_link @@ -251,12 +279,20 @@ ) } > - Delete project + {if @sandbox?, do: "Delete sandbox", else: "Delete project"}
<% end %> - <%= if @live_action == :delete and @can_delete_project do %> + <%= if @live_action == :delete and @can_delete_project and @sandbox? do %> + + <% end %> + <%= if @live_action == :delete and @can_delete_project and not @sandbox? do %> <.live_component module={LightningWeb.Components.ProjectDeletionModal} id={@project.id} @@ -304,6 +340,11 @@ + <.sandbox_settings_banner + :if={@sandbox?} + id="sandbox-banner-credentials" + variant={:editable} + /> <:panel hash="webhook_security" class="space-y-4"> - <.section_header - title="Webhook security" - subtitle="Webhook authentication methods that are used with the starting trigger in workflows." - permissions_message="webhook auth methods." - can_perform_action={@can_write_webhook_auth_method} - action_button_text="New auth method" - action_button_click={ - JS.push("show_modal", value: %{target: "new_webhook_auth_method"}) - } - action_button_disabled={!@can_write_webhook_auth_method} - action_button_id="add_new_auth_method" - /> - <.modal - :if={ - @active_modal in [ - :new_webhook_auth_method, - :edit_webhook_auth_method - ] - } - id="webhook_auth_method_modal" - width="min-w-1/3 max-w-xl" - on_close={JS.push("close_active_modal")} - show={true} - > + <%= if @sandbox? do %> + <.section_header + title="Webhook security" + subtitle="Webhook authentication methods that are used with the starting trigger in workflows." + /> +
+
+ Webhook authentication is managed in the parent project +
+ + Methods are shared with this sandbox and enforced on its webhook triggers, but can only be created, edited, or deleted from the parent. + + <.link + :if={@parent_project} + navigate={ + ~p"/projects/#{@parent_project.id}/settings#webhook_security" + } + class="mt-2 inline-block text-xs font-medium text-indigo-600 hover:text-indigo-900" + > + Manage them in the parent project ({@parent_project.name}) + +
+ <% else %> + <.section_header + title="Webhook security" + subtitle="Webhook authentication methods that are used with the starting trigger in workflows." + permissions_message="webhook auth methods." + can_perform_action={@can_write_webhook_auth_method} + action_button_text="New auth method" + action_button_click={ + JS.push("show_modal", value: %{target: "new_webhook_auth_method"}) + } + action_button_disabled={!@can_write_webhook_auth_method} + action_button_id="add_new_auth_method" + /> + <.modal + :if={ + @active_modal in [ + :new_webhook_auth_method, + :edit_webhook_auth_method + ] + } + id="webhook_auth_method_modal" + width="min-w-1/3 max-w-xl" + on_close={JS.push("close_active_modal")} + show={true} + > + <.live_component + :if={@active_modal == :new_webhook_auth_method} + module={LightningWeb.WorkflowLive.WebhookAuthMethodFormComponent} + id="new_auth_method" + action={:new} + current_user={@current_user} + webhook_auth_method={@active_modal_assigns.webhook_auth_method} + trigger={nil} + on_close={JS.push("close_active_modal")} + return_to={~p"/projects/#{@project.id}/settings#webhook_security"} + /> + <.live_component + :if={@active_modal == :edit_webhook_auth_method} + module={LightningWeb.WorkflowLive.WebhookAuthMethodFormComponent} + id={"edit_auth_method_#{@active_modal_assigns.webhook_auth_method.id}"} + action={:edit} + current_user={@current_user} + webhook_auth_method={@active_modal_assigns.webhook_auth_method} + trigger={nil} + on_close={JS.push("close_active_modal")} + return_to={~p"/projects/#{@project.id}/settings#webhook_security"} + /> + <.live_component - :if={@active_modal == :new_webhook_auth_method} - module={LightningWeb.WorkflowLive.WebhookAuthMethodFormComponent} - id="new_auth_method" - action={:new} + :if={@active_modal == :delete_webhook_auth_method} + module={LightningWeb.WorkflowLive.WebhookAuthMethodDeleteModal} + id={"delete_auth_method_#{@active_modal_assigns.webhook_auth_method.id}"} current_user={@current_user} webhook_auth_method={@active_modal_assigns.webhook_auth_method} - trigger={nil} on_close={JS.push("close_active_modal")} return_to={~p"/projects/#{@project.id}/settings#webhook_security"} /> - <.live_component - :if={@active_modal == :edit_webhook_auth_method} - module={LightningWeb.WorkflowLive.WebhookAuthMethodFormComponent} - id={"edit_auth_method_#{@active_modal_assigns.webhook_auth_method.id}"} - action={:edit} - current_user={@current_user} + - - <.live_component - :if={@active_modal == :delete_webhook_auth_method} - module={LightningWeb.WorkflowLive.WebhookAuthMethodDeleteModal} - id={"delete_auth_method_#{@active_modal_assigns.webhook_auth_method.id}"} - current_user={@current_user} - webhook_auth_method={@active_modal_assigns.webhook_auth_method} - on_close={JS.push("close_active_modal")} - return_to={~p"/projects/#{@project.id}/settings#webhook_security"} - /> - - - <:empty_state> - <.empty_state - icon="hero-plus-circle" - message="No auth methods found." - button_text="Create a new auth method" - button_id="open-create-auth-method-modal" - button_click={ - JS.push("show_modal", - value: %{target: "new_webhook_auth_method"} - ) - } - button_disabled={!@can_write_webhook_auth_method} - /> - - <:linked_triggers :let={auth_method}> - - + <:empty_state> + <.empty_state + icon="hero-plus-circle" + message="No auth methods found." + button_text="Create a new auth method" + button_id="open-create-auth-method-modal" + button_click={ JS.push("show_modal", - value: %{ - target: "linked_triggers_for_webhook_auth_method", - id: auth_method.id - } + value: %{target: "new_webhook_auth_method"} ) } - > - {Enum.count(auth_method.triggers)} - - - No associated triggers... + button_disabled={!@can_write_webhook_auth_method} + /> + + <:linked_triggers :let={auth_method}> + + + {Enum.count(auth_method.triggers)} + + + No associated triggers... + - - - <:action :let={auth_method}> - <%= if @can_write_webhook_auth_method do %> - - View - - <% else %> - - Edit - - <% end %> - - <:action :let={auth_method}> - <%= if @can_write_webhook_auth_method do %> - - Delete - - <% else %> - - Delete - - <% end %> - - + + <:action :let={auth_method}> + <%= if @can_write_webhook_auth_method do %> + + View + + <% else %> + + Edit + + <% end %> + + <:action :let={auth_method}> + <%= if @can_write_webhook_auth_method do %> + + Delete + + <% else %> + + Delete + + <% end %> + + + <% end %> <:panel hash="collaboration" class="space-y-4"> <.section_header @@ -510,6 +579,11 @@ } action_button_id="show_collaborators_modal_button" /> + <.sandbox_settings_banner + :if={@sandbox?} + id="sandbox-banner-collaboration" + variant={:local} + /> <.support_access_toggle can_edit_project={@can_edit_project} project={@project} @@ -548,14 +622,18 @@ remove_user_tooltip( project_user, @current_user, - @can_remove_project_user + @can_remove_project_user, + @project, + @sandbox? ) } disabled={ !user_removable?( project_user, @current_user, - @can_remove_project_user + @can_remove_project_user, + @project, + @sandbox? ) } > @@ -581,7 +659,9 @@ user_removable?( project_user, @current_user, - @can_remove_project_user + @can_remove_project_user, + @project, + @sandbox? ) } id={"remove_#{project_user.id}_modal"} @@ -593,9 +673,17 @@ <.section_header title="Project security" subtitle="View and manage security settings for this project." - permissions_message="multi-factor authentication settings." + permissions_message={ + unless @sandbox?, do: "multi-factor authentication settings." + } can_perform_action={@can_edit_project} /> + <.sandbox_settings_banner + :if={@sandbox?} + id="sandbox-banner-security" + variant={:inherited} + parent_project={@parent_project} + />
<%= if assigns[:mfa_banner] do %> {Phoenix.LiveView.TagEngine.component( @@ -629,10 +717,10 @@