ETS-backed authorization result cache with TTL expiry and a size cap.
Concurrent reads go directly to ETS (no GenServer round-trip). Mutations are serialized through the GenServer to make eviction safe.
Setting cache_ttl to 0 disables the cache. No other value does: a
negative, nil or non-integer cache_ttl is refused by
EndPointBlank.configure/1 (sc-970). While disabled, reads always miss,
writes are refused (including one already queued when the config
changed — see handle_cast/2), and both get/1 and put/2 clear
the whole table — every entry, not just the one being looked up or
written — the moment either of them observes the disabled state. Clearing
the whole table, not just one key, matters: an operator disabling the
cache specifically to force-flush one revoked grant, then re-enabling it,
must not have any other entry already cached — one that request never
touched — resurface once cache_ttl is restored. This mirrors the
clear() primitive the JS, Python, Ruby and Java SDKs of this same
contract expose for the same purpose (authentication-cache.js,
authentication_cache.py, authentication_cache.rb,
AuthenticationCache.java).
cache_ttl, "disabled", and the table are each per BEAM node —
nothing about any of them is shared or propagated across a cluster.
cache_ttl comes from EndPointBlank.Config, an Agent + ETS pair
started by this node's application supervisor; configure/1 — the
only way to set cache_ttl (no ENDPOINTBLANK_CACHE_TTL or other env
var exists for it; see Config's resolve/1) — sets it on the node it
runs on and has no effect anywhere else. So "disabled" is a per-node
fact — a node is disabled only if that node's own cache_ttl is
0 — and so is this module's table (ETS is not distributed; each
node's AuthCache holds only what was cached on it).
A concrete case worth naming: an operator runs configure(cache_ttl: 0)
on node A alone. Node A's own table clears the next time an authorize
call (or a direct get/1/put/2) lands on it. Nodes B and C are
completely unaffected — their cache_ttl is still whatever it was, they
are not "disabled" by any definition this module has, and a revoked
grant already cached on either of them keeps answering for up to its own
TTL. There is no mechanism here, and none planned, by which B or C
"find out" A was disabled. Flushing every node requires configuring
cache_ttl: 0 on every node individually and then getting an
authorize call (or a direct get/1/put/2) to land on each of
them while it is; nothing here makes that happen for you, and nothing
here can turn a single disable into a cluster-wide guarantee.
Residual, deliberately not fixed here (the same in all five SDKs of
this contract): even on one single node, the clear only runs when
get/1 or put/2 is actually called while that node's own
cache_ttl is 0 — never at configure/1 time itself, and never merely
because traffic arrives. In this library the only caller of either
function is EndPointBlank.Commands.EndpointAuthorize (reached through
EndPointBlank.Plug.Authorized), so concretely it takes an authorize
call — or a direct call to get/1/put/2 — landing on a given node
while that node is disabled to flush that node's table. A host that only
ever calls EndPointBlank.Authorization.intake_header/0 or otherwise
authenticates without going through the authorize plug never reaches
AuthCache at all, disabled or not, and toggling cache_ttl around such
a call flushes nothing. Likewise, on any one node,
EndPointBlank.configure(cache_ttl: 0) immediately followed by
EndPointBlank.configure(cache_ttl: 300), with no authorize call (or
direct get/1/put/2) landing on that node in between, flushes nothing.
Call clear/0 directly, on every node, when a flush is the goal and an
intervening authorize call on each one is not something you can rely on.
cache_ttl changes apply to entries already cached (sc-755)
Every entry records both written_at and its own expires_at (the
cache_ttl in force when it was written, added to written_at). On
every read, get/1 re-derives validity from the cache_ttl in force
right now rather than trusting only the stamped expires_at: a hit
requires both
now < expires_at— raisingcache_ttlat runtime never extends an entry past what it was written with, andnow - written_at < current cache_ttl— loweringcache_ttlat runtime applies to it starting on the very next read, not just to entries written after the change.
Both must hold; either one failing is a miss, and a miss on a stored
key deletes that exact entry (:ets.delete_object/2, not :ets.delete/2)
so a fresh write racing the read for the same key is never collateral
damage.
Deliberately not expires_at - now <= current cache_ttl (comparing
remaining time against the new window): once enough real time has
passed, an entry's remaining-until-original-expiry can coincidentally
fall back under a new, shorter TTL window and look valid again even
though it is older than that window allows. Anchoring both checks to the
fixed written_at avoids that clamp trap.
A row left behind by a pre-sc-755 release (<= 0.7.0, three elements:
{key, source_env_id, expires_at}, no written_at) can still be
resident after a hot code upgrade that does not drop the ETS table.
get/1 treats such a row as a miss and deletes it rather than raising.
Cache key: "epb_auth:{client_auth}:{path}:{method}:{app_name}:{version}"
Value stored: the source_application_environment_id from the 201 response.
get/2 and put/3 accept an explicit now (the same monotonic
millisecond clock get/1/put/2 pass by default) and are @doc false:
they exist only so tests can exercise specific points in time
deterministically, without sleeping for real or giving any
configuration-reachable surface control over this cache's clock.
Summary
Functions
Returns a specification to start this module under a supervisor.
Deletes every entry from the cache, regardless of expiry.
Looks up key in the cache.
Stores a successful auth result (source_env_id may be nil) under key.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Deletes every entry from the cache, regardless of expiry.
A single :ets.delete_all_objects/1 on the :public table, safe to call
from any process without going through the GenServer: unlike the size-cap
eviction in handle_cast/2 (a foldl-then-delete-then-insert sequence that
needs serializing to stay consistent), wiping the whole table is one atomic
operation with nothing to coordinate.
Called automatically whenever a get/1 or put/2 call observes
cache_ttl set to 0 on this node — not continuously while it is disabled,
and not on any other node (see the moduledoc). Also exposed publicly so
a host application can force-flush the cache directly, on demand,
matching the clear() the JS, Python, Ruby and Java SDKs already
provide for that purpose.
Looks up key in the cache.
Returns {:hit, source_env_id} only if the entry exists and is still
valid against the cache_ttl in force right now — see the moduledoc
for the two conditions that must both hold. :miss otherwise, which also
deletes a found-but-stale entry (never a fresh one written concurrently
under the same key — see the moduledoc). Always :miss while the cache
is disabled (cache_ttl is 0) — and, on that path, also clears the whole
table so a later re-enable cannot resurrect what looked disabled. See
clear/0.
Stores a successful auth result (source_env_id may be nil) under key.