Context
Sub of Phase 0/1 Epic #11625 (meta-Epic #11624). Graduated from Discussion #11623 Cycle 2.5/2.6.
Closes the Phase 0/1 security boundary — read-side tenant filter + end-to-end fail-closed test suite validates write-side stamping (#11631 Phase 0/1C) actually works.
Topology anchor: Per ADR 0003 — Chroma Topology Unified Only, this work injects tenant filters into collection.query calls against the knowledge-base collection in the unified Chroma daemon. Memory Core's MemoryService.queryMemories + SummaryService.querySummaries filter neo-agent-memory / neo-agent-sessions separately — same pattern, separate collections.
The Problem
V-B-A confirmed (Cycle 2 GPT): QueryService.mjs:116-128 builds Chroma where clause ONLY from type predicate; no tenant/visibility filter. Without read-side enforcement, even with write-side server-stamping (Phase 0/1C), every tenant retrieves every other tenant's content.
The Fix
Read-side filter
In ai/services/knowledge-base/QueryService.mjs and SearchService.mjs:
- Every
collection.query() call injects where: {tenantId: {$in: [<requester>, '<team-namespace>']}} derived from authenticated AgentIdentity context
- Filter context server-side, NOT client payload
- Team-namespace constant (e.g.,
'neo-shared') hardcoded for Neo curated content visibility across tenants
Mirrors MemoryService.mjs:391-410 query-time policy filter pattern.
Q13b enforcement-layer V1 lean
Per Discussion #11623 §4 Q13b — V1 implements application-layer filter (Option A); V2 reconsiders Chroma-layer hardening if cross-tenant leak class manifests in production. Phase 0/1D ships Option A.
Fail-Closed Test Suite
The load-bearing security test substrate (8 cases per Discussion §8 +7 absorption):
- Tenant isolation: tenant A cannot retrieve tenant B
private chunks via query_documents / ask_knowledge_base / any public KB facade
- Team visibility: Neo
'neo-shared' chunks visible across tenants (regression-test for team-equivalence)
- Cross-tenant chunk shadow rejection: same
sourcePath under two tenants yields distinct chunk ids; A's content cannot suppress B's via id collision
- Spoof-rejection — write-side: forged client
tenantId in parsed-chunk-v1 payload → server-overwritten OR rejected per spoofRejectionMode; warning log emitted
- Spoof-rejection — read-side: forged client
tenantId in query parameter → ignored (filter derived from server-side authenticated AgentIdentity only)
- Backup-record path isolation:
manageDatabaseBackup({action: 'import'}) restore path bypasses ingestion validation by-design (preserves embeddings), but tenant boundary still respected via existing record metadata
- Search hydration tenant-aware: search results cite
chunk.metadata.source (now tuple), don't claim file existence under server neoRootDir for non-local-tenant content (placeholder behavior pending Phase 2 Q12 hydration resolution; this AC asserts current SearchService respects tenant-tuple semantics)
- End-to-end via every public facade:
ask_knowledge_base, query_documents, get_class_hierarchy, get_document_by_id, list_documents ALL respect tenant filter; no facade can bypass
Acceptance Criteria
Out of Scope
- Chroma-layer enforcement (Q13b Option B) → future Discussion if Option A leak class manifests
- Hydration mode choice (Q12) → Phase 2D (this sub asserts current SearchService respects tenant-tuple; doesn't refactor hydration)
- AgentIdentity context propagation through service stack → Phase 2 (this sub uses mocked AgentIdentity in tests)
Related
- Parent: #11625
- Blocked-by: Phase 0/1A (schemas), Phase 0/1B (registry), Phase 0/1C (write-side stamping)
- Blocks: Phase 0/1 Epic closeout
- Discussion source: #11623 §4 Q13b + §8 fail-closed test cases + §10 Graduation Criteria #13
Origin Session ID
7360e917-1733-4cdd-a6f3-5ac51c34b838
Handoff Retrieval Hints
QueryService.mjs:116-128 is the read-side surface
SearchService.mjs:95-100 is the search-result-consumer surface
MemoryService.mjs:391-410 is the Memory Core read-side filter pattern to mirror
- Fail-closed test suite is load-bearing — author tests FIRST to define security boundary, then implement filter against them
Context
Sub of Phase 0/1 Epic #11625 (meta-Epic #11624). Graduated from Discussion #11623 Cycle 2.5/2.6.
Closes the Phase 0/1 security boundary — read-side tenant filter + end-to-end fail-closed test suite validates write-side stamping (#11631 Phase 0/1C) actually works.
Topology anchor: Per ADR 0003 — Chroma Topology Unified Only, this work injects tenant filters into
collection.querycalls against theknowledge-basecollection in the unified Chroma daemon. Memory Core'sMemoryService.queryMemories+SummaryService.querySummariesfilterneo-agent-memory/neo-agent-sessionsseparately — same pattern, separate collections.The Problem
V-B-A confirmed (Cycle 2 GPT):
QueryService.mjs:116-128builds Chromawhereclause ONLY fromtypepredicate; no tenant/visibility filter. Without read-side enforcement, even with write-side server-stamping (Phase 0/1C), every tenant retrieves every other tenant's content.The Fix
Read-side filter
In
ai/services/knowledge-base/QueryService.mjsandSearchService.mjs:collection.query()call injectswhere: {tenantId: {$in: [<requester>, '<team-namespace>']}}derived from authenticated AgentIdentity context'neo-shared') hardcoded for Neo curated content visibility across tenantsMirrors
MemoryService.mjs:391-410query-time policy filter pattern.Q13b enforcement-layer V1 lean
Per Discussion #11623 §4 Q13b — V1 implements application-layer filter (Option A); V2 reconsiders Chroma-layer hardening if cross-tenant leak class manifests in production. Phase 0/1D ships Option A.
Fail-Closed Test Suite
The load-bearing security test substrate (8 cases per Discussion §8 +7 absorption):
privatechunks viaquery_documents/ask_knowledge_base/ any public KB facade'neo-shared'chunks visible across tenants (regression-test forteam-equivalence)sourcePathunder two tenants yields distinct chunk ids; A's content cannot suppress B's via id collisiontenantIdinparsed-chunk-v1payload → server-overwritten OR rejected perspoofRejectionMode; warning log emittedtenantIdin query parameter → ignored (filter derived from server-side authenticated AgentIdentity only)manageDatabaseBackup({action: 'import'})restore path bypasses ingestion validation by-design (preserves embeddings), but tenant boundary still respected via existing record metadatachunk.metadata.source(now tuple), don't claim file existence under serverneoRootDirfor non-local-tenant content (placeholder behavior pending Phase 2 Q12 hydration resolution; this AC asserts current SearchService respects tenant-tuple semantics)ask_knowledge_base,query_documents,get_class_hierarchy,get_document_by_id,list_documentsALL respect tenant filter; no facade can bypassAcceptance Criteria
QueryService.queryDocumentsinjectswhere: {tenantId: {...}}from authenticated AgentIdentitySearchServicequery path inherits filter (since it consumesQueryServiceoutput)'neo-shared') documented inaiConfig+ JSDocask_knowledge_base,query_documents,get_class_hierarchy,get_document_by_id,list_documents)Out of Scope
Related
Origin Session ID
7360e917-1733-4cdd-a6f3-5ac51c34b838Handoff Retrieval Hints
QueryService.mjs:116-128is the read-side surfaceSearchService.mjs:95-100is the search-result-consumer surfaceMemoryService.mjs:391-410is the Memory Core read-side filter pattern to mirror