diff --git a/lib/identity/api.sx b/lib/identity/api.sx index d743efdb..b96ea5c9 100644 --- a/lib/identity/api.sx +++ b/lib/identity/api.sx @@ -7,20 +7,19 @@ ;; ;; login / verify / revoke / logout / session_status (sessions + tokens) ;; sessions(Subject) / logout_all(Subject) (subject-wide mgmt) -;; history(Subject) (audit ledger) -;; enroll / member_status / member_project (membership) +;; grants(Subject) (apps with access) +;; history(Subject) (audit ledger) +;; enroll / member_status / member_project (membership) ;; -;; Every grant transition is audited: login records `login`, logout (and -;; each logout under logout_all) records `logout`, and the token table -;; records issue/revoke. logout_all is \"log out everywhere\": it revokes and -;; deregisters every session a subject holds, leaving other subjects' -;; sessions untouched. The coordinator owns the sessions, so an idle -;; session deregisters itself. verify answers IDENTITY only; membership -;; projection reports WHAT a subject is for an app; permission is acl's. +;; Per subject, three views answer \"what does this account look like\": +;; sessions (where it is logged in), grants (which apps have access), and +;; history (what happened). Every grant transition is audited. verify +;; answers IDENTITY only; membership projection reports WHAT a subject is +;; for an app; whether either may do a thing is acl's call. (define identity-api-source - "-module(identity).\n\n start() ->\n spawn(fun () ->\n Audit = identity_audit:start(),\n TokReg = identity_tokens:start(Audit),\n SessReg = identity_registry:start(),\n Members = identity_membership:start(),\n loop(TokReg, SessReg, Audit, Members, 1)\n end).\n\n login(Svc, Subject, Client, Scope) ->\n login(Svc, Subject, Client, Scope, infinity).\n\n login(Svc, Subject, Client, Scope, Ttl) ->\n Svc ! {login, Subject, Client, Scope, Ttl, self()},\n receive {identity_reply, R} -> R end.\n\n verify(Svc, Token) ->\n Svc ! {verify, Token, self()},\n receive {identity_reply, R} -> R end.\n\n revoke(Svc, Token) ->\n Svc ! {revoke, Token, self()},\n receive {identity_reply, R} -> R end.\n\n logout(Svc, SessionId) ->\n Svc ! {logout, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n logout_all(Svc, Subject) ->\n Svc ! {logout_all, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n sessions(Svc, Subject) ->\n Svc ! {sessions, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n session_status(Svc, SessionId) ->\n Svc ! {session_status, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n history(Svc, Subject) ->\n Svc ! {history, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n enroll(Svc, Subject, Tier) ->\n Svc ! {enroll, Subject, Tier, self()},\n receive {identity_reply, R} -> R end.\n\n member_status(Svc, Subject) ->\n Svc ! {member_status, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n member_project(Svc, Subject, App) ->\n Svc ! {member_project, Subject, App, self()},\n receive {identity_reply, R} -> R end.\n\n loop(TokReg, SessReg, Audit, Members, NextId) ->\n receive\n {login, Subject, Client, Scope, Ttl, From} ->\n SessionId = NextId,\n Self = self(),\n S = identity_session:start(SessionId, Subject, Client, Self, Ttl),\n identity_registry:register(SessReg, SessionId, Subject, Client, S),\n identity_audit:record(Audit, Subject, login),\n {ok, Token} = identity_tokens:issue(TokReg, Subject, Client, Scope),\n From ! {identity_reply, {ok, SessionId, Token}},\n loop(TokReg, SessReg, Audit, Members, NextId + 1);\n {verify, Token, From} ->\n From ! {identity_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {logout, SessionId, From} ->\n case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, SessionId),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {logout_all, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> logout_each(SessReg, Audit, Ids)\n end,\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {sessions, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> From ! {identity_reply, Ids}\n end,\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_status, SessionId, From} ->\n R = case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, {_, _, _, St}} -> St;\n {error, St} -> St\n end;\n {error, _} -> gone\n end,\n From ! {identity_reply, R},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {history, Subject, From} ->\n From ! {identity_reply, identity_audit:actions(Audit, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {enroll, Subject, Tier, From} ->\n identity_membership:request(Members, Subject, Tier),\n identity_membership:approve(Members, Subject),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_status, Subject, From} ->\n From ! {identity_reply, identity_membership:status(Members, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_project, Subject, App, From} ->\n From ! {identity_reply, identity_membership:project(Members, Subject, App)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_expired, SessionId} ->\n identity_registry:deregister(SessReg, SessionId),\n loop(TokReg, SessReg, Audit, Members, NextId)\n end.\n\n logout_each(_, _, []) -> ok;\n logout_each(SessReg, Audit, [Sid | Rest]) ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n logout_each(SessReg, Audit, Rest).\n\n audit_logout(Audit, Pid) ->\n case identity_session:lookup(Pid) of\n {ok, {_, Subject, _, _}} -> identity_audit:record(Audit, Subject, logout);\n {error, _} -> ok\n end.") + "-module(identity).\n\n start() ->\n spawn(fun () ->\n Audit = identity_audit:start(),\n TokReg = identity_tokens:start(Audit),\n SessReg = identity_registry:start(),\n Members = identity_membership:start(),\n loop(TokReg, SessReg, Audit, Members, 1)\n end).\n\n login(Svc, Subject, Client, Scope) ->\n login(Svc, Subject, Client, Scope, infinity).\n\n login(Svc, Subject, Client, Scope, Ttl) ->\n Svc ! {login, Subject, Client, Scope, Ttl, self()},\n receive {identity_reply, R} -> R end.\n\n verify(Svc, Token) ->\n Svc ! {verify, Token, self()},\n receive {identity_reply, R} -> R end.\n\n revoke(Svc, Token) ->\n Svc ! {revoke, Token, self()},\n receive {identity_reply, R} -> R end.\n\n logout(Svc, SessionId) ->\n Svc ! {logout, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n logout_all(Svc, Subject) ->\n Svc ! {logout_all, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n sessions(Svc, Subject) ->\n Svc ! {sessions, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n grants(Svc, Subject) ->\n Svc ! {grants, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n session_status(Svc, SessionId) ->\n Svc ! {session_status, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n history(Svc, Subject) ->\n Svc ! {history, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n enroll(Svc, Subject, Tier) ->\n Svc ! {enroll, Subject, Tier, self()},\n receive {identity_reply, R} -> R end.\n\n member_status(Svc, Subject) ->\n Svc ! {member_status, Subject, self()},\n receive {identity_reply, R} -> R end.\n\n member_project(Svc, Subject, App) ->\n Svc ! {member_project, Subject, App, self()},\n receive {identity_reply, R} -> R end.\n\n loop(TokReg, SessReg, Audit, Members, NextId) ->\n receive\n {login, Subject, Client, Scope, Ttl, From} ->\n SessionId = NextId,\n Self = self(),\n S = identity_session:start(SessionId, Subject, Client, Self, Ttl),\n identity_registry:register(SessReg, SessionId, Subject, Client, S),\n identity_audit:record(Audit, Subject, login),\n {ok, Token} = identity_tokens:issue(TokReg, Subject, Client, Scope),\n From ! {identity_reply, {ok, SessionId, Token}},\n loop(TokReg, SessReg, Audit, Members, NextId + 1);\n {verify, Token, From} ->\n From ! {identity_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {logout, SessionId, From} ->\n case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, SessionId),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {logout_all, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> logout_each(SessReg, Audit, Ids)\n end,\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {sessions, Subject, From} ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> From ! {identity_reply, Ids}\n end,\n loop(TokReg, SessReg, Audit, Members, NextId);\n {grants, Subject, From} ->\n From ! {identity_reply, identity_tokens:grants_for(TokReg, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_status, SessionId, From} ->\n R = case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, {_, _, _, St}} -> St;\n {error, St} -> St\n end;\n {error, _} -> gone\n end,\n From ! {identity_reply, R},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {history, Subject, From} ->\n From ! {identity_reply, identity_audit:actions(Audit, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {enroll, Subject, Tier, From} ->\n identity_membership:request(Members, Subject, Tier),\n identity_membership:approve(Members, Subject),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_status, Subject, From} ->\n From ! {identity_reply, identity_membership:status(Members, Subject)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {member_project, Subject, App, From} ->\n From ! {identity_reply, identity_membership:project(Members, Subject, App)},\n loop(TokReg, SessReg, Audit, Members, NextId);\n {session_expired, SessionId} ->\n identity_registry:deregister(SessReg, SessionId),\n loop(TokReg, SessReg, Audit, Members, NextId)\n end.\n\n logout_each(_, _, []) -> ok;\n logout_each(SessReg, Audit, [Sid | Rest]) ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} ->\n audit_logout(Audit, Pid),\n identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n logout_each(SessReg, Audit, Rest).\n\n audit_logout(Audit, Pid) ->\n case identity_session:lookup(Pid) of\n {ok, {_, Subject, _, _}} -> identity_audit:record(Audit, Subject, logout);\n {error, _} -> ok\n end.") (define identity-load-api! (fn () (erlang-load-module identity-api-source))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index a814e6a0..f247cf76 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -49,6 +49,7 @@ SUITES=( "introspect|id-intr-test-pass|id-intr-test-count" "par|id-par-test-pass|id-par-test-count" "dynreg|id-dyn-test-pass|id-dyn-test-count" + "account|id-acct-test-pass|id-acct-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -93,6 +94,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/introspect.sx") (load "lib/identity/tests/par.sx") (load "lib/identity/tests/dynreg.sx") +(load "lib/identity/tests/account.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -135,9 +137,11 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-par-test-pass id-par-test-count)") (epoch 120) (eval "(list id-dyn-test-pass id-dyn-test-count)") +(epoch 121) +(eval "(list id-acct-test-pass id-acct-test-count)") EPOCHS -timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 +timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 parse_pair() { local epoch="$1" diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 3f3692d8..8571a3f2 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 222, - "total": 222, + "total_pass": 229, + "total": 229, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -23,6 +23,7 @@ {"name":"exchange","pass":8,"total":8,"status":"ok"}, {"name":"introspect","pass":9,"total":9,"status":"ok"}, {"name":"par","pass":7,"total":7,"status":"ok"}, - {"name":"dynreg","pass":5,"total":5,"status":"ok"} + {"name":"dynreg","pass":5,"total":5,"status":"ok"}, + {"name":"account","pass":7,"total":7,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 7a36485d..867d4552 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 222 / 222 tests passing** +**Total: 229 / 229 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -25,6 +25,7 @@ | ✅ | introspect | 9 | 9 | | ✅ | par | 7 | 7 | | ✅ | dynreg | 5 | 5 | +| ✅ | account | 7 | 7 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/account.sx b/lib/identity/tests/account.sx new file mode 100644 index 00000000..056d3f9a --- /dev/null +++ b/lib/identity/tests/account.sx @@ -0,0 +1,74 @@ +;; identity/tests/account.sx — \"apps with access\": per-subject active-grant +;; listing, at the token registry (grants_for) and through the facade +;; (identity:grants). Completes the per-subject security trio with sessions +;; and history. + +(define id-acct-test-count 0) +(define id-acct-test-pass 0) +(define id-acct-test-fails (list)) + +(define + id-acct-test + (fn + (name actual expected) + (set! id-acct-test-count (+ id-acct-test-count 1)) + (if + (= actual expected) + (set! id-acct-test-pass (+ id-acct-test-pass 1)) + (append! id-acct-test-fails {:name name :expected expected :actual actual})))) + +(define ida-ev erlang-eval-ast) +(define idanm (fn (v) (get v :name))) + +(identity-load-all!) + +;; ── token-level grants_for ─────────────────────────────────────── + +(id-acct-test + "grants_for lists a subject's active grants" + (ida-ev + "R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:issue(R, bob, web, read),\n length(identity_tokens:grants_for(R, alice))") + 2) + +(id-acct-test + "grants_for excludes revoked grants" + (ida-ev + "R = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:revoke(R, A),\n length(identity_tokens:grants_for(R, alice))") + 1) + +(id-acct-test + "grants_for is empty for a subject with none" + (ida-ev + "R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n length(identity_tokens:grants_for(R, ghost))") + 0) + +(id-acct-test + "each grant entry carries the client" + (idanm + (ida-ev + "R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n case identity_tokens:grants_for(R, alice) of\n [{Client, _Scope}] -> Client;\n _ -> other\n end")) + "web") + +;; ── facade-level grants ────────────────────────────────────────── + +(id-acct-test + "identity:grants lists apps a subject has logged into" + (ida-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:grants(Svc, alice))") + 2) + +(id-acct-test + "revoking a token drops it from identity:grants" + (ida-ev + "Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n identity:revoke(Svc, T1),\n length(identity:grants(Svc, alice))") + 1) + +(id-acct-test + "identity:grants is per-subject" + (ida-ev + "Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n length(identity:grants(Svc, bob))") + 1) + +(define + id-acct-test-summary + (str "account " id-acct-test-pass "/" id-acct-test-count)) diff --git a/lib/identity/token.sx b/lib/identity/token.sx index 0b443dc6..c9f84b98 100644 --- a/lib/identity/token.sx +++ b/lib/identity/token.sx @@ -1,7 +1,8 @@ ;; identity/token.sx — opaque, grant-backed tokens with refresh-token ;; rotation (RFC 6749 §6, RFC 6819 §5.2.2.3), cascading revocation, scope ;; narrowing (RFC 6749 §6 / §3.3), access-token expiry (§4.2.2 expires_in), -;; and full introspection metadata (RFC 7662 §2.2). +;; full introspection metadata (RFC 7662 §2.2), and per-subject grant +;; listing (the \"apps with access\" view). ;; ;; The grant is the unit of authorization and the unit of cascade: an ;; access token and a refresh token both reference a grant {Subject, @@ -12,14 +13,14 @@ ;; ;; Expiry uses a LOGICAL clock — the substrate has no wall clock. The ;; registry holds `Now`; `advance(Reg, N)` moves it forward. Each access -;; token carries `Expires` (Now-at-issue + grant Ttl, or `infinity`) and -;; `Iat` (Now-at-issue). introspect returns inactive once Now reaches -;; Expires; refresh mints a fresh access token with new Iat/Expires. +;; token carries `Expires` and `Iat`. introspect returns inactive once Now +;; reaches Expires; refresh mints a fresh access token with new Iat/Expires. ;; ;; introspect reply shapes (RFC 7662 §2.2): ;; introspect(Tok) -> {active, Subject, Client, Scope} | {inactive} ;; introspect_full(Tok) -> {active, Subject, Client, Scope, Exp, Iat, bearer} ;; | {inactive} +;; grants_for(Subject) -> [{Client, Scope}, ...] (active grants only) ;; ;; State threaded through loop/6: ;; Grants : [{Gid, {Subject, Client, Scope, active|revoked, Ttl}}] @@ -30,7 +31,7 @@ (define identity-token-source - "-module(identity_tokens).\n\n start() ->\n start(none).\n\n start(Audit) ->\n spawn(fun () -> loop([], [], [], 1, 0, Audit) end).\n\n issue(Reg, Subject, Client, Scope) ->\n issue(Reg, Subject, Client, Scope, infinity).\n\n issue(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue, Subject, Client, Scope, Ttl, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n issue_grant(Reg, Subject, Client, Scope, infinity).\n\n issue_grant(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue_grant, Subject, Client, Scope, Ttl, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok) ->\n Reg ! {refresh, RefreshTok, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok, Scope) ->\n Reg ! {refresh_scoped, RefreshTok, Scope, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n introspect_full(Reg, Token) ->\n Reg ! {introspect_full, Token, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n advance(Reg, N) ->\n Reg ! {advance, N, self()},\n receive {token_reply, R} -> R end.\n\n now(Reg) ->\n Reg ! {now, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Grants, Access, Refresh, NextGid, Now, Audit) ->\n receive\n {issue, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{Tok, {Gid, Scope, exp(Now, Ttl), Now}} | Access],\n Refresh, NextGid + 1, Now, Audit);\n {issue_grant, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{A, {Gid, Scope, exp(Now, Ttl), Now}} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1, Now, Audit);\n {refresh, RTok, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Sc, exp(Now, grant_ttl(Gid, Grants)), Now}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end;\n {refresh_scoped, RTok, Requested, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n case subset(Requested, Sc) of\n false ->\n From ! {token_reply, {error, invalid_scope}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Requested, exp(Now, grant_ttl(Gid, Grants)), Now}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants, Now)},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {introspect_full, Tok, From} ->\n From ! {token_reply, introspect_meta(Tok, Access, Grants, Now)},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, Gid} ->\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit)\n end;\n {advance, N, From} ->\n From ! {token_reply, ok},\n loop(Grants, Access, Refresh, NextGid, Now + N, Audit);\n {now, From} ->\n From ! {token_reply, Now},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n exp(_, infinity) -> infinity;\n exp(Now, Ttl) -> Now + Ttl.\n\n not_expired(_, infinity) -> true;\n not_expired(Now, Expires) -> Now < Expires.\n\n audit_event(none, _, _) -> ok;\n audit_event(Audit, Subject, Action) ->\n Audit ! {event, Subject, Action},\n ok.\n\n audit_grant(none, _, _, _) -> ok;\n audit_grant(Audit, Gid, Grants, Action) ->\n {Su, _, _} = grant_info(Gid, Grants),\n Audit ! {event, Su, Action},\n ok.\n\n introspect_access(Tok, Access, Grants, Now) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope, Expires, _Iat}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active, _}} ->\n case not_expired(Now, Expires) of\n true -> {active, Su, Cl, Scope};\n false -> {inactive}\n end;\n {ok, {_, _, _, revoked, _}} -> {inactive}\n end\n end.\n\n introspect_meta(Tok, Access, Grants, Now) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope, Expires, Iat}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active, _}} ->\n case not_expired(Now, Expires) of\n true -> {active, Su, Cl, Scope, Expires, Iat, bearer};\n false -> {inactive}\n end;\n {ok, {_, _, _, revoked, _}} -> {inactive}\n end\n end.\n\n find_gid(Tok, Access, Refresh) ->\n case find(Tok, Access) of\n {ok, {Gid, _, _, _}} -> {ok, Gid};\n none ->\n case find(Tok, Refresh) of\n {ok, {Gid, _}} -> {ok, Gid};\n none -> none\n end\n end.\n\n grant_active(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, active, _}} -> true;\n Other -> false\n end.\n\n grant_info(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {Su, Cl, Sc, _, _}} -> {Su, Cl, Sc}\n end.\n\n grant_ttl(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, _, Ttl}} -> Ttl\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old, Ttl}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St, Ttl}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old, Ttl}} | set_status(Gid, St, Rest)]\n end.\n\n supersede(_, []) -> [];\n supersede(RTok, [{T, {Gid, St}} | Rest]) ->\n case T =:= RTok of\n true -> [{T, {Gid, superseded}} | Rest];\n false -> [{T, {Gid, St}} | supersede(RTok, Rest)]\n end.\n\n subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\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.\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.") + "-module(identity_tokens).\n\n start() ->\n start(none).\n\n start(Audit) ->\n spawn(fun () -> loop([], [], [], 1, 0, Audit) end).\n\n issue(Reg, Subject, Client, Scope) ->\n issue(Reg, Subject, Client, Scope, infinity).\n\n issue(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue, Subject, Client, Scope, Ttl, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n issue_grant(Reg, Subject, Client, Scope, infinity).\n\n issue_grant(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue_grant, Subject, Client, Scope, Ttl, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok) ->\n Reg ! {refresh, RefreshTok, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok, Scope) ->\n Reg ! {refresh_scoped, RefreshTok, Scope, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n introspect_full(Reg, Token) ->\n Reg ! {introspect_full, Token, self()},\n receive {token_reply, R} -> R end.\n\n grants_for(Reg, Subject) ->\n Reg ! {grants_for, Subject, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n advance(Reg, N) ->\n Reg ! {advance, N, self()},\n receive {token_reply, R} -> R end.\n\n now(Reg) ->\n Reg ! {now, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Grants, Access, Refresh, NextGid, Now, Audit) ->\n receive\n {issue, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{Tok, {Gid, Scope, exp(Now, Ttl), Now}} | Access],\n Refresh, NextGid + 1, Now, Audit);\n {issue_grant, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{A, {Gid, Scope, exp(Now, Ttl), Now}} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1, Now, Audit);\n {refresh, RTok, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Sc, exp(Now, grant_ttl(Gid, Grants)), Now}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end;\n {refresh_scoped, RTok, Requested, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n case subset(Requested, Sc) of\n false ->\n From ! {token_reply, {error, invalid_scope}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Requested, exp(Now, grant_ttl(Gid, Grants)), Now}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants, Now)},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {introspect_full, Tok, From} ->\n From ! {token_reply, introspect_meta(Tok, Access, Grants, Now)},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {grants_for, Subject, From} ->\n From ! {token_reply, collect_grants(Subject, Grants, [])},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, Gid} ->\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit)\n end;\n {advance, N, From} ->\n From ! {token_reply, ok},\n loop(Grants, Access, Refresh, NextGid, Now + N, Audit);\n {now, From} ->\n From ! {token_reply, Now},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n exp(_, infinity) -> infinity;\n exp(Now, Ttl) -> Now + Ttl.\n\n not_expired(_, infinity) -> true;\n not_expired(Now, Expires) -> Now < Expires.\n\n audit_event(none, _, _) -> ok;\n audit_event(Audit, Subject, Action) ->\n Audit ! {event, Subject, Action},\n ok.\n\n audit_grant(none, _, _, _) -> ok;\n audit_grant(Audit, Gid, Grants, Action) ->\n {Su, _, _} = grant_info(Gid, Grants),\n Audit ! {event, Su, Action},\n ok.\n\n introspect_access(Tok, Access, Grants, Now) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope, Expires, _Iat}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active, _}} ->\n case not_expired(Now, Expires) of\n true -> {active, Su, Cl, Scope};\n false -> {inactive}\n end;\n {ok, {_, _, _, revoked, _}} -> {inactive}\n end\n end.\n\n introspect_meta(Tok, Access, Grants, Now) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope, Expires, Iat}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active, _}} ->\n case not_expired(Now, Expires) of\n true -> {active, Su, Cl, Scope, Expires, Iat, bearer};\n false -> {inactive}\n end;\n {ok, {_, _, _, revoked, _}} -> {inactive}\n end\n end.\n\n collect_grants(_, [], Acc) -> Acc;\n collect_grants(Subject, [{_Gid, {Su, Cl, Sc, active, _Ttl}} | Rest], Acc) ->\n case Su =:= Subject of\n true -> collect_grants(Subject, Rest, [{Cl, Sc} | Acc]);\n false -> collect_grants(Subject, Rest, Acc)\n end;\n collect_grants(Subject, [{_Gid, {_, _, _, revoked, _}} | Rest], Acc) ->\n collect_grants(Subject, Rest, Acc).\n\n find_gid(Tok, Access, Refresh) ->\n case find(Tok, Access) of\n {ok, {Gid, _, _, _}} -> {ok, Gid};\n none ->\n case find(Tok, Refresh) of\n {ok, {Gid, _}} -> {ok, Gid};\n none -> none\n end\n end.\n\n grant_active(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, active, _}} -> true;\n Other -> false\n end.\n\n grant_info(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {Su, Cl, Sc, _, _}} -> {Su, Cl, Sc}\n end.\n\n grant_ttl(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, _, Ttl}} -> Ttl\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old, Ttl}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St, Ttl}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old, Ttl}} | set_status(Gid, St, Rest)]\n end.\n\n supersede(_, []) -> [];\n supersede(RTok, [{T, {Gid, St}} | Rest]) ->\n case T =:= RTok of\n true -> [{T, {Gid, superseded}} | Rest];\n false -> [{T, {Gid, St}} | supersede(RTok, Rest)]\n end.\n\n subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\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.\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-token! diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index ee4259a9..5d160281 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` → **222/222** (4 phases + 13 ext) — needs `timeout 580` +`bash lib/identity/conformance.sh` → **229/229** (4 phases + 14 ext) — slow (~10min, run in background; internal timeout 1200) ## Ground rules @@ -87,12 +87,21 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [~] OAuth `state`/OIDC `nonce` — low value in this server-centric model (client-side echo); skipped - [x] pushed authorization requests (PAR, RFC 9126): single-use request_uri → consent - [x] dynamic client registration (RFC 7591): server-generated client_id + secret +- [x] "apps with access": `grants_for(Subject)` / `identity:grants` (per-subject active grants) - [x] unify `api.sx` over membership + audit (one facade, audited login/logout) - [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere) - [x] token exchange (RFC 8693): downscope a token into a new independent token - [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type) ## Progress log +- 2026-06-07 — "apps with access" (ext): `identity_tokens:grants_for(Subject)` + lists a subject's ACTIVE grants as `[{Client, Scope}]` (revoked excluded), + exposed through the facade as `identity:grants(Subject)`. Completes the + per-subject account-security trio: sessions (where), grants (which apps), + history (what happened). New tests/account.sx (7). 222→229. NOTE: conformance + is now slow (~10 min, 22 suites); run it in the background — internal + sx_server timeout raised to 1200s. The suite is at its monolithic-runtime + ceiling; further test growth should consider splitting the harness. - 2026-06-07 — dynamic client registration (ext, RFC 7591): `register_dynamic` generates a client_id + secret server-side (make_ref each) and registers the client, returning {ok, ClientId, Secret} — self-service onboarding distinct