- /admin/users: list with search, role filter and cursor pages; create with a one-time temporary password; rename, change role, block; reset password; delete - every change is checked against the role policy, a forbidden field rejects the whole patch; the last active admin is locked in the transaction and cannot be demoted, blocked or deleted - role change and blocking revoke access tokens at once; blocking and password reset also end all sessions - /admin/audit: newest-first journal with actor names - path and query parameter errors are problem+json now Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
10 KiB
Go
342 lines
10 KiB
Go
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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
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, 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)},
|
|
})
|
|
})
|
|
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 {
|
|
DisplayName *string
|
|
Role *Role
|
|
IsBlocked *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.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)})
|
|
}
|
|
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
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|