Implement authorization to deploy apps (#3044)

This commit is contained in:
Alexandre de Souza 2025-08-14 10:25:10 -03:00 committed by GitHub
parent f0a1a3cfac
commit 9043edb748
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 344 additions and 27 deletions

View file

@ -164,6 +164,14 @@ defmodule Livebook.Hubs.TeamClient do
GenServer.call(registry_name(id), {:check_app_access, groups, slug}) GenServer.call(registry_name(id), {:check_app_access, groups, slug})
end 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 """ @doc """
Returns if the Team client is connected. Returns if the Team client is connected.
""" """
@ -338,6 +346,30 @@ defmodule Livebook.Hubs.TeamClient do
end end
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 @impl true
def handle_info(:connected, state) do def handle_info(:connected, state) do
Hubs.Broadcasts.hub_connected(state.hub.id) 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) agent_keys = Enum.map(deployment_group.agent_keys, &build_agent_key/1)
environment_variables = build_environment_variables(state, deployment_group) environment_variables = build_environment_variables(state, deployment_group)
authorization_groups = build_authorization_groups(deployment_group) authorization_groups = build_authorization_groups(deployment_group)
deployment_users = build_deployment_users(deployment_group)
%Teams.DeploymentGroup{ %Teams.DeploymentGroup{
id: deployment_group.id, id: deployment_group.id,
@ -512,7 +545,9 @@ defmodule Livebook.Hubs.TeamClient do
url: nullify(deployment_group.url), url: nullify(deployment_group.url),
teams_auth: deployment_group.teams_auth, teams_auth: deployment_group.teams_auth,
groups_auth: deployment_group.groups_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 end
@ -530,7 +565,8 @@ defmodule Livebook.Hubs.TeamClient do
clustering: nullify(deployment_group_created.clustering), clustering: nullify(deployment_group_created.clustering),
url: nullify(deployment_group_created.url), url: nullify(deployment_group_created.url),
teams_auth: deployment_group_created.teams_auth, teams_auth: deployment_group_created.teams_auth,
authorization_groups: [] authorization_groups: [],
deployment_users: []
} }
end end
@ -539,6 +575,7 @@ defmodule Livebook.Hubs.TeamClient do
agent_keys = Enum.map(deployment_group_updated.agent_keys, &build_agent_key/1) agent_keys = Enum.map(deployment_group_updated.agent_keys, &build_agent_key/1)
environment_variables = build_environment_variables(state, deployment_group_updated) environment_variables = build_environment_variables(state, deployment_group_updated)
authorization_groups = build_authorization_groups(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) {: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), url: nullify(deployment_group_updated.url),
teams_auth: deployment_group_updated.teams_auth, teams_auth: deployment_group_updated.teams_auth,
groups_auth: deployment_group_updated.groups_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 end
@ -596,6 +635,15 @@ defmodule Livebook.Hubs.TeamClient do
end end
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 defp put_agent(state, agent) do
state = remove_agent(state, agent) 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 with {:ok, current_deployment_group} <- fetch_deployment_group(deployment_group.id, state) do
if state.deployment_group_id == deployment_group.id and 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.groups_auth != deployment_group.groups_auth or
current_deployment_group.teams_auth != deployment_group.teams_auth) do current_deployment_group.teams_auth != deployment_group.teams_auth) do
Teams.Broadcasts.server_authorization_updated(deployment_group) Teams.Broadcasts.server_authorization_updated(deployment_group)
end 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 end
put_deployment_group(state, deployment_group) put_deployment_group(state, deployment_group)

View file

@ -294,4 +294,12 @@ defmodule Livebook.Teams do
defp add_external_errors(struct, errors_map) do defp add_external_errors(struct, errors_map) do
struct |> Ecto.Changeset.change() |> add_external_errors(errors_map) struct |> Ecto.Changeset.change() |> add_external_errors(errors_map)
end 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 end

View file

@ -34,6 +34,7 @@ defmodule Livebook.Teams.Broadcasts do
* `{:deployment_group_created, DeploymentGroup.t()}` * `{:deployment_group_created, DeploymentGroup.t()}`
* `{:deployment_group_updated, DeploymentGroup.t()}` * `{:deployment_group_updated, DeploymentGroup.t()}`
* `{:deployment_group_deleted, DeploymentGroup.t()}` * `{:deployment_group_deleted, DeploymentGroup.t()}`
* `{:deployment_users_updated, DeploymentGroup.t()}`
Topic `#{@app_server_topic}`: Topic `#{@app_server_topic}`:
@ -97,6 +98,14 @@ defmodule Livebook.Teams.Broadcasts do
broadcast(@deployment_groups_topic, {:deployment_group_deleted, deployment_group}) broadcast(@deployment_groups_topic, {:deployment_group_deleted, deployment_group})
end 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 """ @doc """
Broadcasts under `#{@app_deployments_topic}` topic when hub received to start a new app deployment. 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 end
@doc """ @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() @spec server_authorization_updated(Teams.DeploymentGroup.t()) :: broadcast()
def server_authorization_updated(%Teams.DeploymentGroup{} = deployment_group) do def server_authorization_updated(%Teams.DeploymentGroup{} = deployment_group) do

View file

@ -15,6 +15,7 @@ defmodule Livebook.Teams.DeploymentGroup do
teams_auth: boolean(), teams_auth: boolean(),
groups_auth: boolean(), groups_auth: boolean(),
authorization_groups: Ecto.Schema.embeds_many(Teams.AuthorizationGroup.t()), 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()), secrets: Ecto.Schema.has_many(Secrets.Secret.t()),
agent_keys: Ecto.Schema.has_many(Teams.AgentKey.t()), agent_keys: Ecto.Schema.has_many(Teams.AgentKey.t()),
environment_variables: Ecto.Schema.has_many(Teams.EnvironmentVariable.t()) environment_variables: Ecto.Schema.has_many(Teams.EnvironmentVariable.t())
@ -29,11 +30,13 @@ defmodule Livebook.Teams.DeploymentGroup do
field :url, :string field :url, :string
field :teams_auth, :boolean, default: true field :teams_auth, :boolean, default: true
field :groups_auth, :boolean, default: false field :groups_auth, :boolean, default: false
field :deploy_auth, :boolean, default: false
has_many :secrets, Secrets.Secret has_many :secrets, Secrets.Secret
has_many :agent_keys, Teams.AgentKey has_many :agent_keys, Teams.AgentKey
has_many :environment_variables, Teams.EnvironmentVariable has_many :environment_variables, Teams.EnvironmentVariable
embeds_many :authorization_groups, Teams.AuthorizationGroup embeds_many :authorization_groups, Teams.AuthorizationGroup
embeds_many :deployment_users, Teams.DeploymentUser
end end
def changeset(deployment_group, attrs \\ %{}) do def changeset(deployment_group, attrs \\ %{}) do

View file

@ -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

View file

@ -8,6 +8,7 @@ defmodule Livebook.Teams.Requests do
@deploy_key_prefix Teams.Constants.deploy_key_prefix() @deploy_key_prefix Teams.Constants.deploy_key_prefix()
@error_message "Something went wrong, try again later or please file a bug if it persists" @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_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 api_result :: {:ok, map()} | error_result()
@typep error_result :: {:error, map() | String.t()} | {:transport_error, String.t()} @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 defp upload(path, content, params, team) do
build_req(team) build_req(team)
|> Req.Request.put_header("content-length", "#{byte_size(content)}") |> 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) |> Req.post(url: path, params: params, body: content)
|> handle_response() |> handle_response()
|> dispatch_messages(team) |> dispatch_messages(team)
@ -337,10 +343,20 @@ defmodule Livebook.Teams.Requests do
defp handle_response(response) do defp handle_response(response) do
case response do case response do
{:ok, %{status: status} = response} when status in 200..299 -> {:ok, response.body} {:ok, %{status: status} = response} when status in 200..299 ->
{:ok, %{status: status} = response} when status in [410, 422] -> return_error(response) {:ok, response.body}
{:ok, %{status: 401}} -> {:transport_error, @unauthorized_error_message}
_otherwise -> {:transport_error, @error_message} {: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
end end

View file

@ -81,6 +81,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
messages={@messages} messages={@messages}
action={@action} action={@action}
initial?={@initial?} initial?={@initial?}
authorized={@authorized}
/> />
</div> </div>
""" """
@ -162,7 +163,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
<.remix_icon icon="rocket-line" /> Deploy <.remix_icon icon="rocket-line" /> Deploy
</.button> </.button>
<% else %> <% 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 <.remix_icon icon="rocket-line" /> Deploy anyway
</.button> </.button>
<% end %> <% end %>
@ -198,6 +204,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
deployment_group={@deployment_group} deployment_group={@deployment_group}
num_agents={@num_agents} num_agents={@num_agents}
num_app_deployments={@num_app_deployments} num_app_deployments={@num_app_deployments}
authorized={@authorized[@deployment_group.id]}
active active
/> />
</div> </div>
@ -224,7 +231,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
<.button color="blue" phx-click="go_add_agent"> <.button color="blue" phx-click="go_add_agent">
<.remix_icon icon="add-line" /> Add app server <.remix_icon icon="add-line" /> Add app server
</.button> </.button>
<.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 <.remix_icon icon="rocket-line" /> Deploy anyway
</.button> </.button>
</div> </div>
@ -250,6 +262,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
deployment_group={deployment_group} deployment_group={deployment_group}
num_agents={@num_agents} num_agents={@num_agents}
num_app_deployments={@num_app_deployments} num_app_deployments={@num_app_deployments}
authorized={@authorized[deployment_group.id]}
selectable selectable
/> />
</div> </div>
@ -301,6 +314,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
attr :active, :boolean, default: false attr :active, :boolean, default: false
attr :selectable, :boolean, default: false attr :selectable, :boolean, default: false
attr :authorized, :boolean, default: true
attr :deployment_group, :map, required: true attr :deployment_group, :map, required: true
attr :num_agents, :map, required: true attr :num_agents, :map, required: true
attr :num_app_deployments, :map, required: true attr :num_app_deployments, :map, required: true
@ -310,29 +324,45 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
~H""" ~H"""
<div <div
class={[ class={[
"border p-3 rounded-lg", "border p-3 rounded-lg relative",
@selectable && "cursor-pointer", cond do
if(@active, !@authorized -> "!block cursor-not-allowed tooltip top opacity-50 bg-gray-50"
do: "border-blue-600 bg-blue-50", @selectable -> "cursor-pointer border-blue-600 bg-blue-50"
else: "border-gray-200" 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} phx-value-id={@deployment_group.id}
{@rest} {@rest}
> >
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex gap-2 items-center text-gray-700"> <div class="flex gap-2 items-center">
<h3 class="text-sm"> <h3 class={[
"text-sm",
if(@authorized, do: "text-gray-700", else: "text-gray-500")
]}>
<span class="font-semibold">{@deployment_group.name}</span> <span class="font-semibold">{@deployment_group.name}</span>
<span :if={url = @deployment_group.url}>({url})</span> <span :if={url = @deployment_group.url}>({url})</span>
</h3> </h3>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 flex-shrink-0">
<div class="text-sm text-gray-700 border-l border-gray-300 pl-2"> <div class={[
"text-sm border-l pl-2",
if(@authorized,
do: "border-gray-300 text-gray-700",
else: "border-gray-300 text-gray-500"
)
]}>
App servers: {@num_agents[@deployment_group.id] || 0} App servers: {@num_agents[@deployment_group.id] || 0}
</div> </div>
<div class="text-sm text-gray-700 border-l border-gray-300 pl-2"> <div class={[
"text-sm border-l pl-2",
if(@authorized,
do: "border-gray-300 text-gray-700",
else: "border-gray-300 text-gray-500"
)
]}>
Apps deployed: {@num_app_deployments[@deployment_group.id] || 0} Apps deployed: {@num_app_deployments[@deployment_group.id] || 0}
</div> </div>
</div> </div>
@ -440,6 +470,11 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
{:noreply, socket |> assign_app_deployments() |> assign_app_deployment()} {:noreply, socket |> assign_app_deployments() |> assign_app_deployment()}
end 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( def handle_info(
{:operation, {:set_notebook_deployment_group, _client_id, deployment_group_id}}, {:operation, {:set_notebook_deployment_group, _client_id, deployment_group_id}},
socket socket
@ -453,13 +488,20 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
def handle_info(_message, socket), do: {:noreply, socket} def handle_info(_message, socket), do: {:noreply, socket}
defp assign_deployment_groups(socket) do defp assign_deployment_groups(socket) do
hub = socket.assigns.hub
deployment_groups = deployment_groups =
socket.assigns.hub hub
|> Teams.get_deployment_groups() |> Teams.get_deployment_groups()
|> Enum.filter(&(&1.mode == :online)) |> Enum.filter(&(&1.mode == :online))
|> Enum.sort_by(& &1.name) |> 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 end
defp assign_app_deployments(socket) do defp assign_app_deployments(socket) do

View file

@ -24,4 +24,10 @@ defmodule LivebookProto.DeploymentGroup do
json_name: "authorizationGroups" json_name: "authorizationGroups"
field :groups_auth, 13, type: :bool, json_name: "groupsAuth" 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 end

View file

@ -23,4 +23,10 @@ defmodule LivebookProto.DeploymentGroupUpdated do
json_name: "authorizationGroups" json_name: "authorizationGroups"
field :groups_auth, 12, type: :bool, json_name: "groupsAuth" 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 end

View file

@ -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

View file

@ -68,6 +68,8 @@ message DeploymentGroup {
bool teams_auth = 11; bool teams_auth = 11;
repeated AuthorizationGroup authorization_groups = 12; repeated AuthorizationGroup authorization_groups = 12;
bool groups_auth = 13; bool groups_auth = 13;
bool deploy_auth = 14;
repeated DeploymentUser deployment_users = 15;
} }
message DeploymentGroupCreated { message DeploymentGroupCreated {
@ -95,6 +97,8 @@ message DeploymentGroupUpdated {
bool teams_auth = 10; bool teams_auth = 10;
repeated AuthorizationGroup authorization_groups = 11; repeated AuthorizationGroup authorization_groups = 11;
bool groups_auth = 12; bool groups_auth = 12;
bool deploy_auth = 13;
repeated DeploymentUser deployment_users = 14;
} }
message DeploymentGroupDeleted { message DeploymentGroupDeleted {
@ -213,6 +217,11 @@ message AuthorizationGroup {
string group_name = 2; string group_name = 2;
} }
message DeploymentUser {
string user_id = 1;
string deployment_group_id = 2;
}
message Event { message Event {
oneof type { oneof type {
SecretCreated secret_created = 1; SecretCreated secret_created = 1;
@ -258,5 +267,4 @@ message BillingStatusCanceling {
int64 cancel_at = 1; int64 cancel_at = 1;
} }
message BillingStatusCanceled { message BillingStatusCanceled {}
}

View file

@ -112,6 +112,58 @@ defmodule LivebookCLI.Integration.DeployTest do
end end
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, """
<!-- livebook:{"app_settings":{"access_type":"public","slug":"#{slug}"},"hub_id":"#{hub_id}"} -->
# #{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 test "fails with invalid deploy key", %{team: team, node: node, org: org, tmp_dir: tmp_dir} do
slug = Utils.random_short_id() slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd") app_path = Path.join(tmp_dir, "#{slug}.livemd")

View file

@ -571,6 +571,56 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
"App deployment created successfully" "App deployment created successfully"
end 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", test "shows an error when the deployment size is higher than the maximum size of 20MB",
%{team: team, conn: conn, session: session} do %{team: team, conn: conn, session: session} do
Session.set_notebook_hub(session.pid, team.id) Session.set_notebook_hub(session.pid, team.id)
@ -603,5 +653,42 @@ defmodule LivebookWeb.Integration.SessionLiveTest do
assert render(view) =~ assert render(view) =~
"Failed to pack files: the notebook and its attachments have exceeded the maximum size of 20MB" "Failed to pack files: the notebook and its attachments have exceeded the maximum size of 20MB"
end 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
end end

View file

@ -208,4 +208,8 @@ defmodule Livebook.TeamsRPC do
def toggle_groups_authorization(node, deployment_group) do def toggle_groups_authorization(node, deployment_group) do
:erpc.call(node, TeamsRPC, :toggle_groups_authorization, [deployment_group]) :erpc.call(node, TeamsRPC, :toggle_groups_authorization, [deployment_group])
end end
def toggle_deployment_authorization(node, deployment_group) do
:erpc.call(node, TeamsRPC, :toggle_deployment_authorization, [deployment_group])
end
end end