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

+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