defmodule BreakGlass.TokenStore do @moduledoc """ GenServer that owns the `:break_glass_tokens` ETS table. Maintains at most one active break-glass session token in memory. A node restart automatically invalidates any active token — by design. ## ETS Table Table name: `:break_glass_tokens` Record format: `{:active_token, token :: binary(), inserted_at :: DateTime.t()}` Access: `:protected` with `read_concurrency: true` `lookup/1` reads ETS directly — no GenServer round-trip. All mutations go through `GenServer.call/2` so writes are serialised and race-free. ## Functions - `build_and_store/0` — generates a 32-byte cryptographically random token, stores it with a UTC timestamp, and returns the binary token - `lookup/1` — returns `{:ok, inserted_at}` if the token matches, or `:error` - `delete/1` — removes the token if it matches; returns `:ok` or `:error` - `clear/0` — unconditionally removes the active token record """ use GenServer @table :break_glass_tokens # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc """ Starts the TokenStore GenServer and registers it under `#{__MODULE__}`. """ @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Generates a 32-byte cryptographically random token, stores it with a UTC insertion timestamp (replacing any previously stored token), and returns the raw binary token. """ @spec build_and_store() :: binary() def build_and_store do GenServer.call(__MODULE__, :build_and_store) end @doc """ Looks up `token` in the ETS table. Reads ETS directly — no GenServer round-trip. Returns `{:ok, inserted_at}` when `token` matches the stored token, or `:error` when there is no match or no token is stored. """ @spec lookup(token :: binary()) :: {:ok, DateTime.t()} | :error def lookup(token) do case :ets.lookup(@table, :active_token) do [{:active_token, ^token, inserted_at}] -> {:ok, inserted_at} _ -> :error end end @doc """ Deletes the stored token if it matches `token`. First checks the stored token against `token` via an ETS read, then delegates the actual removal to the GenServer to serialise the write. Returns `:ok` on a successful match-and-delete, or `:error` when the token does not match (or no token is stored). """ @spec delete(token :: binary()) :: :ok | :error def delete(token) do case :ets.lookup(@table, :active_token) do [{:active_token, ^token, _inserted_at}] -> GenServer.call(__MODULE__, {:delete, token}) _ -> :error end end @doc """ Unconditionally removes the active token record from the ETS table. Returns `:ok`. """ @spec clear() :: :ok def clear do GenServer.call(__MODULE__, :clear) end # --------------------------------------------------------------------------- # GenServer callbacks # --------------------------------------------------------------------------- @impl true def init(_opts) do :ets.new(@table, [:set, :protected, :named_table, {:read_concurrency, true}]) {:ok, %{}} end @impl true def handle_call(:build_and_store, _from, state) do token = :crypto.strong_rand_bytes(32) inserted_at = DateTime.utc_now() :ets.insert(@table, {:active_token, token, inserted_at}) {:reply, token, state} end @impl true def handle_call({:delete, token}, _from, state) do # Re-check under the GenServer lock to avoid TOCTOU result = case :ets.lookup(@table, :active_token) do [{:active_token, ^token, _}] -> :ets.delete(@table, :active_token) :ok _ -> :error end {:reply, result, state} end @impl true def handle_call(:clear, _from, state) do :ets.delete(@table, :active_token) {:reply, :ok, state} end end