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
}
+152
View File
@@ -0,0 +1,152 @@
package auth
import (
"context"
"pipu/api/internal/api/gen"
)
// ListUsers реализует gen.StrictServerInterface.
func (h *Handler) ListUsers(ctx context.Context, req gen.ListUsersRequestObject) (gen.ListUsersResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
var (
q, cursor string
role *Role
)
if req.Params.Q != nil {
q = *req.Params.Q
}
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
if req.Params.Role != nil {
r := Role(*req.Params.Role)
role = &r
}
users, next, err := h.svc.ListUsers(ctx, p, q, role, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AdminUser, 0, len(users))
for _, u := range users {
items = append(items, AdminUserDTO(u))
}
return gen.ListUsers200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// CreateUser реализует gen.StrictServerInterface.
func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObject) (gen.CreateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
if req.Body.DisplayName != nil {
in.DisplayName = *req.Body.DisplayName
}
u, password, err := h.svc.CreateUser(ctx, p, in)
if err != nil {
return nil, err
}
return gen.CreateUser201JSONResponse{User: AdminUserDTO(u), TemporaryPassword: password}, nil
}
// UpdateUser реализует gen.StrictServerInterface.
func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObject) (gen.UpdateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked}
if req.Body.Role != nil {
r := Role(*req.Body.Role)
patch.Role = &r
}
u, err := h.svc.UpdateUser(ctx, p, req.Id, patch)
if err != nil {
return nil, err
}
return gen.UpdateUser200JSONResponse(AdminUserDTO(u)), nil
}
// DeleteUser реализует gen.StrictServerInterface.
func (h *Handler) DeleteUser(ctx context.Context, req gen.DeleteUserRequestObject) (gen.DeleteUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.DeleteUser(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.DeleteUser204Response{}, nil
}
// ResetUserPassword реализует gen.StrictServerInterface.
func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswordRequestObject) (gen.ResetUserPasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
password, err := h.svc.ResetPassword(ctx, p, req.Id)
if err != nil {
return nil, err
}
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
}
// ListAudit реализует gen.StrictServerInterface.
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
cursor := ""
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
records, next, err := h.svc.ListAudit(ctx, p, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AuditEntry, 0, len(records))
for _, r := range records {
e := gen.AuditEntry{
Id: r.ID, Action: r.Action, TargetType: r.TargetType, TargetId: r.TargetID, CreatedAt: r.CreatedAt,
}
if r.ActorID != nil && r.ActorUsername != nil {
e.Actor = &gen.AuditActor{Id: *r.ActorID, Username: *r.ActorUsername}
}
if r.Payload != nil {
payload := r.Payload
e.Payload = &payload
}
items = append(items, e)
}
return gen.ListAudit200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// AdminUserDTO переводит пользователя в схему AdminUser.
func AdminUserDTO(u User) gen.AdminUser {
return gen.AdminUser{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
LastLoginAt: u.LastLoginAt,
}
}
func optional(s string) *string {
if s == "" {
return nil
}
return &s
}
+379
View File
@@ -0,0 +1,379 @@
package auth_test
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
// signIn входит под пользователем, у которого может стоять must_change_password, и сразу меняет пароль.
func (e *env) signIn(username, password string) string {
e.t.Helper()
r := e.mustLogin(username, password, "web")
if user(r)["must_change_password"] == true {
r = e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"new_password": username + "-own-password"}, bearer(access(r)))
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
}
return access(r)
}
func (e *env) createUser(token, username, role string) resp {
e.t.Helper()
return e.do(http.MethodPost, "/api/v1/admin/users",
map[string]any{"username": username, "role": role, "display_name": " " + username + " "}, bearer(token))
}
// Сценарий 2 из docs/testing.md: админ создаёт модератора, модератор — пользователя,
// но не модератора.
func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
created := e.createUser(admin, "Mod.One", "moderator")
require.Equal(t, http.StatusCreated, created.Code, created.Body.String())
modUser := created.body["user"].(map[string]any)
assert.Equal(t, "mod.one", modUser["username"])
assert.Equal(t, "Mod.One", modUser["display_name"])
assert.Equal(t, true, modUser["must_change_password"])
temp := created.body["temporary_password"].(string)
assert.Len(t, temp, 12)
first := e.mustLogin("mod.one", temp, "web")
assert.Equal(t, auth.CodePasswordChangeRequired,
e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(access(first))).code())
mod := e.signIn("mod.one", temp)
listenerUser := e.createUser(mod, "listener", "user")
require.Equal(t, http.StatusCreated, listenerUser.Code)
for _, role := range []string{"moderator", "admin"} {
r := e.createUser(mod, "sneaky-"+role, role)
assert.Equal(t, http.StatusForbidden, r.Code, role)
assert.Equal(t, auth.CodeForbidden, r.code())
}
list := e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(mod))
require.Equal(t, http.StatusOK, list.Code)
var names []string
for _, it := range list.body["items"].([]any) {
names = append(names, it.(map[string]any)["username"].(string))
}
assert.Equal(t, []string{"listener", "mod.one", "root"}, names)
assert.Nil(t, list.body["next_cursor"])
listener := e.signIn("listener", listenerUser.body["temporary_password"].(string))
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(listener)).code())
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
}
func TestCreateUserValidation(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
assert.Equal(t, auth.CodeUsernameTaken, e.createUser(admin, "ROOT", "user").code())
assert.Equal(t, auth.CodeUsernameInvalid, e.createUser(admin, "no spaces", "user").code())
assert.Equal(t, auth.CodeRoleInvalid, e.createUser(admin, "valid", "root").code())
}
// Матрица «кто кого может» через API: сброс пароля, блокировка, смена роли, удаление.
func TestManageUsersMatrix(t *testing.T) {
type actorCase struct {
role auth.Role
// разрешено ли действие над целью с ролью user / moderator / admin
reset, block, role2, del [3]bool
}
cases := []actorCase{
{auth.RoleModerator, [3]bool{true, false, false}, [3]bool{true, false, false}, [3]bool{false, false, false}, [3]bool{false, false, false}},
{auth.RoleAdmin, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}},
}
targets := [3]auth.Role{auth.RoleUser, auth.RoleModerator, auth.RoleAdmin}
for _, ac := range cases {
for ti, tr := range targets {
actions := []struct {
name string
allowed bool
call func(e *env, tok, id string) resp
}{
{"reset", ac.reset[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodPost, "/api/v1/admin/users/"+id+"/reset-password", nil, bearer(tok))
}},
{"block", ac.block[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok))
}},
{"role", ac.role2[ti], func(e *env, tok, id string) resp {
next := "user"
if tr == auth.RoleUser {
next = "moderator"
}
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": next}, bearer(tok))
}},
{"delete", ac.del[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok))
}},
}
for _, a := range actions {
t.Run(fmt.Sprintf("%s %s %s", ac.role, a.name, tr), func(t *testing.T) {
e := newEnv(t)
e.addUser("actor", "actor-password-1", ac.role)
e.addUser("spare-admin", "spare-password-1", auth.RoleAdmin) // чтобы не упереться в «последнего админа»
target := e.addUser("target", "target-password", tr)
tok := e.signIn("actor", "actor-password-1")
r := a.call(e, tok, target.ID.String())
if a.allowed {
assert.Less(t, r.Code, 300, r.Body.String())
} else {
assert.Equal(t, http.StatusForbidden, r.Code, r.Body.String())
assert.Equal(t, auth.CodeForbidden, r.code())
}
})
}
}
}
}
func TestModeratorCannotTouchSelfOrRoles(t *testing.T) {
e := newEnv(t)
mod := e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
u := e.addUser("listener", "listener-pass", auth.RoleUser)
tok := e.signIn("mod", "moderator-pass-1")
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+mod.ID.String(),
map[string]any{"display_name": "Boss"}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
map[string]any{"display_name": "ok", "role": "moderator"}, bearer(tok)).code(), "one forbidden change rejects the patch")
assert.Nil(t, e.store.Users[u.ID].DisplayName, "rejected patch changes nothing")
ok := e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
map[string]any{"display_name": "Listener", "role": "user"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, "Listener", ok.body["display_name"])
}
func TestAdminCannotDemoteBlockOrDeleteSelf(t *testing.T) {
e := newEnv(t)
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
e.addUser("other", "other-password-1", auth.RoleAdmin)
tok := e.signIn("root", "root-password-1")
id := root.ID.String()
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": "user"}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok)).code())
rename := e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"display_name": "Root"}, bearer(tok))
assert.Equal(t, http.StatusOK, rename.Code)
}
func TestLastAdminIsProtected(t *testing.T) {
// Через API последнего админа не затронуть: себя менять нельзя, а действующий админ — второй.
// Остаётся гонка (админ удалён, пока его запрос в пути) — её и закрывает проверка в сервисе.
e := newEnv(t)
only := e.addUser("root", "root-password-1", auth.RoleAdmin)
ghost := auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin}
ctx := context.Background()
role := auth.RoleUser
blocked := true
for name, call := range map[string]func() error{
"demote": func() error { _, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role}); return err },
"block": func() error {
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{IsBlocked: &blocked})
return err
},
"delete": func() error { return e.svc.DeleteUser(ctx, ghost, only.ID) },
} {
err := call()
var ae *apperr.Error
require.ErrorAs(t, err, &ae, name)
assert.Equal(t, auth.CodeLastAdmin, ae.Code, name)
assert.Equal(t, http.StatusConflict, ae.Status, name)
}
assert.Equal(t, auth.RoleAdmin, e.store.Users[only.ID].Role)
assert.False(t, e.store.Users[only.ID].IsBlocked)
assert.Empty(t, e.store.Audit, "rolled back changes leave no audit")
// Заблокированный админ не считается: второго активного нет.
e.addUser("sleeping", "sleeping-pass-1", auth.RoleAdmin, func(u *auth.User) { u.IsBlocked = true })
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role})
assert.ErrorIs(t, err, apperr.Conflict(auth.CodeLastAdmin, ""))
}
func TestBlockAndRoleChangeRevokeAccess(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
web := e.mustLogin("listener", "listener-pass", "web")
cookie := web.cookie(auth.RefreshCookie)
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"role": "moderator"}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, "moderator", r.body["role"])
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(web))).code())
refreshed := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(cookie))
require.Equal(t, http.StatusOK, refreshed.Code, "role change keeps sessions")
assert.Equal(t, "moderator", user(refreshed)["role"])
tok := access(refreshed)
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": true}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, true, r.body["is_blocked"])
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(refreshed.cookie(auth.RefreshCookie))).code(),
"blocking ends sessions")
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
e.mustLogin("listener", "listener-pass", "web")
var actions []string
for _, a := range e.store.Audit {
actions = append(actions, a.Action)
}
assert.Equal(t, []string{"user.role_change", "user.block", "user.unblock"}, actions)
assert.Equal(t, map[string]any{"from": "user", "to": "moderator"}, e.store.Audit[0].Payload)
}
func TestNoOpPatch(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(),
map[string]any{"role": "user", "is_blocked": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Empty(t, e.store.Audit)
assert.Zero(t, e.store.Users[target.ID].TokenVersion)
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodPatch, "/api/v1/admin/users/"+uuid.NewString(),
map[string]any{"display_name": "x"}, bearer(admin)).code())
}
// Сценарий 10 из docs/testing.md: сброс пароля → обязательная смена при входе.
func TestResetPassword(t *testing.T) {
e := newEnv(t)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
target := e.addUser("listener", "listener-pass", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 9 })
mod := e.signIn("mod", "moderator-pass-1")
phone := e.mustLogin("listener", "listener-pass", "android")
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-password", nil, bearer(mod))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
temp := r.body["temporary_password"].(string)
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(phone))).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
assert.Equal(t, auth.CodeInvalidCredentials, e.login("listener", "listener-pass", "web").code())
again := e.mustLogin("listener", temp, "web")
assert.Equal(t, true, user(again)["must_change_password"])
assert.Equal(t, auth.CodePasswordChangeRequired, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(again))).code())
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
}
func TestDeleteUser(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
tok := access(e.mustLogin("listener", "listener-pass", "web"))
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).Code)
assert.NotContains(t, e.store.Users, target.ID)
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).code())
last := e.store.Audit[len(e.store.Audit)-1]
assert.Equal(t, "user.delete", last.Action)
assert.Equal(t, "listener", last.Payload["username"])
}
func TestListUsersFiltersAndPages(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
for _, n := range []string{"anna", "boris", "vera", "gleb", "dina"} {
e.addUser(n, n+"-password", auth.RoleUser)
}
name := "Анна Каренина"
e.addUser("kate", "kate-password", auth.RoleModerator, func(u *auth.User) { u.DisplayName = &name })
admin := e.signIn("root", "root-password-1")
list := func(q url.Values) resp {
return e.do(http.MethodGet, "/api/v1/admin/users?"+q.Encode(), nil, bearer(admin))
}
usernames := func(r resp) []string {
var out []string
for _, it := range r.body["items"].([]any) {
out = append(out, it.(map[string]any)["username"].(string))
}
return out
}
var (
all []string
cursor string
)
for page := 0; ; page++ {
q := url.Values{"limit": {"3"}}
if cursor != "" {
q.Set("cursor", cursor)
}
r := list(q)
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
all = append(all, usernames(r)...)
next, _ := r.body["next_cursor"].(string)
if next == "" {
break
}
cursor = next
require.Less(t, page, 5)
}
assert.Equal(t, []string{"anna", "boris", "dina", "gleb", "kate", "root", "vera"}, all)
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"q": {"анна"}})), "matches display name, case-insensitive")
assert.Equal(t, []string{"anna"}, usernames(list(url.Values{"q": {"AN"}})))
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"role": {"moderator"}})))
assert.Equal(t, auth.CodeCursorBad, list(url.Values{"cursor": {"%%%"}}).code())
assert.Equal(t, http.StatusBadRequest, list(url.Values{"limit": {"lots"}}).Code)
}
func TestAudit(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
admin := e.signIn("root", "root-password-1")
for _, n := range []string{"u1", "u2", "u3"} {
require.Equal(t, http.StatusCreated, e.createUser(admin, n+"-user", "user").Code)
}
r := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2", nil, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
items := r.body["items"].([]any)
require.Len(t, items, 2)
newest := items[0].(map[string]any)
assert.Equal(t, "user.create", newest["action"])
assert.Equal(t, "u3-user", newest["payload"].(map[string]any)["username"])
assert.Equal(t, "root", newest["actor"].(map[string]any)["username"])
assert.Equal(t, "user", newest["target_type"])
next := r.body["next_cursor"].(string)
r2 := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2&cursor="+next, nil, bearer(admin))
rest := r2.body["items"].([]any)
require.Len(t, rest, 1)
assert.Equal(t, "u1-user", rest[0].(map[string]any)["payload"].(map[string]any)["username"])
assert.Nil(t, r2.body["next_cursor"])
mod := e.signIn("mod", "moderator-pass-1")
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/audit", nil, bearer(mod)).code())
}
+117 -6
View File
@@ -23,6 +23,7 @@ type MemStore struct {
Users map[uuid.UUID]auth.User
Tokens map[uuid.UUID]auth.RefreshToken
Audit []auth.AuditEntry
AuditIDs []uuid.UUID
Settings map[string]any
}
@@ -48,13 +49,16 @@ func (s *MemStore) lock() func() {
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
unlock := s.lock()
defer unlock()
users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings)
tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: s.Settings}
users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
audit, auditIDs := slices.Clone(s.Audit), slices.Clone(s.AuditIDs)
tx := &MemStore{
mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings,
}
if err := fn(tx); err != nil {
s.Users, s.Tokens, s.Audit, s.Settings = users, tokens, audit, settings
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = users, tokens, audit, auditIDs, settings
return err
}
s.Users, s.Tokens, s.Audit, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.Settings
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings
return nil
}
@@ -251,13 +255,120 @@ func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Ti
return out, nil
}
// InsertAudit реализует auth.Store.
func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error {
// ListUsers реализует auth.Store.
func (s *MemStore) ListUsers(_ context.Context, f auth.UserFilter) ([]auth.User, error) {
defer s.lock()()
q := strings.ToLower(f.Query)
var out []auth.User
for _, u := range s.Users {
name := ""
if u.DisplayName != nil {
name = strings.ToLower(*u.DisplayName)
}
if q != "" && !strings.Contains(strings.ToLower(u.Username), q) && !strings.Contains(name, q) {
continue
}
if f.Role != nil && u.Role != *f.Role {
continue
}
if f.After != "" && strings.ToLower(u.Username) <= strings.ToLower(f.After) {
continue
}
out = append(out, cloneUser(u))
}
slices.SortFunc(out, func(a, b auth.User) int {
return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username))
})
if len(out) > f.Limit {
out = out[:f.Limit]
}
return out, nil
}
// LockActiveAdmins реализует auth.Store.
func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s.CountAdmins(ctx) }
// UpdateUser реализует auth.Store.
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
err := s.update(id, func(u *auth.User) {
if upd.DisplayName != nil {
u.DisplayName = *upd.DisplayName
}
if upd.Role != nil {
u.Role = *upd.Role
}
if upd.IsBlocked != nil {
u.IsBlocked = *upd.IsBlocked
}
if upd.RevokeTokens {
u.TokenVersion++
}
})
if err != nil {
return auth.User{}, err
}
return s.GetUserByID(ctx, id)
}
// ResetPassword реализует auth.Store.
func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) error {
return s.update(id, func(u *auth.User) {
u.PasswordHash, u.MustChangePassword = hash, true
u.FailedLoginCount, u.LockedUntil = 0, nil
u.TokenVersion++
})
}
// DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL.
func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error {
defer s.lock()()
delete(s.Users, id)
for tid, t := range s.Tokens {
if t.UserID == id {
delete(s.Tokens, tid)
}
}
for i := range s.Audit {
if s.Audit[i].ActorID != nil && *s.Audit[i].ActorID == id {
s.Audit[i].ActorID = nil
}
}
return nil
}
// InsertAudit реализует auth.Store. ID сохраняется в AuditIDs параллельно Audit.
func (s *MemStore) InsertAudit(_ context.Context, id uuid.UUID, e auth.AuditEntry) error {
defer s.lock()()
s.Audit = append(s.Audit, e)
s.AuditIDs = append(s.AuditIDs, id)
return nil
}
// ListAudit реализует auth.Store.
func (s *MemStore) ListAudit(_ context.Context, before uuid.UUID, limit int) ([]auth.AuditRecord, error) {
defer s.lock()()
var out []auth.AuditRecord
for i := len(s.Audit) - 1; i >= 0 && len(out) < limit; i-- {
id, e := s.AuditIDs[i], s.Audit[i]
if before != uuid.Nil && id.String() >= before.String() {
continue
}
r := auth.AuditRecord{ID: id, ActorID: e.ActorID, Action: e.Action, TargetID: e.TargetID, Payload: e.Payload}
if e.TargetType != "" {
tt := e.TargetType
r.TargetType = &tt
}
if e.ActorID != nil {
if u, ok := s.Users[*e.ActorID]; ok {
name := u.Username
r.ActorUsername = &name
}
}
out = append(out, r)
}
return out, nil
}
// GetSettingBool реализует auth.Store.
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
defer s.lock()()
+12
View File
@@ -95,6 +95,18 @@ type Principal struct {
MustChangeCredentials bool
}
// AuditRecord — прочитанная запись audit_log с логином автора.
type AuditRecord struct {
ID uuid.UUID
ActorID *uuid.UUID
ActorUsername *string
Action string
TargetType *string
TargetID *uuid.UUID
Payload map[string]any
CreatedAt time.Time
}
// AuditEntry — запись audit_log.
type AuditEntry struct {
ActorID *uuid.UUID
+130
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -273,6 +274,135 @@ func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.T
return out, nil
}
// ListUsers реализует Store.
func (s *PgStore) ListUsers(ctx context.Context, f UserFilter) ([]User, error) {
var role *string
if f.Role != nil {
r := string(*f.Role)
role = &r
}
// Спецсимволы LIKE в запросе экранируются: ищем подстроку буквально.
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
rows, err := s.q.Query(ctx, `
SELECT `+userColumns+` FROM users
WHERE ($1 = '%%' OR username ILIKE $1 OR display_name ILIKE $1)
AND ($2::user_role IS NULL OR role = $2::user_role)
AND ($3 = '' OR username > $3::citext)
ORDER BY username
LIMIT $4`, pattern, role, f.After, f.Limit)
if err != nil {
return nil, fmt.Errorf("auth: list users: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("auth: list users: %w", err)
}
return out, nil
}
// LockActiveAdmins реализует Store.
func (s *PgStore) LockActiveAdmins(ctx context.Context) (int, error) {
rows, err := s.q.Query(ctx, `SELECT id FROM users WHERE role = 'admin' AND NOT is_blocked FOR UPDATE`)
if err != nil {
return 0, fmt.Errorf("auth: lock admins: %w", err)
}
defer rows.Close()
n := 0
for rows.Next() {
n++
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("auth: lock admins: %w", err)
}
return n, nil
}
// UpdateUser реализует Store.
func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error) {
var (
setName bool
name *string
role *string
)
if upd.DisplayName != nil {
setName, name = true, *upd.DisplayName
}
if upd.Role != nil {
r := string(*upd.Role)
role = &r
}
return scanUser(s.q.QueryRow(ctx, `
UPDATE users SET
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
role = COALESCE($4::user_role, role),
is_blocked = COALESCE($5, is_blocked),
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
updated_at = now()
WHERE id = $1
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens))
}
// ResetPassword реализует Store.
func (s *PgStore) ResetPassword(ctx context.Context, id uuid.UUID, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET password_hash = $2, must_change_password = true, failed_login_count = 0,
locked_until = NULL, token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, hash)
if err != nil {
return fmt.Errorf("auth: reset password: %w", err)
}
return nil
}
// DeleteUser реализует Store. Токены удаляются каскадно, ссылки в audit_log и created_by обнуляются.
func (s *PgStore) DeleteUser(ctx context.Context, id uuid.UUID) error {
if _, err := s.q.Exec(ctx, `DELETE FROM users WHERE id = $1`, id); err != nil {
return fmt.Errorf("auth: delete user: %w", err)
}
return nil
}
// ListAudit реализует Store.
func (s *PgStore) ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error) {
rows, err := s.q.Query(ctx, `
SELECT a.id, a.actor_id, u.username::text, a.action, a.target_type, a.target_id, a.payload, a.created_at
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
WHERE $1 = '00000000-0000-0000-0000-000000000000'::uuid OR a.id < $1
ORDER BY a.id DESC
LIMIT $2`, before, limit)
if err != nil {
return nil, fmt.Errorf("auth: list audit: %w", err)
}
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (AuditRecord, error) {
var (
r AuditRecord
payload []byte
)
if err := row.Scan(&r.ID, &r.ActorID, &r.ActorUsername, &r.Action, &r.TargetType, &r.TargetID, &payload,
&r.CreatedAt); err != nil {
return r, err
}
if payload != nil {
if err := json.Unmarshal(payload, &r.Payload); err != nil {
return r, err
}
}
return r, nil
})
if err != nil {
return nil, fmt.Errorf("auth: list audit: %w", err)
}
return out, nil
}
// InsertAudit реализует Store.
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
var payload []byte
+29
View File
@@ -21,6 +21,22 @@ type ProfileUpdate struct {
UIPrefs *UIPrefs
}
// UserFilter — параметры списка пользователей.
type UserFilter struct {
Query string // подстрока логина или имени
Role *Role
After string // логин последнего элемента предыдущей страницы
Limit int
}
// UserUpdate — изменения из админки. nil — не менять.
type UserUpdate struct {
DisplayName **string
Role *Role
IsBlocked *bool
RevokeTokens bool
}
// Store — доступ к users, refresh_tokens, audit_log и app_settings.
type Store interface {
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
@@ -49,7 +65,20 @@ type Store interface {
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error)
// ListUsers — страница пользователей по возрастанию логина, после логина f.After.
ListUsers(ctx context.Context, f UserFilter) ([]User, error)
// LockActiveAdmins блокирует строки активных админов до конца транзакции и возвращает их число:
// два параллельных понижения не оставят систему без админа.
LockActiveAdmins(ctx context.Context) (int, error)
// UpdateUser применяет изменения админки; RevokeTokens увеличивает token_version.
UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error)
// ResetPassword ставит временный пароль: must_change_password, сброс блокировки входа, token_version + 1.
ResetPassword(ctx context.Context, id uuid.UUID, hash string) error
DeleteUser(ctx context.Context, id uuid.UUID) error
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
// ListAudit — записи журнала новее-первыми, с id меньше before (uuid.Nil — с начала).
ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error)
GetSettingBool(ctx context.Context, key string) (bool, error)
SetSetting(ctx context.Context, key string, value any) error
}