identity: unify api.sx facade over audit + membership (+9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
The identity coordinator now owns an audit ledger and a membership registry alongside its token table (started with the ledger) and session registry. login/logout are audited; new ops history/enroll/member_status/member_project surface the audit and membership axes through the one `identity` door. Identity proves who and reports membership; acl still decides permission. Existing api behaviour unchanged. New tests/facade.sx. 177/177. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,24 @@
|
||||
;; identity/api.sx — the identity service facade.
|
||||
;; identity/api.sx — the unified identity service facade.
|
||||
;;
|
||||
;; `identity:start()` spawns one coordinator process that owns a token
|
||||
;; table and a session registry and ties them together. It exposes the
|
||||
;; whole-domain operations the architecture sketch names:
|
||||
;; `identity:start()` spawns one coordinator that owns the whole domain:
|
||||
;; an audit ledger, a grant-backed token table (wired to that ledger), a
|
||||
;; session registry, and a membership registry. It exposes the
|
||||
;; whole-domain operations through one door:
|
||||
;;
|
||||
;; login(Svc, Subject, Client, Scope[, Ttl]) -> {ok, SessionId, Token}
|
||||
;; verify(Svc, Token) -> {active, Subject, Client, Scope} | {inactive}
|
||||
;; revoke(Svc, Token) -> ok (revokes the token; real, immediate)
|
||||
;; logout(Svc, SessionId) -> ok (tombstones + deregisters a session)
|
||||
;; session_status(Svc, Sid) -> active | expired | revoked | gone
|
||||
;; login / verify / revoke / logout / session_status (sessions + tokens)
|
||||
;; history(Subject) (audit ledger)
|
||||
;; enroll / member_status / member_project (membership)
|
||||
;;
|
||||
;; The coordinator is also the Owner the sessions notify on idle timeout,
|
||||
;; so an expired session deregisters itself from the directory — the
|
||||
;; timeout is the only liveness driver; nothing sweeps.
|
||||
;;
|
||||
;; Delegation boundary: verify/2 answers IDENTITY only — who the token
|
||||
;; belongs to and what scope was granted. It deliberately does NOT answer
|
||||
;; \"may they do X\"; that question belongs to acl-on-sx, which keys off the
|
||||
;; {active, Subject, Client, Scope} this returns.
|
||||
;; Every grant transition is audited: login records `login`, logout records
|
||||
;; `logout`, and the token table (started with the ledger) records
|
||||
;; issue/revoke. The coordinator owns the sessions, so an idle session
|
||||
;; deregisters itself. verify answers IDENTITY only ({active, Subject,
|
||||
;; Client, Scope}); 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 TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n loop(TokReg, SessReg, 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 session_status(Svc, SessionId) ->\n Svc ! {session_status, SessionId, self()},\n receive {identity_reply, R} -> R end.\n\n loop(TokReg, SessReg, 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 {ok, Token} = identity_tokens:issue(TokReg, Subject, Client, Scope),\n From ! {identity_reply, {ok, SessionId, Token}},\n loop(TokReg, SessReg, NextId + 1);\n {verify, Token, From} ->\n From ! {identity_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, NextId);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, NextId);\n {logout, SessionId, From} ->\n case identity_registry:whereis_session(SessReg, SessionId) of\n {ok, Pid} -> identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, SessionId),\n From ! {identity_reply, ok},\n loop(TokReg, SessReg, 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, NextId);\n {session_expired, SessionId} ->\n identity_registry:deregister(SessReg, SessionId),\n loop(TokReg, SessReg, NextId)\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 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 {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 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)))
|
||||
|
||||
@@ -32,4 +29,6 @@
|
||||
(identity-load-session!)
|
||||
(identity-load-token!)
|
||||
(identity-load-registry!)
|
||||
(identity-load-audit!)
|
||||
(identity-load-membership!)
|
||||
(identity-load-api!)))
|
||||
|
||||
@@ -42,6 +42,7 @@ SUITES=(
|
||||
"clients|id-clients-test-pass|id-clients-test-count"
|
||||
"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"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
@@ -78,6 +79,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(load "lib/identity/tests/clients.sx")
|
||||
(load "lib/identity/tests/grants.sx")
|
||||
(load "lib/identity/tests/device.sx")
|
||||
(load "lib/identity/tests/facade.sx")
|
||||
(epoch 100)
|
||||
(eval "(list id-session-test-pass id-session-test-count)")
|
||||
(epoch 101)
|
||||
@@ -106,6 +108,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(eval "(list id-grants-test-pass id-grants-test-count)")
|
||||
(epoch 113)
|
||||
(eval "(list id-device-test-pass id-device-test-count)")
|
||||
(epoch 114)
|
||||
(eval "(list id-facade-test-pass id-facade-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"language": "identity",
|
||||
"total_pass": 168,
|
||||
"total": 168,
|
||||
"total_pass": 177,
|
||||
"total": 177,
|
||||
"suites": [
|
||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
||||
@@ -16,6 +16,7 @@
|
||||
{"name":"expiry","pass":8,"total":8,"status":"ok"},
|
||||
{"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":"device","pass":10,"total":10,"status":"ok"},
|
||||
{"name":"facade","pass":9,"total":9,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# identity-on-sx Scoreboard
|
||||
|
||||
**Total: 168 / 168 tests passing**
|
||||
**Total: 177 / 177 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
@@ -18,6 +18,7 @@
|
||||
| ✅ | clients | 11 | 11 |
|
||||
| ✅ | grants | 9 | 9 |
|
||||
| ✅ | device | 10 | 10 |
|
||||
| ✅ | facade | 9 | 9 |
|
||||
|
||||
|
||||
Generated by `lib/identity/conformance.sh`.
|
||||
|
||||
97
lib/identity/tests/facade.sx
Normal file
97
lib/identity/tests/facade.sx
Normal file
@@ -0,0 +1,97 @@
|
||||
;; identity/tests/facade.sx — the unified facade: one coordinator wiring
|
||||
;; sessions+tokens, the audit ledger, and membership. Exercises the
|
||||
;; cross-module integration (login/logout auditing, audit history, member
|
||||
;; enrollment + projection) through the single `identity` door.
|
||||
|
||||
(define id-facade-test-count 0)
|
||||
(define id-facade-test-pass 0)
|
||||
(define id-facade-test-fails (list))
|
||||
|
||||
(define
|
||||
id-facade-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-facade-test-count (+ id-facade-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-facade-test-pass (+ id-facade-test-pass 1))
|
||||
(append! id-facade-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define idfc-ev erlang-eval-ast)
|
||||
(define idfcnm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-all!)
|
||||
|
||||
;; ── login + logout are audited through the ledger ────────────────
|
||||
|
||||
(id-facade-test
|
||||
"login then logout records login, issue, logout in order"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> ordered;\n Other -> Other\n end"))
|
||||
"ordered")
|
||||
|
||||
(id-facade-test
|
||||
"revoking a token is audited"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> ordered;\n Other -> Other\n end"))
|
||||
"ordered")
|
||||
|
||||
(id-facade-test
|
||||
"history is per-subject"
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, cli, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:history(Svc, alice))")
|
||||
4)
|
||||
|
||||
;; ── membership through the facade ────────────────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"enroll makes the subject an active member"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"active")
|
||||
|
||||
(id-facade-test
|
||||
"enroll keeps the tier"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, _, Tier} -> Tier\n end"))
|
||||
"supporter")
|
||||
|
||||
(id-facade-test
|
||||
"an enrolled member projects per-app"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:enroll(Svc, alice, basic),\n case identity:member_project(Svc, alice, market) of\n {member, _, App} -> App;\n {Tag, _} -> Tag\n end"))
|
||||
"market")
|
||||
|
||||
(id-facade-test
|
||||
"a non-member projects as non_member"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n case identity:member_project(Svc, stranger, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
|
||||
"non_member")
|
||||
|
||||
;; ── the facade still proves identity ─────────────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"verify still returns the subject after login"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
|
||||
"alice")
|
||||
|
||||
;; ── identity and membership are distinct axes ────────────────────
|
||||
|
||||
(id-facade-test
|
||||
"logging in does not enroll membership"
|
||||
(idfcnm
|
||||
(idfc-ev
|
||||
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
|
||||
"none")
|
||||
|
||||
(define
|
||||
id-facade-test-summary
|
||||
(str "facade " id-facade-test-pass "/" id-facade-test-count))
|
||||
Reference in New Issue
Block a user