diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index d5221849..d891a225 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -34,6 +34,7 @@ SUITES=( "api|id-api-test-pass|id-api-test-count" "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" ) cat > "$TMPFILE" << 'EPOCHS' @@ -50,12 +51,14 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/registry.sx") (load "lib/identity/api.sx") (load "lib/identity/oauth.sx") +(load "lib/identity/membership.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") (load "lib/identity/tests/api.sx") (load "lib/identity/tests/oauth.sx") (load "lib/identity/tests/sso.sx") +(load "lib/identity/tests/membership.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -68,6 +71,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-oauth-test-pass id-oauth-test-count)") (epoch 105) (eval "(list id-sso-test-pass id-sso-test-count)") +(epoch 106) +(eval "(list id-membership-test-pass id-membership-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/membership.sx b/lib/identity/membership.sx new file mode 100644 index 00000000..3c249b2f --- /dev/null +++ b/lib/identity/membership.sx @@ -0,0 +1,31 @@ +;; identity/membership.sx — coop membership state + per-app projection. +;; +;; Membership is canonical subject state held by one process, a guarded +;; state machine (invalid transitions are explicit errors, never silent +;; no-ops): +;; +;; none --request--> pending --approve--> active +;; active --lapse--> lapsed --reinstate--> active +;; {pending|active|lapsed} --revoke--> revoked (terminal) +;; +;; A per-app GRANT PROJECTION renders that one canonical state into the +;; view a given client app consumes — mirroring rose-ash's per-app grant +;; verification. The projection is pure identity: it reports WHAT the +;; subject's membership is for that app; it does NOT decide whether the +;; app should let them in. That permission question is acl's, keyed off +;; this projection. +;; +;; project(Subject, App) -> +;; active -> {member, Tier, App} +;; pending -> {pending, App} +;; lapsed -> {lapsed, App} +;; revoked -> {denied, App} +;; none -> {non_member, App} + +(define + identity-membership-source + "-module(identity_membership).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n request(M, Subject, Tier) ->\n M ! {request, Subject, Tier, self()},\n receive {membership_reply, R} -> R end.\n\n approve(M, Subject) ->\n M ! {approve, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n lapse(M, Subject) ->\n M ! {lapse, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n reinstate(M, Subject) ->\n M ! {reinstate, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n revoke(M, Subject) ->\n M ! {revoke, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n status(M, Subject) ->\n M ! {status, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n project(M, Subject, App) ->\n M ! {project, Subject, App, self()},\n receive {membership_reply, R} -> R end.\n\n loop(Members) ->\n receive\n {request, Subject, Tier, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, ok},\n loop([{Subject, {pending, Tier}} | Members]);\n {ok, _} ->\n From ! {membership_reply, {error, exists}},\n loop(Members)\n end;\n {approve, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {pending, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {lapse, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {active, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {lapsed, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {reinstate, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {lapsed, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {revoke, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {_, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {revoked, Tier}, Members))\n end;\n {status, Subject, From} ->\n case find(Subject, Members) of\n none -> From ! {membership_reply, {none}};\n {ok, {St, Tier}} -> From ! {membership_reply, {ok, St, Tier}}\n end,\n loop(Members);\n {project, Subject, App, From} ->\n From ! {membership_reply, project_view(Subject, App, Members)},\n loop(Members);\n {stop, From} ->\n From ! {membership_reply, ok}\n end.\n\n project_view(Subject, App, Members) ->\n case find(Subject, Members) of\n none -> {non_member, App};\n {ok, {active, Tier}} -> {member, Tier, App};\n {ok, {pending, _}} -> {pending, App};\n {ok, {lapsed, _}} -> {lapsed, App};\n {ok, {revoked, _}} -> {denied, App}\n end.\n\n set_record(_, _, []) -> [];\n set_record(Subject, Rec, [{S, Old} | Rest]) ->\n case S =:= Subject of\n true -> [{S, Rec} | Rest];\n false -> [{S, Old} | set_record(Subject, Rec, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.") + +(define + identity-load-membership! + (fn () (erlang-load-module identity-membership-source))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index b1a4783b..4ca9768d 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,13 +1,14 @@ { "language": "identity", - "total_pass": 75, - "total": 75, + "total_pass": 92, + "total": 92, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":18,"total":18,"status":"ok"}, {"name":"registry","pass":9,"total":9,"status":"ok"}, {"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":"sso","pass":10,"total":10,"status":"ok"}, + {"name":"membership","pass":17,"total":17,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 8cbd8a33..151ce467 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 75 / 75 tests passing** +**Total: 92 / 92 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -10,6 +10,7 @@ | ✅ | api | 10 | 10 | | ✅ | oauth | 17 | 17 | | ✅ | sso | 10 | 10 | +| ✅ | membership | 17 | 17 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/membership.sx b/lib/identity/tests/membership.sx new file mode 100644 index 00000000..7c70ef9e --- /dev/null +++ b/lib/identity/tests/membership.sx @@ -0,0 +1,155 @@ +;; identity/tests/membership.sx — membership state machine + per-app +;; grant projection. Valid transitions advance state; invalid ones are +;; explicit errors. The projection renders one canonical state per app. + +(define id-membership-test-count 0) +(define id-membership-test-pass 0) +(define id-membership-test-fails (list)) + +(define + id-membership-test + (fn + (name actual expected) + (set! id-membership-test-count (+ id-membership-test-count 1)) + (if + (= actual expected) + (set! id-membership-test-pass (+ id-membership-test-pass 1)) + (append! id-membership-test-fails {:name name :expected expected :actual actual})))) + +(define idm-ev erlang-eval-ast) +(define idmnm (fn (v) (get v :name))) + +(identity-load-membership!) + +;; ── request → pending → approve → active ───────────────────────── + +(id-membership-test + "request leaves the subject pending" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end")) + "pending") + +(id-membership-test + "approve activates a pending membership" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end")) + "active") + +(id-membership-test + "status keeps the requested tier" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, _, Tier} -> Tier\n end")) + "supporter") + +;; ── guarded transitions: invalid moves are explicit errors ─────── + +(id-membership-test + "requesting twice is an error" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:request(M, alice, basic) of\n ok -> ok;\n {error, Why} -> Why\n end")) + "exists") + +(id-membership-test + "approving an unknown subject is not_found" + (idmnm + (idm-ev + "M = identity_membership:start(),\n case identity_membership:approve(M, ghost) of\n ok -> ok;\n {error, Why} -> Why\n end")) + "not_found") + +(id-membership-test + "approving an already-active membership is an error" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:approve(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end")) + "active") + +;; ── lapse / reinstate ──────────────────────────────────────────── + +(id-membership-test + "active member can lapse" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end")) + "lapsed") + +(id-membership-test + "lapsing a pending membership is an error" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:lapse(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end")) + "pending") + +(id-membership-test + "lapsed member can reinstate to active" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n identity_membership:reinstate(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end")) + "active") + +;; ── revoke is terminal ─────────────────────────────────────────── + +(id-membership-test + "any member can be revoked" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end")) + "revoked") + +(id-membership-test + "a revoked membership cannot be reinstated" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:reinstate(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end")) + "revoked") + +;; ── per-app grant projection ───────────────────────────────────── + +(id-membership-test + "active member projects as member" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end")) + "member") + +(id-membership-test + "projection carries the requesting app" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, market) of\n {member, _, App} -> App\n end")) + "market") + +(id-membership-test + "the same subject projects consistently across apps" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n {member, T1, blog} = identity_membership:project(M, alice, blog),\n {member, T2, events} = identity_membership:project(M, alice, events),\n case T1 =:= T2 of\n true -> T1;\n false -> mismatch\n end")) + "supporter") + +(id-membership-test + "unknown subject projects as non_member" + (idmnm + (idm-ev + "M = identity_membership:start(),\n case identity_membership:project(M, ghost, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end")) + "non_member") + +(id-membership-test + "lapsed member projects as lapsed" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end")) + "lapsed") + +(id-membership-test + "revoked member projects as denied" + (idmnm + (idm-ev + "M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end")) + "denied") + +(define + id-membership-test-summary + (str "membership " id-membership-test-pass "/" id-membership-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 76a93595..4c05b1aa 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` → **75/75** (Phases 1–2 + silent SSO) +`bash lib/identity/conformance.sh` → **92/92** (Phases 1–2 + SSO + membership) ## Ground rules @@ -69,7 +69,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) -- [ ] membership state + per-app grant projection +- [x] membership state + per-app grant projection - [ ] grant verification delegated cache (mirror Redis-cache pattern) ## Phase 4 — Audit + federation @@ -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 — `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)` + renders the one canonical state into a per-app claim + ({member,Tier,App}/{pending,App}/{lapsed,App}/{denied,App}/{non_member,App}) + — identity reports what; acl decides whether. New tests/membership.sx (17). + +17 → 92/92. - 2026-06-07 — silent SSO (`prompt=none`, OIDC §3.1.2.1): `oauth.sx` now owns a session registry; `establish` creates a subject session, `silent_authorize` asks "does this subject have a live session?" → mints a code (skipping