Chroma (chroma-core/chroma, 27.9k stars) is open-source vector and search data infrastructure for AI. It ships two ways: an in-process embedded library (chromadb.Client / PersistentClient, Python/JS bindings over a Rust core) that runs inside the customer's own application alongside their key material, and a multi-tenant hosted service (Chroma Cloud, SOC 2 Type II). The trust boundary of interest: every read and write is scoped to a (tenant, database, collection) triple, and that scoping is enforced almost entirely at one place, the frontend AuthenticateAndAuthorize gate. The data layers below it (the sysdb catalog, the query workers, the log/WAL) resolve work by collection_id / segment_id and trust whatever the frontend hands down. This review covers the Rust core (rust/frontend, rust/sysdb, rust/worker, rust/log-service) and the chroma-mcp agent integration.
-
Frontend authorization is the single isolation layer, and the open-source default is a no-op allow-all. The authz model in rust/frontend-core/src/auth.rs is well-designed: AuthzAction enumerates every operation including Query/Get/Add/Delete/Fork (auth.rs:15-46), AuthzResource carries {tenant, database, collection} (auth.rs:85-90), and there is a collection-aware hook authenticate_and_authorize_collection(.., collection: Collection) (auth.rs:114-120) that is the intended place to verify a fetched collection's tenant against the caller. But the default trait impl impl AuthenticateAndAuthorize for () (auth.rs:136-168) returns a fixed default_identity with tenant = "default_tenant" for every request, ignoring headers, and the only frontend binary wires exactly that no-op in for both auth and quota: Arc::new(()) as _ (rust/frontend/src/bin/frontend_service.rs:13-14). The production deployment must inject a real impl that authenticates the caller and, inside authenticate_and_authorize_collection, verifies collection.tenant == identity.tenant; self-host defaults should fail closed, not open.
-
There is no tenant re-check in sysdb collection and segment resolution, so there is no defense-in-depth below the gate. rust/sysdb/src/sqlite.rs get_collection_with_segments(collection_id) (sqlite.rs:634-680) resolves the collection and all of its segments by collection UUID alone, calling get_collections_with_conn with tenant = None and database = None (sqlite.rs:638-649) and get_segments_with_conn by collection_id only (sqlite.rs:656-658). The same query builder fully supports tenant and database filters (the add_option(tenant..) and inner_join(Databases..) at sqlite.rs:735-754), so the scoping is available but is not applied on this read hot-path. Collection and segment resolution should accept and enforce the (tenant, database) scope so that a collection_id not owned by the caller's tenant cannot be resolved, giving a second isolation layer independent of the frontend gate.
-
The query and get execution path drops the tenant and resolves by (database, collection_id). rust/frontend/src/impls/service_based_frontend.rs query() destructures QueryRequest with .. and never binds tenant_id (service_based_frontend.rs:2234-2245), then fetches collection_and_segments via get_collection_with_segments(Some(database_name), collection_id) (service_based_frontend.rs:2262-2266) with no tenant. The fetched collection carries a tenant field, but it is never compared to the caller. After fetching, the code should validate collection.tenant (and database) against the authenticated identity before serving the query, so that a leaked or forged collection_id cannot yield another tenant's vectors and documents.
-
The lower layers (worker, log/WAL) trust the collection_id handed down. The query worker scans the segments contained in the CollectionAndSegments passed from the frontend with no independent tenant check (rust/worker/src/server.rs scan path, around server.rs:325-342), and the log service prefixes storage by collection_id alone (rust/log-service/src/lib.rs, collection_id.storage_prefix_for_log() around lib.rs:451), so storage-layer isolation is per-collection, not per-tenant. The design should state explicitly whether tenant scoping is enforced at these layers or deliberately delegated upward, and a descriptor arriving without a resolved tenant should be a hard error rather than a silently broad read.
-
A mitigating control worth crediting: collection and segment IDs are random UUIDv4. rust/types/src/collection.rs CollectionUuid::new() (collection.rs:39) and rust/types/src/segment.rs SegmentUuid::new() (segment.rs:47-48) use Uuid::new_v4(), so IDs are not enumerable, which raises the bar on the cross-tenant path in sections 2 and 3. Because the isolation model leans on this unguessability, collection and segment IDs should never leak across tenants in error messages, metrics labels, logs, or shared API responses.
-
chroma-mcp grants an agent broad CRUD over every collection the configured key can reach. chroma-core/chroma-mcp/src/chroma_mcp/server.py builds a single global client from one set of credentials (cloud mode sends an x-chroma-token API key for one tenant and database, server.py:109-126; or http / persistent / ephemeral), then registers tools that each take a free-form collection_name: chroma_query_documents (server.py:395-440), chroma_get_documents (442-490), chroma_add_documents via get_or_create_collection (332-393), chroma_update_documents (492-565), chroma_delete_documents (567-605), chroma_delete_collection (317-329), chroma_fork_collection (297-315), and chroma_modify_collection (269-295). There is no per-collection allowlist and no read-only mode, so an agent, or untrusted content that reaches the agent through indirect prompt injection (the classic risk for a tool-using agent over a retrieval store), can read, overwrite, or delete any collection in the configured scope. The filters accept $regex over document content (the where_document operators documented around server.py:420-423 and 467-470), enabling content-pattern enumeration and potential ReDoS. The MCP server should scope to an explicit collection allowlist and optionally a read-only mode, constrain filter operators, and the credential it holds should grant the minimum the agent needs rather than the whole tenant or database.
-
Embedded mode is fully trusting by design, and the server defaults need to fail closed. The default api implementation is the in-process Rust bindings (chromadb/config.py chroma_api_impl default RustBindingsAPI, config.py:120), and the embedded get_user_identity returns a fixed default identity with no auth (chromadb/api/rust.py:719-724, chromadb/api/segment.py:204-209). Server-mode auth providers default to None (config.py:205, 214) and quota and rate-limit are no-op stubs (chromadb/quota/simple_quota_enforcer, chromadb/rate_limit/simple_rate_limit). The in-process trust model is reasonable for an embedded library, but it means any code or dependency sharing the process has full data access, and a self-hosted server started without explicitly configuring authn/authz is open to anyone who can reach it. Self-host and server entrypoints should warn or fail when started without an auth provider, and the embedded trust boundary (process boundary equals trust boundary, next to the customer's key material) should be made explicit.