Current section
Files
Jump to
Current section
Files
lib/scoria_web/dashboard_scope.ex
defmodule ScoriaWeb.DashboardScope do
@moduledoc """
Host-authenticated tenant scope for the embedded Scoria dashboard.
Use this module when a mounted `/scoria` dashboard needs trusted tenant and
actor values from the host Phoenix app. Host apps own authentication,
authorization, tenant membership, role values, and policy decisions. Scoria
normalizes the resulting scope, assigns it to dashboard LiveViews, and fails
closed when no trusted tenant scope is asserted.
Query params do not choose tenants. Public params and route values may be
useful UI hints for selecting a run, trace, incident, or connector after mount,
but tenant authority must come from the host-authenticated scope.
defmodule MyAppWeb.ScoriaDashboardScope do
@behaviour ScoriaWeb.DashboardScope.Resolver
@impl true
def resolve(_params, _session, socket) do
account = socket.assigns.current_account
user = socket.assigns.current_user
{:ok,
%{
tenant_id: account.id,
actor_id: user.id,
display_tenant: account.name
}}
end
end
The default resolver reads normalized scope keys from socket assigns and
session data; it ignores query params as tenant authority.
See `guides/ownership-boundary.md` for the full Scoria-vs-host responsibility
table and `guides/getting-started.md` for the dashboard mount sequence.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [put_flash: 3, redirect: 2]
alias Phoenix.LiveView.Socket
@unavailable_copy "This Scoria dashboard is not available for this session."
@known_attr_keys %{
"actor_id" => :actor_id,
"display_tenant" => :display_tenant,
"session_id" => :session_id,
"tenant_id" => :tenant_id
}
@scope_keys [:tenant_id, :actor_id, :session_id, :display_tenant]
defstruct [:tenant_id, :actor_id, :session_id, :display_tenant]
@type t :: %__MODULE__{
tenant_id: String.t(),
actor_id: String.t() | nil,
session_id: String.t() | nil,
display_tenant: String.t() | nil
}
defmodule Resolver do
@moduledoc """
Resolver contract for host-owned Scoria dashboard scope.
A resolver receives public LiveView params, private session data, and the
current socket. Params may be used as hints by host code, but Scoria's
default resolver ignores params as tenant authority.
"""
@callback resolve(map(), map(), Socket.t()) ::
{:ok, ScoriaWeb.DashboardScope.t() | map() | keyword()}
| {:error, :unauthorized | :missing_scope}
| {:redirect, String.t()}
| {:halt, Socket.t()}
end
defmodule InvalidReturnError do
defexception [:message]
end
@doc "Normalize explicit dashboard scope attrs into a struct or raise."
def new!(%__MODULE__{} = scope), do: validate!(scope)
def new!(attrs) when is_map(attrs) or is_list(attrs) do
attrs = normalize_attrs(attrs)
%__MODULE__{
tenant_id: required_id!(Map.get(attrs, :tenant_id), :tenant_id),
actor_id: optional_id(Map.get(attrs, :actor_id)),
session_id: optional_id(Map.get(attrs, :session_id)),
display_tenant: optional_label(Map.get(attrs, :display_tenant))
}
|> validate!()
end
@doc "Normalize the default host session keys into dashboard scope."
def from_session(session) when is_map(session) or is_list(session) do
session
|> normalize_attrs()
|> Map.take(@scope_keys)
|> new!()
end
@doc """
Resolve dashboard scope from the default resolver, resolver module, or MFA tuple.
"""
def resolve(:default, _params, session, %Socket{} = socket) do
attrs =
socket.assigns
|> normalize_attrs()
|> Map.take(@scope_keys)
|> Map.merge(session |> normalize_attrs() |> Map.take(@scope_keys))
{:ok, new!(attrs)}
rescue
ArgumentError -> {:error, :missing_scope}
end
def resolve(resolver, params, session, %Socket{} = socket) when is_atom(resolver) do
resolver
|> apply(:resolve, [params, session, socket])
|> normalize_resolver_return(resolver)
end
def resolve(
{resolver, function, extra_args} = resolver_form,
params,
session,
%Socket{} = socket
)
when is_atom(resolver) and is_atom(function) and is_list(extra_args) do
resolver
|> apply(function, [params, session, socket | extra_args])
|> normalize_resolver_return(resolver_form)
end
@doc false
def on_mount(resolver, params, session, %Socket{} = socket) do
case resolve(resolver, params, session, socket) do
{:ok, %__MODULE__{} = scope} ->
{:cont, assign_scope(socket, scope)}
{:error, reason} when reason in [:unauthorized, :missing_scope] ->
{:halt, socket |> put_unavailable_flash() |> redirect(to: "/")}
{:redirect, to} when is_binary(to) ->
{:halt, redirect(socket, to: to)}
{:halt, %Socket{} = socket} ->
{:halt, socket}
end
end
defp normalize_resolver_return({:ok, scope}, _resolver), do: {:ok, new!(scope)}
defp normalize_resolver_return({:error, reason}, _resolver)
when reason in [:unauthorized, :missing_scope],
do: {:error, reason}
defp normalize_resolver_return({:redirect, to}, _resolver) when is_binary(to),
do: {:redirect, to}
defp normalize_resolver_return({:halt, %Socket{} = socket}, _resolver), do: {:halt, socket}
defp normalize_resolver_return(other, resolver) do
raise InvalidReturnError,
message:
"invalid dashboard scope resolver return from #{inspect(resolver)}: #{inspect(other)}"
end
defp assign_scope(socket, %__MODULE__{} = scope) do
socket
|> assign(:scoria_scope, scope)
|> assign(:tenant_id, scope.tenant_id)
|> maybe_assign(:actor_id, scope.actor_id)
|> maybe_assign(:session_id, scope.session_id)
end
defp maybe_assign(socket, _key, nil), do: socket
defp maybe_assign(socket, key, value), do: assign(socket, key, value)
defp put_unavailable_flash(socket) do
socket
|> ensure_flash()
|> put_flash(:error, @unavailable_copy)
end
defp ensure_flash(%Socket{} = socket) do
if Map.has_key?(socket.assigns, :flash) do
socket
else
assign(socket, :flash, %{})
end
end
defp validate!(%__MODULE__{} = scope) do
%__MODULE__{
tenant_id: required_id!(scope.tenant_id, :tenant_id),
actor_id: optional_id(scope.actor_id),
session_id: optional_id(scope.session_id),
display_tenant: optional_label(scope.display_tenant)
}
end
defp normalize_attrs(nil), do: %{}
defp normalize_attrs(%_{} = attrs), do: attrs |> Map.from_struct() |> normalize_attrs()
defp normalize_attrs(attrs) when is_list(attrs),
do: attrs |> Enum.into(%{}) |> normalize_attrs()
defp normalize_attrs(attrs) when is_map(attrs), do: Map.new(attrs, &normalize_pair/1)
defp normalize_pair({key, value}) when is_binary(key),
do: {Map.get(@known_attr_keys, key, key), value}
defp normalize_pair({key, value}), do: {key, value}
defp required_id!(value, field) do
case optional_id(value) do
nil -> raise ArgumentError, "#{field} is required"
id -> id
end
end
defp optional_id(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> nil
id -> id
end
end
defp optional_id(nil), do: nil
defp optional_id(value) do
raise ArgumentError, "scope identifiers must be strings, got: #{inspect(value)}"
end
defp optional_label(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> nil
label -> label
end
end
defp optional_label(nil), do: nil
defp optional_label(value) do
raise ArgumentError, "display_tenant must be a string, got: #{inspect(value)}"
end
end