From db885e15bcf16bbe7d5ebc686cda610cb05e945a Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 03:05:12 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20identity->acl=20delegation=20bounda?= =?UTF-8?q?ry=20=E2=80=94=20401=20gates=20before=20403=20(+8=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delegation.sx makes the loop's central rule concrete: check() introspects the token first — inactive → {error, unauthenticated} (401), acl never consulted — and only an authenticated subject's request is delegated to acl, which returns permit/deny ({error, forbidden} = 403). 401 strictly precedes 403. acl-on-sx (Datalog) is a different SX guest wired at the integration layer, so the decider here is a labelled stub (permits when Action in Scope); swap the pid and the boundary is unchanged. New tests/delegation.sx. 185/185 — extensions backlog clear. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 5 ++ lib/identity/delegation.sx | 34 +++++++++++ lib/identity/scoreboard.json | 7 ++- lib/identity/scoreboard.md | 3 +- lib/identity/tests/delegation.sx | 102 +++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 13 +++- 6 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 lib/identity/delegation.sx create mode 100644 lib/identity/tests/delegation.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index f9cda2f9..21235753 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -43,6 +43,7 @@ SUITES=( "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" + "delegation|id-deleg-test-pass|id-deleg-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -65,6 +66,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/federation.sx") (load "lib/identity/clients.sx") (load "lib/identity/device.sx") +(load "lib/identity/delegation.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") @@ -80,6 +82,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/grants.sx") (load "lib/identity/tests/device.sx") (load "lib/identity/tests/facade.sx") +(load "lib/identity/tests/delegation.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -110,6 +113,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-device-test-pass id-device-test-count)") (epoch 114) (eval "(list id-facade-test-pass id-facade-test-count)") +(epoch 115) +(eval "(list id-deleg-test-pass id-deleg-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/delegation.sx b/lib/identity/delegation.sx new file mode 100644 index 00000000..2e96343e --- /dev/null +++ b/lib/identity/delegation.sx @@ -0,0 +1,34 @@ +;; identity/delegation.sx — the identity -> acl delegation boundary. +;; +;; This is the loop's central architectural rule made concrete: +;; AUTHENTICATION is identity's job; AUTHORIZATION is acl's. A request is +;; checked in two stages, and the order matters: +;; +;; 1. identity proves WHO via the opaque token (introspect). If the token +;; is inactive, the answer is {error, unauthenticated} — a 401. acl is +;; NEVER consulted; \"I don't know who you are\" is not a permission +;; question. +;; 2. only for an authenticated subject does identity construct the +;; permission query {Subject, Scope, Action, Resource} and HAND IT OFF +;; to acl. acl returns permit | deny; deny is {error, forbidden} — a +;; 403. identity itself never decides permission. +;; +;; The real decider is acl-on-sx (Datalog), which runs as a different +;; guest language on SX and is wired in at the integration layer. Here the +;; acl side is a labelled STUB process so the boundary is exercised: it +;; permits when the Action is within the token's granted Scope. Swap the +;; stub pid for the acl adapter and the boundary is unchanged. +;; +;; check(TokReg, Acl, Token, Action, Resource) -> +;; {ok, Subject} | {error, unauthenticated} | {error, forbidden} + +(define + identity-delegation-source + "-module(identity_delegation).\n\n check(TokReg, Acl, Token, Action, Resource) ->\n case identity_tokens:introspect(TokReg, Token) of\n {inactive} ->\n {error, unauthenticated};\n {active, Subject, _Client, Scope} ->\n Acl ! {acl_query, Subject, Scope, Action, Resource, self()},\n receive {acl_verdict, V} ->\n case V of\n permit -> {ok, Subject};\n deny -> {error, forbidden}\n end\n end\n end.\n\n %% --- stub acl decider (stands in for acl-on-sx / Datalog) ---\n %% Permits iff the Action is one of the token's granted scopes. The real\n %% acl decides on rules + facts; this only exercises the handoff shape.\n stub_acl() ->\n spawn(fun () -> acl_loop() end).\n\n acl_loop() ->\n receive\n {acl_query, _Subject, Scope, Action, _Resource, From} ->\n From ! {acl_verdict, decide(Action, Scope)},\n acl_loop();\n stop ->\n ok\n end.\n\n decide(Action, Scope) ->\n case member(Action, Scope) of\n true -> permit;\n false -> deny\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-delegation! + (fn + () + (identity-load-token!) + (erlang-load-module identity-delegation-source))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 17d348a0..bb2185e4 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 177, - "total": 177, + "total_pass": 185, + "total": 185, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -17,6 +17,7 @@ {"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":"facade","pass":9,"total":9,"status":"ok"} + {"name":"facade","pass":9,"total":9,"status":"ok"}, + {"name":"delegation","pass":8,"total":8,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 7656001a..1dafb17c 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 177 / 177 tests passing** +**Total: 185 / 185 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -19,6 +19,7 @@ | ✅ | grants | 9 | 9 | | ✅ | device | 10 | 10 | | ✅ | facade | 9 | 9 | +| ✅ | delegation | 8 | 8 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/delegation.sx b/lib/identity/tests/delegation.sx new file mode 100644 index 00000000..9665bb0b --- /dev/null +++ b/lib/identity/tests/delegation.sx @@ -0,0 +1,102 @@ +;; identity/tests/delegation.sx — the identity -> acl boundary. +;; Authentication (identity) gates BEFORE authorization (acl): an inactive +;; token is unauthenticated (401) and acl is never consulted; only an +;; authenticated subject's request is delegated to acl for permit/deny. + +(define id-deleg-test-count 0) +(define id-deleg-test-pass 0) +(define id-deleg-test-fails (list)) + +(define + id-deleg-test + (fn + (name actual expected) + (set! id-deleg-test-count (+ id-deleg-test-count 1)) + (if + (= actual expected) + (set! id-deleg-test-pass (+ id-deleg-test-pass 1)) + (append! id-deleg-test-fails {:name name :expected expected :actual actual})))) + +(define idl-ev erlang-eval-ast) +(define idlnm (fn (v) (get v :name))) + +(identity-load-delegation!) + +;; Shared prelude: a token registry, a stub acl, and a token granting +;; [read, write] to alice, all bound. +(define + idl-setup + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write])") + +;; ── authenticated + acl permits ────────────────────────────────── + +(id-deleg-test + "an authenticated, permitted request returns the subject" + (idlnm + (idl-ev + (str + idl-setup + ", case identity_delegation:check(R, A, T, read, doc1) of\n {ok, S} -> S;\n {error, W} -> W\n end"))) + "alice") + +;; ── authenticated + acl denies → 403 ───────────────────────────── + +(id-deleg-test + "an authenticated but unpermitted request is forbidden" + (idlnm + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")) + "forbidden") + +;; ── unauthenticated → 401, acl never consulted ─────────────────── + +(id-deleg-test + "a revoked token is unauthenticated, not forbidden" + (idlnm + (idl-ev + (str + idl-setup + ", identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))) + "unauthenticated") + +(id-deleg-test + "an unknown token is unauthenticated" + (idlnm + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n Bogus = make_ref(),\n case identity_delegation:check(R, A, Bogus, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")) + "unauthenticated") + +(id-deleg-test + "an expired token is unauthenticated" + (idlnm + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read], 100),\n identity_tokens:advance(R, 100),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")) + "unauthenticated") + +;; ── 401 takes precedence over 403 (identity gates first) ───────── + +(id-deleg-test + "a revoked token with no matching scope is still unauthenticated" + (idlnm + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [admin]),\n identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")) + "unauthenticated") + +;; ── acl is what decides for an authenticated subject ───────────── + +(id-deleg-test + "the same subject is permitted one action and denied another" + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n Allowed = case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Denied = case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Allowed - Denied") + 1) + +(id-deleg-test + "identity does not widen permission beyond the token scope" + (idlnm + (idl-ev + "R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write]),\n case identity_delegation:check(R, A, T, delete, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")) + "forbidden") + +(define + id-deleg-test-summary + (str "delegation " id-deleg-test-pass "/" id-deleg-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 51eb0ca4..401ed562 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` → **177/177** (4 phases + 7 ext incl unified facade) +`bash lib/identity/conformance.sh` → **185/185** (4 phases + 8 ext; backlog clear) ## Ground rules @@ -83,11 +83,20 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] scope as a set + scope narrowing on refresh (RFC 6749 §6) - [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2) - [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 +- [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) ## Progress log +- 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, + Resource} and hands off to acl, which returns permit/deny (`forbidden` = + 403). 401 strictly precedes 403 (a revoked token with no scope is still + unauthenticated). acl-on-sx (Datalog) is a different SX guest language — + wired at the integration layer — so the decider here is a labelled stub + (permits when Action ∈ Scope); swap the pid, boundary unchanged. New + tests/delegation.sx (8). 177→185. **Extensions backlog clear.** - 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