Repository navigation
fix(auth): end sessions when a user is disabled - #26
Merged
Merged
Conversation
Disabling a user only marked the account; its sessions ended lazily on their next request. A session not used while the user was disabled became valid again after flowcrm:user:enable, although ADR-019 states that disabling the user ends every session and the command reports that every session has ended. ManageUsers::disable now deletes every stored session of the user in the same transaction as the change and its audit event (UserSessions, DbalUserSessions). The sessions table has no user column, so a row belongs to the user when its data holds the user's ID, which the serialized security token always does. The per-request check of the disabled flag stays for a session stored after the disable. Tests: a session not used between disable and enable stays ended, for every session of the user, while another user's session is kept; the existing disable test also covers a session stored after the disable. Two new negative controls (40 in total) and the documented counts.
This was referenced Oct 24, 2026
A login that was in flight while the user was disabled could store its session after the disable had deleted the rows. The per-request check ends such a session while the user is disabled, but if the operator enabled the user before that session made a request, it was valid again. ManageUsers::enable now also deletes every stored session of the user, in the same transaction as the change and its audit event. No session can be valid at that point (a disabled user cannot log in), so nothing legitimate ends. Test: a session row stored after the disable is not revived by the enable (it was before this change). One new negative control (41 in total); the existing "revived by enabling" control now names the disable's call exactly once.
Only the documented negative-control count conflicted: #28 and this branch each add controls, 43 in total.
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
Only the documented negative-control count conflicted: 53 with #26's three controls.
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
Only the documented negative-control count and the test table conflicted: 64 with #26's three controls; the unit-test row keeps this branch's membership entries.
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
…and #32) into the members page branch
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
ADR-019 decision 4 says a session ends on its next request when its user is disabled. Since PR #26, disabling and enabling a user also delete every stored session of the user at once. Record this as a dated amendment and leave the accepted decision text unchanged.
This was referenced Oct 25, 2026
knittingcode
added a commit
that referenced
this pull request
Oct 25, 2026
…n membership changes Membership administration meets the session invalidation of disabled users (ADR-019 decision 4, main #26) in two places, both untested so far: - A disabled ADMIN's session gets 401 on POST, PATCH and DELETE of the member routes and changes nothing. Enabling the user again does not bring that session back, and neither disable nor enable touches the membership: after a new login the ADMIN administers again. - An operator disables an ADMIN whose change is waiting for the organization lock. The request holds its session row (LOCK_TRANSACTIONAL), so the disable's session delete waits for it: the change and its audit event commit first, the disable after, the session row is not brought back, and the next request is a 401. The lock polling helper now takes the statement to look for, so both waits are observed the same way. New negative control: a session row not locked for the request (LOCK_NONE) is detected by the in-flight test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Disabling a user (
flowcrm:user:disable) now deletes every stored session of the user immediately, in the same transaction as the change and its audit event. Before, a session that was not used while the user was disabled became valid again afterflowcrm:user:enable.Motivation
ADR-019 states that "a password change or disabling the user ends them all" (the user's sessions), and the command prints "OK: the user is disabled; every session has ended." In fact the sessions ended only lazily: on each request the firewall compares the stored user with the current one, and
disabledis part of that comparison but not of the credential fingerprint. Once the user was enabled again, an unused session matched again.Reproduced on a production-like stack of main (fe64f7c): two sessions of one user, disable, enable, and the session that made no request in between answered
GET /api/v1/mewith200 OK. The session rows stayed in the table after the disable (10 before, 10 after). Impact: if an operator disables a suspected-compromised account and later enables it without a password reset, an idle attacker session (under 30 minutes idle, under 8 hours since login) works again.Implementation
App\Identity\Application\UserSessions(new port):endAll(Uuid $userId).App\Identity\Infrastructure\Persistence\DbalUserSessions:DELETE FROM sessions WHERE position(convert_to(?, 'UTF8') IN sess_data) > 0on the default connection, so it runs in the transaction ofManageUsers::disable.sessionstable (PdoSessionHandler) has no user column. A stored session belongs to a user when its data holds the user's ID, which the serialized security token always does (SecurityUser::__serialize). Only logged-in sessions are stored, and each holds its own user's ID and random tokens.LOCK_TRANSACTIONAL) is waited for, so its write cannot bring the row back afterwards.ManageUsers::disablecalls it afterUser::disable()and before the audit record.ManageUsers::enablecalls it too (second commit, 0d646e6). A login that was in flight during the disable can store its session after the rows were deleted. The per-request check ends that session while the user is disabled, but an enable before its first request would have revived it. No session can be valid when a user is enabled (a disabled user cannot log in), so deleting them again ends nothing legitimate. Raised in review (D, L-26-1).SecurityUser::isEqualTo) stays, for a session stored after the disable (for example a login that was already past its checks).flowcrm_appalready hasDELETEonsessions(config/database/runtime-privileges.yaml).Testing performed
All runs used a disposable Compose project (
flowcrm-f3dis, compose.yaml + compose.test.yaml, tmpfs database, torn down afterwards).Regression test first, on unchanged main code:
AuthenticationTest::testReEnablingAUserDoesNotRestoreTheirSessionsfailed:After the fix:
AuthenticationTest|ProvisioningCommandTest|AuditTrailTest:OK (30 tests, 336 assertions).OK (271 tests, 2325 assertions).bin/console app:database:verify-privileges --runtime:OK: the database roles and runtime privileges match config/database/runtime-privileges.yaml.php tests/Mutation/run-controls.php):40 of 40 negative controls detected. The two new ones:session revived by enabling the user again -> detected: Tests: 1, Assertions: 7, Failures: 1.disabling one user ends the sessions of others -> detected: Tests: 1, Assertions: 8, Failures: 1.session survives disabling the useris still detected (Assertions: 6, Failures: 1). It would have gone undetected after the fix, because the disable test no longer reached the per-request check. The test now re-inserts the deleted row (a session stored after the disable) and asserts it ends on its next request.Lightweight checks (
check-be.sh, the dev api image with this worktree's sources): unitOK (145 tests, 416 assertions), PHPStan[OK] No errors, PHP-CS-FixerFound 0 of 171 files that can be fixed, Deptrac modules and layersErrors 0, architectureOK (23 tests, 24 assertions).Not run locally: the full
tests/app/*.shsuites (stack, prodlike, frontend, scan). They run in CI.Follow-up commit 0d646e6 (enable also ends sessions), same disposable project:
AuthenticationTest::testEnablingAUserEndsASessionStoredAfterTheDisablefailed withthe late session is not revived — Failed asserting that 200 is identical to 401.The test re-inserts the session row after the disable, then enables the user.AuthenticationTestOK (20 tests, 233 assertions); full integration suiteOK (272 tests, 2333 assertions);verify-privileges --runtimeOK;41 of 41 negative controls detected. The new controla session stored after the disable revived by the enablewas detected (Assertions: 6, Failures: 1). The existingsession revived by enabling the user againcontrol now searches the disable's call specifically (its old search string occurs twice) and is still detected.OK (145 tests, 416 assertions), PHPStan no errors, CS-Fixer 0 fixable, Deptrac 0 errors, architectureOK (23 tests, 24 assertions).Security considerations
GET /api/v1/meare kept, and a negative control widening theDELETEis detected.user.disabledis written in the same transaction. If the audit write fails, the transaction rolls back and the sessions stay.Known limitations
SecurityUser::__serializethat drops the ID must keep a user marker. The new test and control would fail in that case.docs/dev-environment.mdstated 28 controls (it was already out of date at 38); all three documented counts are now the real count. After merging main with fix(auth): match no account for an e-mail address with a NUL byte #28 (1e5e530) it is 43 (38 + fix(auth): match no account for an e-mail address with a NUL byte #28's 2 + these 3); locally43 of 43 negative controls detected, integrationOK (274 tests, 2381 assertions).