A semantic search system has already crossed its security boundary before generation begins: if retrieval admits another customer's chunk, no prompt can make that access legitimate afterward. Short answer: in a multi-tenant ask-your-docs SaaS, derive the customer identity from authenticated server context, bind both the embedding namespace and the mandatory metadata filter inside one retrieval interface, and make the resulting decision reconstructable from an audit record.
This architecture decision record treats similarity search as data access, not as authorization. The application may accept a question and optional within-tenant search preferences, but it must never accept the authoritative tenant identifier, namespace, or base filter from the request body. The design aims for an exactly-once effect under retries, explicit failure boundaries, and evidence that can support reconciliation without copying sensitive document text into a second store.
How should a Node.js SaaS bind each customer namespace and metadata filter for RAG?
The Node.js edge should authenticate the principal, resolve one internal tenant identifier from trusted claims, and construct an immutable request context before calling ingestion, retrieval, reranking, caching, or generation. A client field such as customer_id is merely untrusted data. Even if it happens to equal the authenticated tenant, promoting that field to authority creates a contract that a later handler, worker, or administrative path can misunderstand.
The central invariant is compact: every operation that can expose document-derived information requires trusted tenant context. That context selects a coarse namespace, contributes an unavoidable tenant_id metadata predicate, scopes cache and rate-limit keys, and appears in the audit event. User-selected filters may narrow the authorized set by document type, effective date, or label, but they cannot remove or replace the base predicate. Defense in depth matters here — a namespace limits the consequence of a missing filter, while the filter makes the intended authorization condition visible and testable.
There is no default tenant.
An embedding worker deserves particular suspicion because a queue boundary discards request-local state unless the producer serializes the required identity explicitly and the consumer validates it. Each ingestion command should carry the server-derived tenant ID, a stable document ID, a content version, and an idempotency key. I use 37 identical deliveries in a retry test, followed by one delivery that reuses the key with different content: the first set must converge on one committed version, while the conflicting payload must be rejected with an application-level 409 and an audit event. This is a test construction, not a production incident or a claim about a particular service.
RFC 9110 distinguishes idempotent HTTP methods and explains why a client may retry an idempotent request after a communication failure. An ingestion POST does not acquire that property by wishful naming; it needs an application contract in which the same key and same payload produce one effect, while key reuse with a different payload is a conflict. Exactly-once delivery across a network is the wrong promise. Exactly-once effect, built on durable deduplication and deterministic versioning, is the useful invariant.
Define failure boundaries before choosing storage topology
I divide the critical path into accepted, indexed, queryable, retrieved, and cited states. A document version may be accepted but not yet queryable; a chunk may be queryable but not authorized for the current principal; a retrieved chunk may be authorized but excluded during reranking. Recording those transitions prevents an ambiguous status flag from standing in for several materially different facts.
Authentication failure stops before any vector query. Missing tenant context is a denial, never a shared namespace. A cache lookup cannot occur until the tenant has become part of the key. Generation receives a closed set of authorized chunks and has no secondary retrieval capability. Deletion advances through the same versioned state machine, so a reconciliation job can prove which index versions no longer contain the document rather than trusting a successful queue acknowledgement.
Fail closed.
The most revealing security tests are deliberately adversarial but mechanically simple. Create two tenants whose documents contain the same rare sentence, query through every public and privileged entry point, and assert that result IDs always belong to the authenticated tenant. Then remove tenant context from a queued command, attempt to override the namespace in JSON, replay an ingestion key, rotate an embedding version during active queries, and seed equal question text into both tenants' caches. Property tests should generate tenant pairs and query combinations; deployment checks should canary counts of missing-context denials, idempotency conflicts, orphaned versions, and candidates removed by the mandatory predicate.
Logs need restraint. A useful retrieval audit record contains request ID, tenant ID, authenticated subject, policy version, embedding/index version, selected chunk IDs, filter digest, outcome, and timestamps. It does not need raw document passages or the complete user question by default. Retention duration, legal-hold handling, and who may inspect these records depend on the applicable jurisdiction, contract, and data classification; I'm not sure any generic retention period can be defended without those inputs. Compliance labels do not resolve that uncertainty. A data owner and counsel do.
Compare isolation controls by consequence and operability
Storage layout changes the blast radius and operating burden, but it does not replace application authorization. The relevant comparison is therefore not which topology sounds safest in isolation; it is which combination preserves the invariants under the team's actual migration, deletion, backup, and capacity procedures.
| Layout | Primary boundary | Operational burden | Failure to design against | Appropriate conditions |
|---|---|---|---|---|
| Shared index with mandatory metadata | Server-owned query predicate | Lowest resource multiplication | A new path omits or weakens the predicate | One trust domain or lower-consequence data with a single enforced repository |
| Namespace per tenant | Logical partition plus mandatory predicate | Namespace lifecycle and rollout coordination | Routing drift assigns work to the wrong partition | Many similarly sized tenants and automated lifecycle controls |
| Dedicated resource per tenant | Separate data resource plus application policy | Migrations, backups, and capacity repeat per tenant | Configuration drift and incomplete fleet changes | Contractual boundaries, high-consequence data, or a few large tenants |
| Policy-based hybrid | Explicit isolation class | Tier transitions become security-sensitive | Data remains in an old tier after policy changes | Diverse tenants with governed placement and rehearsed migration |
For a financially consequential corpus, I would normally begin with a tenant namespace plus an immutable metadata predicate because the visible partition makes reconciliation and deletion easier to reason about. The catch is multiplication: embedding-version rollouts, index migrations, capacity alarms, and backup checks now operate across many namespaces. It is not suitable when thousands of tiny tenants would turn lifecycle work into the dominant operational risk; a shared index with a repository API that cannot express an unscoped search may then be the more defensible choice. Conversely, stick with dedicated resources when a contract requires a distinct data boundary or when one customer's workload can materially interfere with another's.
Cost enters the record as measured storage, index overhead, re-embedding frequency, query volume, and operator time. It should not become a substitute for the threat model. Likewise, transcription or other preprocessing does not alter the tenant invariant: an audio pipeline, including one built around the open-source Whisper speech-recognition model, must preserve trusted tenant context across every derived artifact and queue transition.
Measure it.
Put authority in one narrow retrieval contract
The HTTP edge can remain in Node.js while the security contract is language-independent and implemented behind a service boundary. The Go example below focuses on the part worth standardizing: callers provide a question and optional narrowing labels, while trusted middleware provides tenant context; neither namespace nor the mandatory metadata map appears in the public request type.
package retrieval
import (
"context"
"errors"
)
type TenantContext struct {
TenantID string
Subject string
RequestID string
}
type Request struct {
Question string
Limit int
Labels map[string]string
}
type Chunk struct {
ID string
DocumentID string
Text string
}
type VectorStore interface {
Search(
ctx context.Context,
namespace string,
mandatory map[string]string,
narrowing map[string]string,
question string,
limit int,
) ([]Chunk, error)
}
type AuditSink interface {
RecordRetrieval(ctx context.Context, tenant TenantContext, chunkIDs []string) error
}
type Service struct {
store VectorStore
audit AuditSink
}
func (s Service) Retrieve(ctx context.Context, tenant TenantContext, req Request) ([]Chunk, error) {
if tenant.TenantID == "" || tenant.Subject == "" || tenant.RequestID == "" {
return nil, errors.New("trusted tenant context is required")
}
if req.Question == "" || req.Limit < 1 || req.Limit > 20 {
return nil, errors.New("invalid retrieval request")
}
chunks, err := s.store.Search(
ctx,
"tenant:"+tenant.TenantID,
map[string]string{"tenant_id": tenant.TenantID},
copyAllowedLabels(req.Labels),
req.Question,
req.Limit,
)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(chunks))
for _, chunk := range chunks {
ids = append(ids, chunk.ID)
}
if err := s.audit.RecordRetrieval(ctx, tenant, ids); err != nil {
return nil, err
}
return chunks, nil
}
func copyAllowedLabels(labels map[string]string) map[string]string {
allowed := map[string]bool{"document_type": true, "effective_date": true}
result := make(map[string]string)
for key, value := range labels {
if allowed[key] {
result[key] = value
}
}
return result
}
The audit write is on the critical path because this decision values evidence over partially observable availability. Another system may durably buffer signed audit events, but its record must state the loss window and the reconciliation mechanism. Don't hide that choice in a client library. Keep the repository interface small enough that code review can enumerate every operation capable of returning chunks, and prevent a generic “raw query” escape hatch from reaching ordinary handlers.
The rejected design is caller-selected base filtering. It is convenient for a single-user prototype and remains valid inside one already authorized tenant when the caller only narrows fields from an allowlist, but it is a poor public SaaS boundary because authorization then depends on every caller remembering the same predicate forever. Prompt-only isolation and post-generation redaction are rejected for a simpler reason: both act after unauthorized material could have influenced the answer.
This decision should be revisited when tenant count makes partition operations dominate engineering time, contracts change the required data boundary, deletion cannot be reconciled within the promised interval, or measured resource contention invalidates the chosen layout. Until then, the durable rule is plain: authenticate once, propagate trusted context through every asynchronous boundary, authorize before similarity search, and retain enough evidence to reproduce the decision.
Top comments (0)