admin: bypass the reserved-username blocklist at write time too

UpdateUsernameAdmin already skipped the reserved-word check in the
availability lookup, but UserStore.UpdateUsername's own write path
(replacePeerUsernameTx / CollectibleUsernameStore.SetEditableUsername)
enforces the same operator blocklist a second time, independently and
unconditionally. That second check is what was still rejecting an admin
handing out a word they'd deliberately reserved, with "username occupied".

Add UpdateUsernameAdmin/SetEditableUsernameAdmin bypass variants down the
write path (postgres and memory) and route users.Service's actual write
through them when the availability check was already bypassed.
This commit is contained in:
Astra 2026-09-15 16:22:00 +01:00
parent 53872f8fc9
commit ea17d7da0d
6 changed files with 180 additions and 15 deletions

View file

@ -81,10 +81,21 @@ func usernameReservedTx(ctx context.Context, db sqlcgen.DBTX, usernameLower stri
}
func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) {
if reserved, err := usernameReservedTx(ctx, db, usernameLower); err != nil {
return false, err
} else if reserved {
return false, nil
return peerUsernameAvailableChecked(ctx, db, usernameLower, peerType, peerID, true)
}
// peerUsernameAvailableChecked is peerUsernameAvailable with the operator
// blocklist check optional: an operator deliberately reserving a word still
// needs to be able to hand it to a specific account via the admin console,
// so the admin-initiated username-set path skips it (checkReserved=false)
// while self-service username changes always enforce it.
func peerUsernameAvailableChecked(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64, checkReserved bool) (bool, error) {
if checkReserved {
if reserved, err := usernameReservedTx(ctx, db, usernameLower); err != nil {
return false, err
} else if reserved {
return false, nil
}
}
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
if err != nil || !found {
@ -134,11 +145,23 @@ WHERE peer_type = $1
// collectible_usernames and must survive every client-driven username edit,
// otherwise account.updateUsername would silently release a minted asset.
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
return replacePeerUsernameTxChecked(ctx, tx, peerType, peerID, username, usernameLower, true)
}
// replacePeerUsernameTxChecked is replacePeerUsernameTx with the operator
// blocklist check optional: the admin-initiated username-set path
// (UserStore.UpdateUsernameAdmin) skips it so an operator can deliberately
// hand a reserved word to a specific account, while every other caller
// (self-service, bots, channel settings, account deletion) always enforces
// it via replacePeerUsernameTx.
func replacePeerUsernameTxChecked(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, checkReserved bool) error {
if usernameLower != "" {
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
return err
} else if reserved {
return domain.ErrUsernameOccupied
if checkReserved {
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
return err
} else if reserved {
return domain.ErrUsernameOccupied
}
}
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
if err != nil {