feat(admin): transfer user accounts and show local cover coverage
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
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
This commit is contained in:
1 parent
0a4cd4bff1
commit
f1528de948
22 files changed
+1661
-313
No files matched your search
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user