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

+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()()