Packages

phoenix_kit

2.29.1
2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web users multi_session.ex
Raw

lib/phoenix_kit_web/users/multi_session.ex

defmodule PhoenixKitWeb.Users.MultiSession do
  @moduledoc """
  Multi-account session switching.

  The Plug session holds an ordered stack of raw session tokens under
  `:pk_session_accounts`. `hd/1` of the stack is the ROOT account (the original
  login). The currently active token stays in `:user_token`, so all existing auth
  resolution (`fetch_phoenix_kit_current_*`, `on_mount`) is untouched.

  Read helpers (`gate_allowed?/1`, `list_accounts/1`) take the string-keyed session
  map (works from both the plug and the LiveView on_mount). Conn-mutating ops
  (`add_account/3`, `add_authenticated_user/3`, `switch_to/2`, `remove_account/2`,
  logout helpers) take and return a `Plug.Conn`.

  ## Surviving a lost session

  The Plug session is the only place the stack lives, and on most hosts that is
  a *browser-session* cookie: `mix phx.new` ships `@session_options` with no
  `max_age`, so it is gone on browser restart. The remembered identity survives
  that — `PhoenixKitWeb.Users.Auth.ensure_user_token/1` rebuilds the session
  from the remember-me cookie — but that cookie holds exactly ONE token, so
  every account the user had added silently disappeared and the switcher came
  back holding a single row.

  So the added accounts are mirrored into a second persistent cookie
  (`persist_account/2`), and `restore_persisted_accounts/2` rebuilds the whole
  stack next to the remembered root. Three rules keep that mirror honest:

  - **It never outlives the remembered identity.** Nothing is written unless
    this browser already holds a remember-me cookie, so a deliberately
    session-only login stays session-only for every account in it, and
    `remember_me_enabled: false` blocks the mirror exactly as it blocks
    remember-me.
  - **It holds only what the user asked for.** An impersonation is support
    access, not an account of the operator's, so `impersonate/2` appends to the
    session stack and writes nothing here: "sign in as this user" ends with the
    browser session, as it did before any of this existed.
  - **It is bound to the identity that built it.** The cookie names the root
    token it belongs to, and `restore_persisted_accounts/2` refuses a mirror
    naming any other. A fresh login clears the cookie too, but that is hygiene,
    not the control: a browser is free to ignore a deletion, and the tokens in a
    stale mirror stay valid until they expire — so on a shared computer the next
    person to sign in would otherwise inherit the previous user's accounts.

  The restored account becomes the root; the account that happened to be active
  when the browser closed is not remembered. Coming back as the identity you
  logged in as is the predictable outcome — and the safe one, since the
  alternative is resuming inside a borrowed account.
  """

  import Plug.Conn

  alias PhoenixKit.Settings
  alias PhoenixKit.Users.ActiveRole
  alias PhoenixKit.Users.Auth
  alias PhoenixKit.Users.Role
  alias PhoenixKit.Users.Sessions
  alias PhoenixKit.Utils.IpAddress
  alias PhoenixKitWeb.Users.Auth, as: WebAuth

  @stack_key :pk_session_accounts
  @max_accounts 5

  # The durable mirror of the accounts ADDED beyond the remembered one. Separate
  # from the remember-me cookie rather than folded into it: that cookie's value
  # is a bare token read by every released version, and widening it to a list
  # would turn every in-flight cookie into a decode failure — a silent mass
  # sign-out on upgrade.
  @accounts_cookie "_phoenix_kit_web_session_accounts"

  @doc "Maximum number of accounts allowed in one stack."
  def max_accounts, do: @max_accounts

  @doc """
  The list of raw session tokens in the stack. Falls back to the single active
  token when no explicit stack is stored, and `[]` when there is no active token.
  """
  def stack_tokens(session) when is_map(session) do
    case session["pk_session_accounts"] do
      [_ | _] = stack -> stack
      _ -> session["user_token"] |> List.wrap()
    end
  end

  @doc """
  True when the root session belongs to ANY authenticated user AND the
  `multi_session_enabled` setting is on. Evaluated against the root so the
  switcher stays visible even when a secondary account is active.

  Anonymous (no root token / no valid user) always returns false.
  """
  def gate_allowed?(session) when is_map(session) do
    Settings.get_boolean_setting("multi_session_enabled", false) and root_authenticated?(session)
  end

  defp root_authenticated?(session) do
    with [root_token | _] <- stack_tokens(session),
         %Auth.User{} <- root_user_from_token(root_token) do
      true
    else
      _ -> false
    end
  end

  @doc """
  Resolves each stack token to a render struct:
  `%{ref, user, email, role, active?, root?}`. Tokens that no longer resolve to a
  user (expired/deleted) are dropped. `role` labels the roles in effect for
  that account's session (`PhoenixKit.Users.ActiveRole`).
  """
  def list_accounts(session) when is_map(session) do
    active = session["user_token"]
    tokens = stack_tokens(session)

    tokens
    |> Enum.with_index()
    |> Enum.flat_map(fn {token, index} ->
      case {Auth.get_user_by_session_token(token), Auth.get_session_token_record(token)} do
        {%Auth.User{} = user, %{uuid: ref}} ->
          [
            %{
              ref: ref,
              user: user,
              email: user.email,
              role: role_label(user),
              active?: token == active,
              root?: index == 0
            }
          ]

        _ ->
          []
      end
    end)
  end

  @doc """
  Resolves the two transient Scope fields `{multi_session_allowed?, multi_session_accounts}`
  for a session in one call.

  Crucially, the (DB-heavy) account stack is resolved ONLY when the setting is on.
  When `multi_session_enabled` is off — the default — this short-circuits to
  `{false, []}` without touching the DB, so the hot auth path (plug + every
  LiveView mount) pays nothing for a feature that is disabled.

  When it IS on, `allowed?` is derived from the resolved stack (a surviving root
  account) rather than a separate `gate_allowed?/1` call, which would re-resolve
  the root token in its own query on top of the `list_accounts/1` walk.
  """
  def scope_fields(session) when is_map(session) do
    if Settings.get_boolean_setting("multi_session_enabled", false) do
      accounts = list_accounts(session)
      {Enum.any?(accounts, & &1.root?), accounts}
    else
      {false, []}
    end
  end

  @doc """
  Returns the user's most descriptive display role name.

  Priority: Owner > Admin > first custom (non-"User") role > "User". This
  correctly labels custom roles (e.g. "Manager", "Client") instead of
  bucketing all permission-holders as "Admin".

  Use this for any "what is this account?" label. In particular do **not**
  derive one from `Scope.can_access_admin_area?/1`: that gate is true for
  Owner, Admin *or any single permission holder*, so a Client — who holds
  `client_portal` — reads back as "Admin".

  Labels the roles IN EFFECT — for a user acting as one role
  (`PhoenixKit.Users.ActiveRole`), that role — read through
  `ActiveRole.effective_role_names/1` rather than by building a full `Scope`:
  the scope carries an opaque `MapSet` of permissions we don't need here (and
  constructing it tripped a Dialyzer opaqueness warning).
  """
  def role_label(user), do: user |> ActiveRole.effective_role_names() |> role_label_from_roles()

  @doc """
  `role_label/1` for callers that already hold the role names.

  `Auth.User.get_roles/1` queries, so a render path with the names in hand —
  `Scope`'s `cached_roles`, loaded once at `Scope.for_user/1` — should pass them
  here instead of handing over the user and paying for the lookup again.
  """
  @spec role_label_from_roles([String.t()]) :: String.t()
  def role_label_from_roles(roles) when is_list(roles) do
    system = Role.system_roles()

    cond do
      system.owner in roles ->
        system.owner

      system.admin in roles ->
        system.admin

      true ->
        # Pick the first role that isn't the plain "User" baseline.
        # Falls back to "User" (or the system.user name) when no custom role exists.
        Enum.find(roles, system.user, fn r -> r != system.user end)
    end
  end

  @doc """
  Validates credentials and appends a real session for that user to the stack,
  making it the active account. The new account may be any role; the gate is
  enforced by the caller (controller) against the root account.

  Returns `{:error, :already_in_stack}` if the user is already present.
  """
  def add_account(conn, email_or_username, password) do
    session = get_session(conn)
    stack = stack_tokens(session)

    if length(stack) >= @max_accounts do
      {:error, :stack_full}
    else
      # Pass the IP so the per-IP login bucket applies here as it does on the
      # main login form — otherwise only the per-email bucket limits a spray.
      case Auth.get_user_by_email_or_username_and_password(
             email_or_username,
             password,
             IpAddress.extract_from_conn(conn)
           ) do
        {:ok, %Auth.User{is_active: true} = user} ->
          if already_in_stack?(stack, user) do
            {:error, :already_in_stack}
          else
            token = Auth.generate_user_session_token(user)

            conn =
              conn
              |> put_session(@stack_key, stack ++ [token])
              |> renew_and_put_active_token(token)
              |> persist_account(token)

            log_event("session.account_added", root_user(session), user)
            {:ok, conn}
          end

        {:ok, %Auth.User{}} ->
          {:error, :inactive}

        {:error, reason} ->
          {:error, reason}
      end
    end
  end

  @doc """
  Appends an already-authenticated (active) user to the session stack and makes
  them the active account. Shares all invariants with `add_account/3`:

  - Stack-limit check (`:stack_full`)
  - Dedup check — returns `{:error, :already_in_stack}` if the user is already present
  - Session-fixation protection via `renew_and_put_active_token/2`

  Used by the OAuth add-account callback so the same logic applies whether the
  user was authenticated via password or via OAuth.

  ## Options

    * `:event` — the activity-feed action written on success (default
      `"session.account_added"`). It exists so `impersonate/2` can record what
      actually happened instead of a second row saying `session.account_added` —
      in the feed those two are the same sentence, and one of them is a user
      adding an account of their own.
    * `:persist` — whether the account joins this browser's durable stack
      (default `true`). `impersonate/2` passes `false`; see its docstring.
  """
  def add_authenticated_user(conn, user, opts \\ [])

  def add_authenticated_user(conn, %Auth.User{is_active: true} = user, opts) do
    session = get_session(conn)
    stack = stack_tokens(session)

    cond do
      length(stack) >= @max_accounts ->
        {:error, :stack_full}

      already_in_stack?(stack, user) ->
        {:error, :already_in_stack}

      true ->
        token = Auth.generate_user_session_token(user)

        conn =
          conn
          |> put_session(@stack_key, stack ++ [token])
          |> renew_and_put_active_token(token)
          |> maybe_persist_account(token, Keyword.get(opts, :persist, true))

        log_event(Keyword.get(opts, :event, "session.account_added"), root_user(session), user)
        {:ok, conn}
    end
  end

  def add_authenticated_user(_conn, %Auth.User{}, _opts), do: {:error, :inactive}

  defp maybe_persist_account(conn, _token, false), do: conn
  defp maybe_persist_account(conn, token, true), do: persist_account(conn, token)

  @doc """
  Adds `target` to the session stack on an administrator's authority, without
  their password — "log in as this user".

  Shares every invariant of `add_authenticated_user/2` and adds the authority
  checks that separate support access from account takeover:

  - the **root** account decides, never the active one. Otherwise an
    administrator could impersonate a user and, from inside that session,
    impersonate someone the user could never reach;
  - the root must hold the Owner or Admin **role**. Deliberately not
    `can_access_admin_area?/1`: that is true for *any* permission holder, so a
    customer granted one self-service permission would qualify — and could then
    borrow another customer's account;
  - an Owner is never a target. The one account that can undo anything must not
    be reachable by borrowing it;
  - an Admin root cannot take another Admin either — support access is for the
    people being supported, not sideways between staff. An Owner root may,
    because there is nothing above it to escalate to.

  A success is logged as `session.impersonated` — one row, written in place of
  the `session.account_added` the stack append would otherwise have written. A
  refusal is logged as `session.impersonation_refused` with the deciding rule in
  `metadata["reason"]`: an impersonation nobody can see afterwards is the thing
  that makes this feature dangerous, and a *rejected* attempt to borrow the
  owner's account is the entry whoever watches the feed most wants to find.

  Refusal rows carry no `target_uuid` on purpose. `Activity.log/1` fans a row
  with one out to that user's notification inbox, and a refused attempt is a
  signal for the feed, not a message to the person it named.
  """
  @spec impersonate(Plug.Conn.t(), Auth.User.t()) ::
          {:ok, Plug.Conn.t()}
          | {:error,
             :not_allowed
             | :target_is_owner
             | :target_is_staff
             | :stack_full
             | :already_in_stack
             | :inactive
             | :self}
  def impersonate(conn, %Auth.User{} = target) do
    session = get_session(conn)
    actor = root_user(session)

    case authorize_impersonation(actor, target) do
      :ok ->
        case add_authenticated_user(conn, target, event: "session.impersonated", persist: false) do
          {:ok, conn} ->
            {:ok, conn}

          {:error, reason} = error ->
            log_impersonation_refused(actor, target, reason)
            error
        end

      {:error, reason} = error ->
        log_impersonation_refused(actor, target, reason)
        error
    end
  end

  @doc """
  True when the session's ROOT account holds the authority `impersonate/2`
  requires before it will look at a target at all.

  Exposed so the controller can refuse an unauthorized actor *before* it resolves
  the uuid: resolving first answers "does this account exist?" with a distinct
  message, and this endpoint is reachable by every signed-in user, not only by
  staff. Shares `staff?/1` with `authorize_impersonation/2` so the two rules
  cannot drift apart.
  """
  @spec may_impersonate?(map()) :: boolean()
  def may_impersonate?(session) when is_map(session) do
    case root_user(session) do
      %Auth.User{} = actor -> staff?(actor)
      _ -> false
    end
  end

  @doc """
  The account an impersonation would be judged against — the session's ROOT,
  never the account currently active.

  Returns `nil` when `gate_allowed?/1` is false, which makes `impersonable?/2`
  answer false for every target and takes the offer off the menu. That check
  belongs here rather than at the call sites: the controller opens with the
  same gate (`with_gate`), so without it a menu could offer impersonation while
  `multi_session_enabled` is off and the POST would bounce to the home page
  with "Multi-account switching is not available." The authority rules in
  `authorize_impersonation/2` never see the setting, so asking them alone is
  not enough to predict the outcome.

  Pair with `impersonable?/2` to offer the action only where it would succeed.
  A LiveView can hold the result across a mount safely: it is a `User` struct,
  so nothing keeps a session token in the socket.
  """
  @spec impersonation_actor(map()) :: Auth.User.t() | nil
  def impersonation_actor(session) when is_map(session) do
    if gate_allowed?(session), do: root_user(session)
  end

  @doc """
  True when `actor` may borrow `target`'s account.

  Answers with the same rules `impersonate/2` enforces — it calls the very same
  private predicate — so a menu built on this cannot offer an action the
  request would then refuse, and cannot hide one it would have allowed.

  A deactivated target answers false. That refusal (`:inactive`) is raised by
  `add_authenticated_user/2` rather than by the authority rules, so asking the
  rules alone would put the offer on every deactivated row in the admin list —
  where the status is displayed next to it — and every click would come back
  "That account is deactivated."

  The remaining reasons `impersonate/2` may still decline (the stack being
  full, or the target already sitting in it) depend on session state at request
  time, are recoverable, and report themselves through the controller's flash
  rather than by silently removing the option.

  Target roles come from the `:roles` preload when the caller has one — the
  user detail page loads its user through `get_user_with_roles/1` — and from a
  lookup otherwise.
  """
  @spec impersonable?(Auth.User.t() | nil, Auth.User.t()) :: boolean()
  def impersonable?(actor, target)

  def impersonable?(nil, %Auth.User{}), do: false

  def impersonable?(%Auth.User{} = actor, %Auth.User{is_active: true} = target) do
    decide_impersonation(
      actor.uuid,
      actor_roles(actor),
      target.uuid,
      role_names(target)
    ) == :ok
  end

  def impersonable?(%Auth.User{}, %Auth.User{}), do: false

  @doc """
  The subset of `users` the actor may sign in as, as a `MapSet` of uuids.

  `impersonable?/2` reads roles from the database — three lookups per call, once
  the `staff?/1` check is counted — which is fine for one user but is an N+1 per
  row in a list. This reads the actor's roles once and each target's from the
  `:roles` preload the caller already has, falling back to a lookup only for a
  row that arrives without one. Decisions come from the same private predicate
  `impersonate/2` uses, so the two cannot diverge.

  Deactivated rows are left out for the reason given on `impersonable?/2`.

      assign(socket, :impersonable_uuids, MultiSession.impersonable_uuids(actor, users))

  and in the template `:if={user.uuid in @impersonable_uuids}`.
  """
  @spec impersonable_uuids(Auth.User.t() | nil, [Auth.User.t()]) :: MapSet.t()
  def impersonable_uuids(actor, users)

  def impersonable_uuids(nil, _users), do: MapSet.new()

  def impersonable_uuids(%Auth.User{} = actor, users) when is_list(users) do
    actor_roles = actor_roles(actor)

    for %Auth.User{is_active: true} = user <- users,
        decide_impersonation(actor.uuid, actor_roles, user.uuid, role_names(user)) == :ok,
        into: MapSet.new(),
        do: user.uuid
  end

  defp role_names(%Auth.User{roles: roles}) when is_list(roles), do: Enum.map(roles, & &1.name)
  defp role_names(%Auth.User{} = user), do: Auth.User.get_roles(user)

  @doc """
  Records an impersonation attempt refused before a target was resolved, so the
  controller's authority-first ordering does not cost the feed an entry.
  """
  @spec log_impersonation_refusal(Plug.Conn.t(), String.t()) :: :ok
  def log_impersonation_refusal(conn, target_uuid) do
    conn
    |> get_session()
    |> root_user()
    |> log_impersonation_refused(target_uuid, :not_allowed)
  end

  defp authorize_impersonation(nil, _target), do: {:error, :not_allowed}

  defp authorize_impersonation(%Auth.User{} = actor, %Auth.User{} = target) do
    decide_impersonation(
      actor.uuid,
      actor_roles(actor),
      target.uuid,
      Auth.User.get_roles(target)
    )
  end

  # The ACTOR's roles in effect, narrowed to the role the ROOT SESSION is acting
  # as (`PhoenixKit.Users.ActiveRole` — `root_user/1` loads the root account
  # through its own token, which carries the role): an Admin acting as
  # "Seller" holds no impersonation authority, not even by crafting the POST.
  # Targets keep their REAL roles (`role_names/1`, `get_roles/1`) — an Admin
  # acting as a custom role is still an administrator to be protected from
  # being borrowed.
  defp actor_roles(%Auth.User{} = actor), do: ActiveRole.effective_role_names(actor)

  # The rule itself, over role names already in hand. Separated from the lookups
  # so a list render can decide many targets against one actor read; every
  # caller — the request path and the menus — funnels through here.
  defp decide_impersonation(actor_uuid, actor_roles, target_uuid, target_roles) do
    system = Role.system_roles()

    cond do
      actor_uuid == target_uuid ->
        {:error, :self}

      not staff_roles?(actor_roles) ->
        {:error, :not_allowed}

      system.owner in target_roles ->
        {:error, :target_is_owner}

      system.owner in actor_roles ->
        :ok

      system.admin in target_roles ->
        {:error, :target_is_staff}

      true ->
        :ok
    end
  end

  # Owner or Admin by ROLE. Deliberately not `can_access_admin_area?/1` — see
  # `impersonate/2`'s docstring for why a permission check opens the door to
  # any customer holding one self-service permission.
  defp staff?(%Auth.User{} = user), do: user |> actor_roles() |> staff_roles?()

  defp staff_roles?(roles) when is_list(roles) do
    system = Role.system_roles()
    system.owner in roles or system.admin in roles
  end

  @doc "Activates a token already present in the stack, identified by `ref`."
  def switch_to(conn, ref) do
    session = get_session(conn)
    stack = stack_tokens(session)

    case find_token_by_ref(stack, ref) do
      nil ->
        {:error, :not_in_stack}

      token ->
        case Auth.ensure_active_user(Auth.get_user_by_session_token(token)) do
          nil ->
            {:error, :inactive}

          user ->
            conn = renew_and_put_active_token(conn, token)
            log_event("session.switched", root_user(session), user)
            {:ok, conn, user}
        end
    end
  end

  @doc "Removes a non-root token from the stack and deletes it from the DB."
  def remove_account(conn, ref) do
    session = get_session(conn)
    stack = stack_tokens(session)
    [root_token | _] = stack

    case find_token_by_ref(stack, ref) do
      nil ->
        {:error, :not_in_stack}

      ^root_token ->
        {:error, :cannot_remove_root}

      token ->
        Auth.delete_user_session_token(token)
        new_stack = List.delete(stack, token)
        conn = conn |> put_session(@stack_key, new_stack) |> forget_account(token)

        conn =
          if session["user_token"] == token,
            do: put_active_token(conn, root_token),
            else: conn

        {:ok, conn}
    end
  end

  @doc """
  Logs out the active account. When a non-root account is active, deletes it and
  switches back to root (`{:switched, conn, root_user}`). When the root account is
  active, signals a full logout (`{:full, conn}`) for the caller to run.
  """
  def log_out_active(conn) do
    session = get_session(conn)
    stack = stack_tokens(session)
    active = session["user_token"]

    case stack do
      # No session at all (expired, stale tab, double-click on logout): the
      # route is on the unauthenticated scope, so this is a full logout, not a
      # MatchError.
      [] -> {:full, conn}
      [root_token | _] when active == root_token -> {:full, conn}
      [_] -> {:full, conn}
      [root_token | _] -> log_out_to_root(conn, session, stack, active, root_token)
    end
  end

  defp log_out_to_root(conn, session, stack, active, root_token) do
    root_user = Auth.get_user_by_session_token(root_token)

    if is_nil(root_user) do
      # The root token no longer resolves (revoked by an admin, role change,
      # expiry) — nothing to switch back to, so drain everything.
      {:full, conn}
    else
      if live_socket_id = session["live_socket_id"] do
        PhoenixKitWeb.Users.Auth.broadcast_disconnect_for_socket(live_socket_id)
      end

      Auth.delete_user_session_token(active)
      new_stack = List.delete(stack, active)

      conn =
        conn
        |> put_session(@stack_key, new_stack)
        |> put_active_token(root_token)
        |> forget_account(active)

      {:switched, conn, root_user}
    end
  end

  @doc "Deletes every stack token from the DB (used by 'Log out all')."
  def delete_all_stack_tokens(conn) do
    conn |> get_session() |> stack_tokens() |> Enum.each(&Auth.delete_user_session_token/1)
    conn
  end

  # --- durable stack (see "Surviving a lost session" above) ---

  @doc """
  Mirrors `token` into the persistent cookie, so the account survives a lost
  session cookie.

  A no-op unless this browser already holds a remember-me cookie: the mirror
  must not give an account more persistence than the login it was added from.
  """
  @spec persist_account(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def persist_account(conn, token) do
    case remembered_root(conn) do
      nil -> conn
      root -> write_persisted(conn, root, read_persisted(conn, root) ++ [token])
    end
  end

  @doc """
  Drops `token` from the persistent cookie — the counterpart of
  `persist_account/2` for an account being removed or logged out.

  Reads the remembered identity directly rather than through
  `remembered_root/1`: a browser must be able to stop persisting an account it
  already holds even after an operator turns `remember_me_enabled` off.
  """
  @spec forget_account(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def forget_account(conn, token) do
    case WebAuth.remembered_token(conn) do
      nil ->
        conn

      root ->
        tokens = read_persisted(conn, root)

        # An impersonation is in the session stack but never in the mirror, so
        # removing one reaches here with nothing to drop — rewriting the cookie
        # with an unchanged list would put a pointless `Set-Cookie` on that
        # response.
        if token in tokens do
          write_persisted(conn, root, List.delete(tokens, token))
        else
          conn
        end
    end
  end

  @doc """
  Drops the persistent cookie entirely.

  Called wherever the session it mirrors ends or is replaced: full logout, a
  fresh login (the stack belongs to whoever was signed in before), and the
  plug's own recovery path when there is no remembered identity to hang a stack
  on.
  """
  @spec forget_persisted_accounts(Plug.Conn.t()) :: Plug.Conn.t()
  def forget_persisted_accounts(conn) do
    # Presence is checked against the REQUEST cookies — unconditionally emitting
    # a deletion would put a `Set-Cookie` on every anonymous response, since the
    # plug's recovery path runs for every visitor who is not signed in.
    if Map.has_key?(fetch_cookies(conn).req_cookies, @accounts_cookie) do
      # Same path/secure/same_site as the write — browsers will not clear a
      # Secure cookie with a non-Secure deletion, and Plug's own docs require
      # the delete options to match the put.
      delete_resp_cookie(conn, @accounts_cookie, accounts_cookie_delete_options())
    else
      conn
    end
  end

  @doc """
  Rebuilds the session stack around `root_token` from the persistent cookie.

  Called from the plug's remember-me recovery, the one moment a real session is
  reconstructed from cookies alone.

  The mirror is accepted only if it names `root_token` as the identity it was
  built under. Clearing the cookie at login is a request the browser is free to
  ignore, and the tokens in a stale mirror stay valid until they expire — so
  without this check the next person to sign in on a shared browser inherits the
  previous user's accounts, live, in their own switcher. Binding makes that
  unforgeable rather than merely unlikely: a login mints a new session token, so
  a mirror written before it can never name the new root.

  Tokens that no longer resolve to an active user are dropped *and* pruned from
  the cookie: this is the only pass that ever looks at them, so without it a
  revoked account would be re-offered for the cookie's full life.

  Restores nothing while `multi_session_enabled` is off, and takes the cookie
  with it — turning the feature off should not leave a browser quietly holding
  other people's sessions until someone turns it back on.
  """
  @spec restore_persisted_accounts(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def restore_persisted_accounts(conn, root_token) do
    if Settings.get_boolean_setting("multi_session_enabled", false) do
      do_restore(conn, root_token)
    else
      forget_persisted_accounts(conn)
    end
  end

  defp do_restore(conn, root_token) do
    case read_persisted(conn, root_token) do
      # Nothing usable: no mirror, or one built under a different login. Either
      # way this browser should stop carrying it.
      [] ->
        forget_persisted_accounts(conn)

      tokens ->
        live = Enum.filter(tokens, &resolves_to_active_user?/1)

        conn
        |> restore_stack(root_token, live)
        |> prune_persisted(root_token, tokens, live)
    end
  end

  defp restore_stack(conn, _root_token, []), do: conn

  defp restore_stack(conn, root_token, live),
    do: put_session(conn, @stack_key, [root_token | live])

  defp prune_persisted(conn, _root_token, tokens, tokens), do: conn

  defp prune_persisted(conn, root_token, _tokens, live),
    do: write_persisted(conn, root_token, live)

  # The identity a mirror may be written under, or nil. Gated on the site-wide
  # switch as well as the cookie, so `remember_me_enabled: false` blocks the
  # mirror exactly as it blocks the cookie it hangs off.
  defp remembered_root(conn) do
    if WebAuth.remember_me_enabled?(), do: WebAuth.remembered_token(conn)
  end

  defp resolves_to_active_user?(token), do: match?(%Auth.User{}, root_user_from_token(token))

  # The `root` match is the binding check — see `restore_persisted_accounts/2`.
  defp read_persisted(conn, expected_root) do
    conn = fetch_cookies(conn, signed: [@accounts_cookie])

    case conn.cookies[@accounts_cookie] do
      %{root: ^expected_root, accounts: tokens} when is_list(tokens) ->
        Enum.filter(tokens, &is_binary/1)

      _ ->
        []
    end
  end

  defp write_persisted(conn, _root, []), do: forget_persisted_accounts(conn)

  defp write_persisted(conn, root, tokens) do
    # A signed cookie is read back from the REQUEST, so a read after this write
    # still sees the old value within the same request. Every caller reads once
    # and writes once; keep it that way.
    value = %{root: root, accounts: Enum.take(tokens, -(@max_accounts - 1))}

    put_resp_cookie(conn, @accounts_cookie, value, accounts_cookie_options())
  end

  # Shares the remember-me lifetime deliberately: the mirror exists to last
  # exactly as long as the identity it hangs off, never a day longer.
  defp accounts_cookie_options do
    [
      sign: true,
      max_age: WebAuth.remember_me_max_age(),
      same_site: "Lax",
      http_only: true,
      secure: true
    ]
  end

  defp accounts_cookie_delete_options do
    Keyword.take(accounts_cookie_options(), [:same_site, :http_only, :secure])
  end

  # --- internal ---

  # Used for account-switching operations (add/switch): rotates the session ID
  # and drops the CSRF token to prevent session fixation attacks, while
  # preserving all existing session data (configure_session(renew: true) only
  # rotates the id — it does not clear conn.private[:plug_session]).
  defp renew_and_put_active_token(conn, token) do
    Plug.CSRFProtection.delete_csrf_token()

    conn
    |> configure_session(renew: true)
    |> put_active_token(token)
  end

  defp put_active_token(conn, token) do
    conn
    |> put_session(:user_token, token)
    |> put_session(:live_socket_id, Sessions.live_socket_id(token))
  end

  defp already_in_stack?(stack, %Auth.User{} = user) do
    Enum.any?(stack, fn token ->
      case Auth.get_user_by_session_token(token) do
        %Auth.User{uuid: uuid} -> uuid == user.uuid
        _ -> false
      end
    end)
  end

  defp find_token_by_ref(stack, ref) do
    Enum.find(stack, fn token ->
      match?(%{uuid: ^ref}, Auth.get_session_token_record(token))
    end)
  end

  defp root_user(session) do
    case stack_tokens(session) do
      [root_token | _] -> root_user_from_token(root_token)
      _ -> nil
    end
  end

  # The root account decides both whether the switcher is offered and who may
  # impersonate, so it must be resolved through the SAME active-user filter the
  # plugs and `switch_to/2` apply. Resolving it with a bare token lookup left
  # a deactivated Owner/Admin — a principal the system has explicitly cut off —
  # holding a still-valid cookie that these two entry points accepted, letting
  # them mint a fresh session as another live user.
  defp root_user_from_token(root_token) do
    root_token
    |> Auth.get_user_by_session_token()
    |> Auth.ensure_active_user()
  end

  defp log_event(action, %Auth.User{} = actor, %Auth.User{} = target) do
    PhoenixKit.Activity.log(%{
      action: action,
      module: "users",
      mode: "auto",
      actor_uuid: actor.uuid,
      resource_type: "user",
      resource_uuid: target.uuid,
      target_uuid: target.uuid,
      metadata: %{"email" => target.email, "actor_role" => "admin"}
    })
  rescue
    _ -> :ok
  end

  defp log_event(_action, _actor, _target), do: :ok

  # A refused impersonation. `target` is a `%User{}` when one was resolved and a
  # bare uuid when the actor was turned away before the lookup; either way the
  # row names what was reached for. No `target_uuid` — see `impersonate/2`.
  defp log_impersonation_refused(%Auth.User{} = actor, target, reason) do
    {target_uuid, target_email} = refusal_target(target)

    PhoenixKit.Activity.log(%{
      action: "session.impersonation_refused",
      module: "users",
      mode: "auto",
      actor_uuid: actor.uuid,
      resource_type: "user",
      resource_uuid: target_uuid,
      target_uuid: nil,
      metadata:
        %{"reason" => to_string(reason), "email" => target_email}
        |> Enum.reject(fn {_k, v} -> is_nil(v) end)
        |> Map.new()
    })
  rescue
    _ -> :ok
  end

  defp log_impersonation_refused(_actor, _target, _reason), do: :ok

  defp refusal_target(%Auth.User{uuid: uuid, email: email}), do: {uuid, email}
  defp refusal_target(uuid) when is_binary(uuid), do: {uuid, nil}
  defp refusal_target(_target), do: {nil, nil}
end