Chapter 22 · Global Query Filters, Multi-Tenancy, Soft Delete, and Data Partitioning

Global Query Filters for Soft Delete, Tenant Scope, Active Rows, and Security-Sensitive Predicates

Use EF Core global query filters as a default data-scope mechanism for ServiceHub soft deletion and tenant isolation while making SQL predicates, bypass paths, required-navigation effects, and authorization boundaries observable.

Advanced180–240 minutesfilter/security labEF Core 10.0.11 · Microsoft.EntityFrameworkCore.Sqlite 10.0.11 · dotnet-ef 10.0.11 · .NET 10.0.11 · SDK 10.0.400Mandatory free local SQLite path · SQL Server RLS optional in Lesson 5Tenancy/security behavior reviewed: August 27, 2026

Learning outcomes

01

Explain global query filters as model-level LINQ predicates that EF adds automatically, not as database authorization.

02

Evolve ServiceHub with TenantId, IsDeleted, and IsActive while keeping tenant identity explicit in schema and indexes.

03

Inspect the generated SQL and parameters for tenant/soft-delete predicates and prove that IgnoreQueryFilters is a real bypass.

04

Diagnose required-navigation/inner-join surprises when the related entity has a global filter.

05

Implement a soft-delete write path that preserves Revision concurrency semantics rather than issuing a physical DELETE.

06

Define the security and operational controls that must surround query filters in production.

1. The problem: one database, many organizations, one dangerous omission

ServiceHub is about to host several customer organizations in the same database. A normal WorkOrders.Where(...) query is now security-sensitive: forgetting one tenant predicate can return another organization’s data. At the same time, operations wants “deleted” work orders retained for audit, while the normal UI should hide them. Repeating w => w.TenantId == tenantId && !w.IsDeleted in every repository call is not a durable control because omission is the failure mode.

A global query filter is a LINQ predicate attached to an EF entity type in the model. When that root entity type is queried, EF composes the filter into the expression tree before translation. This is valuable default scoping, but it lives in the application model and can be disabled. Treat it as one defense layer, not as the database security boundary.

Security boundary

The authenticated/authorized tenant must come from trusted server-side identity. Never accept a tenant ID from an untrusted route/body/query string and merely pass it into the filter.

2. Make tenancy and lifecycle visible in the relational schema

Chapter 22 deliberately evolves the existing work_orders row. The migration adds a tenant discriminator, a soft-delete flag, and an active flag. Existing rows must be backfilled deterministically before making tenant_id required. This is a schema/data migration, not a hidden model tweak.

C# · model evolution
public sealed partial class WorkOrder{    public string TenantId { get; private set; } = null!;    public bool IsDeleted { get; private set; }    public bool IsActive { get; private set; } = true;    public void SoftDelete()    {        IsDeleted = true;        AdvanceRevision();       // Chapter 04 application-managed token    }}
C# · migration shape to review
migrationBuilder.AddColumn<string>(    name: "tenant_id", table: "work_orders", nullable: false,    defaultValue: "legacy");migrationBuilder.AddColumn<bool>(    name: "is_deleted", table: "work_orders", nullable: false,    defaultValue: false);migrationBuilder.AddColumn<bool>(    name: "is_active", table: "work_orders", nullable: false,    defaultValue: true);migrationBuilder.CreateIndex(    name: "ix_work_orders_tenant_active",    table: "work_orders",    columns: new[] { "tenant_id", "is_deleted", "is_active" });

The default legacy value is a migration bridge for the disposable course database, not a production tenant assignment policy. A real deployment should backfill from authoritative ownership data, verify counts, then remove any unsafe default that could silently assign future rows to the wrong tenant.

3. Context instance state becomes a query parameter

For discriminator-column tenancy, the tenant ID belongs on the short-lived context instance. It does not change the model’s shape, so it should not become part of the model-cache key. EF can parameterize a filter that references context instance state.

C# · EF Core 10 ServiceHub context
public sealed class ServiceHubContext : DbContext{    public string TenantId { get; }    public ServiceHubContext(        DbContextOptions<ServiceHubContext> options,        ITenantContext tenant) : base(options)    {        TenantId = tenant.RequiredTenantId;    }    protected override void OnModelCreating(ModelBuilder modelBuilder)    {        base.OnModelCreating(modelBuilder);        modelBuilder.Entity<WorkOrder>()            .HasQueryFilter(w =>                w.TenantId == TenantId &&                !w.IsDeleted &&                w.IsActive);    }}

Lesson 2 replaces this combined predicate with named EF Core 10 filters. Here the single predicate makes the mechanics easy to observe first.

4. Observe the SQL: defaults are still SQL predicates

C# · ordinary application query
await using var db = factory.CreateForTenant("tenant-a");var query = db.WorkOrders    .Where(w => w.Priority >= 2)    .OrderBy(w => w.Id)    .Select(w => new { w.Id, w.WorkOrderNumber, w.Summary });Console.WriteLine(query.ToQueryString());var rows = await query.ToListAsync(ct);
SQLite · representative translated command
SELECT "w"."work_order_id", "w"."work_order_number", "w"."summary"FROM "work_orders" AS "w"WHERE "w"."tenant_id" = @__ef_filter__TenantId_0  AND NOT ("w"."is_deleted")  AND "w"."is_active"  AND "w"."priority" >= 2ORDER BY "w"."work_order_id"

The key evidence is the parameterized tenant predicate. ToQueryString() proves translation shape, not authorization correctness or runtime plan quality. Command logs should confirm the same predicate on the executed command while sensitive-data logging remains disabled.

5. Deliberate failure: IgnoreQueryFilters is a real bypass

A support engineer needs to restore a soft-deleted work order and discovers IgnoreQueryFilters(). The dangerous shortcut is to use it in a normal tenant endpoint and then add only a work-order number predicate.

C# · WRONG: disables tenant and lifecycle predicates
var row = await db.WorkOrders    .IgnoreQueryFilters()    .SingleAsync(w => w.WorkOrderNumber == request.Number, ct);
Representative SQL after bypass
SELECT ...FROM "work_orders" AS "w"WHERE "w"."work_order_number" = @__request_Number_0LIMIT 2

The absence of tenant_id is the bug. The safe repair is an explicitly authorized administrative path that disables only the lifecycle filter where EF Core 10 named filters are available, while preserving the tenant filter. Lesson 2 implements that pattern.

Do not wrap this away

A generic repository method such as GetAllIncludingDeleted() can become a privilege-escalation API. Bypass capability should be named, authorized, tested, and audited.

6. Soft delete is a write policy, not just a read filter

If callers continue to call Remove, an application-level write hook can convert a tracked delete into an update. It must also preserve the Chapter 04 concurrency token so a stale request does not silently delete a newer version.

C# · convert tracked deletes before SaveChanges
private void ApplySoftDeletes(){    ChangeTracker.DetectChanges();    foreach (var entry in ChangeTracker.Entries<WorkOrder>()                 .Where(e => e.State == EntityState.Deleted))    {        entry.State = EntityState.Modified;        entry.Entity.SoftDelete();        entry.Property(w => w.IsDeleted).IsModified = true;        entry.Property(w => w.Revision).IsModified = true;    }}
Representative concurrency-sensitive UPDATE
UPDATE "work_orders"SET "is_deleted" = 1, "revision" = @p0WHERE "work_order_id" = @p1  AND "revision" = @p2RETURNING 1;

ExecuteDelete is different: it is immediate set-based DML and does not run this tracked-state conversion. A production soft-delete policy must decide whether such APIs are forbidden, wrapped, or explicitly implemented as ExecuteUpdate.

7. Required navigations can change cardinality under filters

Suppose a required WorkOrder.TenantProfile navigation points to a principal that itself has an active-tenant filter. EF is allowed to use an inner join for a required navigation. If the principal is filtered out, the work order may disappear too. That is not “filter leakage”; it follows the relational meaning of requiredness plus an inner join.

Shape Likely SQL consequence Review question
Required navigation to filtered principal INNER JOIN can remove parent rows Should the relationship really be required?
Optional navigation to filtered principal LEFT JOIN can keep the parent with null related row Can application logic tolerate missing related data?
Matching filters on both sides More predictable aggregate scope Are the predicates semantically identical?

Always inspect generated SQL for filter-sensitive Includes/joins. Changing IsRequired(false) merely to “fix” a query is a modeling decision with constraint consequences, not a query-tuning trick.

8. Reproducible SQLite lab

  1. Create a disposable servicehub-tenancy-lab.db from the Chapter 21 schema, then apply AddTenantLifecycleColumns.
  2. Seed two tenants, three visible work orders each, one soft-deleted row, and one inactive row.
  3. Open separate contexts for tenant-a and tenant-b; assert the same unqualified LINQ query returns disjoint IDs.
  4. Capture ToQueryString() and command logs with sensitive data disabled; verify the tenant predicate is parameterized.
  5. Run the deliberate IgnoreQueryFilters() query and record why it crosses the data boundary.
  6. Soft-delete one tracked row and verify the database receives an UPDATE with the original Revision, not a physical DELETE.
  7. Reset by deleting only the disposable lab file.

9. Production judgment

Global filters are appropriate for default lifecycle and tenant scoping when their bypass paths are tightly controlled and verified. They do not replace authentication, authorization, database constraints, least-privilege credentials, row-level security where available, or adversarial tests. Keep the context short-lived, derive tenant identity from trusted server-side state, test every explicit bypass, and include the tenant discriminator in indexes that support real query shapes.

Lesson 2 uses EF Core 10 named filters so a privileged workflow can reveal soft-deleted rows without dropping tenant isolation at the same time.

Check your understanding

  1. Where is a global query filter enforced?
  2. Why should TenantId normally not be part of IModelCacheKeyFactory?
  3. What proves that tenant scoping reached the database command?
  4. Why is IgnoreQueryFilters dangerous?
  5. Why can a required navigation unexpectedly reduce parent rows?
  6. Why does soft delete need a write policy?
Review the answers

1. In EF’s model/query pipeline; EF composes it into LINQ before provider translation. It is not itself a database authorization policy.

2. The tenant ID changes query parameter values, not model shape. Per-tenant model keys waste memory and can create correctness/operational complexity.

3. Generated/executed SQL containing a parameterized tenant predicate, plus tests that two tenant contexts return disjoint rows.

4. It can remove every configured filter, including the tenant boundary, unless EF Core 10 selective disabling is used deliberately.

5. EF may translate it as an INNER JOIN; if the related row is filtered, the parent no longer has a matching joined row.

6. A read filter only hides rows. Without converting or constraining delete operations, code can still physically delete data.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n