fix(security): evaluate tenant CurrentUserId live, not at construction
Production POST /api/cv/variants returned 200 but GET /api/cv/variants/{id}
returned 404, with the query logged as `... FROM CvVariants WHERE FALSE`
(no parameters). The created row had a correct OwnerUserId; the read was
excluded by the global query filter because CurrentUserId was null at
query time. Reproduced locally: it affected EVERY tenant-filtered read
(CV list returned 0 after creating 5, JobApplications returned total 0),
not just CV -- writes worked, reads came back empty.
Root cause: the "local" JwtBearer OnTokenValidated resolves the
request-scoped JobTrackerContext (to run LocalSessionValidator) BEFORE the
authentication middleware assigns HttpContext.User. JobTrackerContext
captured CurrentUserId in its constructor from ICurrentUserService.UserId,
which reads HttpContext.User -- still unauthenticated at that point -- so
CurrentUserId froze to null. That same scoped instance is reused by the
controller, so `CurrentUserId != null && OwnerUserId == CurrentUserId`
compiled to WHERE FALSE for the whole request. POST worked because
CreateAsync sets OwnerUserId from the controller-resolved user, and
inserts are not filtered.
Fix: make CurrentUserId a computed property that reads
ICurrentUserService.UserId live, so the query filters see the
authenticated user at query-execution time. Deny-on-null is preserved
(still null for an unauthenticated principal). LocalSessionValidator is
unaffected -- it already uses IgnoreQueryFilters and queries by explicit
sid.
Verified on a real MariaDB 11 container end to end: create then read a
variant returns 200, the variant list returns all rows, and
GET /api/jobapplications reads normally. Added
CurrentUserIdLiveEvaluationTests pinning the live-evaluation behaviour
(both fail against a constructor snapshot). 422 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,22 @@ namespace JobTrackerApi.Data
|
||||
{
|
||||
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
|
||||
{
|
||||
public string? CurrentUserId { get; }
|
||||
private readonly JobTrackerApi.Services.ICurrentUserService _currentUser;
|
||||
|
||||
// Evaluated live on each access, NOT captured in the constructor. The "local" JwtBearer
|
||||
// OnTokenValidated resolves this request-scoped DbContext to run LocalSessionValidator BEFORE
|
||||
// the authentication middleware assigns HttpContext.User. A constructor snapshot therefore froze
|
||||
// CurrentUserId to null for the whole request, and the same scoped instance is reused by the
|
||||
// controller — so every tenant-filtered read compiled to `WHERE FALSE` and returned nothing
|
||||
// (created rows 404'd on read, lists came back empty) even though writes set OwnerUserId
|
||||
// correctly from the controller-resolved user. Reading it live means the query filters see the
|
||||
// authenticated user at query-execution time. Deny-on-null is preserved: it is still null for an
|
||||
// unauthenticated principal.
|
||||
public string? CurrentUserId => _currentUser.UserId;
|
||||
|
||||
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
|
||||
{
|
||||
CurrentUserId = currentUser.UserId;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public DbSet<Company> Companies => Set<Company>();
|
||||
|
||||
Reference in New Issue
Block a user