EF Core 10 Relational Data · Lesson 14

Named filters and a trusted tenant contract

Disable soft deletion selectively without turning off tenant filtering.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

A context tenant must come from a trusted operation owner

A tenant filter needs a value associated with the context instance, but the source of that value is an application authority decision. Reading an arbitrary tenant field from a request and putting it into a context does not authenticate the caller. This fixture supplies trusted A and B labels directly and keeps each context’s TenantId immutable. The model references that context property so different instances can parameterize the same cached model correctly. The named NotDeleted filter is another independent read rule. Keeping those names separate permits a reviewed administrative query to include deleted rows without implicitly exposing other tenants.

Disable only the selected filter and verify both tenants

Trusted fixture setup inserts visible and deleted rows for both tenants, explicitly demonstrating that query filters do not police writes. A normal A context sees only VisibleA. IgnoreQueryFilters with the NotDeleted name returns both A rows while the assertion verifies that B is still absent. A separate B context sees VisibleB, checking that the first tenant was not frozen into model-wide behavior. The all-filters-off count of four is deliberately privileged evidence, not the normal read path. All results are genuinely materialized or counted by SQLite through EF10’s named-filter API.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var seed = new LabDb(options, "A"))
{
    await seed.Database.EnsureCreatedAsync();
    // Trusted fixture seeding is intentionally cross-tenant; query filters do not enforce writes.
    seed.Items.AddRange(new Item { TenantId = "A", Name = "VisibleA" }, new Item { TenantId = "A", Name = "DeletedA", IsDeleted = true }, new Item { TenantId = "B", Name = "VisibleB" }, new Item { TenantId = "B", Name = "DeletedB", IsDeleted = true }); await seed.SaveChangesAsync();
}
await using (var tenantA = new LabDb(options, "A"))
{
    var visible = await tenantA.Items.AsNoTracking().SingleAsync(); Check(visible.Name == "VisibleA", "tenant A normal"); Console.WriteLine("tenant-a:" + visible.Name);
    var withDeleted = await tenantA.Items.IgnoreQueryFilters(["NotDeleted"]).AsNoTracking().ToListAsync(); Check(withDeleted.Count == 2 && withDeleted.All(x => x.TenantId == "A"), "selective filter keeps tenant"); Console.WriteLine("with-deleted:2");
    Check(await tenantA.Items.IgnoreQueryFilters().CountAsync() == 4, "privileged all filters off");
}
await using (var tenantB = new LabDb(options, "B")) { Check((await tenantB.Items.SingleAsync()).Name == "VisibleB", "context tenant parameter"); Console.WriteLine("tenant-b:1"); }
Console.WriteLine("all-filters-off:4");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }

Trace a read truth table without advertising tenant isolation

The .NET9 practice receives three records and applies a stated tenant predicate plus a deleted flag. A with-not-deleted count is one; allowing deleted rows while retaining A gives two. These are ordinary supplied-value decisions with ordinal string equality. They do not translate a global query filter, derive an authenticated tenant, constrain an insert or prevent privileged bypasses. Their value is to specify the desired default read set independently. The real provider test then verifies that its actual named filters produce the corresponding stored result under separate context instances.

A default query filter is not an unbypassable security boundary

IgnoreQueryFilters can disable defaults, and raw SQL or other database clients can have their own access paths. Filters do not automatically reject an entity whose TenantId was assigned incorrectly before Add or update. Required navigations and related filters can also alter query result cardinality through inner joins. A production tenant design needs trusted resolution, write validation, privileged-query review, relationship invariants and potentially database-enforced isolation. Pooling introduces another per-lease state concern; an old tenant value must never leak into a new operation. The lesson’s immutable constructor-owned value avoids silently teaching mutable ambient tenant state.

Practice: prove selective bypass does not remove tenant scope

Add another deleted A row and another visible B row, then verify exact IDs for normal A, A including deleted and normal B. Do not rely only on counts, because two different result sets can share a count. Keep the all-filters-off query isolated as a privileged test path. Next attempt a deliberately mismatched TenantId write in a controlled fixture and observe that the filter itself is not write authorization; design a separate rejecting write boundary before treating the behavior as secure. The point is to expose that missing contract explicitly, not to normalize cross-tenant writes in a production adapter.

  • Hint: preserve tenant filtering when bypassing soft deletion for an approved operation.
  • Hint: default query filtering and write authorization need independent checks.

Practice: specify trusted scope and permitted bypasses

Extend the pure truth table to include an absent tenant, an unknown tenant, a deleted row and an administrative include-deleted permission. Decide which inputs are rejected before constructing the operation configuration, and keep privilege separate from the tenant string. Test exact expected row identities for each accepted configuration. Do not implement fake HasQueryFilter methods or suggest that ordinary list filtering supplies a tenant security system. The model should describe the operation’s intended data scope so its real adapter can be reviewed for bypasses, write ownership and context lifetime against actual provider behavior.

  • Hint: an encoded or well-formed tenant identifier is not proof of authority.
  • Hint: named bypass privileges should be explicit rather than inferred from a missing filter.

Review default scope and privileged paths together

The external run verifies named filters, selective disabling and separate A/B context parameterization on EF10 with SQLite. It also deliberately reveals the unrestricted all-filter path. The portable practice is only a predicate check. Neither establishes complete multitenant authorization, database row-level security or cross-provider behavior. A useful review names who supplies TenantId, which queries can bypass defaults, which writes enforce tenant ownership and how contexts are obtained. Preserve those facts when adding navigation relationships, factories or pooling; otherwise a convenient default filter can be mistaken for a stronger isolation guarantee the application never implemented.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

.NET10 / EF Core10 / real SQLite — Named filters and a trusted tenant contract

Set up the SDK project

  1. Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
  2. Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
  3. Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
  4. Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.

Download complete SDK project ↗

Project files

EfLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>
</Project>
Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var seed = new LabDb(options, "A"))
{
    await seed.Database.EnsureCreatedAsync();
    // Trusted fixture seeding is intentionally cross-tenant; query filters do not enforce writes.
    seed.Items.AddRange(new Item { TenantId = "A", Name = "VisibleA" }, new Item { TenantId = "A", Name = "DeletedA", IsDeleted = true }, new Item { TenantId = "B", Name = "VisibleB" }, new Item { TenantId = "B", Name = "DeletedB", IsDeleted = true }); await seed.SaveChangesAsync();
}
await using (var tenantA = new LabDb(options, "A"))
{
    var visible = await tenantA.Items.AsNoTracking().SingleAsync(); Check(visible.Name == "VisibleA", "tenant A normal"); Console.WriteLine("tenant-a:" + visible.Name);
    var withDeleted = await tenantA.Items.IgnoreQueryFilters(["NotDeleted"]).AsNoTracking().ToListAsync(); Check(withDeleted.Count == 2 && withDeleted.All(x => x.TenantId == "A"), "selective filter keeps tenant"); Console.WriteLine("with-deleted:2");
    Check(await tenantA.Items.IgnoreQueryFilters().CountAsync() == 4, "privileged all filters off");
}
await using (var tenantB = new LabDb(options, "B")) { Check((await tenantB.Items.SingleAsync()).Name == "VisibleB", "context tenant parameter"); Console.WriteLine("tenant-b:1"); }
Console.WriteLine("all-filters-off:4");

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.csDownload file ↗
using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
    public string TenantId { get; }
    public LabDb(DbContextOptions<LabDb> options, string tenantId) : base(options)
    { if (string.IsNullOrWhiteSpace(tenantId)) throw new System.ArgumentException("Trusted tenant required", nameof(tenantId)); TenantId = tenantId; }
    public DbSet<Item> Items => Set<Item>();
    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<Item>().HasQueryFilter("Tenant", x => x.TenantId == TenantId).HasQueryFilter("NotDeleted", x => !x.IsDeleted);
    }
}
public sealed class Item { public int Id { get; set; } public string Name { get; set; } = ""; public string TenantId { get; set; } = ""; public bool IsDeleted { get; set; } }

Expected result

The external program must complete its Check assertions and print: tenant-a:VisibleA with-deleted:2 tenant-b:1 all-filters-off:4 It uses actual SQLite materialization and saving; no external service or production database is contacted.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

var supplied = new[] { new Row("A", false), new Row("A", true), new Row("B", false) };
Console.WriteLine(supplied.Count(x => x.Tenant == "A" && !x.Deleted));
Console.WriteLine(supplied.Count(x => x.Tenant == "A"));
record Row(string Tenant, bool Deleted);
Open C# practice

C# practice output

1
2
Try this
Named query filters are read defaults, not write authorization or an unbypassable tenant security boundary.

Further reading