Architecture Decision Record
ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions
Status
Accepted (2026-07-15).
Context
Soft-delete is the framework's default deletion model (ADR-005): AuditableBaseEntity.Delete() sets
IsDeleted = true and EF global query filters hide the row, but the record survives for audit,
referential integrity, and undelete. Deactivating a user account is therefore a soft-delete, not a
row removal.
Authentication is stateless JWT (ADR-004): Identity mints an access token, and every service
validates it by signature and expiry without a per-request lookup against the account store. That is
exactly what makes it scale, and it is also the problem here. A JWT is a bearer credential that stays
valid until it expires; nothing in signature validation asks "is this account still active?" So when
an administrator soft-deletes a user, that user's already-issued access token keeps passing
validation on every service until it expires on its own. The same gap applies to the SSR session
cookie that carries the JWT for fresh browser GETs (ADR-022): the cookie handler populates
HttpContext.User from a token the API already issued, so a deleted user's prerender path also stays
authenticated until the token lifetime runs out.
Closing that gap the textbook way (a token deny-list or a per-request account-status check) reintroduces exactly the stateful, per-request store lookup that stateless JWT was chosen to avoid. We wanted the deletion to take effect quickly without paying a database round trip on every authenticated request, and without coupling every extracted service to the Identity database.
Decision
Add a shared-pipeline middleware, SoftDeletedUserMiddleware
(Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:15, BR-133), that
rejects an authenticated caller with HTTP 401 once the caller's account has been soft-deleted, backed
by a short cache so the account-status lookup is not paid on every request.
- It runs after authentication, before authorization.
UseCommonMiddlewarePipelineregisters it atSource/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:102, immediately afterUseAuthentication/UseRateLimiter(WebApplicationExtensions.cs:96,101) and beforeUseAuthorization(WebApplicationExtensions.cs:103), soHttpContext.Useris already populated and the check gates every downstream endpoint. - Anonymous requests pass straight through. When
ICurrentUserService.UserIdis null the middleware calls the next delegate and returns without any lookup (SoftDeletedUserMiddleware.cs:43-51), so unauthenticated traffic pays nothing. - The account-status check is an abstraction the Identity module implements.
ISoftDeletedUserValidator(Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:7) exposes a singleIsUserSoftDeletedAsync(userId, ...)method (ISoftDeletedUserValidator.cs:15). Both apps' Identity modules implement it with one filter-bypassing existence query,repository.ExistsAsync(u => u.Id == userId && u.IsDeleted, ignoreQueryFilters: true, ...)(ADC:MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/SoftDeletedUserValidator.cs:10,21-24; Store:MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/SoftDeletedUserValidator.cs:10,21-24), registered scoped in each Identity module's DI (MMCA.ADC.Identity.Application/DependencyInjection.cs:32,MMCA.Store.Identity.Application/DependencyInjection.cs:39). The query bypasses the soft-delete global query filter deliberately, because a plain read would hide the very row it needs to find. - A 30-second cache amortizes the lookup.
CacheDurationisTimeSpan.FromSeconds(30)(SoftDeletedUserMiddleware.cs:17). The middleware readsuser:deleted:{userId}fromICacheServicefirst (SoftDeletedUserMiddleware.cs:63-66): a cachedtrueshort-circuits to 401 with no database call (SoftDeletedUserMiddleware.cs:66-71); a cache miss runs the validator once, caches the boolean for 30 seconds, and 401s if deleted (SoftDeletedUserMiddleware.cs:73-84); a cachedfalsefalls through to the next delegate (SoftDeletedUserMiddleware.cs:86). So a given user costs at most one status query per 30-second window per cache scope, not one per request. - It no-ops in services that do not host Identity. The validator is resolved lazily via
context.RequestServices.GetService<ISoftDeletedUserValidator>()(SoftDeletedUserMiddleware.cs:53) rather than as anInvokeAsyncparameter. In an extracted service that does not host Identity (for example Store's Catalog or Sales service), no implementation is registered, so the middleware passes the request through (SoftDeletedUserMiddleware.cs:54-61): Identity is the source of truth and already validated the token upstream. Resolving it as a constructor/parameter dependency would instead 500 every request in those services. MMCA.Helpdesk wires the same pipeline (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:96) but hosts only a Tickets module and registers no validator, so it takes the same no-op path.
SoftDeletedUserMiddlewareTests
(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Middleware/SoftDeletedUserMiddlewareTests.cs)
covers the branches: anonymous pass-through, no-validator pass-through with no cache call, a live
non-deleted pass, a live deleted 401, a cached-deleted 401 with no database call, and a cached
non-deleted pass with no database call.
The effect is a bounded revocation window, not instant revocation: after an account is soft-deleted, its still-valid tokens keep working only until the cached status expires (at most the 30-second cache lifetime once the account has been queried at least once in that window), instead of until the token itself expires.
Rationale
- Bounds the stateless-JWT revocation gap cheaply. Stateless JWT (ADR-004) has no built-in revocation, so a deactivated account would otherwise stay usable for the full remaining token lifetime. A 30-second cached check turns "valid until the token expires" into "rejected within about 30 seconds," which is the point of the middleware.
- The cache is what keeps it stateless-friendly. Checking account status on every request would put a database read back in the hot path of every authenticated call, the cost stateless JWT was meant to avoid. Caching the boolean for 30 seconds keeps the lookup rate at most once per user per window, so the common case stays a cache hit.
- The validator abstraction keeps the middleware in the framework. The middleware lives in
MMCA.Common.APIand depends only onISoftDeletedUserValidator, so it needs no reference to any app'sUserentity; each Identity module supplies the query against its own store. - Lazy resolution is what makes it safe everywhere. One pipeline runs in Identity-hosting and non-Identity hosts alike; resolving the validator lazily lets the same middleware gate real requests where Identity lives and stay inert where it does not, without a per-host pipeline variant.
Trade-offs
- Revocation is bounded, not immediate. A soft-deleted user whose status is cached as not-deleted
keeps passing until that cache entry expires (up to 30 seconds). Shrinking the window costs more
database lookups; lengthening it widens the exposure. 30 seconds is the chosen balance, and it is a
compile-time constant (
SoftDeletedUserMiddleware.cs:17), not configurable per host today. - The no-op posture trusts upstream validation. In a service with no validator registered, a soft-deleted user's token is accepted for its full lifetime at that service, on the assumption that Identity is the source of truth and the token was validated upstream. Only Identity-hosting hosts actually enforce the revocation; extracted non-Identity services do not re-check.
- Cache-scope-dependent. The window is per cache scope: with a distributed cache the revocation is shared across replicas, but with a per-instance memory cache each instance carries its own 30-second window, so the effective revocation lag is per replica.
- Enforcement depends on registration. An Identity-hosting service that fails to register
ISoftDeletedUserValidatorsilently degrades to the no-op path, the same audit-the-inventory caveat other opt-in framework capabilities carry (ADR-005).
Related
ADR-005 (soft-delete is the deletion model whose still-authenticated tokens this middleware revokes; deleting a user is a soft-delete, not a row removal), ADR-004 (the stateless RS256/JWKS validation that has no built-in revocation, which this bounds without a per-request store lookup), ADR-022 (the SSR session cookie carries the same JWT into the prerender path this middleware also gates).