feat(api): user management and audit log for admins (M1)
- /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:
1 parent
ed4a492dba
commit
f075863f15
13 files changed
+2578
-70
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user