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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user