Support Zero Trust authentication (#1938)

Co-authored-by: José Valim <jose.valim@gmail.com>
Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
Cristine Guadelupe 2023-06-20 23:25:25 +01:00 committed by GitHub
parent 5d2a1f4a55
commit efb28fbdf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 341 additions and 33 deletions

View file

@ -242,6 +242,12 @@ The following environment variables can be used to configure Livebook on boot:
standard schemes by default (such as http and https). Set it to a comma-separated
list of schemes.
* LIVEBOOK_IDENTITY_PROVIDER - controls whether Zero Trust Identity is enabled.
Set it to your provider and the correspondent key to enable it.
Currently supported providers are Cloudflare and GoogleIap.
The respective keys are the team name (domain) for CloudFlare and the audience (aud) for GoogleIAP.
E.g. `"cloudflare:<your-team-name>"`, `"googleiap:<your-audience>`
<!-- Environment variables -->
When running Livebook Desktop, Livebook will invoke on boot a file named

View file

@ -194,6 +194,11 @@ defmodule Livebook do
if allowed_uri_schemes = Livebook.Config.allowed_uri_schemes!("LIVEBOOK_ALLOW_URI_SCHEMES") do
config :livebook, :allowed_uri_schemes, allowed_uri_schemes
end
config :livebook,
:identity_provider,
Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") ||
{LivebookWeb.Cookies, :unused}
end
@doc """

View file

@ -44,6 +44,7 @@ defmodule Livebook.Application do
{DynamicSupervisor, name: Livebook.HubsSupervisor, strategy: :one_for_one}
] ++
iframe_server_specs() ++
identity_provider() ++
[
# Start the Endpoint (http/https)
# We skip the access url as we do our own logging below
@ -267,4 +268,9 @@ defmodule Livebook.Application do
"Failed to start Livebook iframe server because port #{port} is already in use"
)
end
defp identity_provider() do
{module, key} = Livebook.Config.identity_provider()
[{module, name: LivebookWeb.ZTA, identity: [key: key]}]
end
end

View file

@ -168,6 +168,22 @@ defmodule Livebook.Config do
Application.fetch_env!(:livebook, :shutdown_callback)
end
@doc """
Returns the identity provider.
"""
@spec identity_provider() :: tuple()
def identity_provider() do
Application.fetch_env!(:livebook, :identity_provider)
end
@doc """
Returns if the identity data is readonly.
"""
@spec identity_readonly?() :: boolean()
def identity_readonly?() do
not match?({LivebookWeb.Cookies, _}, Livebook.Config.identity_provider())
end
@doc """
Returns whether the application is running inside an iframe.
"""
@ -493,4 +509,23 @@ defmodule Livebook.Config do
IO.puts("\nERROR!!! [Livebook] " <> message)
System.halt(1)
end
@doc """
Parses zero trust identity provider from env.
"""
def identity_provider!(env) do
case System.get_env(env) do
"googleiap:" <> rest ->
{Livebook.ZTA.GoogleIAP, rest}
"cloudflare:" <> rest ->
{Livebook.ZTA.Cloudflare, rest}
nil ->
nil
_ ->
abort!("invalid configuration for identity provider")
end
end
end

View file

@ -0,0 +1,95 @@
defmodule Livebook.ZTA.Cloudflare do
@moduledoc false
use GenServer
require Logger
import Plug.Conn
@assertion "cf-access-jwt-assertion"
@renew_afer 24 * 60 * 60 * 1000
defstruct [:name, :req_options, :identity]
def start_link(opts) do
identity = identity(opts[:identity][:key])
options = [req_options: [url: identity.certs], identity: identity]
GenServer.start_link(__MODULE__, options, name: opts[:name])
end
def authenticate(name, conn) do
token = get_req_header(conn, @assertion)
GenServer.call(name, {:authenticate, token})
end
@impl true
def init(options) do
:ets.new(options[:name], [:public, :named_table])
{:ok, struct!(__MODULE__, options)}
end
@impl true
def handle_call(:get_keys, _from, state) do
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
{:reply, keys, state}
end
def handle_call({:authenticate, token}, _from, state) do
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
user = authenticate(token, state.identity, keys)
{:reply, user, state}
end
@impl true
def handle_info(:request, state) do
request_and_store_in_ets(state)
{:noreply, state}
end
defp request_and_store_in_ets(state) do
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
keys = Req.request!(state.req_options).body["keys"]
:ets.insert(state.name, keys: keys)
Process.send_after(self(), :request, @renew_afer)
keys
end
defp get_from_ets(name) do
case :ets.lookup(name, :keys) do
[keys: keys] -> keys
[] -> nil
end
end
defp authenticate(token, identity, keys) do
with [token] <- token,
{:ok, token} <- verify_token(token, keys),
:ok <- verify_iss(token, identity.iss) do
%{name: token.fields["email"]}
else
_ -> nil
end
end
defp verify_token(token, keys) do
Enum.find_value(keys, fn key ->
case JOSE.JWT.verify(key, token) do
{true, token, _s} -> {:ok, token}
{_, _t, _s} -> nil
end
end)
end
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
defp verify_iss(_, _), do: nil
defp identity(key) do
%{
key: key,
key_type: "domain",
iss: "https://#{key}.cloudflareaccess.com",
certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs",
assertion: "cf-access-jwt-assertion",
email: "cf-access-authenticated-user-email"
}
end
end

View file

@ -0,0 +1,95 @@
defmodule Livebook.ZTA.GoogleIAP do
@moduledoc false
use GenServer
require Logger
import Plug.Conn
@assertion "cf-access-jwt-assertion"
@renew_afer 24 * 60 * 60 * 1000
defstruct [:name, :req_options, :identity]
def start_link(opts) do
identity = identity(opts[:identity][:key])
options = [req_options: [url: identity.certs], identity: identity]
GenServer.start_link(__MODULE__, options, name: opts[:name])
end
def authenticate(name, conn) do
token = get_req_header(conn, @assertion)
GenServer.call(name, {:authenticate, token})
end
@impl true
def init(options) do
:ets.new(options[:name], [:public, :named_table])
{:ok, struct!(__MODULE__, options)}
end
@impl true
def handle_call(:get_keys, _from, state) do
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
{:reply, keys, state}
end
def handle_call({:authenticate, token}, _from, state) do
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
user = authenticate(token, state.identity, keys)
{:reply, user, state}
end
@impl true
def handle_info(:request, state) do
request_and_store_in_ets(state)
{:noreply, state}
end
defp request_and_store_in_ets(state) do
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
keys = Req.request!(state.req_options).body["keys"]
:ets.insert(state.name, keys: keys)
Process.send_after(self(), :request, @renew_afer)
keys
end
defp get_from_ets(name) do
case :ets.lookup(name, :keys) do
[keys: keys] -> keys
[] -> nil
end
end
defp authenticate(token, identity, keys) do
with [token] <- token,
{:ok, token} <- verify_token(token, keys),
:ok <- verify_iss(token, identity.iss) do
%{name: token.fields["email"]}
else
_ -> nil
end
end
defp verify_token(token, keys) do
Enum.find_value(keys, fn key ->
case JOSE.JWT.verify(key, token) do
{true, token, _s} -> {:ok, token}
{_, _t, _s} -> nil
end
end)
end
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
defp verify_iss(_, _), do: nil
defp identity(key) do
%{
key: key,
key_type: "aud",
iss: "https://cloud.google.com/iap",
certs: "https://www.gstatic.com/iap/verify/public_key",
assertion: "x-goog-iap-jwt-assertion",
email: "x-goog-authenticated-user-email"
}
end
end

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
<title><%= @status %> - Livebook</title>
<link rel="stylesheet" href={~p"/assets/app.css"} />
</head>
<body>
<div class="h-screen flex items-center justify-center bg-gray-900">
<div class="flex flex-col space-y-4 items-center">
<a href={~p"/"}>
<img src={~p"/images/logo.png"} height="128" width="128" alt="livebook" />
</a>
<div class="text-2xl text-gray-50">
No Numbats allowed here!
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
defmodule LivebookWeb.Cookies do
# This module implements the ZTA contract specific to Livebook cookies
@moduledoc false
def authenticate(_, _conn) do
%{}
end
def child_spec(_opts) do
%{id: __MODULE__, start: {Function, :identity, [:ignore]}}
end
end

View file

@ -35,9 +35,11 @@ defmodule LivebookWeb.UserHook do
defp build_current_user(session, socket) do
%{"current_user_id" => current_user_id} = session
user = %{User.new() | id: current_user_id}
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
connect_params = get_connect_params(socket) || %{}
attrs = connect_params["user_data"] || session["user_data"] || %{}
attrs = Map.merge(attrs, identity_data)
case Livebook.Users.update_user(user, attrs) do
{:ok, user} -> user

View file

@ -1732,8 +1732,7 @@ defmodule LivebookWeb.SessionLive do
end
defp after_operation(socket, _prev_socket, {:insert_cell, client_id, _, _, _, cell_id, _attrs}) do
{:ok, cell, _section} =
Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
{:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
socket = push_cell_editor_payloads(socket, socket.private.data, [cell])
@ -1764,8 +1763,7 @@ defmodule LivebookWeb.SessionLive do
end
defp after_operation(socket, _prev_socket, {:restore_cell, client_id, cell_id}) do
{:ok, cell, _section} =
Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
{:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
socket = push_cell_editor_payloads(socket, socket.private.data, [cell])

View file

@ -35,7 +35,12 @@ defmodule LivebookWeb.UserComponent do
phx-hook="UserForm"
>
<div class="flex flex-col space-y-5">
<.text_field field={f[:name]} label="Display name" spellcheck="false" />
<.text_field
field={f[:name]}
label="Display name"
spellcheck="false"
disabled={Livebook.Config.identity_readonly?()}
/>
<.hex_color_field
field={f[:hex_color]}
label="Cursor color"

View file

@ -16,6 +16,7 @@ defmodule LivebookWeb.UserPlug do
@behaviour Plug
import Plug.Conn
import Phoenix.Controller
alias Livebook.Users.User
@ -26,6 +27,7 @@ defmodule LivebookWeb.UserPlug do
def call(conn, _opts) do
conn
|> ensure_current_user_id()
|> ensure_user_identity()
|> ensure_user_data()
|> mirror_user_data_in_session()
end
@ -39,11 +41,27 @@ defmodule LivebookWeb.UserPlug do
end
end
defp ensure_user_identity(conn) do
{module, _} = Livebook.Config.identity_provider()
identity_data = module.authenticate(LivebookWeb.ZTA, conn)
if identity_data do
put_session(conn, :identity_data, identity_data)
else
conn
|> put_status(:forbidden)
|> put_view(LivebookWeb.ErrorHTML)
|> render("403.html")
|> halt()
end
end
defp ensure_user_data(conn) do
if Map.has_key?(conn.req_cookies, "lb:user_data") do
conn
else
user_data = user_data(User.new())
identity_data = get_session(conn, :identity_data)
user_data = User.new() |> user_data() |> Map.merge(identity_data)
encoded = user_data |> Jason.encode!() |> Base.encode64()
# We disable HttpOnly, so that it can be accessed on the client

View file

@ -107,7 +107,10 @@ defmodule Livebook.MixProject do
{:protobuf, "~> 0.8.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test},
{:bypass, "~> 2.1", only: :test}
{:bypass, "~> 2.1", only: :test},
# ZTA deps
{:jose, "~> 1.11.5"},
{:req, "~> 0.3.8"}
]
end

View file

@ -9,12 +9,16 @@
"earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"},
"ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
"floki": {:hex, :floki, "0.34.3", "5e2dcaec5d7c228ce5b1d3501502e308b2d79eb655e4191751a1fe491c37feac", [:mix], [], "hexpm", "9577440eea5b97924b4bf3c7ea55f7b8b6dce589f9b28b096cc294a8dc342341"},
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.3", "aab42fff792a74649916236d0b01f560a0b3f03ca5dea693c230d1c44736b50e", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "ca3810ca44cc8532e3dce499cc17f958596695d226bb578b2fbb88c09b5954b0"},
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"phoenix": {:hex, :phoenix, "1.7.5", "3234bc87185e6a2103a15a3b1399f19775b093a6923c4064436e49cdab8ce5d2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.1", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "5abad1789f06a3572ee5e5d5151993ed35b9e2711537904cc457a40229587979"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"},
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
@ -28,6 +32,7 @@
"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"},
"req": {:hex, :req, "0.3.8", "e254074435c970b1d7699777f1a8466acbacab5e6ba4a264d35053bf52c03467", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a17244d18a7fbf3e9892c38c10628224f6f7974fd364392ca0d85f91e3cc8251"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},

View file

@ -135,32 +135,31 @@ defmodule Livebook.Notebook.Export.ElixirTest do
end
test "comments out non-elixir code cells" do
notebook =
%{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| source: """
Enum.to_list(1..10)\
"""
},
%{
Notebook.Cell.new(:code)
| language: :erlang,
source: """
lists:seq(1, 10).\
"""
}
]
}
]
}
notebook = %{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| source: """
Enum.to_list(1..10)\
"""
},
%{
Notebook.Cell.new(:code)
| language: :erlang,
source: """
lists:seq(1, 10).\
"""
}
]
}
]
}
expected_document = """
# Run as: iex --dot-iex path/to/notebook.exs