Files
pipu-music/services/api/internal/auth/transfer.go
T
mrleo1nid f1528de948
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
feat(admin): transfer user accounts and show local cover coverage
2026-10-23 02:17:06 +03:00

140 lines
4.4 KiB
Go

package auth
import (
"context"
"errors"
"pipu/api/internal/platform/apperr"
)
const MaxArchivedUsers = 10000
const MaxUserArchiveBytes = 16 << 20
// ArchivedUser is deliberately limited to identity, credentials and access rights.
// IDs, sessions and personal music data never cross the transfer boundary.
type ArchivedUser struct {
Username string
DisplayName *string
Role Role
PasswordHash string
IsBlocked bool
MustChangePassword bool
MustChangeCredentials bool
ReviewsUploads bool
ManagesUpdates bool
}
func archiveInvalid() error {
return apperr.Validation("file", "validation.user_archive_invalid", "invalid user archive", nil)
}
func (s *Service) ExportUsers(ctx context.Context, p Principal) ([]ArchivedUser, error) {
if p.Role != RoleAdmin {
return nil, forbidden()
}
out := make([]ArchivedUser, 0)
err := s.store.WithTx(ctx, func(tx Store) error {
after := ""
for {
users, err := tx.ListUsers(ctx, UserFilter{After: after, Limit: 200})
if err != nil {
return err
}
if len(users) == 0 {
break
}
for _, u := range users {
if len(out) == MaxArchivedUsers {
return archiveInvalid()
}
out = append(out, ArchivedUser{
Username: u.Username, DisplayName: u.DisplayName, Role: u.Role, PasswordHash: u.PasswordHash,
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials, ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
})
}
after = users[len(users)-1].Username
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "users.export", TargetType: "users", Payload: map[string]any{"count": len(out)},
})
})
return out, err
}
// ImportUsers creates all missing accounts in one transaction. Existing accounts
// retain their passwords, roles, sessions and personal music data.
func (s *Service) ImportUsers(ctx context.Context, p Principal, version int, entries []ArchivedUser) (int, []string, error) {
if p.Role != RoleAdmin {
return 0, nil, forbidden()
}
if version != 1 || len(entries) > MaxArchivedUsers {
return 0, nil, archiveInvalid()
}
seen := make(map[string]bool, len(entries))
for _, a := range entries {
username := NormalizeUsername(a.Username)
if username != a.Username || ValidateUsername("username", username) != nil || seen[username] || !a.Role.Valid() {
return 0, nil, archiveInvalid()
}
seen[username] = true
if a.DisplayName != nil {
if _, err := displayName(*a.DisplayName); err != nil {
return 0, nil, archiveInvalid()
}
}
if len(a.PasswordHash) > 512 {
return 0, nil, archiveInvalid()
}
params, salt, key, err := decodePHC(a.PasswordHash)
// Imported PHC parameters are used on the next login; bound CPU and RAM
// before accepting them, without performing expensive password hashing.
if err != nil || params.Threads == 0 || params.Threads > 16 || params.Time == 0 || params.Time > 10 ||
params.MemoryKiB < 8*uint32(params.Threads) || params.MemoryKiB > 256*1024 ||
len(salt) < 8 || len(salt) > 64 || len(key) < 16 || len(key) > 64 {
return 0, nil, archiveInvalid()
}
}
created := 0
skipped := make([]string, 0)
err := s.store.WithTx(ctx, func(tx Store) error {
for _, a := range entries {
_, err := tx.GetUserByUsername(ctx, a.Username)
if err == nil {
skipped = append(skipped, a.Username)
continue
}
if !errors.Is(err, ErrNotFound) {
return err
}
locale := "ru"
if len(s.cfg.SupportedLocales) > 0 {
locale = s.cfg.SupportedLocales[0]
}
u := User{
ID: s.ids.New(), Username: a.Username, DisplayName: a.DisplayName, PasswordHash: a.PasswordHash,
Role: a.Role, Locale: locale, CreatedAt: s.clock.Now(), MustChangePassword: a.MustChangePassword,
MustChangeCredentials: a.MustChangeCredentials, ReviewsUploads: a.ReviewsUploads, ManagesUpdates: a.ManagesUpdates,
}
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
return err
}
if a.IsBlocked {
blocked := true
if _, err := tx.UpdateUser(ctx, u.ID, UserUpdate{IsBlocked: &blocked}); err != nil {
return err
}
}
created++
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "users.import", TargetType: "users",
Payload: map[string]any{"created": created, "skipped": len(skipped)},
})
})
if err != nil {
return 0, nil, err
}
return created, skipped, nil
}