package auth import ( "context" "encoding/base64" "errors" "strings" "unicode/utf8" "github.com/google/uuid" "pipu/api/internal/platform/apperr" ) // Коды ошибок админки пользователей. const ( CodeForbidden = "auth.forbidden" CodeUserNotFound = "users.not_found" CodeLastAdmin = "users.last_admin" CodeRoleInvalid = "validation.role_invalid" CodeCursorBad = "validation.cursor_invalid" ) // Длина временного пароля (создание пользователя, сброс). const tempPasswordLen = 12 // Пределы размера страницы. const ( defaultPageSize = 50 maxPageSize = 200 ) func forbidden() error { return apperr.Forbidden(CodeForbidden, "not allowed") } func pageSize(limit *int) int { if limit == nil || *limit <= 0 { return defaultPageSize } return min(*limit, maxPageSize) } func actorOf(p Principal) Actor { return Actor{ID: p.UserID, Role: p.Role} } func actorOfUser(u User) Actor { return Actor{ID: u.ID, Role: u.Role} } // ListUsers — страница пользователей для админки (admin, moderator). func (s *Service) ListUsers(ctx context.Context, p Principal, query string, role *Role, cursor string, limit *int, ) ([]User, string, error) { if !Can(p.Role, PermListUsers) { return nil, "", forbidden() } if role != nil && !role.Valid() { return nil, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil) } after := "" if cursor != "" { raw, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil) } after = string(raw) } n := pageSize(limit) users, err := s.store.ListUsers(ctx, UserFilter{Query: strings.TrimSpace(query), Role: role, After: after, Limit: n + 1}) if err != nil { return nil, "", err } next := "" if len(users) > n { users = users[:n] next = base64.RawURLEncoding.EncodeToString([]byte(users[n-1].Username)) } return users, next, nil } // CreateUserInput — форма создания пользователя. type CreateUserInput struct { Username string DisplayName string Role Role ManagesUpdates bool ReviewsUploads bool } // CreateUser создаёт пользователя с временным паролем, который возвращается один раз. func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInput) (User, string, error) { if !in.Role.Valid() { return User{}, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil) } if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) { return User{}, "", forbidden() } if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin { return User{}, "", forbidden() } username := NormalizeUsername(in.Username) if err := ValidateUsername("username", username); err != nil { return User{}, "", err } name, err := displayName(in.DisplayName) if err != nil { return User{}, "", err } password, err := GeneratePassword(tempPasswordLen) if err != nil { return User{}, "", err } hash, err := s.hasher.Hash(password) if err != nil { return User{}, "", err } locale := "ru" if len(s.cfg.SupportedLocales) > 0 { locale = s.cfg.SupportedLocales[0] } u := User{ ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, ReviewsUploads: in.ReviewsUploads, ManagesUpdates: in.ManagesUpdates, Locale: locale, MustChangePassword: true, CreatedAt: s.clock.Now(), } err = s.store.WithTx(ctx, func(tx Store) error { if err := tx.CreateUser(ctx, u, &p.UserID); err != nil { return err } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID, Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads, "manages_updates": in.ManagesUpdates}, }) }) if errors.Is(err, ErrUsernameTaken) { return User{}, "", apperr.Validation("username", CodeUsernameTaken, "username is taken", nil) } if err != nil { return User{}, "", err } return u, password, nil } // displayName обрезает пробелы и проверяет длину; пустое имя — nil. func displayName(s string) (*string, error) { s = strings.TrimSpace(s) if utf8.RuneCountInString(s) > MaxDisplayName { return nil, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long", map[string]any{"max": MaxDisplayName}) } if s == "" { return nil, nil } return &s, nil } // UserPatch — изменения пользователя из админки. nil — не менять. type UserPatch struct { Username *string DisplayName *string Role *Role IsBlocked *bool ManagesUpdates *bool ReviewsUploads *bool } // UpdateUser меняет логин, имя, роль и блокировку с проверкой прав по каждому изменению. func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) { if patch.Role != nil && !patch.Role.Valid() { return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil) } var out User err := s.store.WithTx(ctx, func(tx Store) error { target, err := s.target(ctx, tx, id) if err != nil { return err } actor, tgt := actorOf(p), actorOfUser(target) var ( upd UserUpdate audits []AuditEntry ) audit := func(action string, payload map[string]any) { audits = append(audits, AuditEntry{ ActorID: &p.UserID, Action: action, TargetType: "user", TargetID: &target.ID, Payload: payload, }) } if patch.Username != nil { if p.Role != RoleAdmin { return forbidden() } username := NormalizeUsername(*patch.Username) if err := ValidateUsername("username", username); err != nil { return err } if username != target.Username { upd.Username = &username audit("user.username_change", map[string]any{"from": target.Username, "to": username}) } } if patch.DisplayName != nil { if !CanManageUser(actor, tgt, UserEdit) { return forbidden() } name, err := displayName(*patch.DisplayName) if err != nil { return err } upd.DisplayName = &name audit("user.rename", map[string]any{"display_name": name}) } roleChange := patch.Role != nil && *patch.Role != target.Role if roleChange { if !CanManageUser(actor, tgt, UserChangeRole) || !CanAssignRole(p.Role, *patch.Role) { return forbidden() } upd.Role, upd.RevokeTokens = patch.Role, true audit("user.role_change", map[string]any{"from": string(target.Role), "to": string(*patch.Role)}) } if patch.ReviewsUploads != nil && *patch.ReviewsUploads != target.ReviewsUploads { if p.Role != RoleAdmin { return forbidden() } upd.ReviewsUploads = patch.ReviewsUploads audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads}) } if patch.ManagesUpdates != nil { if p.Role != RoleAdmin { return forbidden() } if *patch.ManagesUpdates != target.ManagesUpdates { upd.ManagesUpdates = patch.ManagesUpdates audit("user.manages_updates", map[string]any{"manages_updates": *patch.ManagesUpdates}) } } blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked if blockChange { if !CanManageUser(actor, tgt, UserBlock) { return forbidden() } upd.IsBlocked, upd.RevokeTokens = patch.IsBlocked, true if *patch.IsBlocked { audit("user.block", nil) } else { audit("user.unblock", nil) } } if len(audits) == 0 { // Пустое изменение: отвечаем текущим состоянием, но только тем, кому оно доступно. if !CanManageUser(actor, tgt, UserEdit) { return forbidden() } out = target return nil } losesAdmin := target.Role == RoleAdmin && !target.IsBlocked && ((roleChange && *patch.Role != RoleAdmin) || (blockChange && *patch.IsBlocked)) if losesAdmin { if err := s.ensureNotLastAdmin(ctx, tx); err != nil { return err } } if out, err = tx.UpdateUser(ctx, target.ID, upd); err != nil { return err } if blockChange && *patch.IsBlocked { if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil { return err } } for _, a := range audits { if err := tx.InsertAudit(ctx, s.ids.New(), a); err != nil { return err } } return nil }) if errors.Is(err, ErrUsernameTaken) { return User{}, apperr.Validation("username", CodeUsernameTaken, "username is taken", nil) } return out, err } func (s *Service) target(ctx context.Context, st Store, id uuid.UUID) (User, error) { u, err := st.GetUserByID(ctx, id) if errors.Is(err, ErrNotFound) { return User{}, apperr.NotFound(CodeUserNotFound, "user not found") } return u, err } func (s *Service) ensureNotLastAdmin(ctx context.Context, tx Store) error { n, err := tx.LockActiveAdmins(ctx) if err != nil { return err } if n <= 1 { return apperr.Conflict(CodeLastAdmin, "the last active admin cannot be demoted, blocked or deleted") } return nil } // ResetPassword выдаёт новый временный пароль и завершает все сессии пользователя. func (s *Service) ResetPassword(ctx context.Context, p Principal, id uuid.UUID) (string, error) { password, err := GeneratePassword(tempPasswordLen) if err != nil { return "", err } hash, err := s.hasher.Hash(password) if err != nil { return "", err } err = s.store.WithTx(ctx, func(tx Store) error { target, err := s.target(ctx, tx, id) if err != nil { return err } if !CanManageUser(actorOf(p), actorOfUser(target), UserResetPassword) { return forbidden() } if err := tx.ResetPassword(ctx, target.ID, hash); err != nil { return err } if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil { return err } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "user.password_reset", TargetType: "user", TargetID: &target.ID, }) }) if err != nil { return "", err } return password, nil } // ResetRecommendations начинает рекомендации пользователя с нуля; онбординг покажется снова. func (s *Service) ResetRecommendations(ctx context.Context, p Principal, id uuid.UUID) error { return s.store.WithTx(ctx, func(tx Store) error { target, err := s.target(ctx, tx, id) if err != nil { return err } if !CanManageUser(actorOf(p), actorOfUser(target), UserResetRecommendations) { return forbidden() } if err := tx.ResetRecommendations(ctx, target.ID); err != nil { return err } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "user.recommendations_reset", TargetType: "user", TargetID: &target.ID, }) }) } // DeleteUser удаляет пользователя (только админ, не себя и не последнего админа). func (s *Service) DeleteUser(ctx context.Context, p Principal, id uuid.UUID) error { return s.store.WithTx(ctx, func(tx Store) error { target, err := s.target(ctx, tx, id) if err != nil { return err } if !CanManageUser(actorOf(p), actorOfUser(target), UserDelete) { return forbidden() } if target.Role == RoleAdmin && !target.IsBlocked { if err := s.ensureNotLastAdmin(ctx, tx); err != nil { return err } } if err := tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "user.delete", TargetType: "user", TargetID: &target.ID, Payload: map[string]any{"username": target.Username, "role": string(target.Role)}, }); err != nil { return err } return tx.DeleteUser(ctx, target.ID) }) } // ListAudit — страница журнала действий (только админ). func (s *Service) ListAudit(ctx context.Context, p Principal, cursor string, limit *int) ([]AuditRecord, string, error) { if !Can(p.Role, PermSystemStatus) { return nil, "", forbidden() } before := uuid.Nil if cursor != "" { var err error if before, err = uuid.Parse(cursor); err != nil { return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil) } } n := pageSize(limit) records, err := s.store.ListAudit(ctx, before, n+1) if err != nil { return nil, "", err } next := "" if len(records) > n { records = records[:n] next = records[n-1].ID.String() } return records, next, nil }