Build a Multi-Tenant AI Assistant with Laravel, Vue, and RAG Without Leaking Customer Data

The leak won't throw an exception.

A support agent for Tenant A asks a question. The retriever runs a vector similarity search. The query is valid SQL, the response is 200 OK, and the model produces a fluent, confident answer. The answer contains a paragraph from Tenant B's onboarding document. No error, no warning, no failed test. The data left the building inside a sentence that looked exactly like every other sentence the assistant had ever produced.

This is the defining failure mode of multi-tenant RAG. It is not a model problem. It is not a prompt problem. It is a plumbing problem, and it lives in the places your application forgets to look: the database session, the queue worker, the cache key, the S3 path, the vector index traversal, and the identity context that disappears the moment a request becomes a job.

Laravel 13, the Laravel AI SDK, Vue 3, and PostgreSQL with pgvector give you the pieces to close every one of those gaps. But the pieces don't assemble themselves into isolation. You have to decide, explicitly and repeatedly, that the model is never the authorization layer.

📋 Table of Contents

1. The Model Is Not Your Authorization Layer

Scenario:
A user asks the assistant to “summarize the document with ID 8472.” The document belongs to another tenant. The retriever returns it because the query didn't include a tenant filter, and the model summarizes it accurately. The response is wrong in the only way that matters: it disclosed something the user was never allowed to see.

Why it matters:
A language model has no reliable concept of identity. It sees text and produces text. If your access control depends on the model “not mentioning” data it was given, you have not built access control. You have built a hope. OWASP's guidance is blunt on this point: privileged functions should be handled in code, not by relying on the model to behave. A prompt injection can rewrite the model's instructions, but it cannot rewrite a WHERE clause that the application never included — which is exactly why the filter has to be in the application, before the prompt exists.

Solution:
Make tenant identity a first-class part of the request context, resolved from authentication, never from user-supplied text. Every retrieval path reads that context. Every tool that touches data reads it. The model receives already-scoped content and never receives an instruction to filter.

namespace App\Support;

final class TenantContext
{
    private ?string $tenantId = null;

    public function set(string $tenantId): void
    {
        $this->tenantId = $tenantId;
    }

    public function id(): string
    {
        if ($this->tenantId === null) {
            throw new TenantContextMissing();
        }

        return $this->tenantId;
    }

    public function forget(): void
    {
        $this->tenantId = null;
    }
}

The retriever reads it from the container, not from the prompt:

final class TenantAwareRetriever
{
    public function __construct(private TenantContext $tenant) {}

    public function retrieve(string $query, int $limit = 5): Collection
    {
        return KnowledgeChunk::query()
            ->where('tenant_id', $this->tenant->id())
            ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.35)
            ->limit($limit)
            ->get();
    }
}

Why this works:
The tenant ID has exactly one source: the authenticated session. The model cannot influence it, the user cannot supply it, and a prompt injection cannot change it. The filter is applied before the query runs, so there is no window in which unscoped data exists in the application's memory.

🧠 The important part: The model should never be asked to “only show this tenant's data.” By the time the model sees the context, the scoping decision has already been made. The model's job is to reason over what it was given, not to enforce who was allowed to give it.

2. Application Filters Are a Suggestion; RLS Is a Boundary

Scenario:
A developer joins the team, writes a new admin report, and forgets the where('tenant_id', ...) clause. The report runs in production for three weeks before anyone notices it includes every tenant's rows. Nothing in the test suite failed, because the test suite only tested the happy path.

Why it matters:
A WHERE clause is a query parameter, not a security control. The moment any code path omits it — a raw query, a new repository method, a background job with an uncleared context — the isolation layer is gone. The failure is silent because valid SQL returns valid rows. Application-side filtering is reactive; it happens after the query is parsed, and it depends on every developer remembering to write it correctly every time.

Solution:
Move the boundary into PostgreSQL with Row Level Security. RLS makes rows invisible to a session unless a policy allows them. The application doesn't have to remember the filter, because the database enforces it regardless of what the query looks like.

The migration needs a tenant column and a policy:

Schema::create('knowledge_chunks', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignUlid('tenant_id')->index();
    $table->foreignUlid('document_id')->index();
    $table->text('content');
    $table->vector('embedding', dimensions: 1536);
    $table->timestamps();
});
ALTER TABLE knowledge_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_chunks FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy
ON knowledge_chunks
FOR ALL
TO public
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

FORCE ROW LEVEL SECURITY matters. Without it, the table owner bypasses the policy, which means your application's database user can still see everything. Enable it, and verify that the owner is not exempt.

The middleware sets the session variable on every request:

final class SetTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenantId = $request->user()?->tenant_id;

        if (! $tenantId) {
            abort(403, 'Tenant context is required.');
        }

        DB::statement("SET LOCAL app.current_tenant_id = ?", [$tenantId]);

        return $next($request);
    }
}

SET LOCAL scopes the setting to the current transaction. That detail is not cosmetic. A session-scoped setting can persist across requests on a pooled connection and leak tenant context into the next request. Transaction-local is the safe default.

Why this works:
The database engine, not the application, decides which rows exist for the current session. A missing WHERE clause no longer leaks data, because the rows were never visible. The policy is evaluated by PostgreSQL for every SELECT, UPDATE, and DELETE, so it applies to raw queries, Eloquent, and any package that talks to the same connection.

🚨 Production warning: Backups need a dedicated role with BYPASSRLS. pg_dump turns row security off by default, and a role without BYPASSRLS will make the backup fail rather than silently produce an incomplete dump. Provision that role deliberately, and don't use it for application traffic.

3. Vector Indexes Leak Differently Than Tables

Scenario:
RLS is enabled. The table is isolated. But the HNSW index is shared across all tenants. A query for Tenant A traverses the index graph, and the approximate search returns candidates that belong to Tenant B before the RLS policy filters them out. The final result is correct, but the query path touched data it should never have seen, and the recall for small tenants drops because the index is dominated by larger neighbors.

Why it matters:
Vector indexes are not ordinary B-trees. HNSW is a graph traversal, and the graph is built over the entire table unless you partition it. A shared approximate index means one tenant's vectors change the recall and latency of every other tenant's queries. RLS prevents the final rows from leaking, but it does not prevent the index from spending its traversal budget on rows the tenant cannot see — and a tenant with a small share of the table can end up with fewer results than the LIMIT asks for.

Solution:
Choose an index strategy that matches the tenant's share of the data. There is no universal answer, but there are four shapes that cover most workloads.

StrategyIsolationPerformanceBest fit
B-tree on tenant_id + exact scanStrongExact within tenant; cost grows with tenant sizeTenants with a small share of a large table
Partial HNSW per tenantStrongPredictable latency; one index per tenantA few stable, latency-sensitive tenants
List partitioning by tenant_idStrongIndex per partition; planning cost grows with partitionsMany tenants that must not share a graph
Shared HNSW + iterative scanDepends on RLSRecall degrades for small tenants; CPU risesA shared index after enabling hnsw.iterative_scan

For a shared index, enable iterative scans and understand the trade-off:

SET hnsw.iterative_scan = 'strict_order';
SET hnsw.max_scan_tuples = 20000;

iterative_scan makes pgvector keep walking until the LIMIT is filled, instead of stopping after the initial candidate set. strict_order preserves exact distance ordering; relaxed_order is faster but may reorder results slightly.

Why this works:
The index strategy determines whether the tenant filter is applied before or after the graph traversal. A tenant-local index applies it before, so the traversal never spends budget on rows the tenant cannot see. A shared index applies it after, which is acceptable for large tenants but degrades for small ones.

🔍 Why this matters: The “ghost context” problem is not just a leak. It's a retrieval quality problem. A shared approximate index is a shared resource, and a small tenant's recall is determined by how much of the graph belongs to its neighbors. Isolation and relevance are the same design decision.

4. The Queue Worker Forgets Who It Works For

Scenario:
A document-ingestion job is dispatched for Tenant A. The job is serialized, queued, and picked up by a worker forty seconds later. The worker has no HTTP session, so the TenantContext singleton is empty. The job writes embeddings without a tenant ID, or worse, writes them under whatever tenant was last set on that worker. The data is now in the wrong place, and no exception was thrown.

Why it matters:
Queued jobs run in a separate process with no HTTP context. A global scope that reads “the current tenant” from the request reads nothing in a worker. If the job doesn't explicitly rehydrate the tenant, it can silently operate with no tenant or the wrong one. The same blind spot applies to storage paths, mail senders, and cache keys.

Solution:
Every tenant-aware job implements an interface, and job middleware sets and clears the context around the job's execution. The clearing happens in a finally block, because an exception mid-job must not leave stale tenant context on a reused worker.

interface TenantAware
{
    public function tenantId(): string;
}
final class TenantJobMiddleware
{
    public function __construct(private TenantContext $tenant) {}

    public function handle(object $job, Closure $next): void
    {
        if (! $job instanceof TenantAware) {
            $next($job);
            return;
        }

        $this->tenant->set($job->tenantId());

        try {
            $next($job);
        } finally {
            $this->tenant->forget();
        }
    }
}

The job itself carries the tenant ID explicitly:

final class IngestDocument implements ShouldQueue, TenantAware
{
    use Queueable;

    public function __construct(
        public readonly string $documentId,
        private readonly string $tenantId,
    ) {}

    public function tenantId(): string
    {
        return $this->tenantId;
    }

    public function middleware(): array
    {
        return [new TenantJobMiddleware(app(TenantContext::class))];
    }

    public function handle(): void
    {
        $document = Document::findOrFail($this->documentId);

        $chunks = $this->chunk($document);

        foreach ($chunks as $chunk) {
            KnowledgeChunk::create([
                'tenant_id' => $this->tenantId,
                'document_id' => $document->id,
                'content' => $chunk,
                'embedding' => Embeddings::for([$chunk])->generate()->embeddings[0],
            ]);
        }
    }
}

Why this works:
The tenant ID is serialized with the job, so it survives the trip through the queue. The middleware sets the context before the job runs and clears it afterward, so a worker that handles Tenant A's job and then Tenant B's job cannot accidentally carry A's context into B's execution. The finally block is the critical part; without it, an exception leaves the worker contaminated for the next job.

⚠️ Gotcha: Laravel Octane reuses workers across requests. If you set tenant context in middleware and don't clear it on the way out, the next request on that worker inherits it. The same discipline that applies to queued jobs applies to long-lived HTTP workers: set the context, do the work, clear it in finally.

5. Cache Keys, File Paths, and the Other Places Tenants Escape

Scenario:
The tenant filter is in the database, the queue context is correct, and the RAG pipeline is scoped. Then a retrieved answer is cached under the key answer:policy-refund. Tenant B asks a similar question, gets a cache hit, and receives Tenant A's answer. The leak didn't happen in the database. It happened in Redis.

Why it matters:
Tenant isolation is only as strong as the weakest scoped resource. Cache keys, filesystem paths, object storage prefixes, outbound email senders, and notification channels all carry tenant identity implicitly, and none of them are covered by a database RLS policy. A single unscoped cache key is enough to undo every layer of database isolation you built.

Solution:
Make tenant identity part of every key, path, and sender. If the platform doesn't support scoping, wrap it in a service that does.

final class TenantCache
{
    public function __construct(private TenantContext $tenant) {}

    public function remember(string $key, Closure $callback, int $ttl = 3600): mixed
    {
        return Cache::remember(
            "tenant:{$this->tenant->id()}:{$key}",
            $ttl,
            $callback,
        );
    }
}

For files, build a per-tenant disk instead of sharing a global one:

Storage::build([
    'driver' => 's3',
    'root' => "tenants/{$tenantId}/documents",
    'bucket' => config('filesystems.disks.s3.bucket'),
]);

For Laravel Pennant feature flags, scope to the tenant rather than the user. Pennant resolves flags against a scope, and in a multi-tenant application the team or organization is almost always the correct scope:

Feature::resolveScopeUsing(fn () => tenant()->id());

Feature::for(tenant()->id())->activate('advanced-rag');

Why this works:
Scoping the cache key and the storage root makes the tenant boundary explicit at the point of use. There is no ambient “current tenant” that a new code path can forget. The key or path either contains the tenant or it doesn't, and a missing tenant is a visible bug rather than a silent leak.

💡 Practical note: Audit every Cache::get, Storage::put, and Mail::to call in the codebase for tenant scoping. These are the places where isolation is assumed rather than enforced, and they're the places where a missing tenant is least likely to be caught by a database-level test.

6. Retrieval Quality Under a Tenant Filter

Scenario:
RLS is working. The tenant filter is applied. But recall drops for small tenants because the vector index is dominated by larger neighbors, and pure vector search misses exact identifiers — SKUs, error codes, invoice numbers — that don't have meaningful embeddings.

Why it matters:
Isolation is necessary but not sufficient. A tenant-safe assistant that can't find the right document is still broken, just in a less dramatic way. Pure dense retrieval is weak on exact terms and rare tokens. In a multi-tenant index, the problem compounds because the tenant's share of the corpus is smaller, and the approximate search has fewer relevant neighbors to work with.

Solution:
Use hybrid retrieval inside the tenant boundary. Run a dense query and a keyword query in parallel, both filtered by tenant, and fuse the results with Reciprocal Rank Fusion. RRF ignores incompatible raw scores and merges by rank position, which is exactly what you want when one retriever returns cosine distance and the other returns a text-search rank.

final class HybridRetriever
{
    public function __construct(private TenantContext $tenant) {}

    public function retrieve(string $query, int $limit = 6): Collection
    {
        $tenantId = $this->tenant->id();

        $dense = DB::select(
            'SELECT id, content, 1 - (embedding <=> ?) AS score
             FROM knowledge_chunks
             WHERE tenant_id = ?
             ORDER BY embedding <=> ?
             LIMIT 30',
            [$this->embedding($query), $tenantId, $this->embedding($query)],
        );

        $keyword = DB::select(
            "SELECT id, content, ts_rank_cd(tsv, plainto_tsquery('simple', ?)) AS score
             FROM knowledge_chunks
             WHERE tenant_id = ? AND tsv @@ plainto_tsquery('simple', ?)
             ORDER BY score DESC
             LIMIT 30",
            [$query, $tenantId, $query],
        );

        return $this->fuse($dense, $keyword, $limit);
    }

    private function fuse(array $dense, array $keyword, int $limit): Collection
    {
        $scores = [];

        foreach ($dense as $rank => $row) {
            $scores[$row->id] = ($scores[$row->id] ?? 0) + 1.0 / (60 + $rank + 1);
        }

        foreach ($keyword as $rank => $row) {
            $scores[$row->id] = ($scores[$row->id] ?? 0) + 0.7 / (60 + $rank + 1);
        }

        arsort($scores);

        return KnowledgeChunk::whereIn('id', array_slice(array_keys($scores), 0, $limit))
            ->get()
            ->sortBy(fn ($chunk) => array_search($chunk->id, array_keys($scores), true))
            ->values();
    }
}

The weights are not arbitrary. Dense retrieval is the primary signal; keyword retrieval corrects for exact-match failures. A weight of 1.0 for dense and 0.7 for keyword is a reasonable starting point, but the correct values depend on your corpus and query distribution. Tune them against an evaluation set, not by intuition.

Why this works:
Both retrievers run under the same tenant predicate, so fusion cannot introduce cross-tenant candidates. RRF normalizes two incompatible scoring systems by using rank position rather than raw score. The result is better recall on both semantic paraphrases and exact identifiers, without widening the isolation boundary.

⚠️ Gotcha: The tsv column has to be generated from the chunk content and indexed. Without a GIN index on tsv, the keyword query does a sequential scan, and the hybrid retrieval becomes slower than the dense-only path it was meant to improve.

7. Permissions Change; the Index Has to Change With Them

Scenario:
A user's access to a document is revoked. The application's permission table is updated, and the UI stops showing the document. But the vector index still contains the chunks. A RAG query retrieves them, because the retrieval layer filters by tenant, not by document-level ACL. The revoked document is still being quoted in answers.

Why it matters:
Tenant isolation is coarser than document-level authorization. Within a tenant, users may have different access to different documents, folders, or data classifications. If the vector store doesn't know about those distinctions, retrieval will surface content the asking user is no longer allowed to read. Stale copies of revoked documents are a quiet leak, and they're especially hard to catch because the tenant boundary is intact.

Solution:
Carry access labels on every chunk at ingestion time, and filter on them at retrieval time. Labels are attached by the ingestion pipeline, not by the document author, and they are updated when source permissions change.

Schema::table('knowledge_chunks', function (Blueprint $table) {
    $table->jsonb('access_labels')->default('[]');
    $table->index('access_labels', null, 'gin');
});

At ingestion:

$chunk = KnowledgeChunk::create([
    'tenant_id' => $tenantId,
    'document_id' => $document->id,
    'content' => $content,
    'embedding' => $embedding,
    'access_labels' => $document->accessLabels(),
]);

At retrieval:

$chunks = KnowledgeChunk::query()
    ->where('tenant_id', $tenant->id())
    ->whereJsonContains('access_labels', $user->accessLevel())
    ->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.35)
    ->limit(5)
    ->get();

When permissions change, the index has to change with them. A listener on the permission revocation event should update or delete the affected chunks:

final class RevokeDocumentAccess
{
    public function handle(DocumentAccessRevoked $event): void
    {
        KnowledgeChunk::where('document_id', $event->documentId)
            ->update(['access_labels' => $event->remainingLabels()]);
    }
}

Why this works:
The access label travels with the chunk, so retrieval can filter at the same granularity as the source system. The label is set by the ingestion pipeline from the source document's permissions, not by the user who uploaded it, which prevents a document author from widening access. The revocation listener closes the gap between the permission system and the index.

🧠 The important part: A permission-aware retriever protects confidentiality across users, but it does not stop poisoning from a writer who is allowed to write, and it does not stop prompt injection inside a document the user is allowed to read. Those need separate controls. Permission filtering is one layer, not the whole security model.

8. The Vue Layer Is a Trust Boundary Too

Scenario:
The Laravel API scopes every query by tenant. The Vue frontend fetches conversation history and renders it. A user manipulates the request to fetch a conversation by ID from another tenant. The API returns 404 because the conversation query is scoped — unless the frontend also caches the response and serves it from a shared key, or the SSE stream is keyed only by run ID without a tenant check.

Why it matters:
The frontend is not a security boundary, but it is a place where scoping assumptions leak. Conversation IDs, run IDs, and stream endpoints are all identifiers that a user can probe. If any of them resolve resources without checking the authenticated tenant, the backend's isolation is bypassed by a direct request.

Solution:
Treat every identifier as untrusted, and resolve resources through a tenant-scoped query, not a global find. Use route model binding with a scoped query, or an explicit resolver that returns 404 for any ID outside the tenant.

Route::get('/conversations/{conversation}', function (Conversation $conversation) {
    return $conversation->messages;
})->middleware(['auth', 'tenant']);

The route model binding is only safe if the model's global scope is active. For token-authenticated or stream endpoints where the global scope may not be set, resolve explicitly:

final class TenantConversationResolver
{
    public function __construct(private TenantContext $tenant) {}

    public function resolve(string $id): Conversation
    {
        return Conversation::query()
            ->where('tenant_id', $this->tenant->id())
            ->where('id', $id)
            ->firstOrFail();
    }
}

For SSE streams, the stream endpoint must check the run's tenant before opening the stream. A stream keyed only by runId is an enumeration risk if run IDs are guessable; use ULIDs and verify ownership before the first byte.

Why this works:
The API never resolves a resource by ID alone. Every lookup includes the tenant predicate, so an ID from another tenant returns the same 404 as an ID that doesn't exist. The frontend can't grant access the backend didn't give it, and the stream can't leak events for a run the caller doesn't own.

🚨 Production warning: The Laravel AI SDK's streaming and broadcasting features can expose run events over WebSockets. If the channel authorization callback doesn't check the tenant, a subscriber can receive events for a run they don't own. Authorize the channel against the run's tenant, not against the user's general authentication state.

9. Test for the Leak You Can’t See

Scenario:
The test suite passes. Every feature test uses a single tenant. The multi-tenant code paths are never exercised with two tenants at once, so the tests confirm that the assistant works, not that it isolates.

Why it matters:
A test that only uses one tenant cannot detect a cross-tenant leak. The leak happens when Tenant A's request touches Tenant B's data, and that scenario doesn't exist in a single-tenant test. Isolation needs adversarial tests that deliberately try to cross the boundary.

Solution:
Write three tests and run them on every release.

Test one: the direct cross-tenant query. Create a document for Tenant B whose content is the only correct answer to a question. Ask the question as Tenant A. Assert that the answer does not contain Tenant B's content.

it('does not retrieve another tenant\'s document', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    $document = Document::factory()->for($tenantB)->create([
        'content' => 'The secret return code is ZX-99.',
    ]);

    $this->actingAs(User::factory()->for($tenantA)->create());

    $response = $this->postJson('/api/assistant/ask', [
        'question' => 'What is the secret return code?',
    ]);

    $response->assertOk();
    expect($response->json('answer'))->not->toContain('ZX-99');
});

Test two: the missing tenant context. Send a request with an authenticated user but no tenant. The request must be refused, not served with a default tenant.

it('refuses a request without tenant context', function () {
    $user = User::factory()->create(['tenant_id' => null]);

    $this->actingAs($user)
        ->getJson('/api/assistant/conversations')
        ->assertForbidden();
});

Test three: the revoked permission. Grant a user access to a document, ask a question that the document answers, then revoke access, re-index, and ask again. The second answer must not contain the document's content.

it('stops citing a document after access is revoked', function () {
    // ... grant access, index, ask, assert content is present ...

    $this->revokeAccess($user, $document);
    $this->reindex($document);

    $response = $this->postJson('/api/assistant/ask', [
        'question' => 'What does the policy say?',
    ]);

    expect($response->json('answer'))->not->toContain('the revoked clause');
});

Why this works:
The first test catches unscoped retrieval. The second catches missing tenant resolution. The third catches stale index state after a permission change. Together they cover the three most common isolation failures: the query that forgot the filter, the context that was never set, and the index that was never updated.

💡 Practical note: Run these tests against the real database with RLS enabled, not against SQLite. SQLite doesn't have row-level security, so a test that passes on SQLite can pass while the production database is misconfigured. The test environment should match the production database engine for anything that touches isolation.

10. Evaluate Isolation and Answer Quality Separately

Scenario:
The assistant's answer quality improves after a retrieval change, so the team ships it. A week later, a security review finds that the change also widened the retrieval scope by removing an access-label filter. The quality metric went up because the assistant was retrieving more documents, including ones it shouldn't have.

Why it matters:
Answer quality and isolation are different metrics, and optimizing one can degrade the other. A retrieval change that increases recall may increase cross-tenant leakage. If the evaluation only measures answer quality, the regression is invisible until a customer reports it.

Solution:
Evaluate two separate suites. The quality suite measures retrieval precision, answer faithfulness, citation accuracy, and refusal rate. The isolation suite measures cross-tenant leakage, permission-filter correctness, and tenant-context failures. Neither suite should be able to pass by sacrificing the other.

A minimal evaluation gate:

final class RagEvaluationGate
{
    public function __construct(private EvalDataset $dataset) {}

    public function run(): EvalResult
    {
        $quality = $this->dataset->qualityCases()->map(
            fn ($case) => $this->scoreQuality($case)
        );

        $isolation = $this->dataset->isolationCases()->map(
            fn ($case) => $this->scoreIsolation($case)
        );

        return new EvalResult(
            citationHitRate: $quality->avg('citation_hit'),
            faithfulness: $quality->avg('faithfulness'),
            refusalAccuracy: $quality->avg('refusal_correct'),
            crossTenantLeakRate: $isolation->avg('leaked'),
        );
    }
}

The thresholds are policy, not science. A starting point might be citation hit rate ≥ 0.85, faithfulness ≥ 0.90, refusal accuracy ≥ 0.95, and cross-tenant leak rate = 0. The leak rate is not a metric to improve over time; it's a binary gate. Any non-zero value blocks the release.

Why this works:
Separating the suites makes the trade-off visible. A retrieval change that improves quality but increases leak rate fails the gate, which forces the team to fix the scoping before shipping. The isolation suite doesn't need a large dataset; it needs adversarial cases that specifically attempt to cross the tenant boundary.

🔍 Why this matters: Evaluation is not just about answer quality. For a multi-tenant assistant, the most important metric is the one that should never move: the number of cross-tenant leaks. Treat it as a release blocker, not a dashboard number.

The Isolation Decision Guide

Multi-tenant RAG is not one decision. It's a stack of decisions, and each layer has a different failure mode.

LayerWhat it protectsWhat it does not protectDefault choice
Tenant contextIdentity source for every scoped operationNothing by itselfResolve from auth, never from input
Eloquent global scopeApplication-level query filteringRaw queries, missing context in jobsUse it, but don't rely on it alone
PostgreSQL RLSRow visibility at the database engineIndex traversal, cache, filesEnable with FORCE and transaction-local context
Vector index strategyRetrieval quality within tenantFinal row authorizationMatch index shape to tenant share
Access labelsDocument-level permissions within a tenantPoisoning, prompt injectionAttach at ingestion, filter at retrieval
Queue middlewareTenant context in workersStorage paths, mail sendersSet and forget in finally
Cache and storage scopingKey/path isolationAnything not keyed by tenantPrefix every key and path
API resolversID-based access to conversations and runsSSE channel authorizationScope every lookup by tenant
EvaluationDetects regressionsPrevents new attack classesSeparate quality and isolation suites

If I were building this from scratch, the order I'd implement it in is not the order of the table. I'd start with tenant context and RLS, because those two decisions determine whether the rest of the system has a boundary to enforce. Then queue middleware and cache scoping, because those are where context disappears. Then hybrid retrieval and access labels, because those determine whether the assistant is useful without being over-permissive. Then the API resolvers and evaluation gates, because those catch the regressions that the earlier layers can't see.

The one thing I wouldn't do is trust the model to be careful. A model that has been told to “only use this tenant's data” is a model that can be persuaded otherwise by a document, a tool result, or a cleverly phrased question. The isolation has to live in the database session, the queue middleware, the cache key, and the route resolver — in code that runs before the model sees a single token.

The test that tells you whether you've succeeded is not whether the assistant answers correctly. It's whether Tenant A can ask a question whose only answer lives in Tenant B's documents and receive a clean, honest “I don't have that information.” If that test passes under load, across queues, after a permission revocation, and with a prompt injection in the corpus, the boundary is real. If it doesn't, the model is still the only thing standing between your customers' data and everyone else.

Post a Comment

Previous Post Next Post