EndPointBlank.AuthCache (end_point_blank_elixir v0.9.0)

Copy Markdown View Source

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 — raising cache_ttl at runtime never extends an entry past what it was written with, and
  • now - written_at < current cache_ttl — lowering cache_ttl at 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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

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.

get(key)

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.

put(key, source_env_id)

Stores a successful auth result (source_env_id may be nil) under key.

start_link(opts \\ [])