ultisuite-backend/internal/api/admin/service.go
R3D347HR4Y e10e60fc9e Implement comprehensive user management and admin RBAC features
- 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.
2026-05-22 22:41:58 +02:00

583 lines
16 KiB
Go

package admin
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/query"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
var ErrNotFound = errors.New("not found")
type Service struct {
db *pgxpool.Pool
audit *securityaudit.Logger
logger *slog.Logger
}
func NewService(db *pgxpool.Pool, audit *securityaudit.Logger) *Service {
return &Service{
db: db,
audit: audit,
logger: slog.Default().With("component", "admin-service"),
}
}
type UsersList struct {
Users []map[string]any `json:"users"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
type UserFilter struct {
Status string
Q string
}
func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter UserFilter) (UsersList, error) {
whereSQL, args := buildUserFilter(filter)
var total int64
totalSQL := "SELECT COUNT(*) FROM users" + whereSQL
if err := s.db.QueryRow(ctx, totalSQL, args...).Scan(&total); err != nil {
return UsersList{}, err
}
listSQL := `
SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at
FROM users` + whereSQL + `
ORDER BY created_at DESC
LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
args = append(args, params.Limit(), params.Offset())
rows, err := s.db.Query(ctx, listSQL, args...)
if err != nil {
return UsersList{}, err
}
defer rows.Close()
users := make([]map[string]any, 0)
for rows.Next() {
var id, extID, email, name, status string
var invitedAt, disabledAt *time.Time
var createdAt, updatedAt time.Time
if err := rows.Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil {
return UsersList{}, err
}
users = append(users, map[string]any{
"id": id,
"external_id": extID,
"email": email,
"name": name,
"status": status,
"invited_at": invitedAt,
"disabled_at": disabledAt,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
if err := rows.Err(); err != nil {
return UsersList{}, err
}
return UsersList{
Users: users,
Pagination: params.Meta(&total),
}, nil
}
func buildUserFilter(filter UserFilter) (string, []any) {
clauses := make([]string, 0, 2)
args := make([]any, 0, 2)
if strings.TrimSpace(filter.Status) != "" {
args = append(args, strings.TrimSpace(filter.Status))
clauses = append(clauses, "status = $"+strconv.Itoa(len(args)))
}
if strings.TrimSpace(filter.Q) != "" {
q := "%" + strings.ToLower(strings.TrimSpace(filter.Q)) + "%"
args = append(args, q)
idx := strconv.Itoa(len(args))
clauses = append(clauses, "(LOWER(email) LIKE $"+idx+" OR LOWER(name) LIKE $"+idx+" OR LOWER(external_id) LIKE $"+idx+")")
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) {
var id, extID, email, name, status string
var invitedAt, disabledAt *time.Time
var createdAt, updatedAt time.Time
err := s.db.QueryRow(ctx, `
SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at
FROM users WHERE id = $1
`, userID).Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
var mailCount, mailUsedStorage int64
if err := s.db.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(COALESCE(m.raw_size, 0)), 0)
FROM messages m
JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = $1
`, userID).Scan(&mailCount, &mailUsedStorage); err != nil {
return nil, err
}
var mailMax, driveMax, photosMax int64
if err := s.db.QueryRow(ctx, `
SELECT
COALESCE((preferences->>'mail_max_storage_bytes')::bigint, 5368709120),
COALESCE((preferences->>'drive_max_storage_bytes')::bigint, 5368709120),
COALESCE((preferences->>'photos_max_storage_bytes')::bigint, 5368709120)
FROM settings WHERE user_id = $1
`, userID).Scan(&mailMax, &driveMax, &photosMax); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
return map[string]any{
"id": id,
"external_id": extID,
"email": email,
"name": name,
"status": status,
"invited_at": invitedAt,
"disabled_at": disabledAt,
"created_at": createdAt,
"updated_at": updatedAt,
"quota": map[string]any{
"mail": map[string]any{
"count": mailCount,
"used_storage_bytes": mailUsedStorage,
"max_storage_bytes": mailMax,
},
"drive": map[string]any{
"used_storage_bytes": int64(0),
"max_storage_bytes": driveMax,
},
"photos": map[string]any{
"used_storage_bytes": int64(0),
"max_storage_bytes": photosMax,
},
},
}, nil
}
func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUserRequest) (map[string]any, error) {
var id string
if err := s.db.QueryRow(ctx, `
INSERT INTO users (external_id, email, name, status)
VALUES ($1, $2, $3, 'active')
RETURNING id
`, strings.TrimSpace(req.ExternalID), strings.ToLower(strings.TrimSpace(req.Email)), strings.TrimSpace(req.Name)).Scan(&id); err != nil {
return nil, err
}
s.logAudit(ctx, actorSub, "create_user", map[string]any{
"target_user": id,
"external_id": strings.TrimSpace(req.ExternalID),
"email": strings.ToLower(strings.TrimSpace(req.Email)),
"initial_name": strings.TrimSpace(req.Name),
})
return s.GetUser(ctx, id)
}
func (s *Service) InviteUser(ctx context.Context, actorSub string, req inviteUserRequest) (map[string]any, error) {
var id string
inviteExternalID := "invite:" + uuid.NewString()
if err := s.db.QueryRow(ctx, `
INSERT INTO users (external_id, email, name, status, invited_at)
VALUES ($1, $2, $3, 'invited', NOW())
RETURNING id
`, inviteExternalID, strings.ToLower(strings.TrimSpace(req.Email)), strings.TrimSpace(req.Name)).Scan(&id); err != nil {
return nil, err
}
s.logAudit(ctx, actorSub, "invite_user", map[string]any{
"target_user": id,
"email": strings.ToLower(strings.TrimSpace(req.Email)),
})
return s.GetUser(ctx, id)
}
func (s *Service) UpdateUser(ctx context.Context, actorSub, userID string, req updateUserRequest) (map[string]any, error) {
result, err := s.db.Exec(ctx, `
UPDATE users
SET email = COALESCE($2, email),
name = COALESCE($3, name),
updated_at = NOW()
WHERE id = $1
`, userID, trimStringPtr(req.Email), trimStringPtr(req.Name))
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, ErrNotFound
}
s.logAudit(ctx, actorSub, "update_user", map[string]any{
"target_user": userID,
})
return s.GetUser(ctx, userID)
}
func trimStringPtr(v *string) *string {
if v == nil {
return nil
}
s := strings.TrimSpace(*v)
return &s
}
func (s *Service) SetQuota(ctx context.Context, actorSub, userID string, req setQuotaRequest) error {
exists, err := s.userExists(ctx, userID)
if err != nil {
return err
}
if !exists {
return ErrNotFound
}
_, err = s.db.Exec(ctx, `
INSERT INTO settings (user_id, preferences)
VALUES (
$1,
jsonb_strip_nulls(jsonb_build_object(
'mail_max_storage_bytes', $2::text,
'drive_max_storage_bytes', $3::text,
'photos_max_storage_bytes', $4::text
))
)
ON CONFLICT (user_id) DO UPDATE SET
preferences = settings.preferences || jsonb_strip_nulls(jsonb_build_object(
'mail_max_storage_bytes', $2::text,
'drive_max_storage_bytes', $3::text,
'photos_max_storage_bytes', $4::text
)),
updated_at = NOW()
`, userID, req.MailMaxStorageBytes, req.DriveMaxStorageBytes, req.PhotosMaxStorageBytes)
if err != nil {
return err
}
s.logAudit(ctx, actorSub, "set_quota", map[string]any{
"target_user": userID,
"mail_max_storage_bytes": req.MailMaxStorageBytes,
"drive_max_storage_bytes": req.DriveMaxStorageBytes,
"photos_max_storage_bytes": req.PhotosMaxStorageBytes,
"multi_service_quota_patch": true,
})
return nil
}
func (s *Service) DisableUser(ctx context.Context, actorSub, userID string) error {
result, err := s.db.Exec(ctx, `
UPDATE users
SET status = 'disabled', disabled_at = NOW(), updated_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
s.logAudit(ctx, actorSub, "disable_user", map[string]any{"target_user": userID})
return nil
}
func (s *Service) ReactivateUser(ctx context.Context, actorSub, userID string) error {
result, err := s.db.Exec(ctx, `
UPDATE users
SET status = 'active', disabled_at = NULL, updated_at = NOW()
WHERE id = $1
`, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
s.logAudit(ctx, actorSub, "reactivate_user", map[string]any{"target_user": userID})
return nil
}
func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error {
result, err := s.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
s.logAudit(ctx, actorSub, "delete_user", map[string]any{"target_user": userID})
return nil
}
func (s *Service) userExists(ctx context.Context, userID string) (bool, error) {
var exists bool
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
type AuditLogsList struct {
Logs []map[string]any `json:"logs"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (AuditLogsList, error) {
var total int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&total); err != nil {
return AuditLogsList{}, err
}
rows, err := s.db.Query(ctx, `
SELECT id, actor, action, details, created_at
FROM audit_logs ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, params.Limit(), params.Offset())
if err != nil {
return AuditLogsList{}, err
}
defer rows.Close()
logs := make([]map[string]any, 0)
for rows.Next() {
var id, actor, action string
var details []byte
var createdAt time.Time
if err := rows.Scan(&id, &actor, &action, &details, &createdAt); err != nil {
return AuditLogsList{}, err
}
logs = append(logs, map[string]any{
"id": id,
"actor": actor,
"action": action,
"details": json.RawMessage(details),
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
return AuditLogsList{}, err
}
return AuditLogsList{
Logs: logs,
Pagination: params.Meta(&total),
}, nil
}
type AuditExport struct {
Content []byte
ContentType string
FileName string
}
func (s *Service) ExportAuditLogs(ctx context.Context, format string, limit int) (AuditExport, error) {
rows, err := s.db.Query(ctx, `
SELECT id, actor, action, details, created_at
FROM audit_logs
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil {
return AuditExport{}, err
}
defer rows.Close()
type rowItem struct {
ID string
Actor string
Action string
Details []byte
CreatedAt time.Time
}
items := make([]rowItem, 0, limit)
for rows.Next() {
var item rowItem
if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.Details, &item.CreatedAt); err != nil {
return AuditExport{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return AuditExport{}, err
}
now := time.Now().UTC().Format("20060102T150405Z")
switch format {
case "csv":
buf := &bytes.Buffer{}
w := csv.NewWriter(buf)
if err := w.Write([]string{"id", "actor", "action", "details_json", "created_at"}); err != nil {
return AuditExport{}, err
}
for _, item := range items {
if err := w.Write([]string{
item.ID, item.Actor, item.Action, string(item.Details), item.CreatedAt.UTC().Format(time.RFC3339),
}); err != nil {
return AuditExport{}, err
}
}
w.Flush()
if err := w.Error(); err != nil {
return AuditExport{}, err
}
return AuditExport{
Content: buf.Bytes(),
ContentType: "text/csv; charset=utf-8",
FileName: fmt.Sprintf("audit-%s.csv", now),
}, nil
default:
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
for _, item := range items {
entry := map[string]any{
"id": item.ID,
"actor": item.Actor,
"action": item.Action,
"details": json.RawMessage(item.Details),
"created_at": item.CreatedAt.UTC().Format(time.RFC3339),
}
if err := enc.Encode(entry); err != nil {
return AuditExport{}, err
}
}
return AuditExport{
Content: buf.Bytes(),
ContentType: "application/x-ndjson; charset=utf-8",
FileName: fmt.Sprintf("audit-%s.ndjson", now),
}, nil
}
}
func (s *Service) GetStats(ctx context.Context) (map[string]any, error) {
stats := map[string]any{}
var totalUsers, activeUsers, disabledUsers, invitedUsers int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers); err != nil {
return nil, err
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'active'`).Scan(&activeUsers); err != nil {
return nil, err
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'disabled'`).Scan(&disabledUsers); err != nil {
return nil, err
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE status = 'invited'`).Scan(&invitedUsers); err != nil {
return nil, err
}
var mailCount, accountCount, audit24h int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM messages`).Scan(&mailCount); err != nil {
return nil, err
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM mail_accounts`).Scan(&accountCount); err != nil {
return nil, err
}
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs WHERE created_at >= NOW() - INTERVAL '24 hour'`).Scan(&audit24h); err != nil {
return nil, err
}
type topActor struct {
Actor string `json:"actor"`
Count int64 `json:"count"`
}
topActors := make([]topActor, 0, 5)
rows, err := s.db.Query(ctx, `
SELECT actor, COUNT(*) as c
FROM audit_logs
WHERE created_at >= NOW() - INTERVAL '7 day'
GROUP BY actor
ORDER BY c DESC
LIMIT 5
`)
if err != nil {
return nil, err
}
for rows.Next() {
var item topActor
if err := rows.Scan(&item.Actor, &item.Count); err != nil {
rows.Close()
return nil, err
}
topActors = append(topActors, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
var usersNearMailQuota int64
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM (
SELECT
u.id,
COALESCE(SUM(COALESCE(m.raw_size, 0)), 0) AS used_storage,
COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, 5368709120) AS max_storage
FROM users u
LEFT JOIN mail_accounts ma ON ma.user_id = u.id
LEFT JOIN messages m ON m.account_id = ma.id
LEFT JOIN settings s ON s.user_id = u.id
GROUP BY u.id, s.preferences
) q
WHERE q.max_storage > 0 AND q.used_storage::numeric / q.max_storage::numeric >= 0.9
`).Scan(&usersNearMailQuota); err != nil {
return nil, err
}
stats["users"] = map[string]any{
"total": totalUsers,
"active": activeUsers,
"disabled": disabledUsers,
"invited": invitedUsers,
}
stats["services"] = map[string]any{
"mail_accounts_total": accountCount,
"messages_total": mailCount,
"audit_events_24h": audit24h,
}
stats["quotas"] = map[string]any{
"users_near_mail_quota_90pct": usersNearMailQuota,
}
stats["audit"] = map[string]any{
"top_actors_7d": topActors,
}
return stats, nil
}
func (s *Service) logAudit(ctx context.Context, actor, action string, details map[string]any) {
if s.audit == nil {
return
}
if action == "delete_user" {
s.audit.Log(ctx, actor, securityaudit.ActionCriticalDeletion, map[string]any{
"target": "user",
"target_user": details["target_user"],
"admin_action": true,
})
return
}
s.audit.Log(ctx, actor, securityaudit.ActionAdminAction, map[string]any{
"action": action,
"details": details,
})
}