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})
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)

View file

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

View file

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

View file

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

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

View file

@ -81,6 +81,7 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
messages={@messages}
action={@action}
initial?={@initial?}
authorized={@authorized}
/>
</div>
"""
@ -162,7 +163,12 @@ defmodule LivebookWeb.SessionLive.AppTeamsLive do
<.remix_icon icon="rocket-line" /> Deploy
</.button>
<% 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
</.button>
<% 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
/>
</div>
@ -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>
<.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
</.button>
</div>
@ -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
/>
</div>
@ -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"""
<div
class={[
"border p-3 rounded-lg",
@selectable && "cursor-pointer",
if(@active,
do: "border-blue-600 bg-blue-50",
else: "border-gray-200"
)
"border p-3 rounded-lg relative",
cond do
!@authorized -> "!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}
>
<div class="flex justify-between items-center">
<div class="flex gap-2 items-center text-gray-700">
<h3 class="text-sm">
<div class="flex gap-2 items-center">
<h3 class={[
"text-sm",
if(@authorized, do: "text-gray-700", else: "text-gray-500")
]}>
<span class="font-semibold">{@deployment_group.name}</span>
<span :if={url = @deployment_group.url}>({url})</span>
</h3>
</div>
<div class="flex gap-2">
<div class="text-sm text-gray-700 border-l border-gray-300 pl-2">
<div class="flex gap-2 flex-shrink-0">
<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}
</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}
</div>
</div>
@ -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

View file

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

View file

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

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;
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 {}

View file

@ -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, """
<!-- 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
slug = Utils.random_short_id()
app_path = Path.join(tmp_dir, "#{slug}.livemd")

View file

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

View file

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