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