From e448220b334edce107249a1ffb3f78f8407cb5bb Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 01:29:08 +0000 Subject: [PATCH] identity: trust-gated federated identity + cross-instance mapping (Phase 4 complete, +13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit federation.sx — peer-asserted subjects, advisory and trust-gated. An assertion is accepted only from an explicitly trusted peer (else {error, untrusted}) and is flagged {peer_asserted, Peer}, never promoted to local authority; acl decides what a peer-asserted identity may do. Cross- instance subject mapping namespaces remote subjects by peer ({federated, Peer, Remote}) so two peers' "alice" never collide, with optional explicit aliasing. Adds an audit-completeness test. New tests/federation.sx. All four phases done — 124/124. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 5 ++ lib/identity/federation.sx | 30 ++++++++ lib/identity/scoreboard.json | 7 +- lib/identity/scoreboard.md | 5 +- lib/identity/tests/audit.sx | 12 +++- lib/identity/tests/federation.sx | 115 +++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 15 +++- 7 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 lib/identity/federation.sx create mode 100644 lib/identity/tests/federation.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index c8610af2..fa2857d1 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -37,6 +37,7 @@ SUITES=( "membership|id-membership-test-pass|id-membership-test-count" "cache|id-cache-test-pass|id-cache-test-count" "audit|id-audit-test-pass|id-audit-test-count" + "federation|id-fed-test-pass|id-fed-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -56,6 +57,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/membership.sx") (load "lib/identity/cache.sx") (load "lib/identity/audit.sx") +(load "lib/identity/federation.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") @@ -65,6 +67,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/membership.sx") (load "lib/identity/tests/cache.sx") (load "lib/identity/tests/audit.sx") +(load "lib/identity/tests/federation.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -83,6 +86,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-cache-test-pass id-cache-test-count)") (epoch 108) (eval "(list id-audit-test-pass id-audit-test-count)") +(epoch 109) +(eval "(list id-fed-test-pass id-fed-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/federation.sx b/lib/identity/federation.sx new file mode 100644 index 00000000..d6ac9bda --- /dev/null +++ b/lib/identity/federation.sx @@ -0,0 +1,30 @@ +;; identity/federation.sx — federated identity: peer-asserted subjects, +;; advisory and trust-gated. +;; +;; A peer instance can assert \"this remote subject authenticated with me\". +;; We accept such an assertion ONLY from a peer we explicitly trust +;; (trust-gated); an assertion from an unknown peer is {error, untrusted}, +;; never silently honoured. Even when accepted, the resulting identity is +;; ADVISORY: it is flagged peer_asserted with its origin peer, never +;; promoted to local authority. Downstream (acl) decides how much a +;; peer-asserted identity may do; identity only records who asserted it. +;; +;; Cross-instance subject mapping turns a (Peer, RemoteSubject) pair into a +;; stable local subject. By default it is namespaced — {federated, Peer, +;; RemoteSubject} — so two peers' \"alice\" never collide; an explicit map +;; can alias a remote subject to a local one. +;; +;; trust(F, Peer) / untrust(F, Peer) / trusted(F, Peer) +;; map(F, Peer, Remote, Local) -> ok (optional alias) +;; resolve(F, Peer, Remote) -> {ok, LocalSubject} +;; assert_id(F, Peer, Remote) -> {ok, LocalSubject} +;; | {error, untrusted} +;; provenance(F, LocalSubject) -> {peer_asserted, Peer} | {local} + +(define + identity-federation-source + "-module(identity_federation).\n\n start() ->\n spawn(fun () -> loop([], [], []) end).\n\n trust(F, Peer) ->\n F ! {trust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n untrust(F, Peer) ->\n F ! {untrust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n trusted(F, Peer) ->\n F ! {trusted, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n map(F, Peer, Remote, Local) ->\n F ! {map, Peer, Remote, Local, self()},\n receive {fed_reply, R} -> R end.\n\n resolve(F, Peer, Remote) ->\n F ! {resolve, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n assert_id(F, Peer, Remote) ->\n F ! {assert_id, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n provenance(F, Local) ->\n F ! {provenance, Local, self()},\n receive {fed_reply, R} -> R end.\n\n loop(Trusted, Maps, Asserted) ->\n receive\n {trust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(add_unique(Peer, Trusted), Maps, Asserted);\n {untrust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(drop(Peer, Trusted), Maps, Asserted);\n {trusted, Peer, From} ->\n From ! {fed_reply, member(Peer, Trusted)},\n loop(Trusted, Maps, Asserted);\n {map, Peer, Remote, Local, From} ->\n From ! {fed_reply, ok},\n loop(Trusted, [{{Peer, Remote}, Local} | drop_map(Peer, Remote, Maps)], Asserted);\n {resolve, Peer, Remote, From} ->\n From ! {fed_reply, {ok, resolve_local(Peer, Remote, Maps)}},\n loop(Trusted, Maps, Asserted);\n {assert_id, Peer, Remote, From} ->\n case member(Peer, Trusted) of\n false ->\n From ! {fed_reply, {error, untrusted}},\n loop(Trusted, Maps, Asserted);\n true ->\n Local = resolve_local(Peer, Remote, Maps),\n From ! {fed_reply, {ok, Local}},\n loop(Trusted, Maps, [{Local, Peer} | drop_assert(Local, Asserted)])\n end;\n {provenance, Local, From} ->\n case find_assert(Local, Asserted) of\n {ok, Peer} -> From ! {fed_reply, {peer_asserted, Peer}};\n none -> From ! {fed_reply, {local}}\n end,\n loop(Trusted, Maps, Asserted);\n {stop, From} ->\n From ! {fed_reply, ok}\n end.\n\n resolve_local(Peer, Remote, Maps) ->\n case find_map(Peer, Remote, Maps) of\n {ok, Local} -> Local;\n none -> {federated, Peer, Remote}\n end.\n\n find_map(_, _, []) -> none;\n find_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> {ok, Local};\n false -> find_map(Peer, Remote, Rest)\n end.\n\n drop_map(_, _, []) -> [];\n drop_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> drop_map(Peer, Remote, Rest);\n false -> [{{P, R}, Local} | drop_map(Peer, Remote, Rest)]\n end.\n\n same(P, Peer, R, Remote) ->\n case P =:= Peer of\n true -> R =:= Remote;\n false -> false\n end.\n\n find_assert(_, []) -> none;\n find_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> {ok, Peer};\n false -> find_assert(Local, Rest)\n end.\n\n drop_assert(_, []) -> [];\n drop_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> drop_assert(Local, Rest);\n false -> [{L, Peer} | drop_assert(Local, Rest)]\n end.\n\n add_unique(X, Xs) ->\n case member(X, Xs) of\n true -> Xs;\n false -> [X | Xs]\n end.\n\n drop(_, []) -> [];\n drop(X, [Y | Rest]) ->\n case X =:= Y of\n true -> drop(X, Rest);\n false -> [Y | drop(X, Rest)]\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.") + +(define + identity-load-federation! + (fn () (erlang-load-module identity-federation-source))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index ff952f8b..a7d4497e 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 111, - "total": 111, + "total_pass": 124, + "total": 124, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":18,"total":18,"status":"ok"}, @@ -11,6 +11,7 @@ {"name":"sso","pass":10,"total":10,"status":"ok"}, {"name":"membership","pass":17,"total":17,"status":"ok"}, {"name":"cache","pass":9,"total":9,"status":"ok"}, - {"name":"audit","pass":10,"total":10,"status":"ok"} + {"name":"audit","pass":11,"total":11,"status":"ok"}, + {"name":"federation","pass":12,"total":12,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 8206db67..2094f5ce 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 111 / 111 tests passing** +**Total: 124 / 124 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -12,7 +12,8 @@ | ✅ | sso | 10 | 10 | | ✅ | membership | 17 | 17 | | ✅ | cache | 9 | 9 | -| ✅ | audit | 10 | 10 | +| ✅ | audit | 11 | 11 | +| ✅ | federation | 12 | 12 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/audit.sx b/lib/identity/tests/audit.sx index 4d8652a4..b246f1af 100644 --- a/lib/identity/tests/audit.sx +++ b/lib/identity/tests/audit.sx @@ -1,8 +1,8 @@ ;; identity/tests/audit.sx — the grant audit ledger. Every grant ;; transition is recorded; the ledger is queryable per subject and ;; chronological. Covers issue/refresh/revoke wiring through the token -;; registry, reuse-triggered revoke, per-subject isolation, and direct -;; ledger use. +;; registry, reuse-triggered revoke, per-subject isolation, completeness, +;; and direct ledger use. (define id-audit-test-count 0) (define id-audit-test-pass 0) @@ -79,6 +79,14 @@ "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n length(identity_audit:all(A))") 2) +;; ── completeness: no grant transition is dropped ───────────────── + +(id-audit-test + "the ledger is complete across a mixed transition stream" + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, cli, read),\n identity_tokens:refresh(Reg, R),\n {ok, B} = identity_tokens:issue(Reg, bob, web, read),\n identity_tokens:revoke(Reg, B),\n length(identity_audit:all(A))") + 5) + ;; ── start/0 stays unaudited (no regression) ────────────────────── (id-audit-test diff --git a/lib/identity/tests/federation.sx b/lib/identity/tests/federation.sx new file mode 100644 index 00000000..3ffddb1f --- /dev/null +++ b/lib/identity/tests/federation.sx @@ -0,0 +1,115 @@ +;; identity/tests/federation.sx — federated identity: trust-gated, +;; advisory peer assertions + cross-instance subject mapping. + +(define id-fed-test-count 0) +(define id-fed-test-pass 0) +(define id-fed-test-fails (list)) + +(define + id-fed-test + (fn + (name actual expected) + (set! id-fed-test-count (+ id-fed-test-count 1)) + (if + (= actual expected) + (set! id-fed-test-pass (+ id-fed-test-pass 1)) + (append! id-fed-test-fails {:name name :expected expected :actual actual})))) + +(define idf-ev erlang-eval-ast) +(define idfnm (fn (v) (get v :name))) + +(identity-load-federation!) + +;; ── trust gating ───────────────────────────────────────────────── + +(id-fed-test + "an assertion from an untrusted peer is rejected" + (idfnm + (idf-ev + "F = identity_federation:start(),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end")) + "untrusted") + +(id-fed-test + "a trusted peer's assertion is accepted" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end")) + "accepted") + +(id-fed-test + "untrust closes the door to future assertions" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:untrust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end")) + "untrusted") + +(id-fed-test + "trusted? is true for a trusted peer" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer1) of\n true -> yes;\n false -> no\n end")) + "yes") + +(id-fed-test + "trusted? is false for an unknown peer" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer2) of\n true -> yes;\n false -> no\n end")) + "no") + +;; ── advisory provenance ────────────────────────────────────────── + +(id-fed-test + "an asserted identity is flagged peer_asserted with its origin" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n {ok, L} = identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, L) of\n {peer_asserted, P} -> P;\n {local} -> local\n end")) + "peer1") + +(id-fed-test + "a non-federated subject has local provenance" + (idfnm + (idf-ev + "F = identity_federation:start(),\n case identity_federation:provenance(F, alice) of\n {peer_asserted, _} -> peer_asserted;\n {local} -> local\n end")) + "local") + +;; ── cross-instance subject mapping ─────────────────────────────── + +(id-fed-test + "remote subjects are namespaced by peer by default" + (idfnm + (idf-ev + "F = identity_federation:start(),\n case identity_federation:resolve(F, peer1, alice) of\n {ok, {federated, _, Remote}} -> Remote;\n _ -> other\n end")) + "alice") + +(id-fed-test + "the same remote name from two peers maps to distinct subjects" + (idfnm + (idf-ev + "F = identity_federation:start(),\n {ok, L1} = identity_federation:resolve(F, peer1, alice),\n {ok, L2} = identity_federation:resolve(F, peer2, alice),\n case L1 =:= L2 of\n true -> collision;\n false -> distinct\n end")) + "distinct") + +(id-fed-test + "an explicit map aliases a remote subject to a local one" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, alice_local} -> mapped;\n {ok, _} -> unmapped;\n {error, W} -> W\n end")) + "mapped") + +(id-fed-test + "a mapped subject keeps peer_asserted provenance" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, alice_local) of\n {peer_asserted, P} -> P;\n {local} -> local\n end")) + "peer1") + +(id-fed-test + "two peers asserting same name keep separate provenance" + (idfnm + (idf-ev + "F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:trust(F, peer2),\n {ok, L1} = identity_federation:assert_id(F, peer1, alice),\n {ok, _L2} = identity_federation:assert_id(F, peer2, alice),\n case identity_federation:provenance(F, L1) of\n {peer_asserted, P} -> P;\n {local} -> local\n end")) + "peer1") + +(define + id-fed-test-summary + (str "federation " id-fed-test-pass "/" id-fed-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index dd3ce2fb..3ed3f930 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` → **111/111** (Phases 1–3 + audit ledger) +`bash lib/identity/conformance.sh` → **124/124** (all four phases complete) ## Ground rules @@ -74,10 +74,19 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ## Phase 4 — Audit + federation - [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)` -- [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub -- [ ] tests: audit completeness, cross-instance subject mapping +- [x] federated identity (peer-asserted subject) — advisory, trust-gated stub +- [x] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-07 — `federation.sx`: trust-gated, advisory federated identity. + A peer assertion is accepted only from an explicitly trusted peer + (else `{error, untrusted}`) and is flagged `{peer_asserted, Peer}`, never + promoted to local authority — acl decides what it may do. Cross-instance + subject mapping namespaces remote subjects by peer (`{federated, Peer, + Remote}`) so two peers' "alice" never collide, with optional explicit + aliasing. Added an audit-completeness test (mixed transition stream → no + event dropped). New tests/federation.sx (12). **Phase 4 complete — all four + phases done.** +13 → 124/124. - 2026-06-07 — `audit.sx`: append-only grant audit ledger (an Erlang process). `token.sx` gains `start/1(Audit)` and emits issue/refresh/revoke events (incl. reuse-triggered revoke); `start/0` stays unaudited (no