A retrieval assistant where who you are decides what you can retrieve — and the model never gets a vote. Tenancy and document-level permissions are enforced server-side from a verified identity, so two identical questions return two different correct answers, and one tenant can never see another's data.
The Chat view — tenant-scoped retrieval, cited answers, and the access layer reporting how many passages it 🔒 hid (other tenant / wrong group). The confidential Q3 pricing is the “1 hidden” here.
Several tenants share one app and one model. Each user logs in and receives a verified principal — a tenant plus a set of access groups. Every search is scoped to that principal server-side: the model can drive retrieval, but it can never choose whose data it reaches. The result is the security-critical pattern for multi-tenant RAG, made concrete and testable.
The tenant comes from the verified session token, never the request body or the model. There is deliberately no tenant field on the chat or ingest API.
Each tenant's vectors live in a physically separate collection (index/<tenant>/), so a cross-tenant hit is impossible — not merely filtered.
access_groupsWithin a tenant, each document carries an access-group list. A user retrieves it only if their verified groups intersect — that's how unreleased pricing stays with the pricing team.
One choke point applies a tenant gate and a group gate. Every chunk is stamped with its tenant, so even a packing bug degrades to “no results,” never a leak.
The search_knowledge_base tool exposes only a query. A prompt-injected model that smuggles tenant=acme into its call is simply ignored — there's a test for exactly that.
Two identical questions → two different correct answers, and Globex can never retrieve Acme's unreleased pricing — proven deterministically on every push, no model or network needed.
PBKDF2-hashed credentials, per-user bearer sessions, and a directory of tenants, groups and roles. Threads are per-user; you never see another user's conversations.
NDJSON streaming renders the agent trajectory live — including a “🔒 N hidden” pill showing how many candidates the ACL removed — with grounded [n] citations.
No Bedrock key? Chat falls back to a deterministic grounded answer over your accessible documents. The access decision is identical — it happens before any model runs.
The moment more than one customer, team, or clearance level shares a knowledge base, “retrieve the relevant passages” becomes “retrieve the relevant passages this caller is allowed to see.” Getting that wrong is a data breach; this is the pattern that gets it right.
One deployment serving many customer organizations, each strictly walled off from the others' documents and chat history.
Unreleased pricing, HR files, board decks — visible to the right group inside a company, invisible to everyone else, from the same assistant.
M&A diligence, legal, healthcare — where “who could this content have reached” must be answerable and provable.
A support agent's assistant that answers only from the customer's own tickets, contracts and configuration.
One search box over many departments' wikis, where results honor each employee's group memberships automatically.
A small, dependency-light Python backend and a vanilla-JS frontend with no build step. The security boundary is a single, auditable function.
The bearer token resolves to a principal; retrieval flows through one function that applies both gates. Grep the server for principal.tenant_id — that value only ever comes from the token.
bearer token ─▶ Principal { tenant, groups } (verified — never the body, never the model) │ user turn ─▶ model ──search?──▶ access.retrieve(principal, query) │ tenant gate → index/<tenant>/ (physical split) │ group gate → access_groups ∩ groups ▼ visible passages ─▶ grounded, cited answer
A user signs in; the server verifies the password (PBKDF2) and mints a bearer session that resolves to a Principal — { tenant_id, groups, role }. This is the single source of tenancy for the rest of the request.
The agent hands the model one tool, search_knowledge_base(query). The tool schema has no tenant, no groups. The executor reads args["query"] and nothing else, so any injected scope argument is inert.
Retrieval loads the collection at index/<principal.tenant_id>/. With one collection per tenant, another tenant's vectors aren't candidates at all. A defensive check also drops any hit whose stamped tenant disagrees.
Each candidate carries the document's access_groups. A document with none is visible to every member of the tenant; otherwise the caller must hold one of the listed groups. The predicate is pure and total — easy to test, hard to bypass.
Visible passages are numbered and cited [n]; the answer streams token by token with the trajectory, including how many candidates were hidden. With no model configured, a deterministic grounded answer over the same filtered passages takes over.
A pytest suite runs the exact retrieval path over the demo corpus with a deterministic hash embedder: two identical questions yield two different correct answers, a battery of adversarial Globex queries (including a prompt-injected tenant=acme) return zero Acme content, and the group gate holds within a tenant.
Ships with a pre-seeded app_config.db, so the Bedrock region, key and model carry over — and it seeds two demo tenants, five users and a small corpus on first run.
# install & launch pip install -r requirements.txt python server.py # → http://127.0.0.1:8300 · overview at /overview # then, in the browser: 1. Sign in as acme-sales / demo1234 → ask “how much does your cloud product cost per month?” 2. Sign out, sign in as globex-sales → same question, different answer 3. Settings → model & Bedrock connection (password: bala@143) # prove the isolation (hermetic — just numpy + pytest) pip install -r requirements-ci.txt pytest tests/ -v # the leakage test