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