admin: allow assigning a reserved username to a user via the admin console

Add users.Service.UpdateUsernameAdmin, which skips the
config.ReservedUsernames block that self-service UpdateUsername enforces.
Operators need to be able to hand a reserved word to a specific account
even though regular users can't claim it themselves.
This commit is contained in:
Astra 2026-09-15 12:04:26 +01:00
parent 2ffdb1beb5
commit d83034e8dc
4 changed files with 50 additions and 4 deletions

View file

@ -206,7 +206,12 @@ type UsersService interface {
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error)
SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error)
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
// UpdateUsernameAdmin sets a username on the operator's behalf, bypassing
// the config.ReservedUsernames block (see users.Service.UpdateUsernameAdmin) --
// self-service UpdateUsername enforces that list, but an operator
// deliberately assigning a reserved word to an account needs to be able
// to do so.
UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error)
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
@ -1438,7 +1443,7 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
updated, err := s.users.UpdateUsername(ctx, req.UserID, username)
updated, err := s.users.UpdateUsernameAdmin(ctx, req.UserID, username)
if err != nil {
return CommandResult{}, err
}

View file

@ -781,6 +781,10 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
return u, nil
}
func (f *fakeUsersService) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) {
return f.UpdateUsername(ctx, userID, username)
}
func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
u, ok := f.users[userID]
if !ok {

View file

@ -318,8 +318,21 @@ func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, user
return !found || u.ID == selfID, nil
}
// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。
// UpdateUsername 修改当前用户的主 usernameself-service。空字符串表示删除 username。
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
return s.updateUsername(ctx, userID, username, true)
}
// UpdateUsernameAdmin sets a user's username on the operator's behalf (via
// the admin console). Unlike UpdateUsername, it does not consult
// config.ReservedUsernames -- an operator who deliberately reserved a word
// still needs to be able to hand it to a specific account. Validity and
// availability (no collision with another account) are still enforced.
func (s *Service) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) {
return s.updateUsername(ctx, userID, username, false)
}
func (s *Service) updateUsername(ctx context.Context, userID int64, username string, enforceReserved bool) (domain.User, error) {
self, err := s.loadSelf(ctx, userID)
if err != nil {
return domain.User{}, err
@ -334,7 +347,7 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
return s.projectOne(ctx, self.ID, self)
}
if username != "" {
if !validUsername(username) || s.reserved.Contains(username) {
if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) {
return domain.User{}, domain.ErrUsernameInvalid
}
ok, err := s.checkUsernameAvailable(ctx, self.ID, username)

View file

@ -127,6 +127,30 @@ func TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting(t *testing.T) {
}
}
// TestServiceUpdateUsernameAdminBypassesReserved locks in that the admin
// console can deliberately assign a config.ReservedUsernames word to an
// account, even though self-service UpdateUsername refuses the same claim.
func TestServiceUpdateUsernameAdminBypassesReserved(t *testing.T) {
ctx := context.Background()
store := memory.NewUserStore()
target, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000005", FirstName: "Target"})
if err != nil {
t.Fatalf("create target: %v", err)
}
svc := NewService(store, WithReservedUsernames([]string{"admin"}))
if _, err := svc.UpdateUsername(ctx, target.ID, "admin"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("self-service claim of reserved username err = %v, want username invalid", err)
}
u, err := svc.UpdateUsernameAdmin(ctx, target.ID, "admin")
if err != nil {
t.Fatalf("admin claim of reserved username: %v", err)
}
if u.Username != "admin" {
t.Fatalf("admin claim of reserved username: got username %q, want %q", u.Username, "admin")
}
}
// marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser()
// for a fixed username lookup, since memory.UserStore.Create always assigns an
// id from its own auto-increment sequence and can never produce the fixed