G7: make multi-tenant isolation a test, and close the two leaks it found - #540
Merged
Conversation
🦋 Changeset detectedLatest commit: fca96ba The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
brentrager
force-pushed
the
g7-multitenancy
branch
from
August 23, 2026 02:13
1937133 to
e61b039
Compare
`tests/multitenancy.rs` was filed as "likely already passes for OLTP; the
test makes it a guarantee". It did not. Writing the suite found two live
cross-tenant holes.
One SHARED suite (rust/adapters/multitenancy_suite.rs, `#[path]`-included
by each adapter's tests/multitenancy.rs) runs the same body against
in-memory, Postgres and DynamoDB — one adapter instance, two orgs, the
multi-tenant pod shape — with a positive control on every isolation
assertion so a backend returning nothing cannot pass vacuously. A second
suite drives the real `handler::handle_frame` from an attacker
authenticated to another org.
1. Cross-tenant session access on every by-id path. Org was resolved per
connection only to STAMP new sessions; `may_read_conversation` checked
the owner email and never the org, and its deliberate
ownerless-is-open rule is exactly the widget's default state. An
attacker authenticated to org B who learned an org-A session id could
read the session, replay its history through a turn, retitle the
conversation, and resume it — minting a session bound to the victim's
org, which flows into the turn's ToolProviderContext. The Lambda
transport had no check at all. Fixed at the `scoped_session` /
`may_read_conversation` chokepoints and the Lambda's `get_session` /
`send_message`, denying indistinguishably from not-found. A connection
with no verified org (anonymous — the widget's normal state) is
unchanged.
2. Knowledge was not tenant-isolated where the backend is not
org-partitioned. `AclKnowledgeStore` filtered by user/group only,
assuming the inner store had already filtered by org — true for
Postgres/DynamoDB, false for the in-memory adapter and any adapter
using the `knowledge_for_access` trait default. Compounding it,
`POST /admin/connectors/{id}/index` ingested through the org-blind
`knowledge()` handle for every tenant (same shape as G3: the seam
existed, one caller went around it), so on Postgres every connector
document was written with `organization_id = NULL` — invisible to
every org-scoped read. The ACL store now records each document's org
and enforces the tenant boundary before the ACL; DynamoDB honours
`AccessContext::organization_id` for its query partition (Postgres
already did); both prefer the document's own `org_id` at ingest; the
admin run goes through the org-bound seam.
Behavior change: a retrieval whose AccessContext carries an org now sees
only documents recorded as that org's, matching the Postgres SQL
pre-filter so all three backends agree. Seed through
`knowledge_for_access` or stamp `org_id` — the two in-repo seeders
updated here show the pattern.
Every fix is proven by reverting it in place and re-running.
Residuals (adapter by-id reads, anonymous connections, participant-less
conversations, `CheckpointStore`'s missing org dimension, unverified S3
Vectors) are written up in `docs/Planning/Feature Gaps.md` §G7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brentrager
force-pushed
the
g7-multitenancy
branch
from
August 23, 2026 02:23
e61b039 to
fca96ba
Compare
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.
Problem
G7 was filed as "Likely already passes for OLTP via
organizationId; the test makes it a guarantee." It did not pass. Writing the suite found two live cross-tenant holes.What's here
One shared conformance suite —
rust/adapters/multitenancy_suite.rs,#[path]-included by each adapter'stests/multitenancy.rs, so the isolation property is asserted from exactly one source and runs against in-memory, Postgres (pgvector container) and DynamoDB (dynamodb-local). One adapter instance, two orgs — the multi-tenant pod shape, and the harshest variant. Every isolation assertion is paired with a positive control, so a backend that returns nothing cannot pass vacuously.A server-level suite —
smooth-operator-server/tests/multitenancy.rs— drives the realhandler::handle_framefrom an attacker authenticated to another org.Now guaranteed by a test, on all three backends
idempotency_keyget two distinct conversations, where an org-blind claim would have handed org B org A's conversation row;knowledge()handle;🚨 Leak 1 — cross-tenant session access on every by-id path
Org was resolved per connection only to stamp new sessions.
may_read_conversation(handler.rs) checked the owner email and never the org, and its deliberate ownerless-is-open rule (a conversation with nouserparticipant carrying an email stays readable) is exactly the embeddable widget's default state.An attacker authenticated to org B who learned an org-A session id could:
get_session)get_conversation_messages)send_message)rename_conversation) — a writeToolProviderContextThe Lambda transport had no check at all:
dispatch::get_session/send_messageacted on whateverstorage.get_sessionreturned.Fixed at the chokepoints —
scoped_sessionandmay_read_conversationtake the connection'sauth_organd refuse another tenant's row indistinguishably from not-found; the Lambda gained the same check off the frame's verified principal. A connection with no verified org (anonymous / tokenless — the widget's normal state) is unchanged.Proof it fails without the fix (
same_orgstubbed totrue, re-run):🚨 Leak 2 — knowledge is not tenant-isolated where the backend isn't org-partitioned
AclKnowledgeStorefiltered by user/group only, on the assumption the wrapped store had already filtered by org. True for Postgres/DynamoDB; false for the in-memory adapter and any third-party adapter using theknowledge_for_accesstrait default.Compounding it,
POST /admin/connectors/{id}/indexingested through the org-blindknowledge()handle for every tenant — the same shape as G3: the seam existed and one caller went around it. On Postgres that wroteorganization_id = NULL, which the org-filtered read can never match, so connector-ingested knowledge silently returned nothing; on DynamoDB it wrote whichever partition the adapter was constructed for.AclKnowledgeStorenow records each document's owning org (from theorg_idmetadata the ingestion pipeline stamps, falling back to the org the ingesting handle is bound to) and enforces the tenant boundary before the ACL.DynamoKnowledgeBasehonoursAccessContext::organization_idfor the query partition and the document'sorg_idfor the ingest partition — mirroring whatPgKnowledgeBase::with_accessalready did.PgKnowledgeBase::ingestprefers the document'sorg_id, so the org-blind handle still lands rows in the right tenant.knowledge_for_access.Proof each fails without its fix (revert in place, re-run):
CROSS-TENANT LEAK: org B's knowledge retrieval returned org A's document: [KnowledgeResult { chunk: "The alphamarkermem escalation code…" }, …]query_org/ingest_orgfix:CROSS-TENANT LEAK: org B's knowledge retrieval returned org A's document: [… document_id: "doc-a-ddb" …]a document ingested through the org-blind handle but stamped with org A's org_id must be retrievable by org A — it was lost insteadPostgres was already isolated for retrieval via the access-bound seam — its change is the org-blind-ingest half only, and I say so rather than claiming more.
A retrieval whose
AccessContextcarries an org now sees only documents recorded as that org's — matching the Postgres backend's existing SQL pre-filter, so all three backends finally agree. A document ingested through the rawknowledge()handle with noorg_idmetadata belongs to no tenant and is therefore invisible to a turn that has one.This is the fail-closed branch, deliberately: fail-open would have meant one tenant's unstamped documents reaching another. Migration is one line — stamp
org_id, or ingest throughstorage.knowledge_for_access(&AccessContext::default().with_organization_id(org)). Two in-repo seeders (acl_trusted_mode.rs,scenario_parity.rs) are updated here and show the pattern; they are the only in-repo callers that were affected.Residuals — true today, NOT guaranteed
Written up in
docs/Planning/Feature Gaps.md§G7 rather than papered over:StorageAdapter's by-id reads (get_conversation,get_message,get_session,list_participants_by_conversation) take no org and are not org-checked at the adapter. Enforcement lives at the caller; a new caller can still forget.Sessionalways carries its org.CheckpointStorehas no org dimension: isolation rests entirely on agent-id uniqueness (the server mints a fresh UUID perAgentand never reads checkpoints back). A host reusing a stable agent id across tenants would commingle conversation state.s3-vectorsfeature and needs real AWS; the suite exercises the brute-force DynamoDB backend only.Verification
cargo test --workspace— 697 passed, 0 failed (rebased ontomain@ 🦋 New version release #539/G1: fence the ingest→ACL chain at the pipeline seam, mark G1/G2/G9 shipped #535)cargo clippy --all-targets— clean, no warningscargo fmt --all --check— cleanChangeset:
.changeset/g7-multitenancy-isolation.md(minor — behavior change above).🤖 Generated with Claude Code
CI on the rebased head: rust ✅ · kind-deploy-smoke ✅ · anchor-guard ✅.