894 lines
24 KiB
Go
894 lines
24 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/config"
|
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
|
"github.com/ultisuite/ulti-backend/internal/permission"
|
|
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
|
platformusers "github.com/ultisuite/ulti-backend/internal/users"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
type Service struct {
|
|
db *pgxpool.Pool
|
|
audit *securityaudit.Logger
|
|
cfg *config.Config
|
|
nc *nextcloud.Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, cfg *config.Config, nc *nextcloud.Client) *Service {
|
|
return &Service{
|
|
db: db,
|
|
audit: audit,
|
|
cfg: cfg,
|
|
nc: nc,
|
|
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
|
|
Role 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, platform_admin, 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() {
|
|
user, err := scanUserRow(rows)
|
|
if err != nil {
|
|
return UsersList{}, err
|
|
}
|
|
users = append(users, user)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return UsersList{}, err
|
|
}
|
|
if err := s.attachUsersStorage(ctx, users); err != nil {
|
|
return UsersList{}, err
|
|
}
|
|
|
|
return UsersList{
|
|
Users: users,
|
|
Pagination: params.Meta(&total),
|
|
}, nil
|
|
}
|
|
|
|
func buildUserFilter(filter UserFilter) (string, []any) {
|
|
clauses := make([]string, 0, 3)
|
|
args := make([]any, 0, 3)
|
|
if role := strings.TrimSpace(filter.Role); role != "" {
|
|
switch permission.AccountRole(role) {
|
|
case permission.AccountRoleAdmin:
|
|
clauses = append(clauses, "platform_admin = true AND status = 'active'")
|
|
case permission.AccountRoleUser:
|
|
clauses = append(clauses, "platform_admin = false AND status = 'active'")
|
|
case permission.AccountRoleGuest:
|
|
clauses = append(clauses, "status = 'invited'")
|
|
case permission.AccountRoleSuspended:
|
|
clauses = append(clauses, "status = 'disabled'")
|
|
}
|
|
}
|
|
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) {
|
|
row := s.db.QueryRow(ctx, `
|
|
SELECT id, external_id, email, name, status, platform_admin, invited_at, disabled_at, created_at, updated_at
|
|
FROM users WHERE id = $1
|
|
`, userID)
|
|
user, err := scanUserRow(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
mailCount, mailUsedStorage, err := s.mailUsageForUser(ctx, userID)
|
|
if 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
|
|
}
|
|
|
|
email, _ := user["email"].(string)
|
|
extID, _ := user["external_id"].(string)
|
|
driveUsedStorage := s.driveUsageForUser(ctx, email, extID)
|
|
|
|
user["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": driveUsedStorage,
|
|
"max_storage_bytes": driveMax,
|
|
},
|
|
"photos": map[string]any{
|
|
"used_storage_bytes": int64(0),
|
|
"max_storage_bytes": photosMax,
|
|
},
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
type userRowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanUserRow(row userRowScanner) (map[string]any, error) {
|
|
var id, extID, email, name, status string
|
|
var platformAdmin bool
|
|
var invitedAt, disabledAt *time.Time
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&id, &extID, &email, &name, &status, &platformAdmin, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": id,
|
|
"external_id": extID,
|
|
"email": email,
|
|
"name": name,
|
|
"status": status,
|
|
"platform_admin": platformAdmin,
|
|
"role": string(permission.DeriveAccountRole(platformAdmin, status)),
|
|
"invited_at": invitedAt,
|
|
"disabled_at": disabledAt,
|
|
"created_at": createdAt,
|
|
"updated_at": updatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) SetUserRole(ctx context.Context, actorSub, userID, role string) (map[string]any, error) {
|
|
accountRole, ok := permission.ParseAccountRole(role)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid role")
|
|
}
|
|
|
|
var extID string
|
|
var currentAdmin bool
|
|
var currentStatus string
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT external_id, platform_admin, status FROM users WHERE id = $1
|
|
`, userID).Scan(&extID, ¤tAdmin, ¤tStatus)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if currentAdmin && accountRole != permission.AccountRoleAdmin {
|
|
count, err := platformusers.CountPlatformAdmins(ctx, s.db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count <= 1 {
|
|
return nil, platformusers.ErrLastPlatformAdmin
|
|
}
|
|
}
|
|
|
|
switch accountRole {
|
|
case permission.AccountRoleAdmin:
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users
|
|
SET platform_admin = true,
|
|
status = 'active',
|
|
disabled_at = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, userID)
|
|
case permission.AccountRoleUser:
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users
|
|
SET platform_admin = false,
|
|
status = 'active',
|
|
disabled_at = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, userID)
|
|
case permission.AccountRoleGuest:
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users
|
|
SET platform_admin = false,
|
|
status = 'invited',
|
|
invited_at = COALESCE(invited_at, NOW()),
|
|
disabled_at = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, userID)
|
|
case permission.AccountRoleSuspended:
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users
|
|
SET platform_admin = false,
|
|
status = 'disabled',
|
|
disabled_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, userID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.logAudit(ctx, actorSub, "set_user_role", map[string]any{
|
|
"target_user": userID,
|
|
"role": accountRole,
|
|
"from_status": currentStatus,
|
|
"from_admin": currentAdmin,
|
|
})
|
|
return s.GetUser(ctx, userID)
|
|
}
|
|
|
|
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),
|
|
})
|
|
if err := s.applyOrgDefaultQuotas(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
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)),
|
|
})
|
|
if err := s.applyOrgDefaultQuotas(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
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(
|
|
octet_length(COALESCE(m.body_text, '')) +
|
|
octet_length(COALESCE(m.body_html, '')) +
|
|
COALESCE(att.attachment_bytes, 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 (
|
|
SELECT message_id, SUM(size) AS attachment_bytes
|
|
FROM attachments
|
|
GROUP BY message_id
|
|
) att ON att.message_id = m.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,
|
|
}
|
|
|
|
storage, err := s.globalStorageStats(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["storage"] = storage
|
|
|
|
stats["audit"] = map[string]any{
|
|
"top_actors_7d": topActors,
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
const defaultQuotaBytes int64 = 5368709120
|
|
|
|
func (s *Service) globalStorageStats(ctx context.Context) (map[string]any, error) {
|
|
var mailUsed int64
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT COALESCE(SUM(
|
|
octet_length(COALESCE(m.body_text, '')) +
|
|
octet_length(COALESCE(m.body_html, '')) +
|
|
COALESCE(att.attachment_bytes, 0)
|
|
), 0)
|
|
FROM messages m
|
|
LEFT JOIN (
|
|
SELECT message_id, SUM(size) AS attachment_bytes
|
|
FROM attachments
|
|
GROUP BY message_id
|
|
) att ON att.message_id = m.id
|
|
`).Scan(&mailUsed); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var mailAllocated, driveAllocated, photosAllocated int64
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT
|
|
COALESCE(SUM(COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, $1)), 0),
|
|
COALESCE(SUM(COALESCE((s.preferences->>'drive_max_storage_bytes')::bigint, $1)), 0),
|
|
COALESCE(SUM(COALESCE((s.preferences->>'photos_max_storage_bytes')::bigint, $1)), 0)
|
|
FROM users u
|
|
LEFT JOIN settings s ON s.user_id = u.id
|
|
`, defaultQuotaBytes).Scan(&mailAllocated, &driveAllocated, &photosAllocated); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
driveUsed := s.sumDriveUsageAllUsers(ctx)
|
|
|
|
return map[string]any{
|
|
"mail": map[string]any{
|
|
"used_bytes": mailUsed,
|
|
"allocated_bytes": mailAllocated,
|
|
},
|
|
"drive": map[string]any{
|
|
"used_bytes": driveUsed,
|
|
"allocated_bytes": driveAllocated,
|
|
"tracked": s.nc != nil,
|
|
},
|
|
"photos": map[string]any{
|
|
"used_bytes": int64(0),
|
|
"allocated_bytes": photosAllocated,
|
|
"tracked": false,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) sumDriveUsageAllUsers(ctx context.Context) int64 {
|
|
if s.nc == nil {
|
|
return 0
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT email, external_id FROM users WHERE status != 'disabled'
|
|
`)
|
|
if err != nil {
|
|
s.logger.Debug("drive usage aggregate failed", "error", err)
|
|
return 0
|
|
}
|
|
defer rows.Close()
|
|
|
|
var total int64
|
|
for rows.Next() {
|
|
var email, externalID string
|
|
if err := rows.Scan(&email, &externalID); err != nil {
|
|
continue
|
|
}
|
|
total += s.driveUsageForUser(ctx, email, externalID)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (s *Service) attachUsersStorage(ctx context.Context, users []map[string]any) error {
|
|
if len(users) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(users))
|
|
byID := make(map[string]map[string]any, len(users))
|
|
for _, user := range users {
|
|
id, _ := user["id"].(string)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
byID[id] = user
|
|
}
|
|
mailUsed, err := s.mailUsageByUserIDs(ctx, ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for id, user := range byID {
|
|
email, _ := user["email"].(string)
|
|
extID, _ := user["external_id"].(string)
|
|
user["storage"] = map[string]any{
|
|
"mail_used_bytes": mailUsed[id],
|
|
"drive_used_bytes": s.driveUsageForUser(ctx, email, extID),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) mailUsageByUserIDs(ctx context.Context, userIDs []string) (map[string]int64, error) {
|
|
out := make(map[string]int64, len(userIDs))
|
|
if len(userIDs) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT
|
|
ma.user_id::text,
|
|
COALESCE(SUM(
|
|
octet_length(COALESCE(m.body_text, '')) +
|
|
octet_length(COALESCE(m.body_html, '')) +
|
|
COALESCE(att.attachment_bytes, 0)
|
|
), 0)
|
|
FROM mail_accounts ma
|
|
LEFT JOIN messages m ON m.account_id = ma.id
|
|
LEFT JOIN (
|
|
SELECT message_id, SUM(size) AS attachment_bytes
|
|
FROM attachments
|
|
GROUP BY message_id
|
|
) att ON att.message_id = m.id
|
|
WHERE ma.user_id = ANY($1::uuid[])
|
|
GROUP BY ma.user_id
|
|
`, userIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var userID string
|
|
var used int64
|
|
if err := rows.Scan(&userID, &used); err != nil {
|
|
return nil, err
|
|
}
|
|
out[userID] = used
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Service) mailUsageForUser(ctx context.Context, userID string) (count, bytes int64, err error) {
|
|
err = s.db.QueryRow(ctx, `
|
|
SELECT
|
|
COALESCE(COUNT(m.id), 0),
|
|
COALESCE(SUM(
|
|
octet_length(COALESCE(m.body_text, '')) +
|
|
octet_length(COALESCE(m.body_html, '')) +
|
|
COALESCE(att.attachment_bytes, 0)
|
|
), 0)
|
|
FROM messages m
|
|
JOIN mail_accounts ma ON m.account_id = ma.id
|
|
LEFT JOIN (
|
|
SELECT message_id, SUM(size) AS attachment_bytes
|
|
FROM attachments
|
|
GROUP BY message_id
|
|
) att ON att.message_id = m.id
|
|
WHERE ma.user_id = $1
|
|
`, userID).Scan(&count, &bytes)
|
|
return count, bytes, err
|
|
}
|
|
|
|
func (s *Service) driveUsageForUser(ctx context.Context, email, externalID string) int64 {
|
|
if s.nc == nil {
|
|
return 0
|
|
}
|
|
ncUserID := nextcloud.UserIDFromClaims(email, externalID)
|
|
if ncUserID == "" {
|
|
return 0
|
|
}
|
|
quota, err := s.nc.GetQuota(ctx, ncUserID)
|
|
if err != nil {
|
|
s.logger.Debug("drive quota lookup failed", "nc_user", ncUserID, "error", err)
|
|
return 0
|
|
}
|
|
if quota.Used < 0 {
|
|
return 0
|
|
}
|
|
return quota.Used
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|