feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s

- /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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:18:02 +03:00
1 parent ed4a492dba
commit f075863f15
13 files changed
+2578 -70

No files matched your search

+341
View File
@@ -0,0 +1,341 @@
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
}