feat(auth): make upload review a separate grant instead of an admin privilege

Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-21 10:07:05 +03:00
1 parent 066ba24834
commit 74da457362
27 files changed
+503 -319

No files matched your search

+21 -8
View File
@@ -75,9 +75,10 @@ func (s *Service) ListUsers(ctx context.Context, p Principal, query string, role
// CreateUserInput — форма создания пользователя.
type CreateUserInput struct {
Username string
DisplayName string
Role Role
Username string
DisplayName string
Role Role
ReviewsUploads bool
}
// CreateUser создаёт пользователя с временным паролем, который возвращается один раз.
@@ -88,6 +89,9 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
return User{}, "", forbidden()
}
if in.ReviewsUploads && p.Role != RoleAdmin {
return User{}, "", forbidden()
}
username := NormalizeUsername(in.Username)
if err := ValidateUsername("username", username); err != nil {
return User{}, "", err
@@ -109,7 +113,8 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
locale = s.cfg.SupportedLocales[0]
}
u := User{
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, Locale: locale,
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role,
ReviewsUploads: in.ReviewsUploads, Locale: locale,
MustChangePassword: true, CreatedAt: s.clock.Now(),
}
err = s.store.WithTx(ctx, func(tx Store) error {
@@ -118,7 +123,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username, "role": string(in.Role)},
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads},
})
})
if errors.Is(err, ErrUsernameTaken) {
@@ -145,9 +150,10 @@ func displayName(s string) (*string, error) {
// UserPatch — изменения пользователя из админки. nil — не менять.
type UserPatch struct {
DisplayName *string
Role *Role
IsBlocked *bool
DisplayName *string
Role *Role
IsBlocked *bool
ReviewsUploads *bool
}
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
@@ -191,6 +197,13 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
upd.Role, upd.RevokeTokens = patch.Role, true
audit("user.role_change", map[string]any{"from": string(target.Role), "to": string(*patch.Role)})
}
if patch.ReviewsUploads != nil && *patch.ReviewsUploads != target.ReviewsUploads {
if p.Role != RoleAdmin {
return forbidden()
}
upd.ReviewsUploads = patch.ReviewsUploads
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads})
}
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
if blockChange {
if !CanManageUser(actor, tgt, UserBlock) {