Chroma core + chroma-mcp: single-layer tenant isolation (frontend authz gate, no tenant re-check in sysdb/query execution) and a broad-CRUD agent surface

Published 4 months ago

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Focus on the architectural commitments visible at commit 43171c5: where tenant isolation is enforced versus trusted, and the blast radius of the MCP integration. Cite specific file:line locations. Prioritize the defense-in-depth question, whether a single bug at the frontend authz gate, or a single over-scoped MCP key, can cross the tenant boundary, given that the data layers below resolve by collection_id without an independent tenant check.

Security Requirements

7
  • CriticalExplicit Tenant Validation on Query and Get Execution PathsOPLANE_REQ-00087433

    All query and get execution paths (e.g., rust/frontend/src/impls/service_based_frontend.rs:query) MUST explicitly validate that the fetched collection's tenant matches the authenticated identity's tenant before returning any data. This check must occur after collection resolution and before serving the query result, ensuring that a leaked or forged collection_id cannot be used to access another tenant's data.

    The current implementation drops tenant information and only validates database and collection_id, which is insufficient for tenant isolation.

    If this requirement is not met, an attacker with knowledge of another tenant's collection_id can retrieve their data, resulting in cross-tenant data leakage.

    Not Implemented

    The query and get execution methods drop the tenant entirely and resolve by (database, collection_id), performing no post-resolution comparison of the fetched collection's tenant against the caller.

    Against rust/frontend/src/impls/service_based_frontend.rs:

    • Tenant Validation After Collection Resolution (primary): FAIL. query() destructures QueryRequest with .. and never binds tenant_id (service_based_frontend.rs:2234-2245), fetches via get_collection_with_segments(Some(database_name), collection_id) (2262-2266), and never compares collection_and_segments.collection.tenant to the caller.
    • Direct Query with Foreign Tenant's Collection ID (primary): FAIL. With a known or leaked collection_id and a matching database name, resolution succeeds with no tenant gate at this layer.
    • Get Operation with Forged Collection ID (primary): FAIL. Same shape on the get path.
    • Simultaneous Queries to Multiple Tenants' Collections (primary): FAIL. No per-request tenant binding to separate them here.
    • Query Using Own Tenant's Collection ID (primary): PASS (legitimate access works).
    • Bypass via Database Name Manipulation / Nonexistent or Tampered Collection ID / Audit (additional): the database name is validated (2247-2251), so database scoping is present, but tenant validation and cross-tenant audit are absent at this layer.

    Net: tenant validation on the read path is delegated upward to the authz gate (REQ-00087431) and is not re-asserted here, so if that gate is a no-op or carries a tenant-confusion bug there is no second check: the method serves whatever (database, collection_id) resolves. Database scoping is present; tenant scoping is not.

  • CriticalFail-Closed Behavior and Explicit Warning on Missing Auth Provider in Self-Hosted and Server ModesOPLANE_REQ-00087437

    Self-hosted and server entrypoints (e.g., chromadb/config.py, chromadb/api/rust.py, chromadb/api/segment.py) MUST fail closed (deny all requests) or emit a clear, actionable warning if started without an explicit authentication and authorization provider. The system must not default to open access in any network-exposed mode.

    Default-deny is a core security principle. Open-by-default is unsafe for any server-mode deployment.

    If this requirement is not met, a misconfigured or default deployment exposes all data to unauthenticated access, leading to immediate compromise.

    Not Implemented

    Server mode defaults to no authentication and fails open (it silently skips auth when no provider is configured, with no warning), and the default embedded api is unauthenticated by design.

    Against chromadb/config.py, chromadb/server/fastapi/__init__.py, chromadb/api/rust.py, chromadb/api/segment.py:

    • No Default Open Access in Configuration Absence / Deny All Requests When No Auth Provider (Server Mode) (primary): FAIL. chroma_server_authn_provider and chroma_server_authz_provider default to None (config.py:205, 214), and sync_auth_request returns early without authenticating when self.authn_provider is falsy (fastapi/init.py:530), then skips authorization when self.authz_provider is falsy (fastapi/init.py:542). The server starts and serves all requests.
    • Explicit Warning on Missing Auth Provider at Startup / Warning Contains Remediation (primary and additional): FAIL. Providers are simply left None (fastapi/init.py:227-233); no startup warning is emitted.
    • No Access on Partial or Invalid Auth Provider Configuration (primary): FAIL. Missing config yields open access, not fail-closed.
    • Fail-Closed Behavior in chromadb/api/rust.py and segment.py Entrypoints (primary): FAIL by design. Embedded get_user_identity returns a fixed default identity with no auth (rust.py:719, segment.py:204); the default chroma_api_impl is the embedded RustBindingsAPI (config.py:120).
    • Fail-Closed Behavior in Dockerized Deployment (primary): FAIL. The shipped image inherits the same open defaults unless auth env is set.
    • Hot Reload / Non-Standard Entrypoint Import (additional): same open-by-default behavior.

    Net: the embedded trust model (process boundary equals trust boundary, next to the customer's key material) is reasonable for an in-process library, but the server path fails open rather than closed and emits no warning, so a self-hosted Chroma server started without explicit auth env is reachable unauthenticated. The requirement asks for fail-closed behavior or an explicit, actionable warning; neither is present in this commit.

  • CriticalMandatory Tenant and Database Authorization Enforcement in Frontend GateOPLANE_REQ-00087431

    The frontend AuthenticateAndAuthorize implementation (rust/frontend-core/src/auth.rs) MUST enforce that every request is authenticated and authorized for the specific (tenant, database, collection) triple being accessed. The default allow-all implementation (impl AuthenticateAndAuthorize for ()) must NOT be used in any production or self-hosted deployment. The system MUST fail closed (deny all requests) if no explicit authentication and authorization provider is configured. The check inside authenticate_and_authorize_collection must verify that collection.tenant == identity.tenant for every operation.

    The frontend gate is the only layer enforcing tenant isolation. If it is bypassed or misconfigured, there is no other control preventing unauthorized cross-tenant access.

    If this requirement is not met, any user or process can access or modify data for any tenant, leading to total cross-tenant data exposure and loss of isolation. A single misconfiguration or bug at the frontend gate would allow full compromise.

    Not Implemented

    In this open-source commit the frontend authorization gate ships as a no-op allow-all, so tenant and database enforcement is absent by default; the authz scaffolding is well-designed but the shipped binary does not use it (production Chroma Cloud presumably injects a real impl that is not in this repo).

    Against rust/frontend-core/src/auth.rs, rust/frontend/src/bin/frontend_service.rs:

    • Reject Use of Allow-All Auth Implementation (primary): FAIL. The only frontend binary wires Arc::new(()) as _ for both auth and quota (frontend_service.rs:13-14), and impl AuthenticateAndAuthorize for () returns a fixed default_identity{tenant:"default_tenant"} from every method, ignoring headers (auth.rs:128-168). The process starts; it does not refuse or warn.
    • Deny Access When No Auth Provider Configured (primary): FAIL. The default path allows all requests rather than denying.
    • Cross-Tenant Collection Access Denied / Collection Belongs to Correct Tenant (primary): FAIL. authenticate_and_authorize_collection receives the fetched Collection (auth.rs:114-120), the correct place to assert collection.tenant == identity.tenant, but the () impl ignores it (auth.rs:148-158).
    • Fail Closed on Unknown Tenant / Identity Parsing Error (primary): FAIL. Headers are never parsed; every caller resolves to default_tenant.
    • Database-Level Authorization Enforcement (primary): FAIL. No database-membership check in the default impl.
    • Allow Access to Own Tenant Collection (primary): PASS, but vacuously (all access is allowed).
    • Concurrent Mixed Tenants / Error Message Does Not Leak Tenant Existence (additional): FAIL or N/A (vacuous, since no denials are produced).

    Net: the architecture is sound (a granular AuthzAction enum, an AuthzResource{tenant,database,collection}, and a collection-aware authz hook that receives the resolved collection), but the open-source default is open. Tenant isolation depends entirely on a real AuthenticateAndAuthorize implementation being injected at deploy time, with nothing failing closed if it is missing. This is the single load-bearing isolation layer, and in this commit it is a no-op.

  • CriticalScoped Credential and Collection Allowlisting in chroma-mcp Agent IntegrationOPLANE_REQ-00087436

    The chroma-mcp agent (chroma-core/chroma-mcp/src/chroma_mcp/server.py) MUST support and enforce an explicit allowlist of permitted collections and optionally a read-only mode for each agent credential. The agent's credential must be scoped to the minimum set of collections and permissions required, and filter operators (e.g., $regex) must be constrained to prevent enumeration and ReDoS. The agent must not have broad CRUD access to all collections unless explicitly required.

    Agents are a high-risk integration point, especially when exposed to untrusted content or prompt injection. Principle of least privilege and explicit scoping are essential.

    If this requirement is not met, a compromised or over-permissive agent credential allows full read/write/delete access to all collections in the tenant or database, and filter abuse could lead to denial of service.

    Not Implemented

    The chroma-mcp server grants an agent full CRUD over every collection the single configured credential can reach, with no per-collection allowlist, no read-only mode, and unconstrained content filter operators.

    Against chroma-core/chroma-mcp src/chroma_mcp/server.py:

    • Broad CRUD Access Not Permitted by Default (primary): FAIL. A single global client is built once (server.py:72-141) and every tool takes a free-form collection_name, so by default the agent can reach any collection in scope.
    • Access Denied to Non-Allowlisted Collection / Explicit Multi-Collection Allowlist Enforcement (primary): FAIL. There is no allowlist construct anywhere; get_collection(collection_name) is called with whatever name the model supplies (query at server.py:431, get at 480).
    • Write Operation Blocked in Read-Only Mode (primary): FAIL. No read-only mode exists; chroma_add_documents (332-393), chroma_update_documents (492-565), chroma_delete_documents (567-605), and chroma_delete_collection (317-329) are always registered.
    • Allowlisted Collection Read Permitted (primary): PASS only trivially (all reads are permitted).
    • Filter Operator $regex Denied for Unallowlisted Fields / ReDoS Mitigation on $regex (primary): FAIL. where_document accepts $regex and $not_regex passed straight through to the engine (documented at server.py:420-423, 467-470) with no operator restriction or complexity bound.
    • Agent Credential Minimum Privilege Verification (primary): FAIL. Cloud mode holds one x-chroma-token for one tenant and database (server.py:109-126) and exposes the full toolset over it; the credential's scope is whatever the key grants, not the minimum the agent needs.
    • Credential Cannot Escalate at Runtime / Audit Log of Denied Access (additional): no allowlist exists, so there is no denial path and no denial audit.

    Net: this is the broad-query-API, broad-blast-radius surface. The MCP correctly requires explicit tenant, database, and key, and always uses SSL in cloud mode (credit), but within that scope an agent, or untrusted content reaching the agent via indirect prompt injection over the retrieval store, can read, overwrite, fork, or delete any collection and run content regexes. No allowlist, read-only mode, or operator constraint is present.

  • CriticalDefense-in-Depth Tenant and Database Scoping in Data Layer Collection and Segment ResolutionOPLANE_REQ-00087432

    All collection and segment resolution functions in the data layer (e.g., rust/sysdb/src/sqlite.rs:get_collection_with_segments) MUST require and enforce the (tenant, database) scope for every read and write operation. The queries must filter by tenant and database, not just collection_id, and must reject any request where the tenant or database does not match the authenticated principal. This provides an independent isolation layer below the frontend.

    Relying solely on the frontend for isolation creates a single point of failure. Defense-in-depth requires that the data layer independently enforces tenant and database boundaries.

    If this requirement is not met, a bug or bypass in the frontend gate allows attackers to enumerate or access collections and segments belonging to other tenants by supplying their collection_id, breaking isolation.

    Partially Implemented

    The catalog enforces the (tenant, database) scope on list and delete paths but drops it on the collection-plus-segment read-resolution hot-path, so there is no independent tenant check behind the frontend for query and get.

    Against rust/sysdb/src/sqlite.rs:

    • Bypass with Only Collection ID (primary): FAIL. get_collection_with_segments(collection_id) resolves the collection and its segments by UUID alone, calling get_collections_with_conn(.., Some(collection_id), None /*name*/, None /*tenant*/, None /*database*/, ..) (sqlite.rs:634-649) and get_segments_with_conn(.., collection_id, ..) (sqlite.rs:656-658).
    • Direct Access with Foreign Collection ID / Mismatched Tenant and Database (primary): FAIL. Same path: any existing collection_id resolves regardless of tenant or database.
    • SQL Query Inspection for Tenant and Database Filtering (primary): FAIL for this path. The generated WHERE binds only collection_id because tenant and database are None, so their add_option(..) clauses are skipped (sqlite.rs:735-754).
    • Missing Tenant or Database in Function Call (primary): FAIL. The function signature does not accept tenant or database, so it cannot reject a missing scope.
    • Attempt Write Operation to Foreign Tenant's Collection (primary): PASS. The delete path is tenant and database scoped: delete_collection_with_conn(tenant, database, collection_id, segment_ids) (sqlite.rs:600-609).
    • Authenticated Principal with Multiple Database Memberships / Legacy API Path (additional): partial credit. The same builder applies tenant and database when they are supplied (sqlite.rs:741-749), and list/get_collections pass them, so list is scoped.

    Net: the tenant and database filter exists in the query builder and is applied on writes, deletes, and lists, but the read-resolution path that backs query and get (get_collection_with_segments) deliberately omits it, so a leaked collection_id is sufficient to resolve another tenant's collection at the catalog layer. The fix is small (thread the caller's tenant and database into this one resolver), which is why this is partial rather than absent.

  • HighExplicit Tenant Scoping and Error Handling in Worker and Log Service LayersOPLANE_REQ-00087434

    The worker (rust/worker/src/server.rs) and log service (rust/log-service/src/lib.rs) layers MUST require and validate tenant information for every operation, and MUST treat any descriptor or request lacking a resolved tenant as a hard error. These layers must not silently accept or process requests scoped only by collection_id.

    Explicit tenant scoping at every layer prevents privilege escalation and data leakage in the event of a bug or bypass in the frontend.

    If this requirement is not met, lower layers may process or store data for the wrong tenant, or allow unauthorized access if handed a collection_id from another tenant, compounding the impact of any upstream authz bug.

    Not Implemented

    The query worker and the log service operate on collection_id and segment_id handed down from the frontend and perform no independent tenant validation; the worker carries the tenant value but uses it only for metering, not authorization.

    Against rust/worker/src/server.rs, rust/log-service/src/lib.rs:

    • Worker: Missing Tenant in Request (primary): FAIL. The scan path reads collection_id = collection_and_segments.collection.collection_id and executes (server.rs:325-338); it does not hard-error on a missing or unresolved tenant.
    • Worker: Tenant/Collection Mismatch (primary): FAIL. The worker trusts the CollectionAndSegments descriptor; the tenant field is present but only copied into the metering context (tenant: collection_and_segments.collection.tenant.clone(), server.rs:276), never checked.
    • Worker: Empty String Tenant (primary): FAIL. No tenant validation, so an empty or defaulted tenant is not rejected.
    • Log Service: Only Collection Provided / Null Tenant in Descriptor (primary): FAIL. Log storage is keyed purely by collection_id: collection_id.storage_prefix_for_log() (lib.rs:451, and the same pattern at 1484, 2136, 2277, 2486, 3043, 3122). There is no tenant in the storage path to reject.
    • Log Service: Production Audit for Tenantless Requests (primary): FAIL. The model has no tenant at this layer, so tenantless processing is the norm.
    • Malformed Tenant / Nested Descriptor / Batch Multi-Tenant / Error Content (additional): N/A or FAIL; tenant is not part of these layers' contract.

    Net: this is a deliberate architectural delegation (the lower layers trust the frontend to have authorized the request and isolate per collection_id), not an incidental bug. It is graded NOT_IMPLEMENTED against the requirement because there is no hard-error on a missing tenant and no independent enforcement, which is exactly what removes defense-in-depth: a collection_id that escapes the frontend gate is honored unconditionally by the worker and the log/WAL.

  • HighCollection and Segment ID Non-Disclosure Across Tenant BoundariesOPLANE_REQ-00087435

    Collection and segment UUIDs (rust/types/src/collection.rs, rust/types/src/segment.rs) MUST NOT be disclosed in error messages, logs, metrics, or API responses to principals outside the owning tenant. All such identifiers must be treated as sensitive and only visible within the tenant's scope.

    The security model relies on the unguessability of UUIDv4 IDs. Leaking them undermines this assumption and increases the attack surface.

    If this requirement is not met, attackers may enumerate valid collection or segment IDs, increasing the risk of targeted attacks or cross-tenant access if other controls fail.

    Partially Implemented

    The unguessability foundation is real (random UUIDv4 collection and segment IDs), but there is no explicit non-disclosure control: the not-found error echoes the requested collection_id and IDs are pervasive in tracing and metering.

    Against rust/types/src/collection.rs, rust/types/src/segment.rs, rust/sysdb/src/sqlite.rs:

    • (foundation) ID unguessability: CollectionUuid::new() and SegmentUuid::new() use Uuid::new_v4() (collection.rs:39, segment.rs:47-48), so IDs are not enumerable. This is the real control the requirement leans on.
    • Error Message Does Not Leak Collection or Segment UUID (primary): FAIL. GetCollectionWithSegmentsError::NotFound(collection_id.to_string()) echoes the requested collection_id back (sqlite.rs:650-654), which also distinguishes existence from non-existence.
    • Bulk/List API Does Not Leak Other Tenants' UUIDs (primary): PASS. List and get_collections are tenant and database scoped (sqlite.rs:741-749), so they return only the caller's own IDs.
    • Internal Error Stack Traces Are Not Exposed Externally (primary): PASS. Typed errors, no stack traces in responses.
    • API Response Does Not Include Foreign Collection or Segment UUIDs (primary): PASS for normal paths (responses carry the caller's own collection IDs), contingent on the tenant gate above holding.
    • Log Output Scrubbing / Metrics Do Not Expose Cross-Tenant UUIDs (primary): FAIL. collection_id and segment_id are standard fields in tracing and metering across the Rust services; there is no tenant-scoped scrubbing.
    • Validation Error / Rate-Limit / Audit Log UUID Leakage (additional): no UUID-scrubbing layer exists; FAIL where applicable.

    Net: cross-tenant list disclosure is prevented by tenant-scoped catalog reads, and the UUIDv4 design makes IDs unguessable, but the requirement's stricter claim (treat IDs as secret in errors, logs, and metrics) does not hold: the not-found error echoes the requested ID and IDs are pervasive in observability. Partial: the foundational control is present, the disclosure hardening is not.