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