An AI assistant returns a useful passage, but the passage belongs to another tenant. The answer may be accurate and well cited while still being a serious application failure. A prompt asking the model to respect tenant boundaries cannot repair an unauthorized document that the application already supplied.

Derive the active tenant from a trusted, authorized server-side identity and apply that scope before documents are selected for a RAG request. Use the same scope for document lookup, citations, caches, and conversation state. Test with foreign documents that would otherwise rank ahead of the permitted results.

This tutorial demonstrates the retrieval boundary in a small C# console program. It uses in-memory records and synthetic relevance scores so the isolation behavior can be inspected without a vector service or an AI provider.

Where should tenant identity come from?

The active tenant must come from an identity or membership decision the application trusts. A tenant_id value supplied in a request body, URL, or arbitrary header is only a request to use that tenant until the server has authorized it.

In an ASP.NET Core application, configure authentication and authorization before using the resulting principal. Microsoft's JWT bearer guidance describes validating tokens, including their signature and relevant issuer, audience, and lifetime properties. A tenant claim additionally needs the meaning your application expects: authorized membership in the active tenant.

The example assumes one validated authentication identity and one application-defined tenant claim. If your application supports several memberships, perform the active-tenant selection and membership check before reaching this retrieval method.

Setting a ClaimsIdentity authentication type does not validate credentials. The principals constructed below are fixtures for exercising the retrieval code, not an authentication implementation.

Why filter before selecting the top results?

The fixture contains two documents for tenant A and one for tenant B. The foreign document has the highest synthetic relevance score. This makes an omitted filter visible instead of relying on accidental ranking.

Applying tenant scope before Take also preserves the requested number of permitted results where enough exists. Searching globally for one result and filtering afterward could return nothing even though a relevant authorized document exists below the foreign result.

Microsoft's security filter pattern describes filtering search results using a security identifier. It explicitly distinguishes that filtering from authentication or authorization. Your application must establish the trusted scope and prevent callers from bypassing the filtered path.

Run the C# example

Use a .NET 8 console project with C# 12 and its default implicit usings. No external packages or credentials are required. The code was compiled with the compiler bundled in .NET SDK 8.0.414 and executed on runtime 8.0.20, Linux x64.

Create the project:

dotnet new console --name TenantRetrievalDemo --framework net8.0
cd TenantRetrievalDemo

Replace Program.cs with this code:

using System.Security.Claims;

var chunks = new[]
{
    new Chunk("a-1", "tenant-a", "Batch size is 8.", 0.80),
    new Chunk("b-1", "tenant-b", "Private batch size is 80.", 0.99),
    new Chunk("a-2", "tenant-a", "Batch retries need review.", 0.70)
};
var search = new TenantRetriever(chunks);

// These principals are test fixtures, not authenticated HTTP requests.
static ClaimsPrincipal Fixture(params string[] tenants) => new(
    new ClaimsIdentity(tenants.Select(t => new Claim("tenant_id", t)),
        authenticationType: "fixture"));

static void ExpectDenied(Action action)
{
    try { action(); }
    catch (UnauthorizedAccessException) { return; }
    throw new Exception("Expected access to be denied.");
}

var a = search.Find(Fixture("tenant-a"), take: 2);
if (!a.Select(x => x.Id).SequenceEqual(new[] { "a-1", "a-2" }))
    throw new Exception("Tenant A isolation failed.");
Console.WriteLine("tenant-a: a-1, a-2");

var b = search.Find(Fixture("tenant-b"), take: 2);
if (b.Length != 1 || b[0].Id != "b-1")
    throw new Exception("Tenant B isolation failed.");
Console.WriteLine("tenant-b: b-1");

ExpectDenied(() => search.Find(new ClaimsPrincipal(), 1));
ExpectDenied(() => search.Find(Fixture(), 1));
ExpectDenied(() => search.Find(Fixture("tenant-a", "tenant-b"), 1));
Console.WriteLine("missing and ambiguous identity: DENIED");

if (search.Find(Fixture("tenant-c"), 1).Length != 0)
    throw new Exception("Empty tenant received foreign data.");
Console.WriteLine("tenant-c: no documents");

public sealed record Chunk(string Id, string TenantId, string Text,
    double FixtureScore);

public sealed class TenantRetriever
{
    private readonly Chunk[] chunks;

    public TenantRetriever(IEnumerable<Chunk> chunks) =>
        this.chunks = chunks.ToArray();

    public Chunk[] Find(ClaimsPrincipal trustedPrincipal, int take)
    {
        if (take is < 1 or > 20)
            throw new ArgumentOutOfRangeException(nameof(take));

        var identities = trustedPrincipal.Identities
            .Where(identity => identity.IsAuthenticated).ToArray();
        if (identities.Length != 1)
            throw new UnauthorizedAccessException("One trusted identity required.");

        var tenants = identities[0].FindAll("tenant_id")
            .Select(claim => claim.Value).ToArray();
        if (tenants.Length != 1 || string.IsNullOrWhiteSpace(tenants[0]))
            throw new UnauthorizedAccessException("One tenant claim required.");

        string tenant = tenants[0];
        return chunks
            .Where(chunk => string.Equals(chunk.TenantId, tenant,
                StringComparison.Ordinal))
            .OrderByDescending(chunk => chunk.FixtureScore)
            .ThenBy(chunk => chunk.Id, StringComparer.Ordinal)
            .Take(take)
            .ToArray();
    }
}

Find accepts a trusted principal and a result limit. It does not accept a caller-selected tenant string. Missing or ambiguous authenticated identities and tenant claims fail closed. The repository applies an ordinal tenant comparison before ordering and limiting results.

The scores are fixed fixture values. This program checks access scoping; it does not implement embeddings, semantic ranking, or database-level authorization.

What does successful execution look like?

Run dotnet run. The executed checks produced:

tenant-a: a-1, a-2
tenant-b: b-1
missing and ambiguous identity: DENIED
tenant-c: no documents

Tenant A receives its own two records even though tenant B's record has the highest score. Tenant B receives only its record. A valid fixture tenant with no records gets an empty result rather than a fallback to another tenant's collection.

An editorial team such as Ranknod could use a comparable test when reviewing a hypothetical AI workspace design, checking that separate research collections remain separate even when their documents answer the same question.

These assertions validate the in-memory method. A production implementation still needs integration tests against the actual search service, authentication configuration, and every document access route.

Which other paths need the same boundary?

Retrieval is only one route by which a document can reach an answer. A citation endpoint might fetch a document by ID. A cache might reuse an answer from a previous request. Conversation history might already contain a passage from another workspace.

Path

Required scope check

Search

Restrict eligible records using the authorized tenant.

Direct document lookup

Match both tenant and document identifier.

Citation expansion

Reauthorize the referenced document before returning its contents.

Cached answer

Include tenant and relevant permission/version state in cache identity.

Conversation retrieval

Verify that the caller can access the conversation and its retained context.

Background ingestion

Derive ownership from trusted job metadata and verify it before writing records.

Tenant partitioning may still be too broad when users within a tenant have different permissions. Add the applicable document or group authorization rules before passing content to the model.

How should you test the real integration?

Create two controlled tenants with distinct marker documents and overlapping subject matter. Ensure the foreign document would be a strong retrieval candidate. Capture the context actually sent to the model and assert that it contains only permitted records.

Repeat the request through direct-ID lookup and citation expansion. Warm the cache with one tenant, then ask the same question as the other. Exercise missing claims, multiple tenant claims, revoked membership, and requests that try to supply a different tenant identifier.

Test background work as well as HTTP endpoints. A queue message with the wrong tenant association can contaminate an index before any user performs a search. Scope should be established when the record is written and enforced when it is read.

What should happen when scope is missing?

Stop the request and return the application's appropriate authentication or authorization response. Do not choose the first tenant, reuse a prior request's context, or fall back to an unrestricted search.

Keep diagnostics useful without logging foreign document contents. Record the request identifier, the authorization outcome, and the access path that was checked.

A tenant boundary is effective when every path supplying evidence to the AI uses the same authorized scope. Start with a foreign document that would otherwise win the search, and make the test fail whenever that document reaches the wrong context.