identity: membership state machine + per-app grant projection (17 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
membership.sx — coop membership as a guarded state machine
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
transitions return explicit {error, CurrentStatus}, never silent no-ops.
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 the membership is; acl decides
whether the app should honour it. New tests/membership.sx. 92/92.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
31
lib/identity/membership.sx
Normal file
31
lib/identity/membership.sx
Normal file
@@ -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)))
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
155
lib/identity/tests/membership.sx
Normal file
155
lib/identity/tests/membership.sx
Normal file
@@ -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))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user