HR Agentic is a prototype I built: a recruiting and HR platform where many organizations share one PostgreSQL database. Jobs, candidates, interviews, employees, offers, audit logs, all in the same tables, separated by an OrganizationId column. The backend is ASP.NET Core with EF Core and Npgsql, the front end is Next.js 14.
Isolation is done with EF Core global query filters, and when I reviewed the code the filters were exactly right. Every tenant-owned entity had one, and no service bypassed them. The problem was one file over, in the small class that tells the filter which organization it is looking at.
In short:
- Global query filters are a good default. They turn “remember to filter by tenant” into something you have to opt out of.
- A filter is only as trustworthy as the value it compares against. The tenant source matters more than the filter.
- In my prototype, that value fell back to an unauthenticated
X-Tenant-Idheader, then to a hard-coded demo organization. - Tracing it properly showed a narrower live bug than my first note claimed, and a bigger structural one.
- The fix: tenant only from the authenticated principal’s claims, no header, no default, fail closed, and tests that try to cross the line.
What global query filters do well
A global query filter is a predicate EF Core adds to every query for an entity type. In this project the model configuration looks like this, repeated for each tenant-owned table:
modelBuilder.Entity<Candidate>(entity =>
{
entity.ToTable("candidates");
entity.HasQueryFilter(x => x.OrganizationId == _tenantContext.OrganizationId);
});
_tenantContext is a scoped service injected into the DbContext. EF Core treats the reference to a field on the context as a parameter, so the same compiled query runs with a different organization id for each request.
Fourteen of the seventeen entity sets carry this filter. The three that don’t are organizations, users, and billing plans, and each has a reason: sign-in has to find a user by email before it knows their organization, and billing plans are a catalog every organization shares.
What this buys you is a safe default:
- A service that writes
db.Candidates.Where(...)gets tenant scoping without asking for it. - Looking up a record by id from another organization returns nothing, so the controller returns 404, not someone else’s data.
- The only way around it is
IgnoreQueryFilters(), which is easy to search for. There are no calls to it in this codebase.
That is the part that looked solved. But the filter never asks where the organization id came from. It compares a column to whatever the tenant context returns, and it will do that just as faithfully for a forged value as for a real one.
The question the filter can’t answer
Here is the tenant context, lightly simplified. The only change from the prototype is that I replaced the literal GUID with a name:
// Simplified from the prototype. The hard-coded GUID is replaced by a name.
private static Guid ResolveOrganizationId(IHttpContextAccessor accessor, IConfiguration config)
{
var http = accessor.HttpContext;
// 1. The signed-in user's organization claim
if (http?.User?.Identity?.IsAuthenticated == true &&
Guid.TryParse(http.User.FindFirst("org_id")?.Value, out var fromClaim))
return fromClaim;
// 2. Any request header called X-Tenant-Id
if (http?.Request.Headers.TryGetValue("X-Tenant-Id", out var header) == true &&
Guid.TryParse(header.ToString(), out var fromHeader))
return fromHeader;
// 3. A configured default
if (Guid.TryParse(config["Tenant:DefaultOrganizationId"], out var fromConfig))
return fromConfig;
// 4. The seeded demo organization
return DemoOrganizationId;
}
Each line has a plausible reason to exist. A header is handy when you are calling the API by hand. A default means nothing crashes when there is no user. But put together, they describe a tenant context that never says “I don’t know”. It always returns an organization, and only the first rung checks who is asking.
The configured default is not set anywhere in the repository’s configuration, so in practice rung 3 is skipped and rung 4 decides. The hard-coded value is the same id the database seeder uses for the demo organization it creates on first start.
Tracing what actually happens
My first note on this read “header fallback allows cross-tenant reads”. Before writing that down as a finding, I traced every way a request can reach a filtered query. The answer was more specific than the note.
Reads through the data endpoints are not open today. Every data controller (jobs, candidates, interviews, employees, dashboard, platform) carries [Authorize], and authentication is a cookie. A request without a valid cookie never reaches a service. And every sign-in path builds its claims in one method that always adds org_id from the user’s record. The cookie is protected by ASP.NET Core data protection, so a client cannot edit the claim. Put together: any request that reaches a data endpoint has a valid org_id claim, rung 1 wins, and the header is ignored.
Writes through the anonymous endpoints do reach the fallback. The auth controller has anonymous endpoints (register, login, accept invite, password reset), and they all write audit log entries. The audit service stamps each entry with tenantContext.OrganizationId.
The clearest case is the password reset request. It is anonymous, it never signs anyone in, and when it finds an active user with the given email, it writes an audit entry recording that a reset was requested for that user’s id. At that moment there is no authenticated user, so the tenant context goes down the ladder:
- With an
X-Tenant-Idheader, the entry lands in whichever organization the caller names. A foreign key from audit logs to organizations means the id has to belong to a real organization, but an id is an identifier, not a credential. - Without the header, the entry lands in the demo organization.
The platform overview endpoint returns each organization’s ten most recent audit entries. So a reset requested for a user in organization A can show up in organization B’s activity feed, with A’s user id in it. And anyone who knows a user’s email can drop entries into the audit log of any organization whose id they have. It is a small leak and a log-integrity problem, not a data breach. But it is real, and it exists only because of rungs 2 and 4.
The read path is closed by accident, not by design. That is the bigger finding. Isolation for reads depends on two facts that live in other files: an attribute on each controller, and a claim added in a sign-in helper. Change either and the filter silently starts scoping to a header or to the demo organization:
- An endpoint marked
[AllowAnonymous]that reads filtered data, for example a public job board. - A second authentication scheme, such as API keys or tokens from another issuer, whose principal has no
org_idclaim. - Any code that runs outside a request with a user, such as a background job.
None of these would throw. The query would run, return rows from the wrong organization, and look perfectly normal.
The fixed design
The rule I wanted is short: the organization comes from the authenticated principal, or the request does not touch tenant data. In this app the principal comes from the auth cookie; with bearer tokens it would be the validated token. Either way it is something the server signed, not something the client typed.
That turns into four changes:
- One source. The tenant context reads
org_idfrom an authenticated principal and nothing else. - No default. If there is no principal or no claim, it throws. A query filter that evaluates it throws with it, so no query runs.
- No header. It is not a fallback, a debug mode, or a development-only switch. It is gone.
- Explicit tenant for work without a user. Anonymous endpoints that need an organization pass the one from the record they already loaded. Seeding and jobs say which organization they act for.
// Simplified: one source, no fallbacks.
public sealed class TenantContext(IHttpContextAccessor accessor) : ITenantContext
{
public Guid OrganizationId
{
get
{
var user = accessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true &&
Guid.TryParse(user.FindFirst("org_id")?.Value, out var orgId))
{
return orgId;
}
throw new TenantUnresolvedException();
}
}
}
Whether that exception reaches the client as a 401 or a 500 matters much less than the fact that no query ran. A 500 here means a code path forgot to establish a tenant, which is a bug you want to hear about, not paper over.
The password reset already has the user it is talking about, so it knows the right organization:
// Simplified: the anonymous endpoint names the organization explicitly.
await auditService.LogAsync(
organizationId: user.OrganizationId,
category: "auth",
action: "password-reset-request",
entityId: user.Id.ToString());
The seeder does not need the default either. It only queries the organizations table, which has no filter, and it sets OrganizationId explicitly on every row it creates.
Tests that prove isolation
The prototype has no test project yet, so these are the first tests I would add. They are written against the boundary, not the happy path: each one tries to cross it.
// Simplified.
[Fact]
public void Header_alone_does_not_pick_a_tenant()
{
var http = new DefaultHttpContext();
http.Request.Headers["X-Tenant-Id"] = Guid.NewGuid().ToString();
var tenant = new TenantContext(new HttpContextAccessor { HttpContext = http });
Assert.Throws<TenantUnresolvedException>(() => tenant.OrganizationId);
}
[Fact]
public async Task Signed_in_user_cannot_read_another_org_even_with_the_header()
{
var (orgA, orgB) = await SeedTwoOrganizationsAsync();
var client = await SignedInClientAsync(orgA.AdminEmail);
client.DefaultRequestHeaders.Add("X-Tenant-Id", orgB.Id.ToString());
var response = await client.GetAsync($"/api/candidates/{orgB.CandidateId}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
The rest of the set, in one line each:
- Lists stay in their lane. Signed in as A, list candidates, jobs, and employees, and assert that every returned row belongs to A.
- Anonymous requests get nothing. Call each data endpoint without a cookie and assert it is not a 200.
- Audit entries land where they belong. Request a password reset for a user in A, with a header naming B, and assert the entry is in A’s log and B’s log is unchanged.
- Every endpoint makes a choice. A reflection test that fails if a controller action has neither
[Authorize]nor an explicit[AllowAnonymous], so the next anonymous endpoint is a deliberate decision someone can review.
Two organizations is the minimum fixture for any of this. With one, every isolation test passes trivially.
What I would do differently
- No fallbacks in anything that decides identity. A default tenant is a decision made on behalf of a request that never made one. “I don’t know” has to be a possible answer, and it has to stop the request.
- Trust the filter, audit the source. Query filters did their job perfectly. The review that matters is the five lines that feed them.
- Trace before you file. My first note overstated the read risk and missed the write path. Tracing gave me the real, smaller bug and the more important structural one.
- Don’t let safety live in three files. Reads were safe because of an attribute on each controller and a claim added in a sign-in helper, far from the tenant code. The fixed tenant context is safe on its own.
- Test with two tenants from day one. Isolation bugs are invisible with one.
If you are building multi-tenant systems on EF Core and want to compare notes on where your tenant comes from, you can reach me through my site.