- Introduced CRUD operations for user management, including create, invite, update, disable, and reactivate functionalities. - Enhanced user listing with filtering options based on status and search queries. - Implemented multi-service quota management for users, allowing specification of mail, drive, and photos storage limits. - Added audit log export functionality with validation for format and limit parameters. - Established strict RBAC for admin routes, ensuring proper permission checks for read and write operations. - Updated validation logic for user-related requests and improved error handling across the user management API. - Revised database schema to support new user status and quota fields, along with necessary migrations. - Updated project checklist to reflect the completion of user management and admin RBAC enhancements.
129 lines
4.3 KiB
Go
129 lines
4.3 KiB
Go
package admin
|
|
|
|
import (
|
|
"net/mail"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
|
)
|
|
|
|
const maxQuotaRequestBody = 4 << 10
|
|
|
|
type setQuotaRequest struct {
|
|
MailMaxStorageBytes *int64 `json:"mail_max_storage_bytes"`
|
|
DriveMaxStorageBytes *int64 `json:"drive_max_storage_bytes"`
|
|
PhotosMaxStorageBytes *int64 `json:"photos_max_storage_bytes"`
|
|
}
|
|
|
|
func validateSetQuota(req *setQuotaRequest) *apivalidate.ValidationError {
|
|
if req.MailMaxStorageBytes == nil && req.DriveMaxStorageBytes == nil && req.PhotosMaxStorageBytes == nil {
|
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "quota", Message: "at least one quota field is required"})
|
|
}
|
|
details := make([]apivalidate.FieldDetail, 0, 3)
|
|
if req.MailMaxStorageBytes != nil && *req.MailMaxStorageBytes < 0 {
|
|
details = append(details, apivalidate.FieldDetail{Field: "mail_max_storage_bytes", Message: "must be non-negative"})
|
|
}
|
|
if req.DriveMaxStorageBytes != nil && *req.DriveMaxStorageBytes < 0 {
|
|
details = append(details, apivalidate.FieldDetail{Field: "drive_max_storage_bytes", Message: "must be non-negative"})
|
|
}
|
|
if req.PhotosMaxStorageBytes != nil && *req.PhotosMaxStorageBytes < 0 {
|
|
details = append(details, apivalidate.FieldDetail{Field: "photos_max_storage_bytes", Message: "must be non-negative"})
|
|
}
|
|
if len(details) > 0 {
|
|
return apivalidate.NewValidationError(details...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type createUserRequest struct {
|
|
ExternalID string `json:"external_id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func validateCreateUser(req *createUserRequest) *apivalidate.ValidationError {
|
|
details := make([]apivalidate.FieldDetail, 0, 2)
|
|
if strings.TrimSpace(req.ExternalID) == "" {
|
|
details = append(details, apivalidate.FieldDetail{Field: "external_id", Message: "required"})
|
|
}
|
|
if _, err := mail.ParseAddress(strings.TrimSpace(req.Email)); err != nil {
|
|
details = append(details, apivalidate.FieldDetail{Field: "email", Message: "must be valid"})
|
|
}
|
|
if len(details) > 0 {
|
|
return apivalidate.NewValidationError(details...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type inviteUserRequest struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func validateInviteUser(req *inviteUserRequest) *apivalidate.ValidationError {
|
|
if _, err := mail.ParseAddress(strings.TrimSpace(req.Email)); err != nil {
|
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "email", Message: "must be valid"})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type updateUserRequest struct {
|
|
Email *string `json:"email"`
|
|
Name *string `json:"name"`
|
|
}
|
|
|
|
func validateUpdateUser(req *updateUserRequest) *apivalidate.ValidationError {
|
|
if req.Email == nil && req.Name == nil {
|
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "user", Message: "at least one field is required"})
|
|
}
|
|
if req.Email != nil {
|
|
if _, err := mail.ParseAddress(strings.TrimSpace(*req.Email)); err != nil {
|
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "email", Message: "must be valid"})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateExportFormat(raw string) (string, *apivalidate.ValidationError) {
|
|
format := strings.TrimSpace(raw)
|
|
if format == "" {
|
|
return "ndjson", nil
|
|
}
|
|
if !slices.Contains([]string{"ndjson", "csv"}, format) {
|
|
return "", apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "format", Message: "must be one of: ndjson,csv"})
|
|
}
|
|
return format, nil
|
|
}
|
|
|
|
func validateExportLimit(raw string) (int, *apivalidate.ValidationError) {
|
|
val := strings.TrimSpace(raw)
|
|
if val == "" {
|
|
return 5000, nil
|
|
}
|
|
limit, err := strconv.Atoi(val)
|
|
if err != nil || limit < 1 || limit > 10000 {
|
|
return 0, apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "limit", Message: "must be between 1 and 10000"})
|
|
}
|
|
return limit, nil
|
|
}
|
|
|
|
func validateStatus(raw string) (string, *apivalidate.ValidationError) {
|
|
status := strings.ToLower(strings.TrimSpace(raw))
|
|
if status == "" {
|
|
return "", nil
|
|
}
|
|
if !slices.Contains([]string{"active", "disabled", "invited"}, status) {
|
|
return "", apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "status", Message: "must be one of: active,disabled,invited"})
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func validateUserID(userID string) *apivalidate.ValidationError {
|
|
if strings.TrimSpace(userID) == "" {
|
|
return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "userID", Message: "required"})
|
|
}
|
|
return nil
|
|
}
|