feat(bootstrap): первичная настройка инстанса и CLI-команды

- internal/bootstrap: создание инстанс-админа, главного сервера, ролей
  «Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3),
  общей текстовой комнаты; повторный запуск идемпотентен
- админ получает флаг is_instance_admin и системный бейдж instance_admin
- CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin
  (работают внутри контейнера через DATA_DIR)
- auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход
  флага регистрации — bootstrap выполняет установщик)
- тесты: создание админа и главного сервера, идемпотентность, автовступление
  нового пользователя с ролью @user
This commit is contained in:
2026-09-19 21:37:13 +03:00
parent f61751ed26
commit c0537d3c17
6 changed files with 699 additions and 0 deletions
+79
View File
@@ -211,6 +211,85 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
return user, token, session, nil
}
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
return s.userByEmail(ctx, email)
}
// SetPassword меняет пароль без проверки текущего: используется установщиком
// и CLI-командой reset-password, где пароль задаёт администратор инстанса.
func (s *Service) SetPassword(ctx context.Context, userID uint64, password string) error {
if err := s.policy.validate(password); err != nil {
return ErrWeakPassword
}
hash, err := s.hasher.Hash(password)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil {
return err
}
// Смена пароля администратором отзывает все сессии пользователя.
if err := s.store.DeleteSessionsForUser(ctx, userID); err != nil {
return err
}
return s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", `{"by":"admin"}`)
}
// RegisterAdmin создаёт аккаунт администратора инстанса в обход флага
// регистрации: bootstrap выполняется установщиком (AGENT.md 10.3).
func (s *Service) RegisterAdmin(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) {
username := strings.TrimSpace(in.Username)
if err := crypto.ValidateUsername(username); err != nil {
return nil, "", nil, ErrInvalidUsername
}
if err := s.policy.validate(in.Password); err != nil {
return nil, "", nil, ErrWeakPassword
}
email := crypto.NormalizeEmail(in.Email)
if err := validateEmail(email); err != nil {
return nil, "", nil, err
}
passwordHash, err := s.hasher.Hash(in.Password)
if err != nil {
return nil, "", nil, fmt.Errorf("hash password: %w", err)
}
userID := s.store.NextID()
encrypted, err := s.encryptEmail(userID, email)
if err != nil {
return nil, "", nil, err
}
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
ID: userID,
Username: username,
DisplayName: defaultDisplayName(in.DisplayName, username),
EmailEnc: encrypted,
EmailIndex: s.masterKey.BlindIndex(email),
PasswordHash: passwordHash,
Locale: in.Locale,
})
if err != nil {
return nil, "", nil, err
}
if err := s.store.SetInstanceAdmin(ctx, user.ID, true); err != nil {
return nil, "", nil, err
}
if err := s.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil {
s.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err))
}
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
if err != nil {
return nil, "", nil, err
}
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "bootstrap_admin", in.IP, in.UserAgent, "")
user, err = s.store.GetUser(ctx, user.ID)
if err != nil {
return nil, "", nil, err
}
return user, token, session, nil
}
type LoginInput struct {
Email string
Password string