Add durable multi-tenant authz example (Prolog proof chain + event-sourced store)#31
Merged
Merged
Conversation
…urced store) A companion to examples/openresty that brings Shen-Backpressure's authorization idea to a running endpoint, reusing what shen-lua already has instead of adding an engine: - Policy as a Prolog proof chain (token -> user -> tenant -> resource), kept inside the Datalog fragment by discipline and run on the native soa32 engine. Leaf facts are read from the store via lua.call inside `is`/`when` guards (with `receive` injecting the runtime token/resource), so the store stays the single source of truth and no per-worker fact cache can drift. - Typed core (authz.shen, tc +): a `decision` verdict and a total `render-doc` projection that serializes document content for [granted ...] and no other shape -- the shengen guard-type guarantee, moved to a runtime witness because the facts are dynamic. Verified at load time by the typechecker. - Durable execution via an event-sourced store.lua: every mutation and every decision is appended to a log; the in-memory view is a cache rebuilt by replay on open. File backend (tested under plain luajit) + a faithful lua-resty-lmdb backend for production. GET /api/audit exposes the durable discharge report. selftest.lua drives the whole thing off-nginx and reopens the store from its on-disk log to prove facts and audit survive a restart. Full port suite stays green (453/453). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC5aXvUTaaNgfGHQJjGsvX
…kend parity The lmdb backend was previously a blind spot (needs OpenResty to run). Add a faithful in-process fake of resty.lmdb (get + transaction begin/set/commit) and run the entire selftest scenario through the real store.lua lmdb code path, then assert the file and lmdb backends produce an identical, decision-for-decision audit trail. The lmdb adapter's transactional append + replay logic is now exercised under plain luajit; only the real memory-mapped env still needs nginx. Docs updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC5aXvUTaaNgfGHQJjGsvX
The head of the proof chain (token -> user) is the one leaf fact that lives outside the worker in production — a networked session store. Doing that with blocking I/O would stall nginx's single-threaded worker, which is exactly what OpenResty's cosocket API is for. - auth.lua: resolve token -> user over a raw cosocket (minimal Redis RESP via ngx.socket.tcp connect/send/receive/setkeepalive), fronted by a short-TTL lua_shared_dict cache and degrading to the local store on error. lua-resty-redis /-http are the production wrappers over these same calls. - app.lua: identity resolution is now pluggable (app.use_auth); default stays the local store so off-nginx behavior is unchanged. The Prolog policy is untouched. - selftest.lua: exercises the cosocket code path in-process against a fake ngx.socket.tcp — asserts one round-trip on first lookup, a cache hit (no reconnect) on the second, and the Redis $-1 missing-key path — then runs the whole policy end-to-end with identity over the cosocket and asserts the file, lmdb, and cosocket substrates yield an identical audit trail. - nginx.conf: lua_shared_dict auth_cache + a documented use_auth block to point at Redis. README documents the cosocket layer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC5aXvUTaaNgfGHQJjGsvX
…paque tokens Resolving identity over a cosocket per request overstated it as a default. Whether identity touches the network is a token-format decision: signed tokens (JWT) verify locally with no per-request I/O; only opaque tokens (session ids / introspection) need a remote lookup, and only then a cosocket. Reframe accordingly, no behavior change to the tested paths: - auth.lua: local_resolver documented as the default (local/JWT-style, no I/O); cosocket_resolver clearly marked opt-in, for opaque tokens only. - nginx.conf + README: default is local verification; the cosocket/Redis block is explicitly the opaque-token case, with the honest note that a well-tuned gate (local JWT verify + local LMDB facts) leans on cosockets sparingly. - selftest.lua: the cosocket run is labeled opaque-token coverage; it still exercises the ngx.socket.tcp path and all three substrates agree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC5aXvUTaaNgfGHQJjGsvX
…ail closed Fixes from code review: - #1 write with a missing `content` field now 400s ("content: is required") instead of a granted write silently storing "" over the document. present? distinguishes an absent key from an empty value. - #2 resolve identity (User) and tenant (Tenant) ONCE, in Shen, before the proof, and pass the same values into the query, the [granted ...] witness, and the discharge report. Previously prolog? discarded the bindings and grant/ reason re-derived them via fresh host calls — under the cosocket resolver those yield, so the witness could name a different user than the chain proved. Now the only yielding call (host-token-user) happens once; the remaining leaf facts are local, non-yielding reads. - #4 cosocket resolver fails CLOSED by default (no identity on a store error, so the chain denies); degrading to a local fallback is now an explicit opt-in. Documented / nits: - #3 the lmdb append allocates its seq with a read-modify-write outside the txn, which races across workers; commented in store.lua and called out in the README's multi-worker note (was implied to be the multi-worker fix). - audit is now admin-gated (POST /api/admin/audit) — it lists users and reasons. - bodyless POST to a real route now 400s instead of 404 (handlers validate body). - decision-status is now used by respond/write-through (was dead code). - README: file backend is process-crash durable, not power-loss (no fsync); cosocket fails closed + 5s cache TTL means revocation lag. - app.lua: comment that `host` is an intentional global for the lua.call bridge. selftest.lua adds request-shape guards (missing content, no-wipe, bodyless, audit-needs-admin); file/lmdb/cosocket runs still yield an identical audit trail. Port suite green (453/453). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC5aXvUTaaNgfGHQJjGsvX
…uthz-durable-0wu27q # Conflicts: # examples/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A companion to
examples/openresty(the guestbook) that bringsShen-Backpressure's authorization idea to a running endpoint, reusing what
shen-lua already has instead of adding an engine.
What it is
token → user → tenant → resource), keptinside the Datalog fragment by discipline and run on the native soa32 engine
(FFI/int32). Prolog composes; the durable store supplies each leaf fact via
lua.call, so it stays the single source of truth.authz.shen,tc +): adecisionverdict plusrender-docand
decision-status, both total overdecision. Content is serialized for[granted ...]and no other shape — the shengen guard-type guarantee, moved toa runtime witness because the facts are dynamic. Checked at load time.
store.lua: every mutation andevery decision is appended to a log; the in-memory view is a cache rebuilt by
replay on open. Kill the process, reopen, replay → same facts and audit trail.
Two backends behind one interface: a file backend (default) and a faithful
lua-resty-lmdb backend for production.
auth.lua): local by default(JWT-style verification, no per-request I/O); an opt-in cosocket resolver
(
ngx.socket.tcp, shared-dict cache, fail-closed) for the opaque-token case.POST /api/admin/audit(admin-gated) exposes the durable discharge report:for each decision, which premise carried or failed it.
Tested
selftest.luaruns the whole policy off-nginx against three substrates —file, the lua-resty-lmdb adapter (in-process fake of
resty.lmdb), and identityover a cosocket (in-process fake of
ngx.socket.tcp) — reopens the store fromits durable log to prove facts + audit survive a restart, and asserts all three
produce an identical, decision-for-decision audit trail. Port suite stays green
(453/453).
Review round (addressed)
contentfield now400s instead of silently wiping thedocument.
query, the
[granted ...]witness, and the discharge report — closing theyield-window where the cosocket resolver could make the witness name a
different user than the chain proved.
400rather than404;decision-statusis now on the response path.appendseq allocation races across workers (single-worker as written); the file backend is process-crash but not power-loss
durable; the cosocket cache TTL bounds revocation lag.
Notes / known limits
path to shared state but its seq allocation must be made atomic first.
cosocket use that also closes the cross-worker gap).
🤖 Generated with Claude Code