0016. Security defaults: fail closed, print nothing a branch holds, read storage defensively

Copy Markdown View Source

Status: accepted, 2026-10-22. Follow-up to ADR 0010 (stored actors) and ADR 0015 (the inspector).

Context

A review of the library and the inspector found no injection (every SQL value is a bound parameter, the table name is a constant) and no advisory in any lockfile. It found four places where a default was more permissive than it needed to be: the inspector printed the values of sensitive? attributes in a diff; it acted with no decider when the host's :actor function found nobody signed in; inspect/1 of a branch, and so a crash report of its process, printed the raw input of every captured write; and a malformed state column crashed the caller instead of returning an error.

Decision

  • The inspector redacts sensitive? attributes ([redacted] for before and after). The row stays so the reviewer sees that it changed. Same flag as ADR 0010; no second list.
  • A configured :actor that returns nil means nobody is signed in, and the page refuses every action. Without the option nothing changes: the host has not said the page acts for anyone.
  • Ramal.Branch derives Inspect with only: [:id, :name, :domain, :status, :inserted_at]. Ash redacts sensitive attributes of records but not action input, which the log must keep to replay.
  • Ramal.Storage.Codec.decode_state/2 never raises and never echoes stored values. It reports the kind of exception, rejects a module name that is not shaped like one before it reaches :code.where_is_file/1, and rejects a stored struct whose module has no __struct__/0.
  • CI runs mix hex.audit in the three projects.
  • Follow-up, 2026-10-23: the inspector's :actor is required (it was left optional above so that no host broke). ramal_inspector raises ArgumentError at compile time without it, and the page raises when it opens if the function does not exist (the module may not be compiled yet when the router is). There is no opt-out: a host with no users passes a function that returns a constant.
  • Follow-up, 2026-10-23: optional limits on creating branches. Ramal.Supervisor takes :max_active_branches (open and proposed branches per domain) and :branch_rate ({count, milliseconds}: creations per domain per fixed window); Ramal.branch/2 and Ramal.fork/2 return {:error, %Ramal.Error{reason: :limit_reached}} past either and emit [:ramal, :limit, :reached]. Both are off by default. Counting uses a new optional Ramal.Storage.count/3 (one query with Postgres, list/3 for a backend without it). Limits are per domain, not per user, and the first is soft under concurrency: user scoping stays with the host.
  • Not changed: the ETS tables stay :public (every branch process writes to them) and branch ids stay the only capability to a branch. Both are documented in SECURITY.md as the host's boundary.

Consequences

  • A ramal_inspector call without :actor stops compiling until it names one. The inspector first ships in 0.2.0, so no released version is affected.
  • A host that turns on a limit must handle :limit_reached from Ramal.branch/2 and Ramal.fork/2; the inspector already shows any error from a fork on the page.
  • An :actor function that legitimately returns nil for a service account must return a value for it instead.
  • Debugging a branch in IEx shows less; read branch.log and branch.overlay on purpose.
  • The inspector cannot show a sensitive value to someone who needs to review it. They can read it from the record.
  • A mount-per-live_session rule remains for inspectors with different audiences: the page reads its options from the URL the browser joins with, which LiveView checks against the session's view and live_session name only.