From b1f9c6bef0cbe0af34fd492a0ceec9f7613ae8ba Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 03:16:21 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20subject-wide=20session=20management?= =?UTF-8?q?=20=E2=80=94=20sessions=20+=20logout=5Fall=20(+8=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.sx gains sessions(Subject) (enumerate a subject's live sessions) and logout_all(Subject) ("log out everywhere") — revokes and deregisters every session the subject holds, auditing a logout per session, leaving other subjects' sessions untouched. Builds on registry.sessions_for. New tests/session_mgmt.sx. 193/193. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/api.sx | 16 +++--- lib/identity/conformance.sh | 4 ++ lib/identity/scoreboard.json | 7 +-- lib/identity/scoreboard.md | 3 +- lib/identity/tests/session_mgmt.sx | 81 ++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 8 ++- 6 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 lib/identity/tests/session_mgmt.sx diff --git a/lib/identity/api.sx b/lib/identity/api.sx index b93eb924..d743efdb 100644 --- a/lib/identity/api.sx +++ b/lib/identity/api.sx @@ -6,19 +6,21 @@ ;; whole-domain operations through one door: ;; ;; login / verify / revoke / logout / session_status (sessions + tokens) +;; sessions(Subject) / logout_all(Subject) (subject-wide mgmt) ;; history(Subject) (audit ledger) ;; enroll / member_status / member_project (membership) ;; -;; 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. +;; Every grant transition is audited: login records `login`, logout (and +;; each logout under logout_all) records `logout`, and the token table +;; records issue/revoke. logout_all is \"log out everywhere\": it revokes and +;; deregisters every session a subject holds, leaving other subjects' +;; sessions untouched. The coordinator owns the sessions, so an idle +;; session deregisters itself. verify answers IDENTITY only; membership +;; projection reports WHAT a subject is for an app; permission is acl's. (define identity-api-source - "-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.") + "-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 logout_all(Svc, Subject) ->\n Svc ! {logout_all, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n sessions(Svc, Subject) ->\n Svc ! {sessions, Subject, 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 {logout_all, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> logout_each(SessReg, Audit, Ids)\n end,\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {sessions, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> From ! {identity_reply, Ids}\n end,\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 logout_each(_, _, []) -> ok;\n logout_each(SessReg, Audit, [Sid | Rest]) ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n logout_each(SessReg, Audit, Rest).\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))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 21235753..806ce8a5 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -44,6 +44,7 @@ SUITES=( "device|id-device-test-pass|id-device-test-count" "facade|id-facade-test-pass|id-facade-test-count" "delegation|id-deleg-test-pass|id-deleg-test-count" + "session-mgmt|id-smgmt-test-pass|id-smgmt-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -83,6 +84,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/device.sx") (load "lib/identity/tests/facade.sx") (load "lib/identity/tests/delegation.sx") +(load "lib/identity/tests/session_mgmt.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -115,6 +117,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-facade-test-pass id-facade-test-count)") (epoch 115) (eval "(list id-deleg-test-pass id-deleg-test-count)") +(epoch 116) +(eval "(list id-smgmt-test-pass id-smgmt-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index bb2185e4..0e52bebe 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 185, - "total": 185, + "total_pass": 193, + "total": 193, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -18,6 +18,7 @@ {"name":"grants","pass":9,"total":9,"status":"ok"}, {"name":"device","pass":10,"total":10,"status":"ok"}, {"name":"facade","pass":9,"total":9,"status":"ok"}, - {"name":"delegation","pass":8,"total":8,"status":"ok"} + {"name":"delegation","pass":8,"total":8,"status":"ok"}, + {"name":"session-mgmt","pass":8,"total":8,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 1dafb17c..b2eba5aa 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 185 / 185 tests passing** +**Total: 193 / 193 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -20,6 +20,7 @@ | ✅ | device | 10 | 10 | | ✅ | facade | 9 | 9 | | ✅ | delegation | 8 | 8 | +| ✅ | session-mgmt | 8 | 8 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/session_mgmt.sx b/lib/identity/tests/session_mgmt.sx new file mode 100644 index 00000000..4631eb7e --- /dev/null +++ b/lib/identity/tests/session_mgmt.sx @@ -0,0 +1,81 @@ +;; identity/tests/session_mgmt.sx — subject-wide session management: +;; enumerate a subject's sessions and \"log out everywhere\". + +(define id-smgmt-test-count 0) +(define id-smgmt-test-pass 0) +(define id-smgmt-test-fails (list)) + +(define + id-smgmt-test + (fn + (name actual expected) + (set! id-smgmt-test-count (+ id-smgmt-test-count 1)) + (if + (= actual expected) + (set! id-smgmt-test-pass (+ id-smgmt-test-pass 1)) + (append! id-smgmt-test-fails {:name name :expected expected :actual actual})))) + +(define idsm-ev erlang-eval-ast) +(define idsmnm (fn (v) (get v :name))) + +(identity-load-all!) + +;; ── enumerate a subject's sessions ─────────────────────────────── + +(id-smgmt-test + "sessions lists all of a subject's sessions" + (idsm-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n length(identity:sessions(Svc, alice))") + 2) + +(id-smgmt-test + "sessions is empty for a subject with none" + (idsm-ev + "Svc = identity:start(),\n length(identity:sessions(Svc, stranger))") + 0) + +;; ── log out everywhere ─────────────────────────────────────────── + +(id-smgmt-test + "logout_all ends every session of the subject" + (idsmnm + (idsm-ev + "Svc = identity:start(),\n {ok, S1, _} = identity:login(Svc, alice, web, read),\n {ok, S2, _} = identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n case {identity:session_status(Svc, S1), identity:session_status(Svc, S2)} of\n {gone, gone} -> both_gone;\n _ -> some_left\n end")) + "both_gone") + +(id-smgmt-test + "after logout_all the subject has no sessions" + (idsm-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, alice))") + 0) + +(id-smgmt-test + "logout_all leaves other subjects' sessions intact" + (idsm-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, bob))") + 1) + +(id-smgmt-test + "logout_all on an unknown subject is ok, not a crash" + (idsmnm + (idsm-ev "Svc = identity:start(),\n identity:logout_all(Svc, ghost)")) + "ok") + +;; ── logout_all is audited ──────────────────────────────────────── + +(id-smgmt-test + "logout_all records a logout event" + (idsmnm + (idsm-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:logout_all(Svc, alice),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> audited;\n Other -> Other\n end")) + "audited") + +(id-smgmt-test + "logout_all audits each of several sessions" + (idsm-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:history(Svc, alice))") + 6) + +(define + id-smgmt-test-summary + (str "session-mgmt " id-smgmt-test-pass "/" id-smgmt-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 401ed562..2b5c7d52 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` → **185/185** (4 phases + 8 ext; backlog clear) +`bash lib/identity/conformance.sh` → **193/193** (4 phases + 9 ext) ## Ground rules @@ -86,8 +86,14 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate) - [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange - [x] unify `api.sx` over membership + audit (one facade, audited login/logout) +- [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere) ## Progress log +- 2026-06-07 — subject-wide session management (ext): `api.sx` gains + `sessions(Subject)` (enumerate) and `logout_all(Subject)` ("log out + everywhere") — revokes + deregisters every session a subject holds, + auditing a logout per session, leaving other subjects untouched. Builds on + registry.sessions_for. New tests/session_mgmt.sx (8). 185→193. - 2026-06-07 — `delegation.sx` (ext): the identity→acl boundary made concrete. `check` introspects the token first: inactive → `{error, unauthenticated}` (401, acl never consulted); active → constructs {Subject, Scope, Action,