identity: delegated grant-verification cache with generation invalidation (Phase 3 complete, +9)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
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) <noreply@anthropic.com>
This commit is contained in:
29
lib/identity/cache.sx
Normal file
29
lib/identity/cache.sx
Normal file
@@ -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)))
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
102
lib/identity/tests/cache.sx
Normal file
102
lib/identity/tests/cache.sx
Normal file
@@ -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))
|
||||
@@ -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)`
|
||||
|
||||
Reference in New Issue
Block a user