diff --git a/lib/livebook/hubs/team_client.ex b/lib/livebook/hubs/team_client.ex index 08b93a778..9250680e6 100644 --- a/lib/livebook/hubs/team_client.ex +++ b/lib/livebook/hubs/team_client.ex @@ -164,6 +164,14 @@ defmodule Livebook.Hubs.TeamClient do GenServer.call(registry_name(id), {:check_app_access, groups, slug}) end + @doc """ + Returns if the given user has access to deploy apps to given deployment group. + """ + @spec user_can_deploy?(String.t(), pos_integer() | nil, String.t()) :: boolean() + def user_can_deploy?(id, user_id, deployment_group_id) do + GenServer.call(registry_name(id), {:user_can_deploy?, user_id, deployment_group_id}) + end + @doc """ Returns if the Team client is connected. """ @@ -338,6 +346,30 @@ defmodule Livebook.Hubs.TeamClient do end end + def handle_call({:user_can_deploy?, user_id, id}, _caller, state) do + # App servers/Offline instances should not be able to deploy apps + if state.deployment_group_id || user_id == nil do + {:reply, false, state} + else + case fetch_deployment_group(id, state) do + {:ok, deployment_group} -> + deployment_user = %Teams.DeploymentUser{ + user_id: to_string(user_id), + deployment_group_id: id + } + + authorized? = + not deployment_group.deploy_auth or + deployment_user in deployment_group.deployment_users + + {:reply, authorized?, state} + + _ -> + {:reply, false, state} + end + end + end + @impl true def handle_info(:connected, state) do Hubs.Broadcasts.hub_connected(state.hub.id) @@ -499,6 +531,7 @@ defmodule Livebook.Hubs.TeamClient do agent_keys = Enum.map(deployment_group.agent_keys, &build_agent_key/1) environment_variables = build_environment_variables(state, deployment_group) authorization_groups = build_authorization_groups(deployment_group) + deployment_users = build_deployment_users(deployment_group) %Teams.DeploymentGroup{ id: deployment_group.id, @@ -512,7 +545,9 @@ defmodule Livebook.Hubs.TeamClient do url: nullify(deployment_group.url), teams_auth: deployment_group.teams_auth, groups_auth: deployment_group.groups_auth, - authorization_groups: authorization_groups + deploy_auth: deployment_group.deploy_auth, + authorization_groups: authorization_groups, + deployment_users: deployment_users } end @@ -530,7 +565,8 @@ defmodule Livebook.Hubs.TeamClient do clustering: nullify(deployment_group_created.clustering), url: nullify(deployment_group_created.url), teams_auth: deployment_group_created.teams_auth, - authorization_groups: [] + authorization_groups: [], + deployment_users: [] } end @@ -539,6 +575,7 @@ defmodule Livebook.Hubs.TeamClient do agent_keys = Enum.map(deployment_group_updated.agent_keys, &build_agent_key/1) environment_variables = build_environment_variables(state, deployment_group_updated) authorization_groups = build_authorization_groups(deployment_group_updated) + deployment_users = build_deployment_users(deployment_group_updated) {:ok, deployment_group} = fetch_deployment_group(deployment_group_updated.id, state) @@ -552,7 +589,9 @@ defmodule Livebook.Hubs.TeamClient do url: nullify(deployment_group_updated.url), teams_auth: deployment_group_updated.teams_auth, groups_auth: deployment_group_updated.groups_auth, - authorization_groups: authorization_groups + deploy_auth: deployment_group_updated.deploy_auth, + authorization_groups: authorization_groups, + deployment_users: deployment_users } end @@ -596,6 +635,15 @@ defmodule Livebook.Hubs.TeamClient do end end + defp build_deployment_users(%{deployment_users: deployment_users}) do + for deployment_user <- deployment_users do + %Teams.DeploymentUser{ + user_id: deployment_user.user_id, + deployment_group_id: deployment_user.deployment_group_id + } + end + end + defp put_agent(state, agent) do state = remove_agent(state, agent) @@ -696,11 +744,19 @@ defmodule Livebook.Hubs.TeamClient do with {:ok, current_deployment_group} <- fetch_deployment_group(deployment_group.id, state) do if state.deployment_group_id == deployment_group.id and - (current_deployment_group.authorization_groups != deployment_group.authorization_groups or + (current_deployment_group.authorization_groups != + deployment_group.authorization_groups or current_deployment_group.groups_auth != deployment_group.groups_auth or current_deployment_group.teams_auth != deployment_group.teams_auth) do Teams.Broadcasts.server_authorization_updated(deployment_group) end + + if state.deployment_group_id == nil and + (current_deployment_group.deployment_users != + deployment_group.deployment_users or + current_deployment_group.deploy_auth != deployment_group.deploy_auth) do + Teams.Broadcasts.deployment_users_updated(deployment_group) + end end put_deployment_group(state, deployment_group) diff --git a/lib/livebook/teams.ex b/lib/livebook/teams.ex index 35bfc1956..8e688b54d 100644 --- a/lib/livebook/teams.ex +++ b/lib/livebook/teams.ex @@ -294,4 +294,12 @@ defmodule Livebook.Teams do defp add_external_errors(struct, errors_map) do struct |> Ecto.Changeset.change() |> add_external_errors(errors_map) end + + @doc """ + Checks if the given user has access to deploy apps to given deployment group. + """ + @spec user_can_deploy?(Team.t(), Teams.DeploymentGroup.t()) :: boolean() + def user_can_deploy?(%Team{} = team, %Teams.DeploymentGroup{} = deployment_group) do + TeamClient.user_can_deploy?(team.id, team.user_id, deployment_group.id) + end end diff --git a/lib/livebook/teams/broadcasts.ex b/lib/livebook/teams/broadcasts.ex index 590af23bd..d94d17eb1 100644 --- a/lib/livebook/teams/broadcasts.ex +++ b/lib/livebook/teams/broadcasts.ex @@ -34,6 +34,7 @@ defmodule Livebook.Teams.Broadcasts do * `{:deployment_group_created, DeploymentGroup.t()}` * `{:deployment_group_updated, DeploymentGroup.t()}` * `{:deployment_group_deleted, DeploymentGroup.t()}` + * `{:deployment_users_updated, DeploymentGroup.t()}` Topic `#{@app_server_topic}`: @@ -97,6 +98,14 @@ defmodule Livebook.Teams.Broadcasts do broadcast(@deployment_groups_topic, {:deployment_group_deleted, deployment_group}) end + @doc """ + Broadcasts under `#{@deployment_groups_topic}` topic when hub received an updated deployment group that changed which users have access to deploy apps. + """ + @spec deployment_users_updated(Teams.DeploymentGroup.t()) :: broadcast() + def deployment_users_updated(%Teams.DeploymentGroup{} = deployment_group) do + broadcast(@deployment_groups_topic, {:deployment_users_updated, deployment_group}) + end + @doc """ Broadcasts under `#{@app_deployments_topic}` topic when hub received to start a new app deployment. """ @@ -138,7 +147,7 @@ defmodule Livebook.Teams.Broadcasts do end @doc """ - Broadcasts under `#{@app_server_topic}` topic when hub received a updated deployment group that changed which groups have access to the server. + Broadcasts under `#{@app_server_topic}` topic when hub received an updated deployment group that changed which groups have access to the server. """ @spec server_authorization_updated(Teams.DeploymentGroup.t()) :: broadcast() def server_authorization_updated(%Teams.DeploymentGroup{} = deployment_group) do diff --git a/lib/livebook/teams/deployment_group.ex b/lib/livebook/teams/deployment_group.ex index 606140fdf..1d3a75dd7 100644 --- a/lib/livebook/teams/deployment_group.ex +++ b/lib/livebook/teams/deployment_group.ex @@ -15,6 +15,7 @@ defmodule Livebook.Teams.DeploymentGroup do teams_auth: boolean(), groups_auth: boolean(), authorization_groups: Ecto.Schema.embeds_many(Teams.AuthorizationGroup.t()), + deployment_users: Ecto.Schema.embeds_many(Teams.DeploymentUser.t()), secrets: Ecto.Schema.has_many(Secrets.Secret.t()), agent_keys: Ecto.Schema.has_many(Teams.AgentKey.t()), environment_variables: Ecto.Schema.has_many(Teams.EnvironmentVariable.t()) @@ -29,11 +30,13 @@ defmodule Livebook.Teams.DeploymentGroup do field :url, :string field :teams_auth, :boolean, default: true field :groups_auth, :boolean, default: false + field :deploy_auth, :boolean, default: false has_many :secrets, Secrets.Secret has_many :agent_keys, Teams.AgentKey has_many :environment_variables, Teams.EnvironmentVariable embeds_many :authorization_groups, Teams.AuthorizationGroup + embeds_many :deployment_users, Teams.DeploymentUser end def changeset(deployment_group, attrs \\ %{}) do diff --git a/lib/livebook/teams/deployment_user.ex b/lib/livebook/teams/deployment_user.ex new file mode 100644 index 000000000..9fb3d8c60 --- /dev/null +++ b/lib/livebook/teams/deployment_user.ex @@ -0,0 +1,14 @@ +defmodule Livebook.Teams.DeploymentUser do + use Ecto.Schema + + @type t :: %__MODULE__{ + user_id: String.t() | nil, + deployment_group_id: String.t() | nil + } + + @primary_key false + embedded_schema do + field :user_id, :string + field :deployment_group_id, :string + end +end diff --git a/lib/livebook/teams/requests.ex b/lib/livebook/teams/requests.ex index 495117601..cd1f5d450 100644 --- a/lib/livebook/teams/requests.ex +++ b/lib/livebook/teams/requests.ex @@ -8,6 +8,7 @@ defmodule Livebook.Teams.Requests do @deploy_key_prefix Teams.Constants.deploy_key_prefix() @error_message "Something went wrong, try again later or please file a bug if it persists" @unauthorized_error_message "You are not authorized to perform this action, make sure you have the access and you are not in a Livebook App Server/Offline instance" + @unauthorized_app_deployment_error_message "You are not authorized to perform this action, make sure you have the access to deploy apps to this deployment group" @typep api_result :: {:ok, map()} | error_result() @typep error_result :: {:error, map() | String.t()} | {:transport_error, String.t()} @@ -300,6 +301,11 @@ defmodule Livebook.Teams.Requests do defp upload(path, content, params, team) do build_req(team) |> Req.Request.put_header("content-length", "#{byte_size(content)}") + |> Req.Request.append_response_steps( + livebook_put_private: fn {request, response} -> + {request, Req.Response.put_private(response, :livebook_app_deployment, true)} + end + ) |> Req.post(url: path, params: params, body: content) |> handle_response() |> dispatch_messages(team) @@ -337,10 +343,20 @@ defmodule Livebook.Teams.Requests do defp handle_response(response) do case response do - {:ok, %{status: status} = response} when status in 200..299 -> {:ok, response.body} - {:ok, %{status: status} = response} when status in [410, 422] -> return_error(response) - {:ok, %{status: 401}} -> {:transport_error, @unauthorized_error_message} - _otherwise -> {:transport_error, @error_message} + {:ok, %{status: status} = response} when status in 200..299 -> + {:ok, response.body} + + {:ok, %{status: status} = response} when status in [410, 422] -> + return_error(response) + + {:ok, %{status: 401, private: %{livebook_app_deployment: true}}} -> + {:transport_error, @unauthorized_app_deployment_error_message} + + {:ok, %{status: 401}} -> + {:transport_error, @unauthorized_error_message} + + _otherwise -> + {:transport_error, @error_message} end end diff --git a/lib/livebook_web/live/session_live/app_teams_live.ex b/lib/livebook_web/live/session_live/app_teams_live.ex index 948eb457d..9e4933d9e 100644 --- a/lib/livebook_web/live/session_live/app_teams_live.ex +++ b/lib/livebook_web/live/session_live/app_teams_live.ex @@ -81,6 +81,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do messages={@messages} action={@action} initial?={@initial?} + authorized={@authorized} /> """ @@ -162,7 +163,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do <.remix_icon icon="rocket-line" /> Deploy <% else %> - <.button color="blue" outlined phx-click="deploy_app"> + <.button + disabled={!@authorized[@deployment_group.id]} + color="blue" + outlined + phx-click="deploy_app" + > <.remix_icon icon="rocket-line" /> Deploy anyway <% end %> @@ -198,6 +204,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do deployment_group={@deployment_group} num_agents={@num_agents} num_app_deployments={@num_app_deployments} + authorized={@authorized[@deployment_group.id]} active /> @@ -224,7 +231,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do <.button color="blue" phx-click="go_add_agent"> <.remix_icon icon="add-line" /> Add app server - <.button color="blue" outlined phx-click="deploy_app"> + <.button + disabled={!@authorized[@deployment_group.id]} + color="blue" + outlined + phx-click="deploy_app" + > <.remix_icon icon="rocket-line" /> Deploy anyway @@ -250,6 +262,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do deployment_group={deployment_group} num_agents={@num_agents} num_app_deployments={@num_app_deployments} + authorized={@authorized[deployment_group.id]} selectable /> @@ -301,6 +314,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do attr :active, :boolean, default: false attr :selectable, :boolean, default: false + attr :authorized, :boolean, default: true attr :deployment_group, :map, required: true attr :num_agents, :map, required: true attr :num_app_deployments, :map, required: true @@ -310,29 +324,45 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do ~H"""
"!block cursor-not-allowed tooltip top opacity-50 bg-gray-50" + @selectable -> "cursor-pointer border-blue-600 bg-blue-50" + true -> "cursor-pointer border-gray-200" + end ]} - phx-click={@selectable && "select_deployment_group"} + data-tooltip={!@authorized && "You are not authorized to deploy to this deployment group"} + phx-click={@selectable && @authorized && "select_deployment_group"} phx-value-id={@deployment_group.id} {@rest} >
-
-

+
+

{@deployment_group.name} ({url})

-
-
+
+
App servers: {@num_agents[@deployment_group.id] || 0}
-
+
Apps deployed: {@num_app_deployments[@deployment_group.id] || 0}
@@ -440,6 +470,11 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do {:noreply, socket |> assign_app_deployments() |> assign_app_deployment()} end + def handle_info({:deployment_users_updated, deployment_group}, socket) + when deployment_group.hub_id == socket.assigns.hub.id do + {:noreply, assign_deployment_groups(socket)} + end + def handle_info( {:operation, {:set_notebook_deployment_group, _client_id, deployment_group_id}}, socket @@ -453,13 +488,20 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do def handle_info(_message, socket), do: {:noreply, socket} defp assign_deployment_groups(socket) do + hub = socket.assigns.hub + deployment_groups = - socket.assigns.hub + hub |> Teams.get_deployment_groups() |> Enum.filter(&(&1.mode == :online)) |> Enum.sort_by(& &1.name) - assign(socket, deployment_groups: deployment_groups) + authorized = + for deployment_group <- deployment_groups, into: %{} do + {deployment_group.id, Teams.user_can_deploy?(hub, deployment_group)} + end + + assign(socket, deployment_groups: deployment_groups, authorized: authorized) end defp assign_app_deployments(socket) do diff --git a/proto/lib/livebook_proto/deployment_group.pb.ex b/proto/lib/livebook_proto/deployment_group.pb.ex index 5e72b4339..90e5839cb 100644 --- a/proto/lib/livebook_proto/deployment_group.pb.ex +++ b/proto/lib/livebook_proto/deployment_group.pb.ex @@ -24,4 +24,10 @@ defmodule LivebookProto.DeploymentGroup do json_name: "authorizationGroups" field :groups_auth, 13, type: :bool, json_name: "groupsAuth" + field :deploy_auth, 14, type: :bool, json_name: "deployAuth" + + field :deployment_users, 15, + repeated: true, + type: LivebookProto.DeploymentUser, + json_name: "deploymentUsers" end diff --git a/proto/lib/livebook_proto/deployment_group_updated.pb.ex b/proto/lib/livebook_proto/deployment_group_updated.pb.ex index 213be9eec..05d5d664b 100644 --- a/proto/lib/livebook_proto/deployment_group_updated.pb.ex +++ b/proto/lib/livebook_proto/deployment_group_updated.pb.ex @@ -23,4 +23,10 @@ defmodule LivebookProto.DeploymentGroupUpdated do json_name: "authorizationGroups" field :groups_auth, 12, type: :bool, json_name: "groupsAuth" + field :deploy_auth, 13, type: :bool, json_name: "deployAuth" + + field :deployment_users, 14, + repeated: true, + type: LivebookProto.DeploymentUser, + json_name: "deploymentUsers" end diff --git a/proto/lib/livebook_proto/deployment_user.pb.ex b/proto/lib/livebook_proto/deployment_user.pb.ex new file mode 100644 index 000000000..c83c8b6f6 --- /dev/null +++ b/proto/lib/livebook_proto/deployment_user.pb.ex @@ -0,0 +1,6 @@ +defmodule LivebookProto.DeploymentUser do + use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3 + + field :user_id, 1, type: :string, json_name: "userId" + field :deployment_group_id, 2, type: :string, json_name: "deploymentGroupId" +end diff --git a/proto/messages.proto b/proto/messages.proto index c648673b7..114905cb2 100644 --- a/proto/messages.proto +++ b/proto/messages.proto @@ -68,6 +68,8 @@ message DeploymentGroup { bool teams_auth = 11; repeated AuthorizationGroup authorization_groups = 12; bool groups_auth = 13; + bool deploy_auth = 14; + repeated DeploymentUser deployment_users = 15; } message DeploymentGroupCreated { @@ -95,6 +97,8 @@ message DeploymentGroupUpdated { bool teams_auth = 10; repeated AuthorizationGroup authorization_groups = 11; bool groups_auth = 12; + bool deploy_auth = 13; + repeated DeploymentUser deployment_users = 14; } message DeploymentGroupDeleted { @@ -213,6 +217,11 @@ message AuthorizationGroup { string group_name = 2; } +message DeploymentUser { + string user_id = 1; + string deployment_group_id = 2; +} + message Event { oneof type { SecretCreated secret_created = 1; @@ -258,5 +267,4 @@ message BillingStatusCanceling { int64 cancel_at = 1; } -message BillingStatusCanceled { -} +message BillingStatusCanceled {} diff --git a/test/livebook_teams/cli/deploy_test.exs b/test/livebook_teams/cli/deploy_test.exs index 17ac230d8..bd73f16e9 100644 --- a/test/livebook_teams/cli/deploy_test.exs +++ b/test/livebook_teams/cli/deploy_test.exs @@ -112,6 +112,58 @@ defmodule LivebookCLI.Integration.DeployTest do end end + test "fails with unauthorized deploy key", + %{team: team, node: node, org: org, tmp_dir: tmp_dir} do + title = "Test CLI Deploy App" + slug = Utils.random_short_id() + app_path = Path.join(tmp_dir, "#{slug}.livemd") + {key, _} = TeamsRPC.create_deploy_key(node, org: org) + + deployment_group = + TeamsRPC.create_deployment_group(node, org: org, url: @url, deploy_auth: true) + + hub_id = team.id + deployment_group_id = to_string(deployment_group.id) + + stamp_notebook(app_path, """ + + + # #{title} + + ## Test Section + + ```elixir + IO.puts("Hello from CLI deployed app!") + ``` + """) + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise(LivebookCLI.Error, "Some app deployments failed.", fn -> + assert deploy( + key, + team.teams_key, + deployment_group.id, + app_path + ) == :ok + end) + end) + + assert output =~ "* Preparing to deploy notebook #{slug}.livemd" + + assert output =~ + "* Test CLI Deploy App failed to deploy. Transport error: You are not authorized to perform this action, make sure you have the access to deploy apps to this deployment group" + + refute_receive {:app_deployment_started, + %{ + title: ^title, + slug: ^slug, + deployment_group_id: ^deployment_group_id, + hub_id: ^hub_id, + deployed_by: "CLI" + }} + end + test "fails with invalid deploy key", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do slug = Utils.random_short_id() app_path = Path.join(tmp_dir, "#{slug}.livemd") diff --git a/test/livebook_teams/web/session_live_test.exs b/test/livebook_teams/web/session_live_test.exs index a998e752b..0bf98cf13 100644 --- a/test/livebook_teams/web/session_live_test.exs +++ b/test/livebook_teams/web/session_live_test.exs @@ -571,6 +571,56 @@ defmodule LivebookWeb.Integration.SessionLiveTest do "App deployment created successfully" end + test "shows tooltip message if user is unauthorized to deploy apps", + %{team: team, node: node, org: org, conn: conn, session: session} do + Session.set_notebook_hub(session.pid, team.id) + + deployment_group = TeamsRPC.create_deployment_group(node, mode: :online, org: org) + id = to_string(deployment_group.id) + assert_receive {:deployment_group_created, %{id: ^id, deploy_auth: false}} + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + view + |> element("a", "Deploy with Livebook Teams") + |> render_click() + + # Step: configuring valid app settings + + assert render(view) =~ "You must configure your app before deploying it." + + slug = Livebook.Utils.random_short_id() + + view + |> element(~s/#app-settings-modal form/) + |> render_submit(%{"app_settings" => %{"slug" => slug}}) + + # From this point forward we are in a child LV + view = find_live_child(view, "app-teams") + assert render(view) =~ "App deployment with Livebook Teams" + + # show the deployment group being able to select to deploy an app + assert has_element?( + view, + ~s/[phx-click="select_deployment_group"][phx-value-id="#{deployment_group.id}"]/ + ) + + # then, we update the deployment group, so it will + # update the view and show the tooltip with unauthorized error message + {:ok, deployment_group} = TeamsRPC.toggle_deployment_authorization(node, deployment_group) + assert_receive {:deployment_group_updated, %{id: ^id, deploy_auth: true}} + + refute has_element?( + view, + ~s/[phx-click="select_deployment_group"][phx-value-id="#{deployment_group.id}"]/ + ) + + assert has_element?( + view, + ~s/[data-tooltip="You are not authorized to deploy to this deployment group"][phx-value-id="#{deployment_group.id}"]/ + ) + end + test "shows an error when the deployment size is higher than the maximum size of 20MB", %{team: team, conn: conn, session: session} do Session.set_notebook_hub(session.pid, team.id) @@ -603,5 +653,42 @@ defmodule LivebookWeb.Integration.SessionLiveTest do assert render(view) =~ "Failed to pack files: the notebook and its attachments have exceeded the maximum size of 20MB" end + + test "shows an error when the deployment is unauthorized", + %{team: team, org: org, node: node, conn: conn, session: session} do + Session.set_notebook_hub(session.pid, team.id) + + slug = Livebook.Utils.random_short_id() + app_settings = %{Livebook.Notebook.AppSettings.new() | slug: slug} + Session.set_app_settings(session.pid, app_settings) + + deployment_group = TeamsRPC.create_deployment_group(node, mode: :online, org: org) + id = to_string(deployment_group.id) + assert_receive {:deployment_group_created, %{id: ^id, deploy_auth: false}} + + {:ok, _} = TeamsRPC.toggle_deployment_authorization(node, deployment_group) + assert_receive {:deployment_group_updated, %{id: ^id, deploy_auth: true}} + + Session.set_notebook_deployment_group(session.pid, id) + assert_receive {:operation, {:set_notebook_deployment_group, _, ^id}} + + %{files_dir: files_dir} = session + image_file = FileSystem.File.resolve(files_dir, "image.jpg") + :ok = FileSystem.File.write(image_file, :crypto.strong_rand_bytes(1024 * 1024)) + Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}]) + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/app-teams") + + # From this point forward we are in a child LV + view = find_live_child(view, "app-teams") + assert render(view) =~ "App deployment with Livebook Teams" + + view + |> element("button", "Deploy") + |> render_click() + + assert render(view) =~ + "You are not authorized to perform this action, make sure you have the access to deploy apps to this deployment group" + end end end diff --git a/test/support/integration/teams_rpc.ex b/test/support/integration/teams_rpc.ex index f8297940c..0fc83733f 100644 --- a/test/support/integration/teams_rpc.ex +++ b/test/support/integration/teams_rpc.ex @@ -208,4 +208,8 @@ defmodule Livebook.TeamsRPC do def toggle_groups_authorization(node, deployment_group) do :erpc.call(node, TeamsRPC, :toggle_groups_authorization, [deployment_group]) end + + def toggle_deployment_authorization(node, deployment_group) do + :erpc.call(node, TeamsRPC, :toggle_deployment_authorization, [deployment_group]) + end end