Holds each running sandbox's egress policy, keyed by its source /30
(005 T060a1/T060a6, 005-FR-011a–FR-011e).
The one way this design can leak across tenants
Everything else here is protected by topology: no sandbox has a route to any other, and identity is the kernel's view of the source address. Those hold continuously. Address reuse does not — it is a lifecycle race, and it is the single point where one tenant can inherit another's allowlist.
The sequence is:
- sandbox A holds
10.0.0.0/30with A's allowlist, - A is destroyed and its /30 returns to the pool,
- sandbox B is provisioned and assigned
10.0.0.0/30, - B connects — and if A's entry is still registered, B gets A's allowlist.
⚠️ Note what makes this dangerous rather than merely wrong: every outward
check still passes. B reaches destinations, denied destinations are refused,
the policy is not editable from inside. The allowlist being enforced is simply
the wrong tenant's. Nothing outward-facing distinguishes that from correct
operation, which is why it is enforced structurally below rather than by an
ordering convention in destroy.
The invariant
A /30 is not available for assignment until its policy entry is gone.
release/1 deletes the entry and only then returns the /30 to the pool;
assign/2 refuses a /30 that still carries one. Both are enforced here rather
than left to callers, because the correct ordering in a destroy callback is
exactly the kind of thing a later refactor reorders without knowing why it was
written that way.
Resolved answers live here too, and that is a lifecycle decision
029 T016 makes a hostname allowlist entry match by consulting what this
sandbox resolved that name to. Those answers are per-sandbox state with
exactly the same reuse hazard as the policy above: sandbox B assigned A's /30
while A's answers survive would inherit A's name bindings, which is the same
cross-tenant error one layer down and just as invisible from outside.
⚠️ So they are not a second store. Holding them here means assign/3's
refusal and release/2's delete cover both at once, and there is no second
ordering for a later refactor to get wrong. A separate Resolutions module
would have been tidier to read and would have re-opened the one leak this
module exists to close.
A record for an unregistered /30 is refused, not created. Creating one would file answers under a sandbox that does not exist, where nothing ever releases them.
Summary
Functions
Assigns source_key to a sandbox with the given allowlist.
Returns a specification to start this module under a supervisor.
Returns the allowlist for source_key, or [] when none is registered.
Files the addresses this sandbox resolved name to (029-FR-012).
True when source_key currently carries a policy.
Removes the policy for source_key. Idempotent (003-FR-013).
What this sandbox resolved, as %{name => MapSet.t(address)}.
Types
@type refusal() :: {:still_registered, ExSandbox.Egress.Policy.source_key()} | :pool_exhausted
Why an assignment was refused. Distinguishable by construction.
Functions
@spec assign( ExSandbox.Egress.Policy.source_key(), [ExSandbox.Egress.Policy.destination()], GenServer.server() ) :: :ok | {:error, refusal()}
Assigns source_key to a sandbox with the given allowlist.
Refuses with {:error, {:still_registered, key}} when the /30 still carries a
previous tenant's policy — see the invariant above.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec lookup(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: [ ExSandbox.Egress.Policy.destination() ]
Returns the allowlist for source_key, or [] when none is registered.
⚠️ [] rather than an error, and rather than nil. An unregistered source
must be denied, and Policy.permits?/2 denies [] — so the miss path and
the deny path are the same path. Returning an error would invite a caller to
handle it, and the tempting handling is to let the connection through while
logging.
@spec record_resolution( ExSandbox.Egress.Policy.source_key(), String.t(), [:inet.ip_address()], GenServer.server() ) :: :ok | {:error, :unknown_source}
Files the addresses this sandbox resolved name to (029-FR-012).
Refuses with {:error, :unknown_source} for a /30 carrying no policy — see
the moduledoc on why an entry is never created here.
⚠️ Answers accumulate rather than replace. A name legitimately resolves to
a different member of a rotation on each query, and a connection opened
against the first answer while the second is being recorded must not be
refused for it. The set is bounded by release/2, which is the sandbox's own
lifetime.
@spec registered?(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: boolean()
True when source_key currently carries a policy.
@spec release(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: :ok
Removes the policy for source_key. Idempotent (003-FR-013).
@spec resolutions(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: %{ optional(String.t()) => MapSet.t(:inet.ip_address()) }
What this sandbox resolved, as %{name => MapSet.t(address)}.
⚠️ %{} on a miss, for the same reason lookup/2 answers []: the miss path
and the deny path must be one path. An empty map permits no name, so a
sandbox that never resolved anything reaches no hostname entry.