From d2f5b49d3f9fbc09d498e098ffe5c3d895ab40d5 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 02:51:48 +0000 Subject: [PATCH] identity: unify api.sx facade over audit + membership (+9 tests) The identity coordinator now owns an audit ledger and a membership registry alongside its token table (started with the ledger) and session registry. login/logout are audited; new ops history/enroll/member_status/member_project surface the audit and membership axes through the one `identity` door. Identity proves who and reports membership; acl still decides permission. Existing api behaviour unchanged. New tests/facade.sx. 177/177. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/api.sx | 35 +++++++------ lib/identity/conformance.sh | 4 ++ lib/identity/scoreboard.json | 7 +-- lib/identity/scoreboard.md | 3 +- lib/identity/tests/facade.sx | 97 ++++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 11 +++- 6 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 lib/identity/tests/facade.sx diff --git a/lib/identity/api.sx b/lib/identity/api.sx index ea3fec2d..b93eb924 100644 --- a/lib/identity/api.sx +++ b/lib/identity/api.sx @@ -1,27 +1,24 @@ -;; identity/api.sx — the identity service facade. +;; identity/api.sx — the unified identity service facade. ;; -;; `identity:start()` spawns one coordinator process that owns a token -;; table and a session registry and ties them together. It exposes the -;; whole-domain operations the architecture sketch names: +;; `identity:start()` spawns one coordinator that owns the whole domain: +;; an audit ledger, a grant-backed token table (wired to that ledger), a +;; session registry, and a membership registry. It exposes the +;; whole-domain operations through one door: ;; -;; login(Svc, Subject, Client, Scope[, Ttl]) -> {ok, SessionId, Token} -;; verify(Svc, Token) -> {active, Subject, Client, Scope} | {inactive} -;; revoke(Svc, Token) -> ok (revokes the token; real, immediate) -;; logout(Svc, SessionId) -> ok (tombstones + deregisters a session) -;; session_status(Svc, Sid) -> active | expired | revoked | gone +;; login / verify / revoke / logout / session_status (sessions + tokens) +;; history(Subject) (audit ledger) +;; enroll / member_status / member_project (membership) ;; -;; The coordinator is also the Owner the sessions notify on idle timeout, -;; so an expired session deregisters itself from the directory — the -;; timeout is the only liveness driver; nothing sweeps. -;; -;; Delegation boundary: verify/2 answers IDENTITY only — who the token -;; belongs to and what scope was granted. It deliberately does NOT answer -;; \"may they do X\"; that question belongs to acl-on-sx, which keys off the -;; {active, Subject, Client, Scope} this returns. +;; Every grant transition is audited: login records `login`, logout records +;; `logout`, and the token table (started with the ledger) records +;; issue/revoke. The coordinator owns the sessions, so an idle session +;; deregisters itself. verify answers IDENTITY only ({active, Subject, +;; Client, Scope}); membership projection reports WHAT a subject is for an +;; app; whether either may do a thing is acl's call. (define identity-api-source - "-module(identity).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n loop(TokReg, SessReg, 1)\n end).\n\n login(Svc, Subject, Client, Scope) ->\n login(Svc, Subject, Client, Scope, infinity).\n\n login(Svc, Subject, Client, Scope, Ttl) ->\n Svc ! {login, Subject, Client, Scope, Ttl, self()},\n receive {identity_reply, R} -> R end.\n\n verify(Svc, Token) ->\n Svc ! {verify, Token, self()},\n receive {identity_reply, R} -> R end.\n\n revoke(Svc, Token) ->\n Svc ! {revoke, Token, self()},\n receive {identity_reply, R} -> R end.\n\n logout(Svc, SessionId) ->\n Svc ! {logout, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n session_status(Svc, SessionId) ->\n Svc ! {session_status, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n loop(TokReg, SessReg, NextId) ->\n receive\n {login, Subject, Client, Scope, Ttl, From} ->\n SessionId = NextId,\n Self = self(),\n S = identity_session:start(SessionId, Subject, Client, Self, Ttl),\n identity_registry:register(SessReg, SessionId, Subject, Client, S),\n {ok, Token} = identity_tokens:issue(TokReg, Subject, Client, Scope),\n From ! {identity_reply, {ok, SessionId, Token}},\n loop(TokReg, SessReg, NextId + 1);\n {verify, Token, From} ->\n From ! {identity_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, NextId);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, NextId);\n {logout, SessionId, From} ->\n case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} -> identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, SessionId),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, NextId);\n {session_status, SessionId, From} ->\n R = case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, {_, _, _, St}} -> St;\n {error, St} -> St\n end;\n {error, _} -> gone\n end,\n From ! {identity_reply, R},\n loop(TokReg, SessReg, NextId);\n {session_expired, SessionId} ->\n identity_registry:deregister(SessReg, SessionId),\n loop(TokReg, SessReg, NextId)\n end.") + "-module(identity).\n\n start() ->\n spawn(fun () ->\n Audit = identity_audit:start(),\n TokReg = identity_tokens:start(Audit),\n SessReg = identity_registry:start(),\n Members = identity_membership:start(),\n loop(TokReg, SessReg, Audit, Members, 1)\n end).\n\n login(Svc, Subject, Client, Scope) ->\n login(Svc, Subject, Client, Scope, infinity).\n\n login(Svc, Subject, Client, Scope, Ttl) ->\n Svc ! {login, Subject, Client, Scope, Ttl, self()},\n receive {identity_reply, R} -> R end.\n\n verify(Svc, Token) ->\n Svc ! {verify, Token, self()},\n receive {identity_reply, R} -> R end.\n\n revoke(Svc, Token) ->\n Svc ! {revoke, Token, self()},\n receive {identity_reply, R} -> R end.\n\n logout(Svc, SessionId) ->\n Svc ! {logout, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n session_status(Svc, SessionId) ->\n Svc ! {session_status, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n history(Svc, Subject) ->\n Svc ! {history, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n enroll(Svc, Subject, Tier) ->\n Svc ! {enroll, Subject, Tier, self()},\n receive {identity_reply, R} -> R end.\n\n member_status(Svc, Subject) ->\n Svc ! {member_status, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n member_project(Svc, Subject, App) ->\n Svc ! {member_project, Subject, App, self()},\n receive {identity_reply, R} -> R end.\n\n loop(TokReg, SessReg, Audit, Members, NextId) ->\n receive\n {login, Subject, Client, Scope, Ttl, From} ->\n SessionId = NextId,\n Self = self(),\n S = identity_session:start(SessionId, Subject, Client, Self, Ttl),\n identity_registry:register(SessReg, SessionId, Subject, Client, S),\n identity_audit:record(Audit, Subject, login),\n {ok, Token} = identity_tokens:issue(TokReg, Subject, Client, Scope),\n From ! {identity_reply, {ok, SessionId, Token}},\n loop(TokReg, SessReg, Audit, Members, NextId + 1);\n {verify, Token, From} ->\n From ! {identity_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {logout, SessionId, From} ->\n case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, SessionId),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_status, SessionId, From} ->\n R = case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, {_, _, _, St}} -> St;\n {error, St} -> St\n end;\n {error, _} -> gone\n end,\n From ! {identity_reply, R},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {history, Subject, From} ->\n From ! {identity_reply, identity_audit:actions(Audit, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {enroll, Subject, Tier, From} ->\n identity_membership:request(Members, Subject, Tier),\n identity_membership:approve(Members, Subject),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_status, Subject, From} ->\n From ! {identity_reply, identity_membership:status(Members, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_project, Subject, App, From} ->\n From ! {identity_reply, identity_membership:project(Members, Subject, App)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_expired, SessionId} ->\n identity_registry:deregister(SessReg, SessionId),\n loop(TokReg, SessReg, Audit, Members, NextId)\n end.\n\n audit_logout(Audit, Pid) ->\n case identity_session:lookup(Pid) of\n {ok, {_, Subject, _, _}} -> identity_audit:record(Audit, Subject, logout);\n {error, _} -> ok\n end.") (define identity-load-api! (fn () (erlang-load-module identity-api-source))) @@ -32,4 +29,6 @@ (identity-load-session!) (identity-load-token!) (identity-load-registry!) + (identity-load-audit!) + (identity-load-membership!) (identity-load-api!))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 51aac252..f9cda2f9 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -42,6 +42,7 @@ SUITES=( "clients|id-clients-test-pass|id-clients-test-count" "grants|id-grants-test-pass|id-grants-test-count" "device|id-device-test-pass|id-device-test-count" + "facade|id-facade-test-pass|id-facade-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -78,6 +79,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/clients.sx") (load "lib/identity/tests/grants.sx") (load "lib/identity/tests/device.sx") +(load "lib/identity/tests/facade.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -106,6 +108,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-grants-test-pass id-grants-test-count)") (epoch 113) (eval "(list id-device-test-pass id-device-test-count)") +(epoch 114) +(eval "(list id-facade-test-pass id-facade-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 25a76068..17d348a0 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 168, - "total": 168, + "total_pass": 177, + "total": 177, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -16,6 +16,7 @@ {"name":"expiry","pass":8,"total":8,"status":"ok"}, {"name":"clients","pass":11,"total":11,"status":"ok"}, {"name":"grants","pass":9,"total":9,"status":"ok"}, - {"name":"device","pass":10,"total":10,"status":"ok"} + {"name":"device","pass":10,"total":10,"status":"ok"}, + {"name":"facade","pass":9,"total":9,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 2dd083ed..7656001a 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 168 / 168 tests passing** +**Total: 177 / 177 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -18,6 +18,7 @@ | ✅ | clients | 11 | 11 | | ✅ | grants | 9 | 9 | | ✅ | device | 10 | 10 | +| ✅ | facade | 9 | 9 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/facade.sx b/lib/identity/tests/facade.sx new file mode 100644 index 00000000..cf5bcc06 --- /dev/null +++ b/lib/identity/tests/facade.sx @@ -0,0 +1,97 @@ +;; identity/tests/facade.sx — the unified facade: one coordinator wiring +;; sessions+tokens, the audit ledger, and membership. Exercises the +;; cross-module integration (login/logout auditing, audit history, member +;; enrollment + projection) through the single `identity` door. + +(define id-facade-test-count 0) +(define id-facade-test-pass 0) +(define id-facade-test-fails (list)) + +(define + id-facade-test + (fn + (name actual expected) + (set! id-facade-test-count (+ id-facade-test-count 1)) + (if + (= actual expected) + (set! id-facade-test-pass (+ id-facade-test-pass 1)) + (append! id-facade-test-fails {:name name :expected expected :actual actual})))) + +(define idfc-ev erlang-eval-ast) +(define idfcnm (fn (v) (get v :name))) + +(identity-load-all!) + +;; ── login + logout are audited through the ledger ──────────────── + +(id-facade-test + "login then logout records login, issue, logout in order" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> ordered;\n Other -> Other\n end")) + "ordered") + +(id-facade-test + "revoking a token is audited" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> ordered;\n Other -> Other\n end")) + "ordered") + +(id-facade-test + "history is per-subject" + (idfc-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, cli, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:history(Svc, alice))") + 4) + +;; ── membership through the facade ──────────────────────────────── + +(id-facade-test + "enroll makes the subject an active member" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end")) + "active") + +(id-facade-test + "enroll keeps the tier" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, _, Tier} -> Tier\n end")) + "supporter") + +(id-facade-test + "an enrolled member projects per-app" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n identity:enroll(Svc, alice, basic),\n case identity:member_project(Svc, alice, market) of\n {member, _, App} -> App;\n {Tag, _} -> Tag\n end")) + "market") + +(id-facade-test + "a non-member projects as non_member" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n case identity:member_project(Svc, stranger, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end")) + "non_member") + +;; ── the facade still proves identity ───────────────────────────── + +(id-facade-test + "verify still returns the subject after login" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end")) + "alice") + +;; ── identity and membership are distinct axes ──────────────────── + +(id-facade-test + "logging in does not enroll membership" + (idfcnm + (idfc-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end")) + "none") + +(define + id-facade-test-summary + (str "facade " id-facade-test-pass "/" id-facade-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 1a854821..51eb0ca4 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` → **168/168** (4 phases + 6 ext incl device grant) +`bash lib/identity/conformance.sh` → **177/177** (4 phases + 7 ext incl unified facade) ## Ground rules @@ -85,9 +85,16 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] client-credentials grant (RFC 6749 §4.4) + device grant (RFC 8628) - [ ] acl-on-sx delegation: wire `verify`/membership projection → an acl decision, integration test - [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange -- [ ] unify `api.sx` over oauth + membership + audit (one facade, audited login/consent) +- [x] unify `api.sx` over membership + audit (one facade, audited login/logout) ## Progress log +- 2026-06-07 — unified facade (ext): `api.sx` coordinator now owns an audit + ledger + a membership registry alongside its token table (started with the + ledger) and session registry. login/logout are audited; new ops + `history`/`enroll`/`member_status`/`member_project` expose the audit + + membership axes through the one `identity` door. identity proves who + + reports membership; acl still decides permission. Existing api behaviour + unchanged (10/10). New tests/facade.sx (9). 168→177. - 2026-06-07 — `device.sx` (ext, RFC 8628): device authorization grant for input-constrained devices. authorize → {device_code, user_code}; the human approve/deny out-of-band by user_code; the device polls by device_code