Add stamp to notebook source and persist allowed hub secrets (#1768)

This commit is contained in:
Jonatan Kłosko 2023-03-10 22:36:51 +01:00 committed by GitHub
parent ae797040cd
commit 7f71f2fe9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 542 additions and 200 deletions

View file

@ -52,12 +52,11 @@ defmodule Livebook.Application do
case Supervisor.start_link(children, opts) do
{:ok, _} = result ->
Livebook.Migration.migrate()
load_lb_env_vars()
clear_env_vars()
display_startup_info()
insert_personal_hub()
Livebook.Hubs.connect_hubs()
migrate_secrets()
deploy_apps()
result
@ -214,31 +213,6 @@ defmodule Livebook.Application do
defp app_specs, do: []
end
defp insert_personal_hub do
unless Livebook.Hubs.hub_exists?(Livebook.Hubs.Personal.id()) do
Livebook.Hubs.save_hub(%Livebook.Hubs.Personal{
id: Livebook.Hubs.Personal.id(),
hub_name: "My Hub",
hub_emoji: "🏠"
})
end
end
# TODO: Remove in the future
defp migrate_secrets do
for %{name: name, value: value} <- Livebook.Storage.all(:secrets) do
secret = %Livebook.Secrets.Secret{
name: name,
value: value,
hub_id: Livebook.Hubs.Personal.id(),
readonly: false
}
Livebook.Secrets.set_secret(secret)
Livebook.Storage.delete(:secrets, name)
end
end
defp deploy_apps() do
if apps_path = Livebook.Config.apps_path() do
Livebook.Apps.deploy_apps_in_dir(apps_path, password: Livebook.Config.apps_path_password())

View file

@ -246,6 +246,24 @@ defmodule Livebook.Hubs do
Provider.delete_secret(hub, secret)
end
@doc """
Generates a notebook stamp.
"""
@spec notebook_stamp(Provider.t(), iodata(), map()) ::
{:ok, Provider.notebook_stamp()} | :skip | :error
def notebook_stamp(hub, notebook_source, metadata) do
Provider.notebook_stamp(hub, notebook_source, metadata)
end
@doc """
Verifies a notebook stamp and returns the decrypted metadata.
"""
@spec verify_notebook_stamp(Provider.t(), iodata(), Provider.notebook_stamp()) ::
{:ok, metadata :: map()} | :error
def verify_notebook_stamp(hub, notebook_source, stamp) do
Provider.verify_notebook_stamp(hub, notebook_source, stamp)
end
@doc """
Checks the hub capability for given hub.
"""

View file

@ -185,4 +185,11 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Enterprise do
reason = EnterpriseClient.get_connection_error(enterprise.id)
"Cannot connect to Hub: #{reason}. Will attempt to reconnect automatically..."
end
# TODO: implement signing through the enterprise server
def notebook_stamp(_hub, _notebook_source, _metadata) do
:skip
end
def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented")
end

View file

@ -162,4 +162,10 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Fly do
def delete_secret(_fly, _secret), do: :ok
def connection_error(_fly), do: raise("not implemented")
def notebook_stamp(_hub, _notebook_source, _metadata) do
:skip
end
def verify_notebook_stamp(_hub, _notebook_source, _stamp), do: raise("not implemented")
end

View file

@ -9,15 +9,17 @@ defmodule Livebook.Hubs.Personal do
@type t :: %__MODULE__{
id: String.t() | nil,
hub_name: String.t() | nil,
hub_emoji: String.t() | nil
hub_emoji: String.t() | nil,
secret_key: String.t() | nil
}
embedded_schema do
field :hub_name, :string
field :hub_emoji, :string
field :secret_key, :string, redact: true
end
@fields ~w(hub_name hub_emoji)a
@fields ~w(hub_name hub_emoji secret_key)a
@doc """
The personal hub fixed id.
@ -83,6 +85,14 @@ defmodule Livebook.Hubs.Personal do
def set_startup_secrets(secrets) do
:persistent_term.put(@secret_startup_key, secrets)
end
@doc """
Generates a random secret key used for stamping the notebook.
"""
@spec generate_secret_key() :: String.t()
def generate_secret_key() do
:crypto.strong_rand_bytes(64) |> Base.url_encode64(padding: false)
end
end
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Personal do
@ -90,7 +100,13 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Personal do
alias Livebook.Secrets
def load(personal, fields) do
%{personal | id: fields.id, hub_name: fields.hub_name, hub_emoji: fields.hub_emoji}
%{
personal
| id: fields.id,
hub_name: fields.hub_name,
hub_emoji: fields.hub_emoji,
secret_key: fields.secret_key
}
end
def to_metadata(personal) do
@ -131,4 +147,51 @@ defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Personal do
end
def connection_error(_personal), do: raise("not implemented")
def notebook_stamp(_hub, _notebook_source, metadata) when metadata == %{} do
:skip
end
def notebook_stamp(hub, notebook_source, metadata) do
# We use AES-GCM-128 to encrypt metadata and generate signature
# for both metadata and the notebook source. We make use of the
# implementation in MessageEncryptor, which conveniently returns
# a single token
{secret, sign_secret} = derive_keys(hub.secret_key)
payload = :erlang.term_to_binary(metadata)
token = Plug.Crypto.MessageEncryptor.encrypt(payload, notebook_source, secret, sign_secret)
stamp = %{"version" => 1, "token" => token}
{:ok, stamp}
end
def verify_notebook_stamp(hub, notebook_source, stamp) do
%{"version" => 1, "token" => token} = stamp
{secret, sign_secret} = derive_keys(hub.secret_key)
case Plug.Crypto.MessageEncryptor.decrypt(token, notebook_source, secret, sign_secret) do
{:ok, payload} ->
metadata = Plug.Crypto.non_executable_binary_to_term(payload)
{:ok, metadata}
:error ->
:error
end
end
defp derive_keys(secret_key) do
binary_key = Base.url_decode64!(secret_key, padding: false)
<<secret::16-bytes, sign_secret::16-bytes>> =
Plug.Crypto.KeyGenerator.generate(binary_key, "notebook signing",
length: 32,
cache: Plug.Crypto.Keys
)
{secret, sign_secret}
end
end

View file

@ -3,10 +3,28 @@ defprotocol Livebook.Hubs.Provider do
alias Livebook.Secrets.Secret
@type t :: Livebook.Hubs.Enterprise.t() | Livebook.Hubs.Fly.t() | Livebook.Hubs.Personal.t()
@type capability :: :connect | :list_secrets | :create_secret
@type capabilities :: list(capability())
@type changeset_errors :: %{required(:errors) => list({String.t(), {Stirng.t(), list()}})}
@type changeset_errors :: %{required(:errors) => list({String.t(), {String.t(), list()}})}
@typedoc """
An provider-specific map stored as notebook stamp.
Notebook stamp is meant to serve two purposes:
1. Signing notebook source to ensure notebook integrity and
authenticity.
2. Storing sensitive notebook metadata in encrypted form, so that
it cannot be read nor modified outside of Livebook.
Those can be achieved using arbitrary cryptography mechanics. The
stamp itself is an opaque map, however it must be JSON-compatible
and use string keys.
"""
@type notebook_stamp :: map()
@doc """
Transforms given hub to `Livebook.Hubs.Metadata` struct.
@ -73,4 +91,21 @@ defprotocol Livebook.Hubs.Provider do
"""
@spec connection_error(t()) :: String.t() | nil
def connection_error(hub)
@doc """
Generates a notebook stamp.
See `t:notebook_stamp/0` for more details.
"""
@spec notebook_stamp(t(), iodata(), map()) :: {:ok, notebook_stamp()} | :skip | :error
def notebook_stamp(hub, notebook_source, metadata)
@doc """
Verifies a notebook stamp and returns the decrypted metadata.
See `t:notebook_stamp/0` for more details.
"""
@spec verify_notebook_stamp(t(), iodata(), notebook_stamp()) ::
{:ok, metadata :: map()} | :error
def verify_notebook_stamp(hub, notebook_source, stamp)
end

View file

@ -23,10 +23,12 @@ defmodule Livebook.LiveMarkdown do
# 5. Comments of the form `<!-- livebook:json_object -->` hold Livebook data
# and may be one of the following:
#
# * a description of a notebook object that cannot be naturally encoded
# * description of a notebook object that cannot be naturally encoded
# using Markdown, in such case the JSON contains a "livebook_object" field
#
# * a metadata that may appear anywhere and applies to the element
# * notebook stamp data with `"stamp"` and `"offset"` fields
#
# * metadata that may appear anywhere and applies to the element
# it directly precedes, recognised metadatas are:
#
# - `{"force_markdown":true}` - an annotation forcing the next Markdown

View file

@ -11,8 +11,13 @@ defmodule Livebook.LiveMarkdown.Export do
ctx = %{include_outputs?: include_outputs?, js_ref_with_data: js_ref_with_data}
iodata = render_notebook(notebook, ctx)
# Add trailing newline
IO.iodata_to_binary([iodata, "\n"])
notebook_source = [iodata, "\n"]
notebook_footer = render_notebook_footer(notebook, notebook_source)
IO.iodata_to_binary([notebook_source, notebook_footer])
end
defp collect_js_output_data(notebook) do
@ -68,7 +73,7 @@ defmodule Livebook.LiveMarkdown.Export do
end
defp notebook_metadata(notebook) do
keys = [:persist_outputs, :autosave_interval_s]
keys = [:persist_outputs, :autosave_interval_s, :hub_id]
metadata = put_unless_default(%{}, Map.take(notebook, keys), Map.take(Notebook.new(), keys))
app_settings_metadata = app_settings_metadata(notebook.app_settings)
@ -245,7 +250,7 @@ defmodule Livebook.LiveMarkdown.Export do
defp render_metadata(metadata) do
metadata_json = Jason.encode!(metadata)
"<!-- livebook:#{metadata_json} -->"
["<!-- livebook:", metadata_json, " -->"]
end
defp prepend_metadata(iodata, metadata) when metadata == %{}, do: iodata
@ -312,4 +317,22 @@ defmodule Livebook.LiveMarkdown.Export do
|> elem(0)
|> Enum.map(fn {_modifiers, string} -> string end)
end
defp render_notebook_footer(notebook, notebook_source) do
metadata = notebook_stamp_metadata(notebook)
with {:ok, hub} <- Livebook.Hubs.get_hub(notebook.hub_id),
{:ok, stamp} <- Livebook.Hubs.notebook_stamp(hub, notebook_source, metadata) do
offset = IO.iodata_length(notebook_source)
json = Jason.encode!(%{"offset" => offset, "stamp" => stamp})
["\n", "<!-- livebook:", json, " -->", "\n"]
else
_ -> []
end
end
defp notebook_stamp_metadata(notebook) do
keys = [:hub_secret_names]
put_unless_default(%{}, Map.take(notebook, keys), Map.take(Notebook.new(), keys))
end
end

View file

@ -8,10 +8,16 @@ defmodule Livebook.LiveMarkdown.Import do
{ast, rewrite_messages} = rewrite_ast(ast)
elements = group_elements(ast)
{stamp_data, elements} = take_stamp_data(elements)
{notebook, build_messages} = build_notebook(elements)
{notebook, postprocess_messages} = postprocess_notebook(notebook)
{notebook, metadata_messages} = postprocess_stamp(notebook, markdown, stamp_data)
{notebook, earmark_messages ++ rewrite_messages ++ build_messages ++ postprocess_messages}
messages =
earmark_messages ++
rewrite_messages ++ build_messages ++ postprocess_messages ++ metadata_messages
{notebook, messages}
end
defp earmark_message_to_string({_severity, line_number, message}) do
@ -171,6 +177,9 @@ defmodule Livebook.LiveMarkdown.Import do
%{"livebook_object" => "smart_cell"} ->
{:cell, :smart, data}
%{"stamp" => _} ->
{:stamp, data}
_ ->
{:metadata, data}
end
@ -311,7 +320,8 @@ defmodule Livebook.LiveMarkdown.Import do
]
end
attrs = notebook_metadata_to_attrs(metadata)
{attrs, metadata_messages} = notebook_metadata_to_attrs(metadata)
messages = messages ++ metadata_messages
# We identify a single leading cell as the setup cell, in any
# other case all extra cells are put in a default section
@ -361,24 +371,31 @@ defmodule Livebook.LiveMarkdown.Import do
defp grab_leading_comments(elems), do: {[], elems}
defp notebook_metadata_to_attrs(metadata) do
Enum.reduce(metadata, %{}, fn
{"persist_outputs", persist_outputs}, attrs ->
Map.put(attrs, :persist_outputs, persist_outputs)
Enum.reduce(metadata, {%{}, []}, fn
{"persist_outputs", persist_outputs}, {attrs, messages} ->
{Map.put(attrs, :persist_outputs, persist_outputs), messages}
{"autosave_interval_s", autosave_interval_s}, attrs ->
Map.put(attrs, :autosave_interval_s, autosave_interval_s)
{"autosave_interval_s", autosave_interval_s}, {attrs, messages} ->
{Map.put(attrs, :autosave_interval_s, autosave_interval_s), messages}
{"app_settings", app_settings_metadata}, attrs ->
{"hub_id", hub_id}, {attrs, messages} ->
if Livebook.Hubs.hub_exists?(hub_id) do
{Map.put(attrs, :hub_id, hub_id), messages}
else
{attrs, messages ++ ["ignoring notebook Hub with unknown id"]}
end
{"app_settings", app_settings_metadata}, {attrs, messages} ->
app_settings =
Map.merge(
Notebook.AppSettings.new(),
app_settings_metadata_to_attrs(app_settings_metadata)
)
Map.put(attrs, :app_settings, app_settings)
{Map.put(attrs, :app_settings, app_settings), messages}
_entry, attrs ->
attrs
_entry, {attrs, messages} ->
{attrs, messages}
end)
end
@ -471,4 +488,40 @@ defmodule Livebook.LiveMarkdown.Import do
{%{notebook | sections: sections}, Enum.reverse(warnings)}
end
defp take_stamp_data([{:stamp, data} | elements]), do: {data, elements}
defp take_stamp_data(elements), do: {nil, elements}
defp postprocess_stamp(notebook, _notebook_source, nil), do: {notebook, []}
defp postprocess_stamp(notebook, notebook_source, stamp_data) do
hub = Livebook.Hubs.fetch_hub!(notebook.hub_id)
with %{"offset" => offset, "stamp" => stamp} <- stamp_data,
{:ok, notebook_source} <- safe_binary_slice(notebook_source, 0, offset),
{:ok, metadata} <- Livebook.Hubs.verify_notebook_stamp(hub, notebook_source, stamp) do
notebook = apply_stamp_metadata(notebook, metadata)
{notebook, []}
else
_ -> {notebook, ["failed to verify notebook stamp"]}
end
end
defp safe_binary_slice(binary, start, size)
when byte_size(binary) < start + size,
do: :error
defp safe_binary_slice(binary, start, size) do
{:ok, binary_slice(binary, start, size)}
end
defp apply_stamp_metadata(notebook, metadata) do
Enum.reduce(metadata, notebook, fn
{:hub_secret_names, hub_secret_names}, notebook ->
%{notebook | hub_secret_names: hub_secret_names}
_entry, notebook ->
notebook
end)
end
end

45
lib/livebook/migration.ex Normal file
View file

@ -0,0 +1,45 @@
defmodule Livebook.Migration do
@moduledoc false
@doc """
Runs all migrations.
"""
@spec migrate() :: :ok
def migrate() do
insert_personal_hub()
move_app_secrets_to_personal_hub()
add_personal_hub_secret_key()
end
defp insert_personal_hub() do
unless Livebook.Hubs.hub_exists?(Livebook.Hubs.Personal.id()) do
Livebook.Hubs.save_hub(%Livebook.Hubs.Personal{
id: Livebook.Hubs.Personal.id(),
hub_name: "My Hub",
hub_emoji: "🏠",
secret_key: Livebook.Hubs.Personal.generate_secret_key()
})
end
end
defp move_app_secrets_to_personal_hub() do
for %{name: name, value: value} <- Livebook.Storage.all(:secrets) do
secret = %Livebook.Secrets.Secret{
name: name,
value: value,
hub_id: Livebook.Hubs.Personal.id(),
readonly: false
}
Livebook.Secrets.set_secret(secret)
Livebook.Storage.delete(:secrets, name)
end
end
defp add_personal_hub_secret_key() do
with :error <- Livebook.Storage.fetch_key(:hubs, Livebook.Hubs.Personal.id(), :secret_key) do
secret_key = Livebook.Hubs.Personal.generate_secret_key()
Livebook.Storage.insert(:hubs, Livebook.Hubs.Personal.id(), secret_key: secret_key)
end
end
end

View file

@ -22,7 +22,9 @@ defmodule Livebook.Notebook do
:persist_outputs,
:autosave_interval_s,
:output_counter,
:app_settings
:app_settings,
:hub_id,
:hub_secret_names
]
alias Livebook.Notebook.{Section, Cell, AppSettings}
@ -38,7 +40,9 @@ defmodule Livebook.Notebook do
persist_outputs: boolean(),
autosave_interval_s: non_neg_integer() | nil,
output_counter: non_neg_integer(),
app_settings: AppSettings.t()
app_settings: AppSettings.t(),
hub_id: String.t(),
hub_secret_names: list(String.t())
}
@version "1.0"
@ -57,7 +61,9 @@ defmodule Livebook.Notebook do
persist_outputs: default_persist_outputs(),
autosave_interval_s: default_autosave_interval_s(),
output_counter: 0,
app_settings: AppSettings.new()
app_settings: AppSettings.new(),
hub_id: Livebook.Hubs.Personal.id(),
hub_secret_names: []
}
|> put_setup_cell(Cell.new(:code))
end

View file

@ -7,7 +7,7 @@ defmodule Livebook.Secrets.Secret do
name: String.t() | nil,
value: String.t() | nil,
hub_id: String.t() | nil,
readonly: boolean()
readonly: boolean() | nil
}
@primary_key {:name, :string, autogenerate: false}

View file

@ -720,6 +720,7 @@ defmodule Livebook.Session do
@impl true
def init(opts) do
Livebook.Settings.subscribe()
Livebook.Hubs.subscribe([:secrets])
id = Keyword.fetch!(opts, :id)
{:ok, worker_pid} = Livebook.Session.Worker.start_link(id)
@ -1500,6 +1501,13 @@ defmodule Livebook.Session do
{:stop, :shutdown, state}
end
def handle_info({event, secret}, state)
when event in [:secret_created, :secret_updated, :secret_deleted] and
secret.hub_id == state.data.notebook.hub_id do
operation = {:sync_hub_secrets, @client_id}
{:noreply, handle_operation(state, operation)}
end
def handle_info(_message, state), do: {:noreply, state}
@impl true

View file

@ -32,14 +32,13 @@ defmodule Livebook.Session.Data do
:clients_map,
:users_map,
:secrets,
:hub_secrets,
:mode,
:apps,
:app_data,
:hub
:app_data
]
alias Livebook.{Notebook, Delta, Runtime, JSInterop, FileSystem}
alias Livebook.Hubs.Provider
alias Livebook.{Notebook, Delta, Runtime, JSInterop, FileSystem, Hubs}
alias Livebook.Users.User
alias Livebook.Notebook.{Cell, Section, AppSettings}
alias Livebook.Utils.Graph
@ -58,10 +57,10 @@ defmodule Livebook.Session.Data do
clients_map: %{client_id() => User.id()},
users_map: %{User.id() => User.t()},
secrets: %{(name :: String.t()) => value :: String.t()},
hub_secrets: list(Livebook.Secrets.Secret.t()),
mode: session_mode(),
apps: list(app()),
app_data: nil | app_data(),
hub: Provider.t()
app_data: nil | app_data()
}
@type section_info :: %{
@ -214,6 +213,8 @@ defmodule Livebook.Session.Data do
| {:mark_as_not_dirty, client_id()}
| {:set_secret, client_id(), secret()}
| {:unset_secret, client_id(), String.t()}
| {:set_notebook_hub, client_id(), String.t()}
| {:sync_hub_secrets, client_id()}
| {:set_app_settings, client_id(), AppSettings.t()}
| {:add_app, client_id(), Livebook.Session.id(), pid()}
| {:set_app_status, client_id(), Livebook.Session.id(), app_status()}
@ -221,7 +222,6 @@ defmodule Livebook.Session.Data do
| {:delete_app, client_id(), Livebook.Session.id()}
| {:app_unregistered, client_id()}
| {:app_stop, client_id()}
| {:set_notebook_hub, client_id(), String.t()}
@type action ::
:connect_runtime
@ -261,6 +261,15 @@ defmodule Livebook.Session.Data do
%{status: :booting, registered: false}
end
hub = Hubs.fetch_hub!(notebook.hub_id)
hub_secrets = Hubs.get_secrets(hub)
secrets =
for secret <- hub_secrets,
secret.name in notebook.hub_secret_names,
do: {secret.name, secret.value},
into: %{}
data = %__MODULE__{
notebook: notebook,
origin: opts[:origin],
@ -274,11 +283,11 @@ defmodule Livebook.Session.Data do
smart_cell_definitions: [],
clients_map: %{},
users_map: %{},
secrets: %{},
secrets: secrets,
hub_secrets: hub_secrets,
mode: opts[:mode],
apps: [],
app_data: app_data,
hub: Livebook.Hubs.fetch_hub!(Livebook.Hubs.Personal.id())
app_data: app_data
}
data
@ -830,6 +839,7 @@ defmodule Livebook.Session.Data do
data
|> with_actions()
|> set_secret(secret)
|> update_notebook_hub_secret_names()
|> wrap_ok()
end
@ -837,6 +847,29 @@ defmodule Livebook.Session.Data do
data
|> with_actions()
|> unset_secret(secret_name)
|> update_notebook_hub_secret_names()
|> wrap_ok()
end
def apply_operation(data, {:set_notebook_hub, _client_id, id}) do
with {:ok, hub} <- Hubs.get_hub(id) do
data
|> with_actions()
|> set_notebook_hub(hub)
|> update_notebook_hub_secret_names()
|> set_dirty()
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:sync_hub_secrets, _client_id}) do
data
|> with_actions()
|> sync_hub_secrets()
|> update_notebook_hub_secret_names()
|> set_dirty()
|> wrap_ok()
end
@ -912,13 +945,6 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:set_notebook_hub, _client_id, id}) do
data
|> with_actions()
|> set_notebook_hub(id)
|> wrap_ok()
end
# ===
defp with_actions(data, actions \\ []), do: {data, actions}
@ -1622,6 +1648,27 @@ defmodule Livebook.Session.Data do
|> set!(notebook: %{data.notebook | name: name})
end
defp set_notebook_hub({data, _} = data_actions, hub) do
data_actions
|> set!(
notebook: %{data.notebook | hub_id: hub.id},
hub_secrets: Hubs.get_secrets(hub)
)
end
defp sync_hub_secrets({data, _} = data_actions) do
hub = Livebook.Hubs.fetch_hub!(data.notebook.hub_id)
secrets = Livebook.Hubs.get_secrets(hub)
set!(data_actions, hub_secrets: secrets)
end
defp update_notebook_hub_secret_names({data, _} = data_actions) do
hub_secret_names =
for secret <- data.hub_secrets, data.secrets[secret.name] == secret.value, do: secret.name
set!(data_actions, notebook: %{data.notebook | hub_secret_names: hub_secret_names})
end
defp set_section_name({data, _} = data_actions, section, name) do
data_actions
|> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name}))
@ -1835,11 +1882,6 @@ defmodule Livebook.Session.Data do
end
end
defp set_notebook_hub(data_actions, id) do
hub = Livebook.Hubs.fetch_hub!(id)
set!(data_actions, hub: hub)
end
defp set_smart_cell_definitions(data_actions, smart_cell_definitions) do
data_actions
|> set!(smart_cell_definitions: smart_cell_definitions)

View file

@ -8,7 +8,6 @@ defmodule LivebookWeb.SessionLive do
alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown}
alias Livebook.Notebook.{Cell, ContentLoader}
alias Livebook.JSInterop
alias Livebook.Hubs
on_mount LivebookWeb.SidebarHook
@ -24,7 +23,6 @@ defmodule LivebookWeb.SessionLive do
Session.register_client(session_pid, self(), socket.assigns.current_user)
Session.subscribe(session_id)
Hubs.subscribe(:secrets)
Livebook.NotebookManager.subscribe_starred_notebooks()
{data, client_id}
@ -60,7 +58,6 @@ defmodule LivebookWeb.SessionLive do
data_view: data_to_view(data),
autofocus_cell_id: autofocus_cell_id(data.notebook),
page_title: get_page_title(data.notebook.name),
saved_secrets: Hubs.get_secrets(data.hub),
select_secret_ref: nil,
select_secret_options: nil,
allowed_uri_schemes: Livebook.Config.allowed_uri_schemes(),
@ -199,8 +196,8 @@ defmodule LivebookWeb.SessionLive do
module={LivebookWeb.SessionLive.SecretsListComponent}
id="secrets-list"
session={@session}
saved_secrets={@saved_secrets}
hub={@data_view.notebook_hub}
saved_secrets={@data_view.hub_secrets}
hub={@data_view.hub}
secrets={@data_view.secrets}
/>
</div>
@ -312,11 +309,11 @@ defmodule LivebookWeb.SessionLive do
<:toggle>
<div
class="inline-flex items-center group cursor-pointer gap-1 mt-1 text-sm text-gray-600 hover:text-gray-800 focus:text-gray-800"
aria-label={@data_view.notebook_hub.hub_name}
aria-label={@data_view.hub.hub_name}
>
<span>in</span>
<span class="text-lg pl-1"><%= @data_view.notebook_hub.hub_emoji %></span>
<span><%= @data_view.notebook_hub.hub_name %></span>
<span class="text-lg pl-1"><%= @data_view.hub.hub_emoji %></span>
<span><%= @data_view.hub.hub_name %></span>
<.remix_icon icon="arrow-down-s-line" class="invisible group-hover:visible" />
</div>
</:toggle>
@ -517,8 +514,8 @@ defmodule LivebookWeb.SessionLive do
id="secrets"
session={@session}
secrets={@data_view.secrets}
hub={@data_view.notebook_hub}
saved_secrets={@saved_secrets}
hub={@data_view.hub}
saved_secrets={@data_view.hub_secrets}
prefill_secret_name={@prefill_secret_name}
select_secret_ref={@select_secret_ref}
select_secret_options={@select_secret_options}
@ -1220,33 +1217,6 @@ defmodule LivebookWeb.SessionLive do
{:noreply, handle_operation(socket, operation)}
end
def handle_info({:secret_created, _secret}, socket) do
{:noreply,
socket
|> refresh_secrets()
|> put_flash(:info, "A new secret has been created on your Livebook Hub")}
end
def handle_info({:secret_updated, _secret}, socket) do
{:noreply,
socket
|> refresh_secrets()
|> put_flash(:info, "An existing secret has been updated on your Livebook Hub")}
end
def handle_info({:secret_deleted, _secret}, socket) do
{:noreply,
socket
|> refresh_secrets()
|> put_flash(:info, "An existing secret has been deleted on your Livebook Hub")}
end
def handle_info(:hub_changed, socket) do
Session.set_notebook_hub(socket.assigns.session.pid, socket.private.data.hub.id)
{:noreply, refresh_secrets(socket)}
end
def handle_info({:error, error}, socket) do
message = error |> to_string() |> upcase_first()
@ -1635,9 +1605,6 @@ defmodule LivebookWeb.SessionLive do
prune_cell_sources(socket)
end
defp after_operation(socket, _prev_socket, {:set_notebook_hub, _client_id, _id}),
do: refresh_secrets(socket)
defp after_operation(socket, _prev_socket, _operation), do: socket
defp handle_actions(socket, actions) do
@ -1816,10 +1783,11 @@ defmodule LivebookWeb.SessionLive do
section_views: section_views(data.notebook.sections, data),
bin_entries: data.bin_entries,
secrets: data.secrets,
hub: Livebook.Hubs.fetch_hub!(data.notebook.hub_id),
hub_secrets: data.hub_secrets,
apps_status: apps_status(data),
app_settings: data.notebook.app_settings,
apps: data.apps,
notebook_hub: data.hub
apps: data.apps
}
end
@ -2086,7 +2054,4 @@ defmodule LivebookWeb.SessionLive do
defp app_status_color(:error), do: "bg-red-400"
defp app_status_color(:shutting_down), do: "bg-gray-500"
defp app_status_color(:stopped), do: "bg-gray-500"
defp refresh_secrets(socket),
do: assign(socket, saved_secrets: Hubs.get_secrets(socket.private.data.hub))
end

View file

@ -27,7 +27,7 @@
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
"protobuf": {:hex, :protobuf, "0.8.0", "61b27d6fd50e7b1b2eb0ee17c1f639906121f4ef965ae0994644eb4c68d4647d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3644ed846fd6f5e3b5c2cd617aa8344641e230edf812a45365fee7622bccd25a"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},

View file

@ -35,8 +35,13 @@ defmodule Livebook.Hubs.ProviderTest do
end
test "get_secrets/1 with startup secrets", %{hub: hub} do
secret = build(:secret, name: "GET_PERSONAL_SECRET", readonly: true)
Livebook.Hubs.Personal.set_startup_secrets([secret])
# Set in test_helper.exs
secret = %Livebook.Secrets.Secret{
name: "STARTUP_SECRET",
value: "value",
hub_id: Livebook.Hubs.Personal.id(),
readonly: true
}
assert secret in Provider.get_secrets(hub)
end

View file

@ -1121,6 +1121,26 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document
end
test "persists hub id when not default" do
Livebook.Factory.insert_hub(:enterprise, id: "enterprise-persisted-id")
notebook = %{
Notebook.new()
| name: "My Notebook",
hub_id: "enterprise-persisted-id"
}
expected_document = """
<!-- livebook:{"hub_id":"enterprise-persisted-id"} -->
# My Notebook
"""
document = Export.notebook_to_livemd(notebook)
assert expected_document == document
end
describe "app settings" do
test "persists non-default app settings" do
notebook = %{
@ -1190,6 +1210,44 @@ defmodule Livebook.LiveMarkdown.ExportTest do
end
end
test "notebook stamp is appended at the end" do
notebook = %{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| source: """
IO.puts("hey")
"""
}
]
}
],
hub_secret_names: ["DB_PASSWORD"]
}
expected_document = ~R"""
# My Notebook
## Section 1
```elixir
IO.puts\("hey"\)
```
<!-- livebook:{"offset":58,"stamp":(.*)} -->
"""
document = Export.notebook_to_livemd(notebook)
assert document =~ expected_document
end
defp spawn_widget_with_data(ref, data) do
spawn(fn ->
receive do

View file

@ -720,6 +720,33 @@ defmodule Livebook.LiveMarkdown.ImportTest do
assert %Notebook{name: "My Notebook", autosave_interval_s: 10} = notebook
end
test "imports notebook hub id when exists" do
Livebook.Factory.insert_hub(:fly, id: "enterprise-persisted-id")
markdown = """
<!-- livebook:{"hub_id":"enterprise-persisted-id"} -->
# My Notebook
"""
{notebook, []} = Import.notebook_from_livemd(markdown)
assert %Notebook{name: "My Notebook", hub_id: "enterprise-persisted-id"} = notebook
end
test "imports ignores hub id when does not exist" do
markdown = """
<!-- livebook:{"hub_id":"nonexistent"} -->
# My Notebook
"""
{notebook, messages} = Import.notebook_from_livemd(markdown)
assert messages == ["ignoring notebook Hub with unknown id"]
assert notebook.hub_id != "nonexistent"
end
describe "app settings" do
test "imports settings" do
markdown = """
@ -998,4 +1025,68 @@ defmodule Livebook.LiveMarkdown.ImportTest do
} = notebook
end
end
describe "notebook stamp" do
test "restores hub secret names from notebook stamp" do
# Generated with:
# %{
# Notebook.new()
# | name: "My Notebook",
# sections: [
# %{
# Notebook.Section.new()
# | name: "Section 1",
# cells: [
# %{
# Notebook.Cell.new(:code)
# | source: """
# IO.puts("hey")
# """
# }
# ]
# }
# ],
# hub_secret_names: ["DB_PASSWORD"]
# }
# |> Livebook.LiveMarkdown.Export.notebook_to_livemd()
# |> IO.puts()
markdown = """
# My Notebook
## Section 1
```elixir
IO.puts("hey")
```
<!-- livebook:{"offset":58,"stamp":{"token":"QTEyOEdDTQ.LF8LTeMYrtq8S7wsKMmk2YgOQzMAkEKT2d8fq1Gz3Ot1mydOgEZ1B4hcEZc.Wec6NwBQ584kE661.a_N-5jDiWrjhHha9zxHQ6JJOmxeqgiya3m6YlKt1Na_DPnEfXyLnengaUzQSrf8.ZoD5r6-H87RpTyvFkvEOQw","version":1}} -->
"""
{notebook, []} = Import.notebook_from_livemd(markdown)
assert %Notebook{hub_secret_names: ["DB_PASSWORD"]} = notebook
end
test "returns a warning when notebook stamp is invalid" do
markdown = """
# My Notebook
## Section 1
```elixir
IO.puts("hey")
```
<!-- livebook:{"offset":58,"stamp":{"token":"invalid","version":1}} -->
"""
{notebook, messages} = Import.notebook_from_livemd(markdown)
assert %Notebook{hub_secret_names: []} = notebook
assert messages == ["failed to verify notebook stamp"]
end
end
end

View file

@ -1250,82 +1250,6 @@ defmodule LivebookWeb.SessionLiveTest do
assert output == "\e[32m\"#{secret.value}\"\e[0m"
end
test "loads secret from temporary storage", %{conn: conn, session: session} do
secret = build(:secret, name: "FOOBARBAZ", value: "ChonkyCat", readonly: true)
Livebook.Hubs.Personal.set_startup_secrets([secret])
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
# Subscribe and executes the code to trigger
# the `System.EnvError` exception and outputs the 'Add secret' button
Session.subscribe(session.id)
section_id = insert_section(session.pid)
code = ~s{System.fetch_env!("LB_#{secret.name}")}
cell_id = insert_text_cell(session.pid, section_id, :code, code)
# Sets the secret directly
Session.set_secret(session.pid, secret)
# Checks if the secret exists and is inside the session,
# then executes the code cell again and checks if the
# secret value is what we expected.
assert_session_secret(view, session.pid, secret)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, output}, _}}
assert output == "\e[32m\"#{secret.value}\"\e[0m"
end
test "granting access for unavailable startup secret using 'Add secret' button",
%{conn: conn, session: session, hub: hub} do
secret = build(:secret, name: "MYSTARTUPSECRET", value: "ChonkyCat", readonly: true)
Livebook.Hubs.Personal.set_startup_secrets([secret])
# Subscribe and executes the code to trigger
# the `System.EnvError` exception and outputs the 'Add secret' button
Session.subscribe(session.id)
section_id = insert_section(session.pid)
code = ~s{System.fetch_env!("LB_#{secret.name}")}
cell_id = insert_text_cell(session.pid, section_id, :code, code)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:add_cell_evaluation_response, _, ^cell_id, _, _}}
# Enters the session to check if the button exists
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
expected_url = ~p"/sessions/#{session.id}/secrets?secret_name=#{secret.name}"
add_secret_button = element(view, "a[href='#{expected_url}']")
assert has_element?(add_secret_button)
# Checks if the secret is persisted
assert secret in Livebook.Hubs.get_secrets(hub)
# Clicks the button and checks if the 'Grant access' banner
# is being shown, so clicks it's button to set the app secret
# to the session, allowing the user to fetches the secret.
render_click(add_secret_button)
secrets_component = with_target(view, "#secrets-modal")
assert render(secrets_component) =~
"in #{hub_label(secret)}. Allow this session to access it?"
grant_access_button = element(secrets_component, "button", "Grant access")
render_click(grant_access_button)
# Checks if the secret exists and is inside the session,
# then executes the code cell again and checks if the
# secret value is what we expected.
assert_session_secret(view, session.pid, secret)
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, {:text, output}, _}}
assert output == "\e[32m\"#{secret.value}\"\e[0m"
end
end
describe "environment variables" do
@ -1489,14 +1413,14 @@ defmodule LivebookWeb.SessionLiveTest do
Session.subscribe(session.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
assert %Livebook.Hubs.Personal{id: ^personal_id} = Session.get_data(session.pid).hub
assert Session.get_data(session.pid).notebook.hub_id == personal_id
view
|> element(~s/#select-hub-#{id}/)
|> render_click()
assert_receive {:operation, {:set_notebook_hub, _, ^id}}
assert Session.get_data(session.pid).hub == hub
assert Session.get_data(session.pid).notebook.hub_id == hub.id
end
end
end

View file

@ -40,6 +40,23 @@ Application.put_env(:livebook, Livebook.Runtime.Embedded,
# Disable autosaving
Livebook.Storage.insert(:settings, "global", autosave_path: nil)
# Set a global startup secret, so that there is at least one
Livebook.Hubs.Personal.set_startup_secrets([
%Livebook.Secrets.Secret{
name: "STARTUP_SECRET",
value: "value",
hub_id: Livebook.Hubs.Personal.id(),
readonly: true
}
])
# Always use the same secret key in tests
secret_key =
"5ji8DpnX761QAWXZwSl-2Y-mdW4yTcMimdOJ8SSxCh44wFE0jEbGBUf-VydKwnTLzBiAUedQKs3X_q1j_3lgrw"
personal_hub = Livebook.Hubs.fetch_hub!(Livebook.Hubs.Personal.id())
Livebook.Hubs.Personal.update_hub(personal_hub, %{secret_key: secret_key})
erl_docs_available? = Code.fetch_docs(:gen_server) != {:error, :chunk_not_found}
windows? = match?({:win32, _}, :os.type())