From 785faf24418c3b559df818b6b2f28877273b7a4c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 01:03:57 +0000 Subject: [PATCH] identity: delegated grant-verification cache with generation invalidation (Phase 3 complete, +9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache.sx — a process wrapping the token registry, memoising introspect. Revocation stays real via generation invalidation: any revoke/refresh bumps a generation counter, so every cached positive instantly becomes a miss and re-validates against the live registry. A revoked token never reads valid out of cache, not for a millisecond. stats() exposes hits/misses. New tests/cache.sx. 101/101. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/cache.sx | 29 ++++++++++ lib/identity/conformance.sh | 5 ++ lib/identity/scoreboard.json | 7 +-- lib/identity/scoreboard.md | 3 +- lib/identity/tests/cache.sx | 102 +++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 11 +++- 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 lib/identity/cache.sx create mode 100644 lib/identity/tests/cache.sx diff --git a/lib/identity/cache.sx b/lib/identity/cache.sx new file mode 100644 index 00000000..4ccc59fd --- /dev/null +++ b/lib/identity/cache.sx @@ -0,0 +1,29 @@ +;; identity/cache.sx — a delegated grant-verification cache, mirroring the +;; Redis-cache pattern apps use in front of grant verification. +;; +;; The cache is a process wrapping a token registry. introspect() is +;; memoised; issue/issue_grant/refresh/revoke pass through. The danger +;; with any cache is staleness: a revoked token must NOT keep reading +;; valid out of the cache, not even for a millisecond (the loop's hard +;; rule). We get that for free with GENERATION invalidation: +;; +;; - each cache entry records the generation it was written at; +;; - a hit requires entry.generation == current generation; +;; - any state-changing op that can invalidate an existing token +;; (revoke — which cascades to a grant; refresh — whose reuse cascades) +;; bumps the generation. +;; +;; So a single revoke instantly invalidates every cached positive: the +;; next introspect is a miss and re-validates against the live registry, +;; which returns {inactive}. Revocation stays real; the cache only ever +;; accelerates the steady state, never overrides a revocation. +;; +;; stats() -> {Hits, Misses} so callers can see the cache is live. + +(define + identity-cache-source + "-module(identity_grant_cache).\n\n start() ->\n spawn(fun () ->\n Reg = identity_tokens:start(),\n loop(Reg, 1, [], 0, 0)\n end).\n\n issue(C, Subject, Client, Scope) ->\n C ! {issue, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n issue_grant(C, Subject, Client, Scope) ->\n C ! {issue_grant, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n refresh(C, RefreshTok) ->\n C ! {refresh, RefreshTok, self()},\n receive {cache_reply, R} -> R end.\n\n introspect(C, Token) ->\n C ! {introspect, Token, self()},\n receive {cache_reply, R} -> R end.\n\n revoke(C, Token) ->\n C ! {revoke, Token, self()},\n receive {cache_reply, R} -> R end.\n\n stats(C) ->\n C ! {stats, self()},\n receive {cache_reply, R} -> R end.\n\n loop(Reg, Gen, Entries, Hits, Misses) ->\n receive\n {introspect, Tok, From} ->\n case lookup_fresh(Tok, Gen, Entries) of\n {hit, Result} ->\n From ! {cache_reply, Result},\n loop(Reg, Gen, Entries, Hits + 1, Misses);\n miss ->\n Result = identity_tokens:introspect(Reg, Tok),\n From ! {cache_reply, Result},\n loop(Reg, Gen, put_entry(Tok, Result, Gen, Entries), Hits, Misses + 1)\n end;\n {issue, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {issue_grant, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue_grant(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {refresh, RTok, From} ->\n From ! {cache_reply, identity_tokens:refresh(Reg, RTok)},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {revoke, Tok, From} ->\n identity_tokens:revoke(Reg, Tok),\n From ! {cache_reply, ok},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {stats, From} ->\n From ! {cache_reply, {Hits, Misses}},\n loop(Reg, Gen, Entries, Hits, Misses)\n end.\n\n lookup_fresh(_, _, []) -> miss;\n lookup_fresh(Tok, Gen, [{T, {Result, G}} | Rest]) ->\n case T =:= Tok of\n true ->\n case G =:= Gen of\n true -> {hit, Result};\n false -> miss\n end;\n false -> lookup_fresh(Tok, Gen, Rest)\n end.\n\n put_entry(Tok, Result, Gen, Entries) ->\n [{Tok, {Result, Gen}} | remove(Tok, Entries)].\n\n remove(_, []) -> [];\n remove(Tok, [{T, V} | Rest]) ->\n case T =:= Tok of\n true -> remove(Tok, Rest);\n false -> [{T, V} | remove(Tok, Rest)]\n end.") + +(define + identity-load-cache! + (fn () (erlang-load-module identity-cache-source))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index d891a225..db90c03e 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -35,6 +35,7 @@ SUITES=( "oauth|id-oauth-test-pass|id-oauth-test-count" "sso|id-sso-test-pass|id-sso-test-count" "membership|id-membership-test-pass|id-membership-test-count" + "cache|id-cache-test-pass|id-cache-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -52,6 +53,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/api.sx") (load "lib/identity/oauth.sx") (load "lib/identity/membership.sx") +(load "lib/identity/cache.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") @@ -59,6 +61,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/oauth.sx") (load "lib/identity/tests/sso.sx") (load "lib/identity/tests/membership.sx") +(load "lib/identity/tests/cache.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -73,6 +76,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-sso-test-pass id-sso-test-count)") (epoch 106) (eval "(list id-membership-test-pass id-membership-test-count)") +(epoch 107) +(eval "(list id-cache-test-pass id-cache-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 4ca9768d..750ae4b8 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 92, - "total": 92, + "total_pass": 101, + "total": 101, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":18,"total":18,"status":"ok"}, @@ -9,6 +9,7 @@ {"name":"api","pass":10,"total":10,"status":"ok"}, {"name":"oauth","pass":17,"total":17,"status":"ok"}, {"name":"sso","pass":10,"total":10,"status":"ok"}, - {"name":"membership","pass":17,"total":17,"status":"ok"} + {"name":"membership","pass":17,"total":17,"status":"ok"}, + {"name":"cache","pass":9,"total":9,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 151ce467..b5be1538 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 92 / 92 tests passing** +**Total: 101 / 101 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -11,6 +11,7 @@ | ✅ | oauth | 17 | 17 | | ✅ | sso | 10 | 10 | | ✅ | membership | 17 | 17 | +| ✅ | cache | 9 | 9 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/cache.sx b/lib/identity/tests/cache.sx new file mode 100644 index 00000000..12092387 --- /dev/null +++ b/lib/identity/tests/cache.sx @@ -0,0 +1,102 @@ +;; identity/tests/cache.sx — delegated grant-verification cache. Proves +;; the cache is live (hits/misses) AND that revocation stays real: a +;; revoked token never reads valid out of the cache, because any revoke +;; bumps the generation and forces re-validation. + +(define id-cache-test-count 0) +(define id-cache-test-pass 0) +(define id-cache-test-fails (list)) + +(define + id-cache-test + (fn + (name actual expected) + (set! id-cache-test-count (+ id-cache-test-count 1)) + (if + (= actual expected) + (set! id-cache-test-pass (+ id-cache-test-pass 1)) + (append! id-cache-test-fails {:name name :expected expected :actual actual})))) + +(define idc-ev erlang-eval-ast) +(define idcnm (fn (v) (get v :name))) + +(identity-load-token!) +(identity-load-cache!) + +;; ── delegation: cache forwards to the registry ─────────────────── + +(id-cache-test + "introspect through the cache returns active" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +;; ── the cache is actually caching ──────────────────────────────── + +(id-cache-test + "a repeated introspect is a cache hit" + (idc-ev + "C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {H, _} -> H end") + 1) + +(id-cache-test + "the first introspect of a token is a miss" + (idc-ev + "C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end") + 1) + +;; ── revocation stays real through the cache (the centrepiece) ───── + +(id-cache-test + "a revoked token introspects inactive through the cache" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) + "inactive") + +(id-cache-test + "revoke invalidates the cache (post-revoke read re-validates)" + (idc-ev + "C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end") + 2) + +;; ── cascade visibility through the cache ────────────────────────── + +(id-cache-test + "cascade revocation is visible through the cache" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n {ok, A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:revoke(C, R),\n case identity_grant_cache:introspect(C, A) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) + "inactive") + +;; ── a sibling token re-validates correctly after a revoke ──────── + +(id-cache-test + "revoking one token leaves an independent token valid" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n {ok, A} = identity_grant_cache:issue(C, alice, web, read),\n {ok, B} = identity_grant_cache:issue(C, bob, cli, write),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:introspect(C, B),\n identity_grant_cache:revoke(C, A),\n case identity_grant_cache:introspect(C, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end")) + "bob") + +;; ── refresh flows through the cache and stays correct ──────────── + +(id-cache-test + "a refreshed token introspects active through the cache" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n {ok, _A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n {ok, A2, _R2} = identity_grant_cache:refresh(C, R),\n case identity_grant_cache:introspect(C, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +;; ── unknown token is inactive, and cached as such ──────────────── + +(id-cache-test + "an unknown token introspects inactive through the cache" + (idcnm + (idc-ev + "C = identity_grant_cache:start(),\n Bogus = make_ref(),\n case identity_grant_cache:introspect(C, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "inactive") + +(define + id-cache-test-summary + (str "cache " id-cache-test-pass "/" id-cache-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 4c05b1aa..7ac033e0 100644 --- a/plans/identity-on-sx.md +++ b/plans/identity-on-sx.md @@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`. ## Status (rolling) -`bash lib/identity/conformance.sh` → **92/92** (Phases 1–2 + SSO + membership) +`bash lib/identity/conformance.sh` → **101/101** (Phases 1–3 complete) ## Ground rules @@ -70,7 +70,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ## Phase 3 — Silent SSO + membership - [x] `prompt=none` cross-app login (one session, many clients) - [x] membership state + per-app grant projection -- [ ] grant verification delegated cache (mirror Redis-cache pattern) +- [x] grant verification delegated cache (mirror Redis-cache pattern) ## Phase 4 — Audit + federation - [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)` @@ -78,6 +78,13 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-07 — `cache.sx`: delegated grant-verification cache (Redis-cache + pattern) wrapping the token registry. introspect memoised; generation + invalidation keeps revocation real — any revoke/refresh bumps a generation + counter so every cached positive instantly becomes a miss and re-validates + against the live registry. A revoked token never reads valid from cache. + stats() exposes hits/misses. New tests/cache.sx (9). **Phase 3 complete.** + +9 → 101/101. - 2026-06-07 — `membership.sx`: coop membership as a guarded state machine (none→pending→active→lapsed⇄active, any→revoked terminal); invalid transitions are explicit `{error, CurrentStatus}`. `project(Subject, App)`