Overview

Actor visibility in Squidie provides a host-controlled authorization boundary that allows deriving less-sensitive views of workflow data for different actors without mutating the durable history. This enables secure multi-tenant access patterns where different users see appropriate levels of detail based on their authorization scope.

Core Concepts

Visibility Scopes

Squidie defines three standard visibility scopes, each providing different levels of information access:

:external (Most Restrictive)

  • Purpose: Minimal information for external users and public APIs
  • Preserves: High-level status, current node state, manual task shape
  • Redacts: All sensitive data including inputs, outputs, errors, metadata, command history, tokens

:operator (Moderate Access)

  • Purpose: Operational detail for support staff and monitoring
  • Preserves: Everything from :external plus reason, attempt counts, next visibility time, anomaly count
  • Redacts: Actual data payloads, sensitive identifiers, secrets

:auditor (Full Access)

  • Purpose: Complete unredacted view for audit trails and debugging
  • Preserves: Everything - no redaction applied
  • Use with caution: Should be restricted to privileged users only

Redacted Fields

The following fields are automatically redacted for non-auditor actors:

  • input, output, result, error - Workflow data payloads
  • payload, metadata - Additional context data
  • command_history - Full command audit trail
  • attempts, attempt_* - Retry and failure details
  • idempotency_key - Deduplication identifiers
  • claim_id, owner_id - Ownership information
  • lease_* - Lease and lock information
  • token, secret - Authentication credentials

Implementation

Basic Usage

# Get a workflow snapshot
{:ok, snapshot} = Squidie.inspect(run_id)

# Redact for external actor (most restrictive)
external_view = Squidie.ReadModel.Visibility.redact(snapshot, :external)

# Redact for operator actor
operator_view = Squidie.ReadModel.Visibility.redact(snapshot, :operator)

# Get full auditor view (no redaction)
auditor_view = Squidie.ReadModel.Visibility.redact(snapshot, :auditor)

Custom Visibility Policies

Host applications can define custom visibility policies to map actors to scopes:

Method 1: Policy Module with Callback

defmodule MyApp.VisibilityPolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @impl true
  def visibility_scope(actor, _view) do
    cond do
      actor.role == "admin" -> :auditor
      actor.role == "support" -> :operator
      true -> :external
    end
  end
end

# Usage
redacted_view = Squidie.ReadModel.Visibility.redact(
  snapshot,
  %{role: "support"},
  MyApp.VisibilityPolicy
)

Method 2: Policy Module with Options

defmodule MyApp.ConfigurablePolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @impl true
  def visibility_scope(actor, view, opts) do
    tenant_id = Keyword.get(opts, :tenant_id)

    cond do
      actor.tenant_id == tenant_id and actor.role == "owner" -> :auditor
      actor.role == "operator" -> :operator
      true -> :external
    end
  end
end

# Usage with options
redacted_view = Squidie.ReadModel.Visibility.redact(
  snapshot,
  %{tenant_id: "acme", role: "owner"},
  {MyApp.ConfigurablePolicy, tenant_id: "acme"}
)

Method 3: Anonymous Function Policy

# Define a custom policy function
policy_fn = fn
  %{admin: true}, _view -> :auditor
  %{support: true}, _view -> :operator
  _, _view -> :external
end

# Apply the policy
redacted_view = Squidie.ReadModel.Visibility.redact(
  snapshot,
  %{support: true},
  policy_fn
)

Integration Patterns

HTTP/API Boundary

defmodule MyAppWeb.WorkflowController do
  use MyAppWeb, :controller

  def show(conn, %{"id" => run_id}) do
    # Get current user/actor from session
    actor = get_current_user(conn)

    # Fetch workflow snapshot
    {:ok, snapshot} = Squidie.inspect(run_id)

    # Apply visibility policy based on actor
    redacted_view = Squidie.ReadModel.Visibility.redact(
      snapshot,
      actor,
      MyApp.VisibilityPolicy
    )

    # Return redacted view to client
    json(conn, redacted_view)
  end
end

LiveView Integration

defmodule MyAppWeb.WorkflowLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(%{"id" => run_id}, session, socket) do
    actor = get_actor_from_session(session)

    # Subscribe to workflow updates
    Squidie.subscribe(run_id)

    # Get initial snapshot with appropriate visibility
    {:ok, snapshot} = Squidie.inspect(run_id)
    redacted_view = apply_visibility(snapshot, actor)

    {:ok, assign(socket, workflow: redacted_view, actor: actor)}
  end

  @impl true
  def handle_info({:workflow_updated, snapshot}, socket) do
    # Apply same visibility policy to updates
    redacted_view = apply_visibility(snapshot, socket.assigns.actor)
    {:noreply, assign(socket, workflow: redacted_view)}
  end

  defp apply_visibility(snapshot, actor) do
    Squidie.ReadModel.Visibility.redact(
      snapshot,
      actor,
      MyApp.VisibilityPolicy
    )
  end
end

Manual Actions with Actor Tracking

defmodule MyApp.WorkflowActions do
  def approve_task(run_id, task_ref, actor) do
    # Actor information is preserved in command history
    Squidie.signal(run_id, {:approve, task_ref}, actor: actor)
  end

  def reject_task(run_id, task_ref, actor, reason) do
    Squidie.signal(
      run_id,
      {:reject, task_ref, reason: reason},
      actor: actor
    )
  end

  def pause_workflow(run_id, actor) do
    Squidie.signal(run_id, :pause, actor: actor)
  end
end

Actor Information in Command History

When actors perform manual actions, their information is captured in the command history:

# Command with actor information
{:ok, _} = Squidie.signal(
  run_id,
  {:approve, "review_task"},
  actor: %{
    id: "user_123",
    email: "reviewer@example.com",
    role: "reviewer"
  }
)

# The actor information is stored in command receipts
{:ok, snapshot} = Squidie.inspect(run_id)

# Auditors can see full command history with actors
auditor_view = Squidie.ReadModel.Visibility.redact(snapshot, :auditor)
# auditor_view.command_history includes actor information

# External users cannot see command history
external_view = Squidie.ReadModel.Visibility.redact(snapshot, :external)
# external_view.command_history is nil (redacted)

Security Best Practices

1. Apply Host Authorization First

Always verify actor permissions at the host boundary before applying visibility:

def show_workflow(conn, %{"id" => run_id}) do
  actor = get_current_user(conn)

  # Host authorization check
  with :ok <- authorize_workflow_access(actor, run_id) do
    {:ok, snapshot} = Squidie.inspect(run_id)

    # Then apply visibility policy
    view = Squidie.ReadModel.Visibility.redact(snapshot, actor, policy())
    json(conn, view)
  else
    {:error, :unauthorized} -> send_resp(conn, 403, "Forbidden")
  end
end

2. Never Expose Raw Snapshots

Never serialize full snapshots directly to untrusted clients:

# WRONG - Exposes sensitive data
def unsafe_endpoint(conn, %{"id" => run_id}) do
  {:ok, snapshot} = Squidie.inspect(run_id)
  json(conn, snapshot)  # DON'T DO THIS!
end

# CORRECT - Always redact first
def safe_endpoint(conn, %{"id" => run_id}) do
  {:ok, snapshot} = Squidie.inspect(run_id)
  actor = get_current_user(conn)
  view = Squidie.ReadModel.Visibility.redact(snapshot, actor, policy())
  json(conn, view)
end

3. Treat Auditor Scope as Privileged

The :auditor scope provides complete access to all data. Restrict it carefully:

defmodule MyApp.StrictPolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @impl true
  def visibility_scope(actor, _view) do
    cond do
      # Only system admins get auditor access
      actor.role == "system_admin" and actor.mfa_verified -> :auditor

      # Support staff get operator access
      actor.role in ["support", "operator"] -> :operator

      # Everyone else gets external view
      true -> :external
    end
  end
end

4. Immutable Durable History

Visibility redaction creates derived views without modifying the durable history:

# Original data remains intact in the journal
{:ok, full_snapshot} = Squidie.inspect(run_id)

# Redaction creates a new view, doesn't modify original
external_view = Squidie.ReadModel.Visibility.redact(full_snapshot, :external)

# The journal still contains all original data
# Only privileged processes should access it directly

Testing Visibility Policies

defmodule MyApp.VisibilityPolicyTest do
  use ExUnit.Case
  alias Squidie.ReadModel.Visibility

  setup do
    # Create test snapshot with sensitive data
    snapshot = %Squidie.ReadModel.Snapshot{
      run_id: "test_run",
      status: :running,
      input: %{secret: "sensitive_data"},
      output: %{result: "private_result"},
      metadata: %{internal: "metadata"}
    }

    {:ok, snapshot: snapshot}
  end

  test "external actors see redacted view", %{snapshot: snapshot} do
    actor = %{role: "external"}

    redacted = Visibility.redact(snapshot, actor, MyApp.VisibilityPolicy)

    assert redacted.status == :running
    assert redacted.input == nil
    assert redacted.output == nil
    assert redacted.metadata == nil
  end

  test "operators see operational details", %{snapshot: snapshot} do
    actor = %{role: "operator"}

    redacted = Visibility.redact(snapshot, actor, MyApp.VisibilityPolicy)

    assert redacted.status == :running
    assert redacted.input == nil  # Still redacted
    assert redacted.output == nil  # Still redacted
    # But operational fields would be visible
  end

  test "auditors see everything", %{snapshot: snapshot} do
    actor = %{role: "admin"}

    view = Visibility.redact(snapshot, actor, MyApp.VisibilityPolicy)

    assert view.input == %{secret: "sensitive_data"}
    assert view.output == %{result: "private_result"}
    assert view.metadata == %{internal: "metadata"}
  end
end

Common Patterns

Multi-Tenant Visibility

defmodule MyApp.MultiTenantPolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @impl true
  def visibility_scope(actor, view, opts) do
    tenant_id = Keyword.get(opts, :tenant_id)

    cond do
      # Tenant owner sees everything for their workflows
      actor.tenant_id == tenant_id and actor.role == "owner" ->
        :auditor

      # Tenant members see operational details
      actor.tenant_id == tenant_id ->
        :operator

      # Cross-tenant support staff see limited details
      actor.role == "support" ->
        :operator

      # Others see minimal information
      true ->
        :external
    end
  end
end

Role-Based Access Control (RBAC)

defmodule MyApp.RBACPolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @role_scopes %{
    "admin" => :auditor,
    "manager" => :auditor,
    "analyst" => :operator,
    "support" => :operator,
    "viewer" => :external,
    "guest" => :external
  }

  @impl true
  def visibility_scope(actor, _view) do
    Map.get(@role_scopes, actor.role, :external)
  end
end

Time-Based Visibility

defmodule MyApp.TimeBasedPolicy do
  @behaviour Squidie.ReadModel.Visibility.Policy

  @impl true
  def visibility_scope(actor, view) do
    workflow_age = DateTime.diff(DateTime.utc_now(), view.created_at, :day)

    cond do
      # Admins always see everything
      actor.role == "admin" -> :auditor

      # Recent workflows - more visibility
      workflow_age < 7 and actor.role == "operator" -> :operator

      # Older workflows - restricted visibility
      workflow_age >= 30 -> :external

      # Default
      true -> :external
    end
  end
end

Troubleshooting

Issue: Sensitive Data Appearing in External Views

Symptom: Data that should be redacted is visible to external actors.

Solution: Verify your visibility policy is returning the correct scope:

# Debug your policy
actor = %{role: "external_user"}
view = %{} # Your view data

scope = MyApp.VisibilityPolicy.visibility_scope(actor, view)
IO.inspect(scope, label: "Visibility scope for actor")

# Should return :external for external users
assert scope == :external

Issue: Command History Not Captured

Symptom: Actor information not appearing in audit logs.

Solution: Ensure you're passing actor information in signals:

# Correct - includes actor
Squidie.signal(run_id, :pause, actor: %{id: "user_123"})

# Incorrect - missing actor
Squidie.signal(run_id, :pause)  # Actor won't be recorded

Issue: Performance with Large Snapshots

Symptom: Slow response times when redacting large workflow snapshots.

Solution: Consider caching redacted views:

defmodule MyApp.CachedVisibility do
  use GenServer

  def get_redacted_view(run_id, actor, policy) do
    cache_key = {run_id, actor_scope(actor), :view}

    case :ets.lookup(:visibility_cache, cache_key) do
      [{^cache_key, cached_view, expiry}] when expiry > now() ->
        cached_view

      _ ->
        {:ok, snapshot} = Squidie.inspect(run_id)
        view = Squidie.ReadModel.Visibility.redact(snapshot, actor, policy)
        cache_view(cache_key, view)
        view
    end
  end
end

See Also