mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-07 13:34:55 +08:00
Implement the user authorization flow with Livebook Teams (#2984)
This commit is contained in:
parent
abe6ea49ce
commit
bf5725847a
22 changed files with 537 additions and 133 deletions
|
@ -62,6 +62,32 @@ defmodule Livebook.Apps do
|
||||||
Livebook.Tracker.list_apps()
|
Livebook.Tracker.list_apps()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns all the running apps authorized to given user.
|
||||||
|
"""
|
||||||
|
@spec list_authorized_apps(Livebook.Users.User.t()) :: list(App.t())
|
||||||
|
def list_authorized_apps(user) do
|
||||||
|
for app <- list_apps(),
|
||||||
|
authorized?(app, user) do
|
||||||
|
app
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns if the given running app is authorized to given user.
|
||||||
|
"""
|
||||||
|
@spec authorized?(App.t(), Livebook.Users.User.t()) :: boolean()
|
||||||
|
def authorized?(app, user)
|
||||||
|
|
||||||
|
def authorized?(%{app_spec: %Livebook.Apps.TeamsAppSpec{}}, %{restricted_apps_groups: []}),
|
||||||
|
do: false
|
||||||
|
|
||||||
|
def authorized?(_app, %{restricted_apps_groups: nil}), do: true
|
||||||
|
|
||||||
|
def authorized?(%{slug: slug, app_spec: %Livebook.Apps.TeamsAppSpec{hub_id: id}}, user) do
|
||||||
|
Livebook.Hubs.TeamClient.user_app_access?(id, user.restricted_apps_groups, slug)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates the given app info across the cluster.
|
Updates the given app info across the cluster.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -6,6 +6,8 @@ defmodule Livebook.Config do
|
||||||
| %{mode: :token, secret: String.t()}
|
| %{mode: :token, secret: String.t()}
|
||||||
| %{mode: :disabled}
|
| %{mode: :disabled}
|
||||||
|
|
||||||
|
@type authentication_mode :: :password | :token | :disabled
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns path to Livebook priv directory.
|
Returns path to Livebook priv directory.
|
||||||
|
|
||||||
|
|
|
@ -142,6 +142,22 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
GenServer.call(registry_name(id), :get_environment_variables)
|
GenServer.call(registry_name(id), :get_environment_variables)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns if the given user groups has full access to app server.
|
||||||
|
"""
|
||||||
|
@spec user_full_access?(String.t(), list(map())) :: boolean()
|
||||||
|
def user_full_access?(id, groups) do
|
||||||
|
GenServer.call(registry_name(id), {:check_full_access, groups})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns if the given user groups has access to given app.
|
||||||
|
"""
|
||||||
|
@spec user_app_access?(String.t(), list(map()), String.t()) :: boolean()
|
||||||
|
def user_app_access?(id, groups, slug) do
|
||||||
|
GenServer.call(registry_name(id), {:check_app_access, groups, slug})
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns if the Team client is connected.
|
Returns if the Team client is connected.
|
||||||
"""
|
"""
|
||||||
|
@ -280,6 +296,29 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_call({:check_full_access, groups}, _caller, %{deployment_group_id: id} = state) do
|
||||||
|
case fetch_deployment_group(id, state) do
|
||||||
|
{:ok, deployment_group} ->
|
||||||
|
{:reply, authorized_group?(deployment_group.authorization_groups, groups), state}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:reply, false, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call({:check_app_access, groups, slug}, _caller, %{deployment_group_id: id} = state) do
|
||||||
|
with {:ok, deployment_group} <- fetch_deployment_group(id, state),
|
||||||
|
{:ok, app_deployment} <- fetch_app_deployment_from_slug(slug, state) do
|
||||||
|
app_access? =
|
||||||
|
authorized_group?(deployment_group.authorization_groups, groups) or
|
||||||
|
authorized_group?(app_deployment.authorization_groups, groups)
|
||||||
|
|
||||||
|
{:reply, app_access?, state}
|
||||||
|
else
|
||||||
|
_ -> {:reply, false, state}
|
||||||
|
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)
|
||||||
|
@ -440,6 +479,7 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
secrets = Enum.map(deployment_group.secrets, &build_secret(state, &1))
|
secrets = Enum.map(deployment_group.secrets, &build_secret(state, &1))
|
||||||
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)
|
||||||
|
|
||||||
%Teams.DeploymentGroup{
|
%Teams.DeploymentGroup{
|
||||||
id: deployment_group.id,
|
id: deployment_group.id,
|
||||||
|
@ -451,7 +491,8 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
environment_variables: environment_variables,
|
environment_variables: environment_variables,
|
||||||
clustering: nullify(deployment_group.clustering),
|
clustering: nullify(deployment_group.clustering),
|
||||||
url: nullify(deployment_group.url),
|
url: nullify(deployment_group.url),
|
||||||
teams_auth: deployment_group.teams_auth
|
teams_auth: deployment_group.teams_auth,
|
||||||
|
authorization_groups: authorization_groups
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -468,7 +509,8 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
environment_variables: [],
|
environment_variables: [],
|
||||||
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: []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -476,6 +518,7 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
secrets = Enum.map(deployment_group_updated.secrets, &build_secret(state, &1))
|
secrets = Enum.map(deployment_group_updated.secrets, &build_secret(state, &1))
|
||||||
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)
|
||||||
|
|
||||||
{:ok, deployment_group} = fetch_deployment_group(deployment_group_updated.id, state)
|
{:ok, deployment_group} = fetch_deployment_group(deployment_group_updated.id, state)
|
||||||
|
|
||||||
|
@ -487,11 +530,14 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
environment_variables: environment_variables,
|
environment_variables: environment_variables,
|
||||||
clustering: atomize(deployment_group_updated.clustering),
|
clustering: atomize(deployment_group_updated.clustering),
|
||||||
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,
|
||||||
|
authorization_groups: authorization_groups
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_app_deployment(state, %LivebookProto.AppDeployment{} = app_deployment) do
|
defp build_app_deployment(state, %LivebookProto.AppDeployment{} = app_deployment) do
|
||||||
|
authorization_groups = build_authorization_groups(app_deployment)
|
||||||
|
|
||||||
%Teams.AppDeployment{
|
%Teams.AppDeployment{
|
||||||
id: app_deployment.id,
|
id: app_deployment.id,
|
||||||
slug: app_deployment.slug,
|
slug: app_deployment.slug,
|
||||||
|
@ -504,7 +550,8 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
deployment_group_id: app_deployment.deployment_group_id,
|
deployment_group_id: app_deployment.deployment_group_id,
|
||||||
file: nil,
|
file: nil,
|
||||||
deployed_by: app_deployment.deployed_by,
|
deployed_by: app_deployment.deployed_by,
|
||||||
deployed_at: DateTime.from_gregorian_seconds(app_deployment.deployed_at)
|
deployed_at: DateTime.from_gregorian_seconds(app_deployment.deployed_at),
|
||||||
|
authorization_groups: authorization_groups
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -519,6 +566,15 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp build_authorization_groups(%{authorization_groups: authorization_groups}) do
|
||||||
|
for authorization_group <- authorization_groups do
|
||||||
|
%Teams.AuthorizationGroup{
|
||||||
|
provider_id: authorization_group.provider_id,
|
||||||
|
group_name: authorization_group.group_name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp put_agent(state, agent) do
|
defp put_agent(state, agent) do
|
||||||
state = remove_agent(state, agent)
|
state = remove_agent(state, agent)
|
||||||
|
|
||||||
|
@ -662,14 +718,7 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_event(:app_deployment_started, %Teams.AppDeployment{} = app_deployment, state) do
|
defp handle_event(:app_deployment_started, %Teams.AppDeployment{} = app_deployment, state) do
|
||||||
deployment_group_id = app_deployment.deployment_group_id
|
manager_sync(app_deployment, state)
|
||||||
|
|
||||||
with {:ok, deployment_group} <- fetch_deployment_group(deployment_group_id, state) do
|
|
||||||
if deployment_group.id == state.deployment_group_id do
|
|
||||||
manager_sync()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Teams.Broadcasts.app_deployment_started(app_deployment)
|
Teams.Broadcasts.app_deployment_started(app_deployment)
|
||||||
put_app_deployment(state, app_deployment)
|
put_app_deployment(state, app_deployment)
|
||||||
end
|
end
|
||||||
|
@ -683,14 +732,7 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_event(:app_deployment_stopped, %Teams.AppDeployment{} = app_deployment, state) do
|
defp handle_event(:app_deployment_stopped, %Teams.AppDeployment{} = app_deployment, state) do
|
||||||
deployment_group_id = app_deployment.deployment_group_id
|
manager_sync(app_deployment, state)
|
||||||
|
|
||||||
with {:ok, deployment_group} <- fetch_deployment_group(deployment_group_id, state) do
|
|
||||||
if deployment_group.id == state.deployment_group_id do
|
|
||||||
manager_sync()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Teams.Broadcasts.app_deployment_stopped(app_deployment)
|
Teams.Broadcasts.app_deployment_stopped(app_deployment)
|
||||||
remove_app_deployment(state, app_deployment)
|
remove_app_deployment(state, app_deployment)
|
||||||
end
|
end
|
||||||
|
@ -701,6 +743,20 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_event(:app_deployment_updated, %Teams.AppDeployment{} = app_deployment, state) do
|
||||||
|
manager_sync(app_deployment, state)
|
||||||
|
Teams.Broadcasts.app_deployment_updated(app_deployment)
|
||||||
|
put_app_deployment(state, app_deployment)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_event(:app_deployment_updated, app_deployment_updated, state) do
|
||||||
|
handle_event(
|
||||||
|
:app_deployment_updated,
|
||||||
|
build_app_deployment(state, app_deployment_updated.app_deployment),
|
||||||
|
state
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_event(:agent_joined, %Teams.Agent{} = agent, state) do
|
defp handle_event(:agent_joined, %Teams.Agent{} = agent, state) do
|
||||||
Teams.Broadcasts.agent_joined(agent)
|
Teams.Broadcasts.agent_joined(agent)
|
||||||
put_agent(state, agent)
|
put_agent(state, agent)
|
||||||
|
@ -921,6 +977,9 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
defp fetch_app_deployment(id, state),
|
defp fetch_app_deployment(id, state),
|
||||||
do: fetch_entry(state.app_deployments, &(&1.id == id), state)
|
do: fetch_entry(state.app_deployments, &(&1.id == id), state)
|
||||||
|
|
||||||
|
defp fetch_app_deployment_from_slug(slug, state),
|
||||||
|
do: fetch_entry(state.app_deployments, &(&1.slug == slug), state)
|
||||||
|
|
||||||
defp fetch_entry(entries, fun, state) do
|
defp fetch_entry(entries, fun, state) do
|
||||||
if entry = Enum.find(entries, fun) do
|
if entry = Enum.find(entries, fun) do
|
||||||
{:ok, entry}
|
{:ok, entry}
|
||||||
|
@ -938,10 +997,20 @@ defmodule Livebook.Hubs.TeamClient do
|
||||||
defp nullify(""), do: nil
|
defp nullify(""), do: nil
|
||||||
defp nullify(value), do: value
|
defp nullify(value), do: value
|
||||||
|
|
||||||
defp manager_sync() do
|
defp manager_sync(app_deployment, state) do
|
||||||
# Each node runs the teams client, but we only need to call sync once
|
# We only need to sync if the app deployment belongs to the current
|
||||||
if Apps.Manager.local?() do
|
# deployment group
|
||||||
Apps.Manager.sync_permanent_apps()
|
if app_deployment.deployment_group_id == state.deployment_group_id do
|
||||||
|
# Each node runs the teams client, but we only need to call sync once
|
||||||
|
if Apps.Manager.local?() do
|
||||||
|
Apps.Manager.sync_permanent_apps()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorized_group?(authorization_groups, groups) do
|
||||||
|
Enum.any?(authorization_groups, fn %{provider_id: id, group_name: name} ->
|
||||||
|
%{"provider_id" => id, "group_name" => name} in groups
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,7 +16,8 @@ defmodule Livebook.Teams.AppDeployment do
|
||||||
deployment_group_id: String.t() | nil,
|
deployment_group_id: String.t() | nil,
|
||||||
file: binary() | nil,
|
file: binary() | nil,
|
||||||
deployed_by: String.t() | nil,
|
deployed_by: String.t() | nil,
|
||||||
deployed_at: DateTime.t() | nil
|
deployed_at: DateTime.t() | nil,
|
||||||
|
authorization_groups: Ecto.Schema.embeds_many(Livebook.Teams.AuthorizationGroup.t())
|
||||||
}
|
}
|
||||||
|
|
||||||
@access_types Livebook.Notebook.AppSettings.access_types()
|
@access_types Livebook.Notebook.AppSettings.access_types()
|
||||||
|
@ -34,6 +35,8 @@ defmodule Livebook.Teams.AppDeployment do
|
||||||
field :file, :string
|
field :file, :string
|
||||||
field :deployed_by, :string
|
field :deployed_by, :string
|
||||||
field :deployed_at, :utc_datetime
|
field :deployed_at, :utc_datetime
|
||||||
|
|
||||||
|
embeds_many :authorization_groups, Livebook.Teams.AuthorizationGroup
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
14
lib/livebook/teams/authorization_group.ex
Normal file
14
lib/livebook/teams/authorization_group.ex
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule Livebook.Teams.AuthorizationGroup do
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
provider_id: String.t() | nil,
|
||||||
|
group_name: String.t() | nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@primary_key false
|
||||||
|
embedded_schema do
|
||||||
|
field :provider_id, :string
|
||||||
|
field :group_name, :string
|
||||||
|
end
|
||||||
|
end
|
|
@ -22,6 +22,7 @@ defmodule Livebook.Teams.Broadcasts do
|
||||||
|
|
||||||
* `{:app_deployment_started, AppDeployment.t()}`
|
* `{:app_deployment_started, AppDeployment.t()}`
|
||||||
* `{:app_deployment_stopped, AppDeployment.t()}`
|
* `{:app_deployment_stopped, AppDeployment.t()}`
|
||||||
|
* `{:app_deployment_updated, AppDeployment.t()}`
|
||||||
|
|
||||||
Topic `#{@clients_topic}`:
|
Topic `#{@clients_topic}`:
|
||||||
|
|
||||||
|
@ -107,6 +108,14 @@ defmodule Livebook.Teams.Broadcasts do
|
||||||
broadcast(@app_deployments_topic, {:app_deployment_stopped, app_deployment})
|
broadcast(@app_deployments_topic, {:app_deployment_stopped, app_deployment})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Broadcasts under `#{@app_deployments_topic}` topic when hub received an updated app deployment.
|
||||||
|
"""
|
||||||
|
@spec app_deployment_updated(Teams.AppDeployment.t()) :: broadcast()
|
||||||
|
def app_deployment_updated(%Teams.AppDeployment{} = app_deployment) do
|
||||||
|
broadcast(@app_deployments_topic, {:app_deployment_updated, app_deployment})
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Broadcasts under `#{@agents_topic}` topic when hub received a new agent.
|
Broadcasts under `#{@agents_topic}` topic when hub received a new agent.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -13,6 +13,7 @@ defmodule Livebook.Teams.DeploymentGroup do
|
||||||
clustering: :auto | :dns | nil,
|
clustering: :auto | :dns | nil,
|
||||||
hub_id: String.t() | nil,
|
hub_id: String.t() | nil,
|
||||||
teams_auth: boolean(),
|
teams_auth: boolean(),
|
||||||
|
authorization_groups: Ecto.Schema.embeds_many(Teams.AuthorizationGroup.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())
|
||||||
|
@ -30,6 +31,7 @@ defmodule Livebook.Teams.DeploymentGroup do
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(deployment_group, attrs \\ %{}) do
|
def changeset(deployment_group, attrs \\ %{}) do
|
||||||
|
|
|
@ -17,6 +17,7 @@ defmodule Livebook.Users.User do
|
||||||
name: String.t() | nil,
|
name: String.t() | nil,
|
||||||
email: String.t() | nil,
|
email: String.t() | nil,
|
||||||
avatar_url: String.t() | nil,
|
avatar_url: String.t() | nil,
|
||||||
|
restricted_apps_groups: list(map()) | nil,
|
||||||
payload: map() | nil,
|
payload: map() | nil,
|
||||||
hex_color: hex_color()
|
hex_color: hex_color()
|
||||||
}
|
}
|
||||||
|
@ -28,6 +29,7 @@ defmodule Livebook.Users.User do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :email, :string
|
field :email, :string
|
||||||
field :avatar_url, :string
|
field :avatar_url, :string
|
||||||
|
field :restricted_apps_groups, {:array, :map}
|
||||||
field :payload, :map
|
field :payload, :map
|
||||||
field :hex_color, Livebook.EctoTypes.HexColor
|
field :hex_color, Livebook.EctoTypes.HexColor
|
||||||
end
|
end
|
||||||
|
@ -42,6 +44,7 @@ defmodule Livebook.Users.User do
|
||||||
name: nil,
|
name: nil,
|
||||||
email: nil,
|
email: nil,
|
||||||
avatar_url: nil,
|
avatar_url: nil,
|
||||||
|
restricted_apps_groups: nil,
|
||||||
payload: nil,
|
payload: nil,
|
||||||
hex_color: Livebook.EctoTypes.HexColor.random()
|
hex_color: Livebook.EctoTypes.HexColor.random()
|
||||||
}
|
}
|
||||||
|
@ -49,7 +52,7 @@ defmodule Livebook.Users.User do
|
||||||
|
|
||||||
def changeset(user, attrs \\ %{}) do
|
def changeset(user, attrs \\ %{}) do
|
||||||
user
|
user
|
||||||
|> cast(attrs, [:name, :email, :avatar_url, :hex_color, :payload])
|
|> cast(attrs, [:name, :email, :avatar_url, :restricted_apps_groups, :hex_color, :payload])
|
||||||
|> validate_required([:hex_color])
|
|> validate_required([:hex_color])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -84,6 +84,7 @@ defmodule Livebook.ZTA.LivebookTeams do
|
||||||
# it means, we couldn't reach to Teams server
|
# it means, we couldn't reach to Teams server
|
||||||
%{"teams_error" => true} ->
|
%{"teams_error" => true} ->
|
||||||
{conn
|
{conn
|
||||||
|
|> put_status(:bad_request)
|
||||||
|> delete_session(:teams_error)
|
|> delete_session(:teams_error)
|
||||||
|> put_view(LivebookWeb.ErrorHTML)
|
|> put_view(LivebookWeb.ErrorHTML)
|
||||||
|> render("400.html", %{status: 400})
|
|> render("400.html", %{status: 400})
|
||||||
|
@ -91,6 +92,7 @@ defmodule Livebook.ZTA.LivebookTeams do
|
||||||
|
|
||||||
%{"teams_failed_reason" => reason} ->
|
%{"teams_failed_reason" => reason} ->
|
||||||
{conn
|
{conn
|
||||||
|
|> put_status(:forbidden)
|
||||||
|> delete_session(:teams_failed_reason)
|
|> delete_session(:teams_failed_reason)
|
||||||
|> put_view(LivebookWeb.ErrorHTML)
|
|> put_view(LivebookWeb.ErrorHTML)
|
||||||
|> render("error.html", %{
|
|> render("error.html", %{
|
||||||
|
@ -157,12 +159,26 @@ defmodule Livebook.ZTA.LivebookTeams do
|
||||||
|
|
||||||
defp get_user_info(team, access_token) do
|
defp get_user_info(team, access_token) do
|
||||||
with {:ok, payload} <- Teams.Requests.get_user_info(team, access_token) do
|
with {:ok, payload} <- Teams.Requests.get_user_info(team, access_token) do
|
||||||
%{"id" => id, "name" => name, "email" => email, "avatar_url" => avatar_url} = payload
|
%{
|
||||||
|
"id" => id,
|
||||||
|
"name" => name,
|
||||||
|
"email" => email,
|
||||||
|
"groups" => groups,
|
||||||
|
"avatar_url" => avatar_url
|
||||||
|
} = payload
|
||||||
|
|
||||||
|
restricted_apps_groups =
|
||||||
|
if Livebook.Hubs.TeamClient.user_full_access?(team.id, groups) do
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
groups
|
||||||
|
end
|
||||||
|
|
||||||
metadata = %{
|
metadata = %{
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
avatar_url: avatar_url,
|
avatar_url: avatar_url,
|
||||||
|
restricted_apps_groups: restricted_apps_groups,
|
||||||
email: email,
|
email: email,
|
||||||
payload: payload
|
payload: payload
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,13 +3,13 @@ defmodule LivebookWeb.ErrorHTML do
|
||||||
|
|
||||||
def render("404.html", assigns) do
|
def render("404.html", assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.error_page status={@status} title="No Numbats here!" />
|
<.error_page status={404} title="No Numbats here!" />
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
def render("403.html", assigns) do
|
def render("403.html", assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.error_page status={@status} title="No Numbats allowed here!" />
|
<.error_page status={403} title="No Numbats allowed here!" />
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -19,6 +19,12 @@ defmodule LivebookWeb.ErrorHTML do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render("401.html", assigns) do
|
||||||
|
~H"""
|
||||||
|
<.error_page status={401} title="Not authorized" details={@details} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
def render(_template, assigns) do
|
def render(_template, assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.error_page
|
<.error_page
|
||||||
|
@ -33,7 +39,7 @@ defmodule LivebookWeb.ErrorHTML do
|
||||||
attr :title, :string, required: true
|
attr :title, :string, required: true
|
||||||
attr :details, :string, default: nil
|
attr :details, :string, default: nil
|
||||||
|
|
||||||
defp error_page(assigns) do
|
def error_page(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
|
@ -4,7 +4,19 @@ defmodule LivebookWeb.AppLive do
|
||||||
import LivebookWeb.AppComponents
|
import LivebookWeb.AppComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug}, _session, socket) when socket.assigns.app_authenticated? do
|
def mount(%{"slug" => slug}, _session, socket) when not socket.assigns.app_authenticated? do
|
||||||
|
if connected?(socket) do
|
||||||
|
{:ok, push_navigate(socket, to: ~p"/apps/#{slug}/authenticate")}
|
||||||
|
else
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) when not socket.assigns.app_authorized? do
|
||||||
|
{:ok, socket, layout: false}
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(%{"slug" => slug}, _session, socket) do
|
||||||
if socket.assigns.app_settings.multi_session do
|
if socket.assigns.app_settings.multi_session do
|
||||||
{:ok, app} = Livebook.Apps.fetch_app(slug)
|
{:ok, app} = Livebook.Apps.fetch_app(slug)
|
||||||
|
|
||||||
|
@ -20,16 +32,8 @@ defmodule LivebookWeb.AppLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def mount(%{"slug" => slug}, _session, socket) do
|
|
||||||
if connected?(socket) do
|
|
||||||
{:ok, push_navigate(socket, to: ~p"/apps/#{slug}/authenticate")}
|
|
||||||
else
|
|
||||||
{:ok, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) when assigns.app_authenticated? do
|
def render(assigns) when assigns.app_authenticated? and assigns.app_authorized? do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="h-full relative overflow-y-auto px-4 md:px-20">
|
<div class="h-full relative overflow-y-auto px-4 md:px-20">
|
||||||
<div class="w-full max-w-screen-lg py-4 mx-auto">
|
<div class="w-full max-w-screen-lg py-4 mx-auto">
|
||||||
|
@ -90,6 +94,16 @@ defmodule LivebookWeb.AppLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render(assigns) when not assigns.app_authorized? do
|
||||||
|
~H"""
|
||||||
|
<LivebookWeb.ErrorHTML.error_page
|
||||||
|
status={401}
|
||||||
|
title="Not authorized"
|
||||||
|
details="You don't have permission to access this app"
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
def render(assigns), do: auth_placeholder(assigns)
|
def render(assigns), do: auth_placeholder(assigns)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
@ -8,10 +8,28 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
alias Livebook.Notebook.Cell
|
alias Livebook.Notebook.Cell
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"slug" => slug, "id" => session_id}, _session, socket)
|
def mount(%{"slug" => slug} = params, _session, socket)
|
||||||
when socket.assigns.app_authenticated? do
|
when not socket.assigns.app_authenticated? do
|
||||||
{:ok, app} = Livebook.Apps.fetch_app(slug)
|
if connected?(socket) do
|
||||||
|
to =
|
||||||
|
if id = params["id"] do
|
||||||
|
~p"/apps/#{slug}/authenticate?id=#{id}"
|
||||||
|
else
|
||||||
|
~p"/apps/#{slug}/authenticate"
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, push_navigate(socket, to: to)}
|
||||||
|
else
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(_params, _session, socket) when not socket.assigns.app_authorized? do
|
||||||
|
{:ok, socket, layout: false}
|
||||||
|
end
|
||||||
|
|
||||||
|
def mount(%{"slug" => slug, "id" => session_id}, _session, socket) do
|
||||||
|
{:ok, app} = Livebook.Apps.fetch_app(slug)
|
||||||
app_session = Enum.find(app.sessions, &(&1.id == session_id))
|
app_session = Enum.find(app.sessions, &(&1.id == session_id))
|
||||||
|
|
||||||
if app_session && app_session.app_status.lifecycle == :active do
|
if app_session && app_session.app_status.lifecycle == :active do
|
||||||
|
@ -23,6 +41,7 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
{data, client_id} =
|
{data, client_id} =
|
||||||
Session.register_client(session_pid, self(), socket.assigns.current_user)
|
Session.register_client(session_pid, self(), socket.assigns.current_user)
|
||||||
|
|
||||||
|
Livebook.Teams.Broadcasts.subscribe(:app_deployments)
|
||||||
Session.subscribe(session_id)
|
Session.subscribe(session_id)
|
||||||
|
|
||||||
{data, client_id}
|
{data, client_id}
|
||||||
|
@ -52,21 +71,6 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def mount(%{"slug" => slug} = params, _session, socket) do
|
|
||||||
if connected?(socket) do
|
|
||||||
to =
|
|
||||||
if id = params["id"] do
|
|
||||||
~p"/apps/#{slug}/authenticate?id=#{id}"
|
|
||||||
else
|
|
||||||
~p"/apps/#{slug}/authenticate"
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, push_navigate(socket, to: to)}
|
|
||||||
else
|
|
||||||
{:ok, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Puts the given assigns in `socket.private`,
|
# Puts the given assigns in `socket.private`,
|
||||||
# to ensure they are not used for rendering.
|
# to ensure they are not used for rendering.
|
||||||
defp assign_private(socket, assigns) do
|
defp assign_private(socket, assigns) do
|
||||||
|
@ -76,7 +80,8 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(%{nonexistent?: true} = assigns) when assigns.app_authenticated? do
|
def render(%{nonexistent?: true} = assigns)
|
||||||
|
when assigns.app_authenticated? and assigns.app_authorized? do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="h-screen flex items-center justify-center">
|
<div class="h-screen flex items-center justify-center">
|
||||||
<div class="flex flex-col space-y-4 items-center">
|
<div class="flex flex-col space-y-4 items-center">
|
||||||
|
@ -95,7 +100,7 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
def render(assigns) when assigns.app_authenticated? do
|
def render(assigns) when assigns.app_authenticated? and assigns.app_authorized? do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="h-full relative overflow-y-auto px-4 md:px-20" data-el-notebook>
|
<div class="h-full relative overflow-y-auto px-4 md:px-20" data-el-notebook>
|
||||||
<div class="w-full max-w-screen-lg py-4 mx-auto" data-el-notebook-content>
|
<div class="w-full max-w-screen-lg py-4 mx-auto" data-el-notebook-content>
|
||||||
|
@ -107,7 +112,7 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
<.remix_icon icon="arrow-down-s-line" />
|
<.remix_icon icon="arrow-down-s-line" />
|
||||||
</button>
|
</button>
|
||||||
</:toggle>
|
</:toggle>
|
||||||
<.menu_item :if={@livebook_authenticated?}>
|
<.menu_item :if={@livebook_authorized?}>
|
||||||
<.link navigate={~p"/"} role="menuitem">
|
<.link navigate={~p"/"} role="menuitem">
|
||||||
<.remix_icon icon="home-6-line" />
|
<.remix_icon icon="home-6-line" />
|
||||||
<span>Home</span>
|
<span>Home</span>
|
||||||
|
@ -134,7 +139,7 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
<span>View source</span>
|
<span>View source</span>
|
||||||
</.link>
|
</.link>
|
||||||
</.menu_item>
|
</.menu_item>
|
||||||
<.menu_item :if={@livebook_authenticated?}>
|
<.menu_item :if={@livebook_authorized?}>
|
||||||
<.link patch={~p"/sessions/#{@session.id}"} role="menuitem">
|
<.link patch={~p"/sessions/#{@session.id}"} role="menuitem">
|
||||||
<.remix_icon icon="terminal-line" />
|
<.remix_icon icon="terminal-line" />
|
||||||
<span>Debug</span>
|
<span>Debug</span>
|
||||||
|
@ -174,7 +179,7 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<span class="tooltip top" data-tooltip="Debug">
|
<span class="tooltip top" data-tooltip="Debug">
|
||||||
<.link
|
<.link
|
||||||
:if={@livebook_authenticated?}
|
:if={@livebook_authorized?}
|
||||||
navigate={~p"/sessions/#{@session.id}" <> "#cell-#{@data_view.errored_cell_id}"}
|
navigate={~p"/sessions/#{@session.id}" <> "#cell-#{@data_view.errored_cell_id}"}
|
||||||
>
|
>
|
||||||
<.remix_icon icon="terminal-line" />
|
<.remix_icon icon="terminal-line" />
|
||||||
|
@ -231,6 +236,16 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render(assigns) when not assigns.app_authorized? do
|
||||||
|
~H"""
|
||||||
|
<LivebookWeb.ErrorHTML.error_page
|
||||||
|
status={401}
|
||||||
|
title="Not authorized"
|
||||||
|
details="You don't have permission to access this app"
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
def render(assigns), do: auth_placeholder(assigns)
|
def render(assigns), do: auth_placeholder(assigns)
|
||||||
|
|
||||||
attr :status, :map, required: true
|
attr :status, :map, required: true
|
||||||
|
@ -364,6 +379,22 @@ defmodule LivebookWeb.AppSessionLive do
|
||||||
{:noreply, redirect_on_closed(socket)}
|
{:noreply, redirect_on_closed(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:app_deployment_updated, %{slug: slug}}, %{assigns: %{slug: slug}} = socket) do
|
||||||
|
# We force the redirection in case of
|
||||||
|
# the current user loses access to this app.
|
||||||
|
|
||||||
|
# With this strategy, we guarantee that unauthorized users
|
||||||
|
# won't be able to keep reading the app which they
|
||||||
|
# should't have access.
|
||||||
|
{:ok, app} = Livebook.Apps.fetch_app(slug)
|
||||||
|
|
||||||
|
if Livebook.Apps.authorized?(app, socket.assigns.current_user) do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
{:noreply, redirect(socket, to: ~p"/apps/#{slug}/sessions/#{socket.assigns.session.id}")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(_message, socket), do: {:noreply, socket}
|
def handle_info(_message, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
defp handle_operation(socket, operation) do
|
defp handle_operation(socket, operation) do
|
||||||
|
|
|
@ -4,10 +4,11 @@ defmodule LivebookWeb.AppsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
|
Livebook.Teams.Broadcasts.subscribe(:app_deployments)
|
||||||
Livebook.Apps.subscribe()
|
Livebook.Apps.subscribe()
|
||||||
end
|
end
|
||||||
|
|
||||||
apps = Livebook.Apps.list_apps()
|
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
|
||||||
empty_apps_path? = Livebook.Apps.empty_apps_path?()
|
empty_apps_path? = Livebook.Apps.empty_apps_path?()
|
||||||
|
|
||||||
{:ok, assign(socket, apps: apps, empty_apps_path?: empty_apps_path?)}
|
{:ok, assign(socket, apps: apps, empty_apps_path?: empty_apps_path?)}
|
||||||
|
@ -89,6 +90,11 @@ defmodule LivebookWeb.AppsLive do
|
||||||
{:noreply, update(socket, :apps, &LivebookWeb.AppComponents.update_app_list(&1, event))}
|
{:noreply, update(socket, :apps, &LivebookWeb.AppComponents.update_app_list(&1, event))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:app_deployment_updated, _app_deployment}, socket) do
|
||||||
|
apps = Livebook.Apps.list_authorized_apps(socket.assigns.current_user)
|
||||||
|
{:noreply, assign(socket, :apps, apps)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(_message, socket), do: {:noreply, socket}
|
def handle_info(_message, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
defp apps_listing(apps) do
|
defp apps_listing(apps) do
|
||||||
|
|
|
@ -34,7 +34,11 @@ defmodule LivebookWeb.AppAuthHook do
|
||||||
# For public apps (or in case the user has full access) it is
|
# For public apps (or in case the user has full access) it is
|
||||||
# set to `true` on both dead and live render
|
# set to `true` on both dead and live render
|
||||||
#
|
#
|
||||||
# * `:livebook_authenticated?` - if the user has full Livebook
|
# * `:app_authorized?` - reflects the app access authorization.
|
||||||
|
# For public apps (or in case the user has full access) it is
|
||||||
|
# set to `true` on both dead and live render
|
||||||
|
#
|
||||||
|
# * `:livebook_authorized?` - if the user has full Livebook
|
||||||
# access
|
# access
|
||||||
#
|
#
|
||||||
# * `:app_settings` - the current app settings
|
# * `:app_settings` - the current app settings
|
||||||
|
@ -45,26 +49,30 @@ defmodule LivebookWeb.AppAuthHook do
|
||||||
LivebookWeb.SessionHelpers.subscribe_to_logout()
|
LivebookWeb.SessionHelpers.subscribe_to_logout()
|
||||||
end
|
end
|
||||||
|
|
||||||
livebook_authenticated? = livebook_authenticated?(session, socket)
|
livebook_authorized? = livebook_authorized?(session, socket)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(livebook_authenticated?: livebook_authenticated?)
|
|> assign(livebook_authorized?: livebook_authorized?)
|
||||||
|> attach_hook(:logout, :handle_info, &handle_info/2)
|
|> attach_hook(:logout, :handle_info, &handle_info/2)
|
||||||
|> attach_hook(:logout, :handle_event, &handle_event/3)
|
|> attach_hook(:logout, :handle_event, &handle_event/3)
|
||||||
|
|
||||||
case Livebook.Apps.fetch_settings(slug) do
|
with {:ok, app} <- Livebook.Apps.fetch_app(slug),
|
||||||
{:ok, %{access_type: :public} = app_settings} ->
|
{:ok, app_settings} <- Livebook.Apps.fetch_settings(slug) do
|
||||||
{:cont, assign(socket, app_authenticated?: true, app_settings: app_settings)}
|
app_authenticated? =
|
||||||
|
case app_settings.access_type do
|
||||||
|
:public -> true
|
||||||
|
:protected -> livebook_authorized? or has_valid_token?(socket, app_settings)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, %{access_type: :protected} = app_settings} ->
|
{:cont,
|
||||||
app_authenticated? = livebook_authenticated? or has_valid_token?(socket, app_settings)
|
assign(socket,
|
||||||
|
app_authenticated?: app_authenticated?,
|
||||||
{:cont,
|
app_authorized?: Livebook.Apps.authorized?(app, socket.assigns.current_user),
|
||||||
assign(socket, app_authenticated?: app_authenticated?, app_settings: app_settings)}
|
app_settings: app_settings
|
||||||
|
)}
|
||||||
:error ->
|
else
|
||||||
{:halt, redirect(socket, to: ~p"/")}
|
_otherwise -> {:halt, redirect(socket, to: ~p"/")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -73,9 +81,9 @@ defmodule LivebookWeb.AppAuthHook do
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp livebook_authenticated?(session, socket) do
|
defp livebook_authorized?(session, socket) do
|
||||||
uri = get_connect_info(socket, :uri)
|
uri = get_connect_info(socket, :uri)
|
||||||
LivebookWeb.AuthPlug.authenticated?(session, uri.port)
|
LivebookWeb.AuthPlug.authorized?(session, uri.port)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_info(:logout, socket) do
|
defp handle_info(:logout, socket) do
|
||||||
|
|
|
@ -6,7 +6,7 @@ defmodule LivebookWeb.AuthHook do
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
uri = get_connect_info(socket, :uri)
|
uri = get_connect_info(socket, :uri)
|
||||||
|
|
||||||
if LivebookWeb.AuthPlug.authenticated?(session || %{}, uri.port) do
|
if LivebookWeb.AuthPlug.authorized?(session || %{}, uri.port) do
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
else
|
else
|
||||||
{:halt, redirect(socket, to: ~p"/")}
|
{:halt, redirect(socket, to: ~p"/")}
|
||||||
|
|
|
@ -12,7 +12,11 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
@impl true
|
@impl true
|
||||||
def call(conn, _opts) do
|
def call(conn, _opts) do
|
||||||
if authenticated?(conn) do
|
if authenticated?(conn) do
|
||||||
conn
|
if not authorized?(conn) do
|
||||||
|
render_unauthorized(conn)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
else
|
else
|
||||||
authenticate(conn)
|
authenticate(conn)
|
||||||
end
|
end
|
||||||
|
@ -36,11 +40,7 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
authenticated?(get_session(conn), conn.port)
|
authenticated?(get_session(conn), conn.port)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
defp authenticated?(session, port) do
|
||||||
Checks if the given session is authenticated.
|
|
||||||
"""
|
|
||||||
@spec authenticated?(map(), non_neg_integer()) :: boolean()
|
|
||||||
def authenticated?(session, port) do
|
|
||||||
case authentication(session) do
|
case authentication(session) do
|
||||||
%{mode: :disabled} ->
|
%{mode: :disabled} ->
|
||||||
true
|
true
|
||||||
|
@ -51,6 +51,35 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if given connection or session is authorized.
|
||||||
|
"""
|
||||||
|
@spec authorized?(Plug.Conn.t()) :: boolean()
|
||||||
|
def authorized?(%Plug.Conn{} = conn) do
|
||||||
|
# Note that if the user has access restricted to specific app pages,
|
||||||
|
# they are not authorized and have no access to any pages guarded
|
||||||
|
# by this plug.
|
||||||
|
authenticated?(conn) and
|
||||||
|
LivebookWeb.UserPlug.build_current_user(
|
||||||
|
get_session(conn),
|
||||||
|
conn.assigns.identity_data,
|
||||||
|
conn.assigns.user_data
|
||||||
|
).restricted_apps_groups == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks if the given session is authorized.
|
||||||
|
"""
|
||||||
|
@spec authorized?(map(), non_neg_integer()) :: boolean()
|
||||||
|
def authorized?(%{} = session, port) do
|
||||||
|
authenticated?(session, port) and
|
||||||
|
LivebookWeb.UserPlug.build_current_user(
|
||||||
|
session,
|
||||||
|
session["identity_data"],
|
||||||
|
session["user_data"]
|
||||||
|
).restricted_apps_groups == nil
|
||||||
|
end
|
||||||
|
|
||||||
defp authenticate(conn) do
|
defp authenticate(conn) do
|
||||||
case authentication(conn) do
|
case authentication(conn) do
|
||||||
%{mode: :password} ->
|
%{mode: :password} ->
|
||||||
|
@ -99,6 +128,18 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_unauthorized(%{path_info: []} = conn) do
|
||||||
|
conn |> redirect(to: ~p"/apps") |> halt()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_unauthorized(conn) do
|
||||||
|
conn
|
||||||
|
|> put_status(:unauthorized)
|
||||||
|
|> put_view(LivebookWeb.ErrorHTML)
|
||||||
|
|> render("401.html", %{details: "You don't have permission to access this server"})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
|
||||||
defp path_with_query(path, params) when params == %{}, do: path
|
defp path_with_query(path, params) when params == %{}, do: path
|
||||||
defp path_with_query(path, params), do: path <> "?" <> URI.encode_query(params)
|
defp path_with_query(path, params), do: path <> "?" <> URI.encode_query(params)
|
||||||
|
|
||||||
|
@ -112,6 +153,7 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
This mirrors `Livebook.Config.authentication/0`, except the it can
|
This mirrors `Livebook.Config.authentication/0`, except the it can
|
||||||
be overridden in tests, for each connection.
|
be overridden in tests, for each connection.
|
||||||
"""
|
"""
|
||||||
|
@spec authentication(Plug.Conn.t() | map()) :: Livebook.Config.authentication()
|
||||||
if Mix.env() == :test do
|
if Mix.env() == :test do
|
||||||
def authentication(%Plug.Conn{} = conn), do: authentication(get_session(conn))
|
def authentication(%Plug.Conn{} = conn), do: authentication(get_session(conn))
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,7 @@ defmodule LivebookWeb.Router do
|
||||||
end
|
end
|
||||||
|
|
||||||
live_session :default,
|
live_session :default,
|
||||||
on_mount: [LivebookWeb.AuthHook, LivebookWeb.UserHook, LivebookWeb.Confirm],
|
on_mount: [LivebookWeb.UserHook, LivebookWeb.AuthHook, LivebookWeb.Confirm],
|
||||||
session: {LivebookWeb.UserPlug, :extra_lv_session, []} do
|
session: {LivebookWeb.UserPlug, :extra_lv_session, []} do
|
||||||
scope "/", LivebookWeb do
|
scope "/", LivebookWeb do
|
||||||
pipe_through [:browser, :auth]
|
pipe_through [:browser, :auth]
|
||||||
|
@ -139,7 +139,7 @@ defmodule LivebookWeb.Router do
|
||||||
end
|
end
|
||||||
|
|
||||||
live_session :apps,
|
live_session :apps,
|
||||||
on_mount: [LivebookWeb.AppAuthHook, LivebookWeb.UserHook, LivebookWeb.Confirm],
|
on_mount: [LivebookWeb.UserHook, LivebookWeb.AppAuthHook, LivebookWeb.Confirm],
|
||||||
session: {LivebookWeb.UserPlug, :extra_lv_session, []} do
|
session: {LivebookWeb.UserPlug, :extra_lv_session, []} do
|
||||||
scope "/", LivebookWeb do
|
scope "/", LivebookWeb do
|
||||||
pipe_through [:browser, :user]
|
pipe_through [:browser, :user]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule LivebookProto.AuthorizationGroup do
|
defmodule LivebookProto.AuthorizationGroup do
|
||||||
use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3
|
use Protobuf, protoc_gen_elixir_version: "0.14.1", syntax: :proto3
|
||||||
|
|
||||||
field :oidc_provider_id, 1, type: :string, json_name: "oidcProviderId"
|
field :provider_id, 1, type: :string, json_name: "providerId"
|
||||||
field :group_name, 2, type: :string, json_name: "groupName"
|
field :group_name, 2, type: :string, json_name: "groupName"
|
||||||
end
|
end
|
||||||
|
|
|
@ -207,7 +207,7 @@ message EnvironmentVariable {
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthorizationGroup {
|
message AuthorizationGroup {
|
||||||
string oidc_provider_id = 1;
|
string provider_id = 1;
|
||||||
string group_name = 2;
|
string group_name = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -690,7 +690,8 @@ defmodule Livebook.Hubs.TeamClientTest do
|
||||||
revision_id: to_string(teams_app_deployment.app_revision.id),
|
revision_id: to_string(teams_app_deployment.app_revision.id),
|
||||||
deployment_group_id: app_deployment.deployment_group_id,
|
deployment_group_id: app_deployment.deployment_group_id,
|
||||||
multi_session: app_deployment.multi_session,
|
multi_session: app_deployment.multi_session,
|
||||||
access_type: to_string(app_deployment.access_type)
|
access_type: to_string(app_deployment.access_type),
|
||||||
|
authorization_groups: []
|
||||||
}
|
}
|
||||||
|
|
||||||
agent_connected = %{agent_connected | app_deployments: [livebook_proto_app_deployment]}
|
agent_connected = %{agent_connected | app_deployments: [livebook_proto_app_deployment]}
|
||||||
|
|
|
@ -16,7 +16,7 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
|
||||||
%{hub_id: ^hub_id, org_id: ^org_id, deployment_group_id: ^deployment_group_id}}
|
%{hub_id: ^hub_id, org_id: ^org_id, deployment_group_id: ^deployment_group_id}}
|
||||||
|
|
||||||
start_supervised!({LivebookTeams, name: test, identity_key: team.id})
|
start_supervised!({LivebookTeams, name: test, identity_key: team.id})
|
||||||
{:ok, deployment_group: deployment_group, team: team}
|
{:ok, deployment_group: deployment_group, org: org, team: team}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "authenticate/3" do
|
describe "authenticate/3" do
|
||||||
|
@ -58,6 +58,164 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
|
||||||
assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])
|
assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "authorizes user to access admin page with full access permission",
|
||||||
|
%{conn: conn, node: node, deployment_group: deployment_group, org: org, test: test} do
|
||||||
|
erpc_call(node, :toggle_groups_authorization, [deployment_group])
|
||||||
|
oidc_provider = erpc_call(node, :create_oidc_provider, [org])
|
||||||
|
|
||||||
|
authorization_group =
|
||||||
|
erpc_call(node, :create_authorization_group, [
|
||||||
|
%{
|
||||||
|
group_name: "developers",
|
||||||
|
access_type: :app_server,
|
||||||
|
oidc_provider: oidc_provider,
|
||||||
|
deployment_group: deployment_group
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
{conn, code, %{restricted_apps_groups: []}} = authenticate_user(conn, node, test)
|
||||||
|
session = get_session(conn)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
build_conn(:get, ~p"/")
|
||||||
|
|> init_test_session(session)
|
||||||
|
|
||||||
|
group = %{
|
||||||
|
"provider_id" => to_string(oidc_provider.id),
|
||||||
|
"group_name" => authorization_group.group_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the user with updated groups
|
||||||
|
erpc_call(node, :update_user_info_groups, [code, [group]])
|
||||||
|
|
||||||
|
assert {%{halted: false}, %{restricted_apps_groups: nil}} =
|
||||||
|
LivebookTeams.authenticate(test, conn, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :tmp_dir
|
||||||
|
test "renders unauthorized user to access app with prefix the user don't have access",
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
node: node,
|
||||||
|
deployment_group: deployment_group,
|
||||||
|
org: org,
|
||||||
|
tmp_dir: tmp_dir,
|
||||||
|
team: team,
|
||||||
|
test: test
|
||||||
|
} do
|
||||||
|
erpc_call(node, :toggle_groups_authorization, [deployment_group])
|
||||||
|
oidc_provider = erpc_call(node, :create_oidc_provider, [org])
|
||||||
|
|
||||||
|
authorization_group =
|
||||||
|
erpc_call(node, :create_authorization_group, [
|
||||||
|
%{
|
||||||
|
group_name: "marketing",
|
||||||
|
access_type: :apps,
|
||||||
|
prefixes: ["mkt-"],
|
||||||
|
oidc_provider: oidc_provider,
|
||||||
|
deployment_group: deployment_group
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
Livebook.Apps.subscribe()
|
||||||
|
slug = "marketing-app"
|
||||||
|
|
||||||
|
notebook = %{
|
||||||
|
Livebook.Notebook.new()
|
||||||
|
| app_settings: %{Livebook.Notebook.AppSettings.new() | slug: slug},
|
||||||
|
file_entries: [],
|
||||||
|
name: slug,
|
||||||
|
hub_id: team.id,
|
||||||
|
deployment_group_id: to_string(deployment_group.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
files_dir = Livebook.FileSystem.File.local(tmp_dir)
|
||||||
|
|
||||||
|
{:ok, %Livebook.Teams.AppDeployment{file: zip_content} = app_deployment} =
|
||||||
|
Livebook.Teams.AppDeployment.new(notebook, files_dir)
|
||||||
|
|
||||||
|
secret_key = Livebook.Teams.derive_key(team.teams_key)
|
||||||
|
encrypted_content = Livebook.Teams.encrypt(zip_content, secret_key)
|
||||||
|
|
||||||
|
Livebook.Teams.Broadcasts.subscribe(:app_deployments)
|
||||||
|
|
||||||
|
app_deployment_id =
|
||||||
|
erpc_call(node, :upload_app_deployment, [
|
||||||
|
org,
|
||||||
|
deployment_group,
|
||||||
|
app_deployment,
|
||||||
|
encrypted_content,
|
||||||
|
# broadcast?
|
||||||
|
true
|
||||||
|
]).id
|
||||||
|
|
||||||
|
app_deployment_id = to_string(app_deployment_id)
|
||||||
|
assert_receive {:app_deployment_started, %{id: ^app_deployment_id}}
|
||||||
|
|
||||||
|
Livebook.Apps.subscribe()
|
||||||
|
|
||||||
|
assert_receive {:app_created, %{pid: pid, slug: ^slug}}
|
||||||
|
|
||||||
|
assert_receive {:app_updated,
|
||||||
|
%{
|
||||||
|
slug: ^slug,
|
||||||
|
sessions: [%{app_status: %{execution: :executed, lifecycle: :active}}]
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Now we need to check if the current user has access to this app
|
||||||
|
{conn, code, %{restricted_apps_groups: []}} = authenticate_user(conn, node, test)
|
||||||
|
session = get_session(conn)
|
||||||
|
|
||||||
|
group = %{
|
||||||
|
"provider_id" => to_string(oidc_provider.id),
|
||||||
|
"group_name" => authorization_group.group_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update user groups
|
||||||
|
erpc_call(node, :update_user_info_groups, [code, [group]])
|
||||||
|
|
||||||
|
# Guarantee we don't list the app for this user
|
||||||
|
conn = build_conn(:get, ~p"/") |> init_test_session(session)
|
||||||
|
{_conn, metadata} = LivebookTeams.authenticate(test, conn, [])
|
||||||
|
{:ok, user} = Livebook.Users.update_user(Livebook.Users.User.new(metadata.id), metadata)
|
||||||
|
assert Livebook.Apps.list_authorized_apps(user) == []
|
||||||
|
|
||||||
|
Livebook.App.close(pid)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders unauthorized user to access admin page with slug prefix access permission",
|
||||||
|
%{conn: conn, node: node, deployment_group: deployment_group, org: org, test: test} do
|
||||||
|
erpc_call(node, :toggle_groups_authorization, [deployment_group])
|
||||||
|
oidc_provider = erpc_call(node, :create_oidc_provider, [org])
|
||||||
|
|
||||||
|
authorization_group =
|
||||||
|
erpc_call(node, :create_authorization_group, [
|
||||||
|
%{
|
||||||
|
group_name: "marketing",
|
||||||
|
access_type: :apps,
|
||||||
|
prefixes: ["mkt-"],
|
||||||
|
oidc_provider: oidc_provider,
|
||||||
|
deployment_group: deployment_group
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
{conn, code, %{restricted_apps_groups: []}} = authenticate_user(conn, node, test)
|
||||||
|
|
||||||
|
group = %{
|
||||||
|
"provider_id" => to_string(oidc_provider.id),
|
||||||
|
"group_name" => authorization_group.group_name
|
||||||
|
}
|
||||||
|
|
||||||
|
erpc_call(node, :update_user_info_groups, [code, [group]])
|
||||||
|
|
||||||
|
conn =
|
||||||
|
build_conn(:get, ~p"/settings")
|
||||||
|
|> init_test_session(get_session(conn))
|
||||||
|
|
||||||
|
assert {_conn, %{restricted_apps_groups: [^group]}} =
|
||||||
|
LivebookTeams.authenticate(test, conn, [])
|
||||||
|
end
|
||||||
|
|
||||||
test "redirects to Livebook Teams with invalid access token",
|
test "redirects to Livebook Teams with invalid access token",
|
||||||
%{conn: conn, test: test} do
|
%{conn: conn, test: test} do
|
||||||
conn = init_test_session(conn, %{livebook_teams_access_token: "1234567890"})
|
conn = init_test_session(conn, %{livebook_teams_access_token: "1234567890"})
|
||||||
|
@ -78,68 +236,60 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
|
||||||
conn = %Plug.Conn{conn | params: params_from_teams}
|
conn = %Plug.Conn{conn | params: params_from_teams}
|
||||||
|
|
||||||
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
||||||
session = Plug.Conn.get_session(conn)
|
|
||||||
|
|
||||||
assert conn.status == 302
|
assert conn.status == 302
|
||||||
|
|
||||||
# Step 2: follow the redirect keeping the session set in previous request
|
# Step 2: follow the redirect keeping the session set in previous request
|
||||||
conn = build_conn(:get, redirected_to(conn))
|
conn =
|
||||||
conn = init_test_session(conn, session)
|
build_conn(:get, redirected_to(conn))
|
||||||
|
|> init_test_session(get_session(conn))
|
||||||
|
|
||||||
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
||||||
|
|
||||||
assert html_response(conn, 200) =~
|
assert html_response(conn, 403) =~
|
||||||
"Failed to authenticate with Livebook Teams: you do not belong to this org"
|
"Failed to authenticate with Livebook Teams: you do not belong to this org"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "logout/2" do
|
describe "logout/2" do
|
||||||
test "revoke access token from Livebook Teams", %{conn: conn, node: node, test: test} do
|
test "revoke access token from Livebook Teams", %{conn: conn, node: node, test: test} do
|
||||||
# Step 1: Get redirected to Livebook Teams
|
{conn, _code, metadata} = authenticate_user(conn, node, test)
|
||||||
conn = init_test_session(conn, %{})
|
|
||||||
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
|
||||||
|
|
||||||
[_, location] = Regex.run(~r/URL\("(.*?)"\)/, html_response(conn, 200))
|
|
||||||
uri = URI.parse(location)
|
|
||||||
assert uri.path == "/identity/authorize"
|
|
||||||
assert %{"code" => code} = URI.decode_query(uri.query)
|
|
||||||
|
|
||||||
erpc_call(node, :allow_auth_request, [code])
|
|
||||||
|
|
||||||
# Step 2: Emulate the redirect back with the code for validation
|
|
||||||
conn =
|
|
||||||
build_conn(:get, "/", %{teams_identity: "", code: code})
|
|
||||||
|> init_test_session(Plug.Conn.get_session(conn))
|
|
||||||
|
|
||||||
assert {conn, %{id: _id, name: _, email: _, payload: %{}} = metadata} =
|
|
||||||
LivebookTeams.authenticate(test, conn, [])
|
|
||||||
|
|
||||||
assert redirected_to(conn, 302) == "/"
|
|
||||||
|
|
||||||
# Step 3: Confirm the token is valid for future requests
|
|
||||||
conn =
|
|
||||||
build_conn(:get, "/")
|
|
||||||
|> init_test_session(Plug.Conn.get_session(conn))
|
|
||||||
|
|
||||||
assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])
|
assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])
|
||||||
|
|
||||||
# Step 4: Revoke the token and the metadata will be invalid for future requests
|
# Revoke the token and the metadata will be invalid for future requests
|
||||||
conn =
|
|
||||||
build_conn(:get, "/")
|
|
||||||
|> init_test_session(Plug.Conn.get_session(conn))
|
|
||||||
|
|
||||||
assert %{status: 302} = conn = LivebookTeams.logout(test, conn)
|
assert %{status: 302} = conn = LivebookTeams.logout(test, conn)
|
||||||
[url] = get_resp_header(conn, "location")
|
[url] = get_resp_header(conn, "location")
|
||||||
assert %{status: 200} = Req.get!(url)
|
assert %{status: 200} = Req.get!(url)
|
||||||
|
|
||||||
# Step 5: It we try to authenticate again, it should redirect to Teams
|
# If we try to authenticate again, it should redirect to Teams
|
||||||
conn =
|
conn =
|
||||||
build_conn(:get, "/")
|
build_conn(:get, ~p"/")
|
||||||
|> init_test_session(Plug.Conn.get_session(conn))
|
|> init_test_session(get_session(conn))
|
||||||
|
|
||||||
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
||||||
assert conn.halted
|
assert conn.halted
|
||||||
assert html_response(conn, 200) =~ "window.location.href = "
|
assert html_response(conn, 200) =~ "window.location.href = "
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authenticate_user(conn, node, test) do
|
||||||
|
conn = init_test_session(conn, %{})
|
||||||
|
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
|
||||||
|
|
||||||
|
[_, location] = Regex.run(~r/URL\("(.*?)"\)/, html_response(conn, 200))
|
||||||
|
uri = URI.parse(location)
|
||||||
|
%{"code" => code} = URI.decode_query(uri.query)
|
||||||
|
|
||||||
|
erpc_call(node, :allow_auth_request, [code])
|
||||||
|
|
||||||
|
session = get_session(conn)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
build_conn(:get, ~p"/", %{teams_identity: "", code: code})
|
||||||
|
|> init_test_session(session)
|
||||||
|
|
||||||
|
{conn, %{id: _id} = metadata} = LivebookTeams.authenticate(test, conn, [])
|
||||||
|
session = get_session(conn)
|
||||||
|
|
||||||
|
{init_test_session(build_conn(), session), code, metadata}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,7 +62,8 @@ defmodule Livebook.Factory do
|
||||||
mode: :offline,
|
mode: :offline,
|
||||||
agent_keys: [],
|
agent_keys: [],
|
||||||
secrets: [],
|
secrets: [],
|
||||||
environment_variables: []
|
environment_variables: [],
|
||||||
|
authorization_groups: []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -123,6 +124,7 @@ defmodule Livebook.Factory do
|
||||||
access_type: :protected,
|
access_type: :protected,
|
||||||
hub_id: Livebook.Hubs.Personal.id(),
|
hub_id: Livebook.Hubs.Personal.id(),
|
||||||
deployment_group_id: "1",
|
deployment_group_id: "1",
|
||||||
|
authorization_groups: [],
|
||||||
deployed_by: "Ada Lovelace",
|
deployed_by: "Ada Lovelace",
|
||||||
deployed_at: deployed_at
|
deployed_at: deployed_at
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue