Skip to content

Add durable multi-tenant authz example (Prolog proof chain + event-sourced store)#31

Merged
pyrex41 merged 6 commits into
mainfrom
claude/backpressure-authz-durable-0wu27q
Jul 2, 2026
Merged

Add durable multi-tenant authz example (Prolog proof chain + event-sourced store)#31
pyrex41 merged 6 commits into
mainfrom
claude/backpressure-authz-durable-0wu27q

Conversation

@pyrex41

@pyrex41 pyrex41 commented Jul 2, 2026

Copy link
Copy Markdown
Owner

A companion to examples/openresty (the guestbook) that brings
Shen-Backpressure's authorization idea to a running endpoint, reusing what
shen-lua already has instead of adding an engine.

What it is

  • Policy as a Prolog proof chain (token → user → tenant → resource), kept
    inside 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.
  • Typed core (authz.shen, tc +): a decision verdict plus render-doc
    and decision-status, both total over decision. Content is serialized for
    [granted ...] and no other shape — the shengen guard-type guarantee, moved to
    a runtime witness because the facts are dynamic. Checked at load time.
  • 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. 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.
  • Identity at the head of the chain (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.lua runs the whole policy off-nginx against three substrates
file, the lua-resty-lmdb adapter (in-process fake of resty.lmdb), and identity
over a cosocket (in-process fake of ngx.socket.tcp) — reopens the store from
its 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)

  • Write with a missing content field now 400s instead of silently wiping the
    document.
  • Identity/tenant are resolved once, before the proof, and reused for the
    query, the [granted ...] witness, and the discharge report — closing the
    yield-window where the cosocket resolver could make the witness name a
    different user than the chain proved.
  • Cosocket resolver fails closed by default; degradation is opt-in.
  • Audit endpoint is admin-gated; bodyless POSTs 400 rather than 404;
    decision-status is now on the response path.
  • Documented: the lmdb append seq 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

  • Single worker as written (the materialized view is per-worker); LMDB is the
    path to shared state but its seq allocation must be made atomic first.
  • A natural follow-up is replicating the event log across nodes (a defensible
    cosocket use that also closes the cross-worker gap).

🤖 Generated with Claude Code

claude added 6 commits July 1, 2026 04:46
…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
@pyrex41 pyrex41 merged commit c340b64 into main Jul 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants