Public groups have always been bindable by every user: CanBindGroup returned
true for any non-exclusive group, and user_allowed_groups only ever carried the
exclusive groups an admin had granted. Admins had no way to hand a single user
a subset of the public groups short of converting a group to exclusive, which
changes it for everyone already using it.
A user now carries restrict_public_groups. While it is false, which is the
default and what every existing row migrates to, nothing changes: every public
group stays bindable. Once an admin turns it on for a user, that user's public
groups are narrowed to the ones listed in user_allowed_groups, the same table
that already gates exclusive groups.
The flag is an administrative control, so it rides on the admin user DTO only
and leaves the shape of the end-user endpoints alone.
The model plaza filter honours the flag as well, so a restricted user is not
shown groups they would be refused when binding a key. Anonymous visitors have
no user record and keep the previous view.
Enforcement rides on the existing CanBindGroup choke point, so it covers key
creation, key updates, and per-request authorization together. An API key bound
to a group that is later withdrawn stops working at request time rather than
lingering as a key that can be listed but not used.
The admin dialog gains a toggle over the public group list. Turning it off
re-checks every public group, so an admin cannot save a list that reads as
restrictive while the restriction itself is disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on
every call, regardless of which fields the caller meant to change. Several
columns on those tables are maintained by dedicated atomic paths (balance
deduction, quota and rate-limit counters, limit adjustments, activity
timestamps), so a caller holding a slightly older snapshot could silently
roll them back - a lost update.
Both methods now take an explicit column mask and persist only the columns
the caller declares; everything else keeps its current database value.
- All user and API-key call sites declare exactly what they mutate, which
turns admin edits and profile saves into genuine partial updates.
- Email uniqueness locking/lookup and allowed_groups sync only run when
those fields are part of the update.
- UserUpdateFields deliberately has no balance/total_recharged members, so
Update cannot touch them. New AdjustBalance/SetBalance apply the change in
a single statement and return before/after values; admin balance
adjustment uses them instead of read-modify-write.
- promo_codes.used_count is no longer written by Update; it is only ever
incremented by the redemption path.
- The billing hot path that marks an API key quota-exhausted writes only
status.
- Dropped a no-op row write in RevokeAllUserTokens: users has no
token_version column, so it persisted nothing while still overwriting
concurrently-updated columns.
Adds integration coverage that a stale snapshot cannot revert concurrent
atomic writes, and unit coverage pinning the column set each entry point
declares.