11 KiB
11 KiB
Implementation Plan: Hardened User Deletion Cleanup + Concurrency-Safe Last-Admin Lock
1. Architectural Reconnaissance
- Codebase style & conventions: TypeScript NestJS 10 backend over
better-sqlite3via TypeORM 0.3. All admin mutations live inAdminService(backend/src/modules/admin/admin.service.ts:109-119) and call a sharedUsersService.applyLastAdminSafeMutationwrapper (backend/src/modules/users/users.service.ts:63-95). The wrapper currently usesdataSource.transaction('SERIALIZABLE', ...)which onbetter-sqlite3still emits a plainBEGIN TRANSACTION(deferred) and only escalates the SQLite write lock at the first write. Two concurrent transactions can therefore both pass the preconditioncount > 1before either commits, racing the invariant. - Data Layer:
user,solve, andrefresh_tokenare the only tables with auser_idcolumn.blog_posthas noauthor_id, and awards are persisted as rows insolveitself (points_awarded,is_first/second/third). The newAddUserDeleteCascades1700000000700migration already addsON DELETE CASCADEonsolve.user_idandrefresh_token.user_id, so a plainmanager.delete(UserEntity, ...)would cascade in practice — but the user request asks us to perform the dependent cleanup explicitly inside the same transaction so the operation is observable to business code, logging, and the post-condition count. - Test Framework & Structure: Jest 29 multi-project config (
tests/jest.config.js); existingtests/backend/last-admin-invariant.spec.tsandtests/backend/admin-players.spec.tsalready exercise the safe-mutation wrapper and the Players endpoints with an in-memory SQLite database. All new tests will be appended to those files (no new top-level test files required) and run vianpm run test:backend. - Required Tools & Dependencies: No new package or global CLI is required. The plan uses the existing TypeORM
QueryRunner, the in-repoApiError/ERROR_CODESenvelope, and SQLite's nativeBEGIN IMMEDIATEsemantics. If a future deployment moves to Postgres/MySQL, the locking strategy below degrades gracefully:setLock('pessimistic_write')is a no-op for read paths and a real row lock on databases that support it, whileBEGIN IMMEDIATEis harmless outside SQLite.
2. Impacted Files
- To Modify:
backend/src/modules/admin/admin.service.ts— perform explicitsolve,refresh_token, and any other user-owned table deletion inside the same transaction as theuserrow delete; report per-table affected counts.backend/src/modules/users/users.service.ts— replace the read/count-only SERIALIZABLE wrapper with aQueryRunner-drivenBEGIN IMMEDIATEtransaction that performs row-level pessimistic locking where supported, retries onSQLITE_BUSY/serialization failures, and rolls back with409 LAST_ADMINif the post-mutation admin count is zero.backend/src/common/errors/error-codes.ts— add a stableCONFLICT_RETRY/CONFLICT_BUSYcode (only if the retry path needs to surface a distinct code; otherwise reuseLAST_ADMINand rely on logs).tests/backend/last-admin-invariant.spec.ts— extend with explicit dependent-cleanup andSQLITE_BUSYretry coverage; add a concurrentPromise.allassertion that at most one writer can drive the admin count to zero.tests/backend/admin-players.spec.ts— add a delete-cascade assertion (no orphansolve/refresh_tokenrows after deletion) and aLAST_ADMINrejection when the sole remaining admin is deleted.
- To Create: None. The dependent table set is already known (solve + refresh_token) and the existing migration already enforces ON DELETE CASCADE; the request explicitly asks us to delete the rows imperatively in the same transaction rather than rely solely on the cascade, so no new migration is required.
3. Proposed Changes
- Backend Logic & APIs — Dependent cleanup on delete (admin.service.ts:109-119):
- Inside the existing
applyLastAdminSafeMutationcallback (which already supplies a TypeORMEntityManager), issue explicit deletions in this order:manager.delete(SolveEntity, { userId: targetId })(captureaffected).manager.delete(RefreshTokenEntity, { userId: targetId })(captureaffected).manager.delete(UserEntity, { id: targetId })— this remains the actual user delete and is the only one that returns 404 when the row is missing; dependent tables short-circuit harmlessly when there are no rows.
- Sum the affected counts and return them from the mutate closure so the
applyLastAdminSafeMutationcan log them atinfolevel (NEVER includepasswordHash,token_hash, or the plaintext password). The cascade is retained as a safety net; the imperative delete is what the request mandates. - Treat any user-owned table that is added in the future as opt-in: introduce a small registry (
USER_OWNED_TABLES = [SolveEntity, RefreshTokenEntity]) iterated by the delete path so we don't have to add new call sites each time a newuserIdcolumn appears. The plan keeps this registry co-located with the service; no new file.
- Inside the existing
- Backend Logic & APIs — Concurrency-safe last-admin wrapper (users.service.ts:63-95):
- Replace the
dataSource.transaction('SERIALIZABLE', ...)call with adataSource.createQueryRunner()-driven flow:await queryRunner.connect()thenawait queryRunner.query('BEGIN IMMEDIATE')so the very first statement acquires the SQLite RESERVED lock, preventing a second writer from racing through the precondition.- Acquire a row-level lock on the target user via
repo.createQueryBuilder('u').setLock('pessimistic_write').where('u.id = :id', { id: targetId }).getOne(). This is a no-op for SQLite (the database-level lock is what matters) but a realSELECT ... FOR UPDATEon Postgres/MySQL if the deployment is ever migrated. - Acquire a second
pessimistic_writelock on the full set of admin rows by selecting alluser.idrows whererole = 'admin'withsetLock('pessimistic_write')(a no-op for SQLite; equivalent to a per-rowFOR UPDATEon databases that support it). The aim is to serialize all concurrent admin mutations across the cluster. - Count admins under the lock, fail fast with
409 LAST_ADMINifcount <= 1and the operation is destructive, otherwise apply the mutation, re-count under the lock, and roll back with409 LAST_ADMINif the post-condition iscount <= 0. - Commit; in the
catch, attemptROLLBACKand rethrow. TranslateSQLITE_BUSY/ serialization failures (error codeSQLITE_BUSYfrombetter-sqlite3and the TypeORMLockNotSupportedError/TransactionAlreadyStartedErrorchain) into a small retry loop (maxRetries = 5, exponential backoff 10ms / 20ms / 40ms / 80ms / 160ms). If retries are exhausted and the last failure still has the system in an indeterminate state, throwApiError.conflict(ERROR_CODES.LAST_ADMIN, ...)so the caller always sees the existing 409 contract.
- Add a database-level safety net: a forward-only migration that adds a partial unique index on the
usertable. The currentuserschema has nois_last_admincolumn to enforce a CHECK on, so the simplest correct approach is to add a server-side trigger that raises onUPDATE/DELETEthat would reduce theadmincount to zero. SQL:CREATE TRIGGER trg_user_last_admin BEFORE UPDATE OF role ON user WHEN NOT EXISTS (SELECT 1 FROM user WHERE role = 'admin' AND id <> NEW.id) AND OLD.role = 'admin' AND NEW.role <> 'admin' BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;plus a siblingBEFORE DELETEtrigger with the same condition. The trigger is the deployment-wide safety net so even a buggy caller that bypasses the service wrapper cannot commit a zero-admin state. The service still rolls back to 409 in normal operation; the trigger ensures the database itself rejects the offending statement if it ever does slip through (e.g. raw SQL or a future parallel module that forgets the wrapper).
- Replace the
- Backend Logic & APIs — Error code & messaging:
- The existing
ApiError.conflict(ERROR_CODES.LAST_ADMIN, message)shape is preserved for every public path. Add a one-line clarifying message in the wrapper:'Cannot delete or demote the last admin; the system must always retain at least one enabled user with role "admin".'. Plaintext, hashes, and tokens are never included.
- The existing
- Frontend Integration:
- No frontend code change is required; the LAST_ADMIN toast, modal handling, and row updates already map the existing 409 response. The plan only strengthens the server contract; the existing tests in
tests/frontend/admin-players.pure.spec.tsandtests/frontend/change-password-modal.spec.tsalready assert the LAST_ADMIN error shape, so no new frontend test is required.
- No frontend code change is required; the LAST_ADMIN toast, modal handling, and row updates already map the existing 409 response. The plan only strengthens the server contract; the existing tests in
- Test Strategy (covered in
tests/backend/last-admin-invariant.spec.tsandtests/backend/admin-players.spec.ts):- Append to
applyLastAdminSafeMutationdescribe block:demote is rejected with 409 LAST_ADMIN when adminCount would reach zero after recount— seed two admins, then in a single transaction demote one to a player role while a second concurrent transaction simultaneously deletes the other; assert at most one of the two requests succeeds and the other receives409 LAST_ADMIN(usePromise.allwith twoapplyLastAdminSafeMutationcalls).delete is rejected with 409 LAST_ADMIN when adminCount would reach zero after recount— same pattern but with delete instead of demote.concurrent role-toggle + delete cannot reduce admin count below 1— exactly the multi-instance invariant the user requested; run twoapplyLastAdminSafeMutationcalls in parallel (one role-toggle, one delete) against a two-admin seed and assert final admin count is1and at least one response is409 LAST_ADMIN.
- Append to admin-players describe block:
deletePlayer cascades/cleans dependent solve + refresh_token rows— create an admin, create a player, manually insert asolverow tied to the player, performDELETE /api/v1/admin/players/:id, then assert thesolveandrefresh_tokentables no longer contain rows for the deleted user.sole-admin delete returns 409 LAST_ADMIN with no row removal— assert theuserrow count is unchanged after the 409.
- Use the existing in-memory better-sqlite3 + Nest
Test.createTestingModulesetup; no new infrastructure. The retry path is exercised by directly invoking the service and stubbing the innerdataSource.createQueryRunner()to throwSQLITE_BUSYonce before succeeding (use a tinyjest.spyOnon the DataSource) — kept narrow so the test stays fast and deterministic.
- Append to
4. Test Strategy
- Target Unit Test File:
tests/backend/last-admin-invariant.spec.ts(service-level) andtests/backend/admin-players.spec.ts(HTTP-level). - Mocking Strategy: Real in-memory SQLite + real Nest application, no DB mocks. The retry path uses a one-line
jest.spyOn(dataSource, 'createQueryRunner')that throws aSQLITE_BUSY-shapedErroron the first call and returns the real runner on the second; this keeps the assertion deterministic without simulating the SQLite lock manager. No new test helpers and no shared state — each test clearsuser,solve, andrefresh_tokenrows inbeforeEachto remain isolated.