identity: identity->acl delegation boundary — 401 gates before 403 (+8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ SUITES=(
|
|||||||
"grants|id-grants-test-pass|id-grants-test-count"
|
"grants|id-grants-test-pass|id-grants-test-count"
|
||||||
"device|id-device-test-pass|id-device-test-count"
|
"device|id-device-test-pass|id-device-test-count"
|
||||||
"facade|id-facade-test-pass|id-facade-test-count"
|
"facade|id-facade-test-pass|id-facade-test-count"
|
||||||
|
"delegation|id-deleg-test-pass|id-deleg-test-count"
|
||||||
)
|
)
|
||||||
|
|
||||||
cat > "$TMPFILE" << 'EPOCHS'
|
cat > "$TMPFILE" << 'EPOCHS'
|
||||||
@@ -65,6 +66,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/identity/federation.sx")
|
(load "lib/identity/federation.sx")
|
||||||
(load "lib/identity/clients.sx")
|
(load "lib/identity/clients.sx")
|
||||||
(load "lib/identity/device.sx")
|
(load "lib/identity/device.sx")
|
||||||
|
(load "lib/identity/delegation.sx")
|
||||||
(load "lib/identity/tests/session.sx")
|
(load "lib/identity/tests/session.sx")
|
||||||
(load "lib/identity/tests/token.sx")
|
(load "lib/identity/tests/token.sx")
|
||||||
(load "lib/identity/tests/registry.sx")
|
(load "lib/identity/tests/registry.sx")
|
||||||
@@ -80,6 +82,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/identity/tests/grants.sx")
|
(load "lib/identity/tests/grants.sx")
|
||||||
(load "lib/identity/tests/device.sx")
|
(load "lib/identity/tests/device.sx")
|
||||||
(load "lib/identity/tests/facade.sx")
|
(load "lib/identity/tests/facade.sx")
|
||||||
|
(load "lib/identity/tests/delegation.sx")
|
||||||
(epoch 100)
|
(epoch 100)
|
||||||
(eval "(list id-session-test-pass id-session-test-count)")
|
(eval "(list id-session-test-pass id-session-test-count)")
|
||||||
(epoch 101)
|
(epoch 101)
|
||||||
@@ -110,6 +113,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(eval "(list id-device-test-pass id-device-test-count)")
|
(eval "(list id-device-test-pass id-device-test-count)")
|
||||||
(epoch 114)
|
(epoch 114)
|
||||||
(eval "(list id-facade-test-pass id-facade-test-count)")
|
(eval "(list id-facade-test-pass id-facade-test-count)")
|
||||||
|
(epoch 115)
|
||||||
|
(eval "(list id-deleg-test-pass id-deleg-test-count)")
|
||||||
EPOCHS
|
EPOCHS
|
||||||
|
|
||||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||||
|
|||||||
34
lib/identity/delegation.sx
Normal file
34
lib/identity/delegation.sx
Normal file
@@ -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)))
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"language": "identity",
|
"language": "identity",
|
||||||
"total_pass": 177,
|
"total_pass": 185,
|
||||||
"total": 177,
|
"total": 185,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
{"name":"session","pass":11,"total":11,"status":"ok"},
|
||||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
{"name":"token","pass":24,"total":24,"status":"ok"},
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
{"name":"clients","pass":11,"total":11,"status":"ok"},
|
{"name":"clients","pass":11,"total":11,"status":"ok"},
|
||||||
{"name":"grants","pass":9,"total":9,"status":"ok"},
|
{"name":"grants","pass":9,"total":9,"status":"ok"},
|
||||||
{"name":"device","pass":10,"total":10,"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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# identity-on-sx Scoreboard
|
# identity-on-sx Scoreboard
|
||||||
|
|
||||||
**Total: 177 / 177 tests passing**
|
**Total: 185 / 185 tests passing**
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
| | Suite | Pass | Total |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
| ✅ | grants | 9 | 9 |
|
| ✅ | grants | 9 | 9 |
|
||||||
| ✅ | device | 10 | 10 |
|
| ✅ | device | 10 | 10 |
|
||||||
| ✅ | facade | 9 | 9 |
|
| ✅ | facade | 9 | 9 |
|
||||||
|
| ✅ | delegation | 8 | 8 |
|
||||||
|
|
||||||
|
|
||||||
Generated by `lib/identity/conformance.sh`.
|
Generated by `lib/identity/conformance.sh`.
|
||||||
|
|||||||
102
lib/identity/tests/delegation.sx
Normal file
102
lib/identity/tests/delegation.sx
Normal file
@@ -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))
|
||||||
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
|||||||
|
|
||||||
## Status (rolling)
|
## 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
|
## 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] 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 registry: public vs confidential clients, client authentication (RFC 6749 §2)
|
||||||
- [x] client-credentials grant (RFC 6749 §4.4) + device grant (RFC 8628)
|
- [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
|
- [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange
|
||||||
- [x] unify `api.sx` over membership + audit (one facade, audited login/logout)
|
- [x] unify `api.sx` over membership + audit (one facade, audited login/logout)
|
||||||
|
|
||||||
## Progress log
|
## 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
|
- 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 + a membership registry alongside its token table (started with the
|
||||||
ledger) and session registry. login/logout are audited; new ops
|
ledger) and session registry. login/logout are audited; new ops
|
||||||
|
|||||||
Reference in New Issue
Block a user