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