Auth & Operator Identity

Copy Markdown View Source

Cairnloop is host-owned: it embeds no authentication and no authorization. You mount its operator dashboard into your router, under your pipelines, so login, sessions, and access control stay entirely yours. Cairnloop only asks one thing of you in return — tell it who the signed-in operator is — and it asks for that through a single, deliberately small seam: the host_user_id value in the dashboard's live session.

This guide explains what host_user_id is for, the two distinct seams you wire (authorization vs. identity injection), the one mistake that quietly breaks both, and the copy-paste pattern that does it correctly.


What host_user_id is — and why it is not cosmetic

host_user_id is the identity of the operator currently using the dashboard — your staff member, not the customer. Cairnloop renders it into the dashboard live session and uses it for two things that matter:

  1. Audit attribution. When an operator takes a governed action (for example, confirming a bulk send), Cairnloop records host_user_id as the actor on the resulting durable event. This is the name that shows up in the audit log as "who did this." If every request carries the same hardcoded id, your audit trail attributes everyone's actions to that one id — which defeats the point of having an audit trail.

  2. Tenant-scoped search. Operator search is scoped by host_user_id. When it is blank, the dashboard does not guess: scoped search degrades to a calm, explicit "Scoped search is unavailable on this surface until the dashboard session provides host_user_id" rather than silently searching the wrong tenant.

So host_user_id is not a display label — it is governance-bearing identity. Getting it right is the difference between a trustworthy audit log and one that lies.

Fail-closed by design

If you do not provide host_user_id, Cairnloop does not crash and does not invent one. Scoped search is withheld and the actor on governed events is nil. The dashboard stays usable, but you are leaving real attribution on the table — treat a nil operator as a wiring bug to fix, not a supported mode.


Two seams, two jobs

Wiring the dashboard for real auth means answering two different questions, with two different mechanisms. Conflating them is the root of most integration confusion.

QuestionMechanismWhere it lives
May this request reach the dashboard at all? (authorization)your router pipe_through pipeline and/or LiveView on_mount hooksthe scope wrapping cairnloop_dashboard/2
Who is the operator on this request? (identity injection)the live session :session valuethe session: option you pass to cairnloop_dashboard/2

cairnloop_dashboard/2 forwards :session, :on_mount, :root_layout, and :layout straight through to Phoenix.LiveView.Router.live_session/3, so both seams are standard Phoenix — there is no Cairnloop-specific auth API to learn.

Seam 1 — authorization: who may enter

Gate access with your own pipeline and, optionally, an on_mount hook. Cairnloop does not call these for you; you compose them:

scope "/support" do
  pipe_through [:browser, :require_admin]

  Cairnloop.Router.cairnloop_dashboard "/",
    on_mount: [{MyAppWeb.UserAuth, :ensure_admin}],
    session: {MyAppWeb.UserAuth, :cairnloop_session, []}
end

pipe_through rejects unauthenticated requests at the plug layer; on_mount re-checks on the LiveView connect (live navigation does not re-run plugs, so an on_mount hook is how you keep an authorization invariant across in-dashboard navigation). Neither of these sets host_user_id — that is seam 2's job.

Seam 2 — identity injection: who the operator is

This is the seam adopters most often get wrong, so it gets its own section below.


The static-map trap

Almost every quickstart — including Cairnloop's own example app — shows the dashboard mounted like this:

# Demo only. Do NOT ship this.
Cairnloop.Router.cairnloop_dashboard "/support",
  session: %{"host_user_id" => "demo_operator"}

That works in a demo because there is only ever one operator. But a literal map passed to :session is evaluated once, when your router module compiles — it is frozen at build time and is identical for every request and every operator forever. There is no conn in scope, so there is no way for a literal map to carry the operator who is actually signed in right now.

Copy that line into a real app and you ship a dashboard where every staff member is "demo_operator": the audit log attributes everyone's actions to one fictional id, and scoped search is scoped to a tenant that does not exist. It compiles, it runs, it looks fine in a screenshot — which is exactly why this is the trap. Nothing fails loudly.


The fix: inject identity per request with an MFA

Phoenix.LiveView.Router.live_session/3 accepts :session as either a static map or an {Module, function, args} tuple. The tuple form is the one you want for real auth: Phoenix invokes it once per HTTP request that establishes the live session, passing the current Plug.Conn as the first argument (apply(Module, function, [conn | args])). Your function reads the authenticated operator off the conn and returns the session map:

defmodule MyAppWeb.UserAuth do
  # ... your existing fetch_current_user/2 plug, on_mount hooks, etc. ...

  @doc """
  Builds the Cairnloop dashboard live session for the current request.

  Wired as `session: {MyAppWeb.UserAuth, :cairnloop_session, []}` on
  `cairnloop_dashboard/2`. Runs per request with the live `conn`, so it can
  read the authenticated operator — something a static `%{...}` map cannot do.
  """
  def cairnloop_session(conn) do
    %{"host_user_id" => to_string(conn.assigns.current_user.id)}
  end
end

Then wire it in your router:

scope "/support" do
  pipe_through [:browser, :require_admin]

  Cairnloop.Router.cairnloop_dashboard "/",
    on_mount: [{MyAppWeb.UserAuth, :ensure_admin}],
    session: {MyAppWeb.UserAuth, :cairnloop_session, []}
end

A few details worth knowing:

  • Stringify the id. Live session values are serialized and verified across the websocket connect; keep them to JSON-friendly primitives. host_user_id is treated as an opaque string everywhere in Cairnloop, so to_string/1 your integer or UUID ids.
  • current_user must already be assigned. cairnloop_session/1 reads conn.assigns, so your authentication plug (the one that assigns current_user) must run in the pipe_through pipeline before the live session is built. If it might be missing, default deliberately rather than letting it raise — see below.
  • The args list is for extra arguments, appended after conn. [] is the common case. Use it if you want one shared function parameterized per mount, e.g. {MyAppWeb.UserAuth, :cairnloop_session, [:support_desk]}cairnloop_session(conn, :support_desk).

Defaulting safely when there may be no operator

If a route can be reached without an authenticated operator (you generally should not allow this for the dashboard, but defensive code is cheap), decide explicitly instead of crashing inside the MFA:

def cairnloop_session(conn) do
  case conn.assigns[:current_user] do
    nil -> %{}                                   # no operator → fail closed (scoped search off, actor nil)
    user -> %{"host_user_id" => to_string(user.id)}
  end
end

Returning %{} leaves host_user_id unset, which Cairnloop already handles gracefully (see Fail-closed by design above). Raising inside the MFA, by contrast, takes down the request.


Mounting more than one dashboard

If you mount the dashboard twice in one router (say, an internal desk and a partner desk with different auth), give each its own :live_session_name so the generated live_session names do not collide, and inject a different operator/tenant scope per mount:

scope "/internal" do
  pipe_through [:browser, :require_staff]
  Cairnloop.Router.cairnloop_dashboard "/support",
    live_session_name: :cairnloop_internal,
    session: {MyAppWeb.UserAuth, :cairnloop_session, [:internal]}
end

scope "/partner" do
  pipe_through [:browser, :require_partner]
  Cairnloop.Router.cairnloop_dashboard "/support",
    live_session_name: :cairnloop_partner,
    session: {MyAppWeb.UserAuth, :cairnloop_session, [:partner]}
end

Checklist

  • [ ] An authentication plug assigns the current operator before the dashboard's live session is
    built (in the `pipe_through` pipeline).
  • [ ] Access is gated with pipe_through and/or an on_mount authorization hook.
  • [ ] :session is an MFA tuple, not a static map — so host_user_id is the real signed-in
    operator, per request.
  • [ ] The MFA returns JSON-friendly primitives (stringified ids).
  • [ ] The no-operator branch is handled deliberately (return %{}, do not raise).
  • [ ] You verified the audit log attributes actions to the actual operator, not a placeholder.

See the worked example in examples/cairnloop_example, whose CairnloopExampleWeb.OperatorAuth demonstrates exactly this MFA seam.