store: users saved in one transaction, the last global kept inside it (audit M7, M9)

M7: DeleteUser counted the global users and then deleted; the handler's
demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1)
serialises statements, not pairs: two globals deleting or demoting each
other at once could leave none. Now:
- DeleteUser: a transaction that deletes first (taking the write lock) and
  then requires a global left (requireAGlobal) - ErrLastGlobal rolls back.
- SaveUser: likewise for a demotion, the count inside the transaction after
  the write. The handler's early count stays only to answer before hashing a
  password; SaveUser decides, and ErrLastGlobal maps to the same message.
- CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM
  users), one statement.

M9: submitUserUpdate made five writes in a row (UpdateUser, sessions,
SetUserRole, ClearUserReach with its error only logged, SetUserReach).
store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one
transaction (a global user's reach cleared); sessions end after it commits.
The create path had the same flaw - user, reach and then e-mail in three
writes - and now goes through AddUser, one transaction; CreateUser stays as
a wrapper for its many test callers. SetUserRole is gone; writeUserReach is
the reach write inside a transaction.

Tests: store - two globals demoting, and deleting, each other from two
goroutines: exactly one refused, one global left; a refused SaveUser (taken
name, a reach to no domain, nothing assigned) changes nothing; promoted to
global loses the reach; AddUser with a bad reach leaves no user; four setups
at once create one user. Handlers (the users pages, direct): the whole
change saved, back to domain with a reach; the last global not demoted and
the typed rename not written; a domain user with nothing assigned refused,
nothing changed; create with e-mail and reach.

CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62.
Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of
./internal/web/handlers ok; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 15:25:41 +03:00
co-authored by Claude Opus 5.5
parent 60c7b85f84
commit f9e75de8d1
7 changed files with 420 additions and 106 deletions
+12
View File
@@ -58,6 +58,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
stored earlier, or imported from another instance, keeps its case, since
its client uses it (audit 2026-10-12, H4).
- **The last global administrator stays.** Deleting or demoting a global
administrator counted the others first and then wrote, so two
administrators deleting or demoting each other at the same moment could
both succeed and leave nobody able to open Server. The count is now taken
in the same transaction as the write; first-user setup is one statement
(audit 2026-10-12, M7).
- **A user is saved whole or not at all.** The users form wrote the name,
the password, the role and the assignments one after another; a failure
part way left a user renamed with the old role, or a domain administrator
with nothing assigned. Saving and creating a user are now one transaction
each, the e-mail included (audit 2026-10-12, M9).
### Added
- **The delivery log measures its own guesses** (migration `0007`). A table