mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-18 06:02:45 +08:00
ZTA - user identity (#2015)
* Get user identity * Moves current_user_id to identity_data * Renames cookies to session_identity * Keep zta keys in the state * User id from GoogleIap * Update cloudflare.ex * Update googleiap.ex * Email only for ZTA * Get the current_user_id from the identity_data * Applying suggestions * Applying suggestions * Fix verify_token --------- Co-authored-by: José Valim <jose.valim@gmail.com>
This commit is contained in:
parent
9541eb91c8
commit
29b712917e
10 changed files with 103 additions and 104 deletions
|
|
@ -198,7 +198,7 @@ defmodule Livebook do
|
||||||
config :livebook,
|
config :livebook,
|
||||||
:identity_provider,
|
:identity_provider,
|
||||||
Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") ||
|
Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") ||
|
||||||
{LivebookWeb.Cookies, :unused}
|
{LivebookWeb.SessionIdentity, :unused}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ defmodule Livebook.Config do
|
||||||
"""
|
"""
|
||||||
@spec identity_readonly?() :: boolean()
|
@spec identity_readonly?() :: boolean()
|
||||||
def identity_readonly?() do
|
def identity_readonly?() do
|
||||||
not match?({LivebookWeb.Cookies, _}, Livebook.Config.identity_provider())
|
not match?({LivebookWeb.SessionIdentity, _}, Livebook.Config.identity_provider())
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ defmodule Livebook.Users.User do
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
id: id(),
|
id: id(),
|
||||||
name: String.t() | nil,
|
name: String.t() | nil,
|
||||||
|
email: String.t() | nil,
|
||||||
hex_color: hex_color()
|
hex_color: hex_color()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ defmodule Livebook.Users.User do
|
||||||
|
|
||||||
embedded_schema do
|
embedded_schema do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
|
field :email, :string
|
||||||
field :hex_color, Livebook.EctoTypes.HexColor
|
field :hex_color, Livebook.EctoTypes.HexColor
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -36,13 +38,14 @@ defmodule Livebook.Users.User do
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
id: Utils.random_id(),
|
id: Utils.random_id(),
|
||||||
name: nil,
|
name: nil,
|
||||||
|
email: nil,
|
||||||
hex_color: Livebook.EctoTypes.HexColor.random()
|
hex_color: Livebook.EctoTypes.HexColor.random()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(user, attrs \\ %{}) do
|
def changeset(user, attrs \\ %{}) do
|
||||||
user
|
user
|
||||||
|> cast(attrs, [:id, :name, :hex_color])
|
|> cast(attrs, [:id, :name, :email, :hex_color])
|
||||||
|> validate_required([:id, :name, :hex_color])
|
|> validate_required([:id, :name, :hex_color])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,70 +8,57 @@ defmodule Livebook.ZTA.Cloudflare do
|
||||||
@assertion "cf-access-jwt-assertion"
|
@assertion "cf-access-jwt-assertion"
|
||||||
@renew_afer 24 * 60 * 60 * 1000
|
@renew_afer 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
defstruct [:name, :req_options, :identity]
|
defstruct [:name, :req_options, :identity, :keys]
|
||||||
|
|
||||||
def start_link(opts) do
|
def start_link(opts) do
|
||||||
identity = identity(opts[:identity][:key])
|
identity = identity(opts[:identity][:key])
|
||||||
options = [req_options: [url: identity.certs], identity: identity]
|
options = [req_options: [url: identity.certs], identity: identity, keys: nil]
|
||||||
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate(name, conn) do
|
def authenticate(name, conn, fields: fields) do
|
||||||
token = get_req_header(conn, @assertion)
|
token = get_req_header(conn, @assertion)
|
||||||
GenServer.call(name, {:authenticate, token})
|
user = GenServer.call(name, {:authenticate, token, fields})
|
||||||
|
{conn, user}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(options) do
|
def init(options) do
|
||||||
:ets.new(options[:name], [:public, :named_table])
|
state = struct!(__MODULE__, options)
|
||||||
{:ok, struct!(__MODULE__, options)}
|
{:ok, %{state | keys: keys(state)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call(:get_keys, _from, state) do
|
def handle_call({:authenticate, token, fields}, _from, state) do
|
||||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
user = authenticated_user(token, fields, state.identity, state.keys)
|
||||||
{: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}
|
{:reply, user, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:request, state) do
|
def handle_info(:renew, state) do
|
||||||
request_and_store_in_ets(state)
|
{:noreply, %{state | keys: keys(state)}}
|
||||||
{:noreply, state}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp request_and_store_in_ets(state) do
|
defp keys(state) do
|
||||||
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
||||||
keys = Req.request!(state.req_options).body["keys"]
|
keys = Req.request!(state.req_options).body["keys"]
|
||||||
:ets.insert(state.name, keys: keys)
|
Process.send_after(self(), :renew, @renew_afer)
|
||||||
Process.send_after(self(), :request, @renew_afer)
|
|
||||||
keys
|
keys
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_from_ets(name) do
|
defp authenticated_user(token, fields, identity, keys) do
|
||||||
case :ets.lookup(name, :keys) do
|
with [encoded_token] <- token,
|
||||||
[keys: keys] -> keys
|
{:ok, token} <- verify_token(encoded_token, keys),
|
||||||
[] -> nil
|
:ok <- verify_iss(token, identity.iss),
|
||||||
end
|
{:ok, user} <- get_user_identity(encoded_token, fields, identity.user_identity) do
|
||||||
end
|
Map.new(user, fn {k, v} -> {String.to_atom(k), to_string(v)} 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
|
else
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp verify_token(token, keys) do
|
defp verify_token(token, keys) do
|
||||||
Enum.find_value(keys, fn key ->
|
Enum.find_value(keys, :error, fn key ->
|
||||||
case JOSE.JWT.verify(key, token) do
|
case JOSE.JWT.verify(key, token) do
|
||||||
{true, token, _s} -> {:ok, token}
|
{true, token, _s} -> {:ok, token}
|
||||||
{_, _t, _s} -> nil
|
{_, _t, _s} -> nil
|
||||||
|
|
@ -80,7 +67,14 @@ defmodule Livebook.ZTA.Cloudflare do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
||||||
defp verify_iss(_, _), do: nil
|
defp verify_iss(_, _), do: :error
|
||||||
|
|
||||||
|
defp get_user_identity(token, fields, url) do
|
||||||
|
token = "CF_Authorization=#{token}"
|
||||||
|
fields = Enum.map(fields, &Atom.to_string/1)
|
||||||
|
resp = Req.request!(url: url, headers: [{"cookie", token}])
|
||||||
|
if resp.status == 200, do: {:ok, Map.take(resp.body, fields)}, else: :error
|
||||||
|
end
|
||||||
|
|
||||||
defp identity(key) do
|
defp identity(key) do
|
||||||
%{
|
%{
|
||||||
|
|
@ -89,7 +83,8 @@ defmodule Livebook.ZTA.Cloudflare do
|
||||||
iss: "https://#{key}.cloudflareaccess.com",
|
iss: "https://#{key}.cloudflareaccess.com",
|
||||||
certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs",
|
certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs",
|
||||||
assertion: "cf-access-jwt-assertion",
|
assertion: "cf-access-jwt-assertion",
|
||||||
email: "cf-access-authenticated-user-email"
|
email: "cf-access-authenticated-user-email",
|
||||||
|
user_identity: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/get-identity"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,73 +5,60 @@ defmodule Livebook.ZTA.GoogleIAP do
|
||||||
require Logger
|
require Logger
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
@assertion "cf-access-jwt-assertion"
|
@assertion "x-goog-iap-jwt-assertion"
|
||||||
@renew_afer 24 * 60 * 60 * 1000
|
@renew_afer 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
defstruct [:name, :req_options, :identity]
|
defstruct [:name, :req_options, :identity, :keys]
|
||||||
|
|
||||||
def start_link(opts) do
|
def start_link(opts) do
|
||||||
identity = identity(opts[:identity][:key])
|
identity = identity(opts[:identity][:key])
|
||||||
options = [req_options: [url: identity.certs], identity: identity]
|
options = [req_options: [url: identity.certs], identity: identity, keys: nil]
|
||||||
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
GenServer.start_link(__MODULE__, options, name: opts[:name])
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate(name, conn) do
|
def authenticate(name, conn, fields: fields) do
|
||||||
token = get_req_header(conn, @assertion)
|
token = get_req_header(conn, @assertion)
|
||||||
GenServer.call(name, {:authenticate, token})
|
user = GenServer.call(name, {:authenticate, token, fields})
|
||||||
|
{conn, user}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(options) do
|
def init(options) do
|
||||||
:ets.new(options[:name], [:public, :named_table])
|
state = struct!(__MODULE__, options)
|
||||||
{:ok, struct!(__MODULE__, options)}
|
{:ok, %{state | keys: keys(state)}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call(:get_keys, _from, state) do
|
def handle_call({:authenticate, token, fields}, _from, state) do
|
||||||
keys = get_from_ets(state.name) || request_and_store_in_ets(state)
|
user = authenticated_user(token, fields, state.identity, state.keys)
|
||||||
{: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}
|
{:reply, user, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:request, state) do
|
def handle_info(:renew, state) do
|
||||||
request_and_store_in_ets(state)
|
{:noreply, %{state | keys: keys(state)}}
|
||||||
{:noreply, state}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp request_and_store_in_ets(state) do
|
defp keys(state) do
|
||||||
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}")
|
||||||
keys = Req.request!(state.req_options).body["keys"]
|
keys = Req.request!(state.req_options).body["keys"]
|
||||||
:ets.insert(state.name, keys: keys)
|
Process.send_after(self(), :renew, @renew_afer)
|
||||||
Process.send_after(self(), :request, @renew_afer)
|
|
||||||
keys
|
keys
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_from_ets(name) do
|
defp authenticated_user(token, fields, identity, keys) do
|
||||||
case :ets.lookup(name, :keys) do
|
with [encoded_token] <- token,
|
||||||
[keys: keys] -> keys
|
{:ok, token} <- verify_token(encoded_token, keys),
|
||||||
[] -> nil
|
:ok <- verify_iss(token, identity.iss),
|
||||||
end
|
{:ok, user} <- get_user_identity(token, fields, identity.user_identity) do
|
||||||
end
|
user
|
||||||
|
|
||||||
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
|
else
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp verify_token(token, keys) do
|
defp verify_token(token, keys) do
|
||||||
Enum.find_value(keys, fn key ->
|
Enum.find_value(keys, :error, fn key ->
|
||||||
case JOSE.JWT.verify(key, token) do
|
case JOSE.JWT.verify(key, token) do
|
||||||
{true, token, _s} -> {:ok, token}
|
{true, token, _s} -> {:ok, token}
|
||||||
{_, _t, _s} -> nil
|
{_, _t, _s} -> nil
|
||||||
|
|
@ -80,7 +67,19 @@ defmodule Livebook.ZTA.GoogleIAP do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok
|
||||||
defp verify_iss(_, _), do: nil
|
defp verify_iss(_, _), do: :error
|
||||||
|
|
||||||
|
defp get_user_identity(%{fields: %{"gcip" => gcip}}, _, _) do
|
||||||
|
user = %{name: gcip["name"], email: gcip["email"], id: gcip["sub"]}
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_user_identity(%{fields: fields}, _, _url) do
|
||||||
|
user = %{name: fields["email"], email: fields["email"], id: fields["sub"]}
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_user_identity(_, _, _), do: :error
|
||||||
|
|
||||||
defp identity(key) do
|
defp identity(key) do
|
||||||
%{
|
%{
|
||||||
|
|
@ -89,7 +88,8 @@ defmodule Livebook.ZTA.GoogleIAP do
|
||||||
iss: "https://cloud.google.com/iap",
|
iss: "https://cloud.google.com/iap",
|
||||||
certs: "https://www.gstatic.com/iap/verify/public_key",
|
certs: "https://www.gstatic.com/iap/verify/public_key",
|
||||||
assertion: "x-goog-iap-jwt-assertion",
|
assertion: "x-goog-iap-jwt-assertion",
|
||||||
email: "x-goog-authenticated-user-email"
|
email: "x-goog-authenticated-user-email",
|
||||||
|
user_identity: "https://www.googleapis.com/plus/v1/people/me"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule LivebookWeb.UserHook do
|
||||||
|
|
||||||
alias Livebook.Users.User
|
alias Livebook.Users.User
|
||||||
|
|
||||||
def on_mount(:default, _params, %{"current_user_id" => current_user_id} = session, socket) do
|
def on_mount(:default, _params, %{"identity_data" => identity_data} = session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
Livebook.Users.subscribe(current_user_id)
|
Livebook.Users.subscribe(identity_data.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -33,8 +33,7 @@ defmodule LivebookWeb.UserHook do
|
||||||
# attributes if the socket is connected. Otherwise uses
|
# attributes if the socket is connected. Otherwise uses
|
||||||
# `user_data` from session.
|
# `user_data` from session.
|
||||||
defp build_current_user(session, socket) do
|
defp build_current_user(session, socket) do
|
||||||
%{"current_user_id" => current_user_id} = session
|
user = User.new()
|
||||||
user = %{User.new() | id: current_user_id}
|
|
||||||
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
|
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
|
||||||
|
|
||||||
connect_params = get_connect_params(socket) || %{}
|
connect_params = get_connect_params(socket) || %{}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ defmodule LivebookWeb.UserComponent do
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
disabled={Livebook.Config.identity_readonly?()}
|
disabled={Livebook.Config.identity_readonly?()}
|
||||||
/>
|
/>
|
||||||
|
<%= if @user.email do %>
|
||||||
|
<.text_field field={f[:email]} label="email" spellcheck="false" disabled="true" />
|
||||||
|
<% end %>
|
||||||
<.hex_color_field
|
<.hex_color_field
|
||||||
field={f[:hex_color]}
|
field={f[:hex_color]}
|
||||||
label="Cursor color"
|
label="Cursor color"
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ defmodule LivebookWeb.UserPlug do
|
||||||
# Initializes the session and cookies with user-related info.
|
# Initializes the session and cookies with user-related info.
|
||||||
#
|
#
|
||||||
# The first time someone visits Livebook
|
# The first time someone visits Livebook
|
||||||
# this plug stores a new random user id
|
# this plug stores a new random user id or the ZTA user
|
||||||
# in the session under `:current_user_id`.
|
# in the session under `:identity_data`.
|
||||||
#
|
#
|
||||||
# Additionally the cookies are checked for the presence
|
# Additionally the cookies are checked for the presence
|
||||||
# of `"user_data"` and if there is none, a new user
|
# of `"user_data"` and if there is none, a new user
|
||||||
|
|
@ -26,24 +26,16 @@ defmodule LivebookWeb.UserPlug do
|
||||||
@impl true
|
@impl true
|
||||||
def call(conn, _opts) do
|
def call(conn, _opts) do
|
||||||
conn
|
conn
|
||||||
|> ensure_current_user_id()
|
|
||||||
|> ensure_user_identity()
|
|> ensure_user_identity()
|
||||||
|> ensure_user_data()
|
|> ensure_user_data()
|
||||||
|> mirror_user_data_in_session()
|
|> mirror_user_data_in_session()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_current_user_id(conn) do
|
|
||||||
if get_session(conn, :current_user_id) do
|
|
||||||
conn
|
|
||||||
else
|
|
||||||
user_id = Livebook.Utils.random_id()
|
|
||||||
put_session(conn, :current_user_id, user_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp ensure_user_identity(conn) do
|
defp ensure_user_identity(conn) do
|
||||||
{module, _} = Livebook.Config.identity_provider()
|
{module, _} = Livebook.Config.identity_provider()
|
||||||
identity_data = module.authenticate(LivebookWeb.ZTA, conn)
|
|
||||||
|
{conn, identity_data} =
|
||||||
|
module.authenticate(LivebookWeb.ZTA, conn, fields: [:id, :name, :email])
|
||||||
|
|
||||||
if identity_data do
|
if identity_data do
|
||||||
put_session(conn, :identity_data, identity_data)
|
put_session(conn, :identity_data, identity_data)
|
||||||
|
|
@ -51,7 +43,7 @@ defmodule LivebookWeb.UserPlug do
|
||||||
conn
|
conn
|
||||||
|> put_status(:forbidden)
|
|> put_status(:forbidden)
|
||||||
|> put_view(LivebookWeb.ErrorHTML)
|
|> put_view(LivebookWeb.ErrorHTML)
|
||||||
|> render("403.html")
|
|> render("403.html", %{status: 403})
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
19
lib/livebook_web/session_identity.ex
Normal file
19
lib/livebook_web/session_identity.ex
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule LivebookWeb.SessionIdentity do
|
||||||
|
# This module implements the ZTA contract specific to Livebook cookies
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
def authenticate(_, conn, _) do
|
||||||
|
if id = get_session(conn, :current_user_id) do
|
||||||
|
{conn, %{id: id}}
|
||||||
|
else
|
||||||
|
user_id = Livebook.Utils.random_id()
|
||||||
|
{put_session(conn, :current_user_id, user_id), %{id: user_id}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def child_spec(_opts) do
|
||||||
|
%{id: __MODULE__, start: {Function, :identity, [:ignore]}}
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue