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.
This commit is contained in:
R3D347HR4Y 2026-05-22 22:41:58 +02:00
parent f0f0b31043
commit e10e60fc9e
10 changed files with 947 additions and 69 deletions

View File

@ -2,12 +2,14 @@ package admin
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
@ -30,15 +32,23 @@ func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger) *Handler {
func (h *Handler) Routes() chi.Router { func (h *Handler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequireRole(permission.RoleAdmin)) read := middleware.RequireAdminScope(permission.AdminScopeRead)
write := middleware.RequireAdminScope(permission.AdminScopeWrite)
r.Get("/users", h.ListUsers) r.With(read).Get("/users", h.ListUsers)
r.Get("/users/{userID}", h.GetUser) r.With(read).Get("/users/{userID}", h.GetUser)
r.Put("/users/{userID}/quota", h.SetQuota)
r.Delete("/users/{userID}", h.DeleteUser)
r.Get("/audit", h.ListAuditLogs) r.With(write).Post("/users", h.CreateUser)
r.Get("/stats", h.GetStats) r.With(write).Post("/users/invite", h.InviteUser)
r.With(write).Put("/users/{userID}", h.UpdateUser)
r.With(write).Post("/users/{userID}/disable", h.DisableUser)
r.With(write).Post("/users/{userID}/reactivate", h.ReactivateUser)
r.With(write).Put("/users/{userID}/quota", h.SetQuota)
r.With(write).Delete("/users/{userID}", h.DeleteUser)
r.With(read).Get("/audit", h.ListAuditLogs)
r.With(read).Get("/audit/export", h.ExportAuditLogs)
r.With(read).Get("/stats", h.GetStats)
return r return r
} }
@ -49,8 +59,16 @@ func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteQueryError(w, r, err) apivalidate.WriteQueryError(w, r, err)
return return
} }
status, verr := validateStatus(r.URL.Query().Get("status"))
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
result, err := h.svc.ListUsers(r.Context(), params) result, err := h.svc.ListUsers(r.Context(), params, UserFilter{
Status: status,
Q: strings.TrimSpace(params.Q),
})
if err != nil { if err != nil {
h.logger.Error("list users", "error", err) h.logger.Error("list users", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
@ -79,13 +97,84 @@ func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteJSON(w, http.StatusOK, user) apiresponse.WriteJSON(w, http.StatusOK, user)
} }
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req createUserRequest
if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil {
return
}
if verr := validateCreateUser(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
user, err := h.svc.CreateUser(r.Context(), claims.Sub, req)
if err != nil {
h.logger.Error("create user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, user)
}
func (h *Handler) InviteUser(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req inviteUserRequest
if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil {
return
}
if verr := validateInviteUser(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
user, err := h.svc.InviteUser(r.Context(), claims.Sub, req)
if err != nil {
h.logger.Error("invite user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, user)
}
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
claims := middleware.ClaimsFromContext(r.Context())
var req updateUserRequest
if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil {
return
}
if verr := validateUpdateUser(&req); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
user, err := h.svc.UpdateUser(r.Context(), claims.Sub, userID, req)
if err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("update user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, user)
}
func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) { func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID") userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil { if verr := validateUserID(userID); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return return
} }
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
var req setQuotaRequest var req setQuotaRequest
@ -97,7 +186,11 @@ func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.svc.SetQuota(r.Context(), claims.Sub, userID, req.MaxStorageBytes); err != nil { if err := h.svc.SetQuota(r.Context(), claims.Sub, userID, req); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("set quota", "error", err) h.logger.Error("set quota", "error", err)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return return
@ -105,13 +198,50 @@ func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) DisableUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
claims := middleware.ClaimsFromContext(r.Context())
if err := h.svc.DisableUser(r.Context(), claims.Sub, userID); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("disable user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ReactivateUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
claims := middleware.ClaimsFromContext(r.Context())
if err := h.svc.ReactivateUser(r.Context(), claims.Sub, userID); err != nil {
if errors.Is(err, ErrNotFound) {
apivalidate.WriteNotFound(w, r, "not found")
return
}
h.logger.Error("reactivate user", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID") userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil { if verr := validateUserID(userID); verr != nil {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return return
} }
claims := middleware.ClaimsFromContext(r.Context()) claims := middleware.ClaimsFromContext(r.Context())
if err := h.svc.DeleteUser(r.Context(), claims.Sub, userID); err != nil { if err := h.svc.DeleteUser(r.Context(), claims.Sub, userID); err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
@ -141,6 +271,31 @@ func (h *Handler) ListAuditLogs(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteJSON(w, http.StatusOK, result) apiresponse.WriteJSON(w, http.StatusOK, result)
} }
func (h *Handler) ExportAuditLogs(w http.ResponseWriter, r *http.Request) {
format, verr := validateExportFormat(r.URL.Query().Get("format"))
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
limit, verr := validateExportLimit(r.URL.Query().Get("limit"))
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
payload, err := h.svc.ExportAuditLogs(r.Context(), format, limit)
if err != nil {
h.logger.Error("export audit logs", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.Header().Set("Content-Type", payload.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, payload.FileName))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload.Content)
}
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.svc.GetStats(r.Context()) stats, err := h.svc.GetStats(r.Context())
if err != nil { if err != nil {

View File

@ -1,12 +1,18 @@
package admin package admin
import ( import (
"bytes"
"context" "context"
"encoding/csv"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"strconv"
"strings"
"time" "time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -35,17 +41,28 @@ type UsersList struct {
Pagination query.PaginationMeta `json:"pagination,omitempty"` Pagination query.PaginationMeta `json:"pagination,omitempty"`
} }
func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (UsersList, error) { 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 var total int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil { totalSQL := "SELECT COUNT(*) FROM users" + whereSQL
if err := s.db.QueryRow(ctx, totalSQL, args...).Scan(&total); err != nil {
return UsersList{}, err return UsersList{}, err
} }
rows, err := s.db.Query(ctx, ` listSQL := `
SELECT id, external_id, email, name, created_at FROM users SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at
FROM users` + whereSQL + `
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2 LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
`, params.Limit(), params.Offset()) args = append(args, params.Limit(), params.Offset())
rows, err := s.db.Query(ctx, listSQL, args...)
if err != nil { if err != nil {
return UsersList{}, err return UsersList{}, err
} }
@ -53,13 +70,22 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (Users
users := make([]map[string]any, 0) users := make([]map[string]any, 0)
for rows.Next() { for rows.Next() {
var id, extID, email, name string var id, extID, email, name, status string
var createdAt time.Time var invitedAt, disabledAt *time.Time
if err := rows.Scan(&id, &extID, &email, &name, &createdAt); err != nil { var createdAt, updatedAt time.Time
if err := rows.Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil {
return UsersList{}, err return UsersList{}, err
} }
users = append(users, map[string]any{ users = append(users, map[string]any{
"id": id, "external_id": extID, "email": email, "name": name, "created_at": createdAt, "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 { if err := rows.Err(); err != nil {
@ -72,12 +98,33 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (Users
}, nil }, 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) { func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) {
var id, extID, email, name string var id, extID, email, name, status string
var createdAt time.Time var invitedAt, disabledAt *time.Time
var createdAt, updatedAt time.Time
err := s.db.QueryRow(ctx, ` err := s.db.QueryRow(ctx, `
SELECT id, external_id, email, name, created_at FROM users WHERE id = $1 SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at
`, userID).Scan(&id, &extID, &email, &name, &createdAt) FROM users WHERE id = $1
`, userID).Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@ -85,50 +132,190 @@ func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, e
return nil, err return nil, err
} }
var mailCount int64 var mailCount, mailUsedStorage int64
if err := s.db.QueryRow(ctx, ` if err := s.db.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0) FROM messages m SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(COALESCE(m.raw_size, 0)), 0)
JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = $1 FROM messages m
`, userID).Scan(&mailCount); err != nil { JOIN mail_accounts ma ON m.account_id = ma.id
WHERE ma.user_id = $1
`, userID).Scan(&mailCount, &mailUsedStorage); err != nil {
return nil, err return nil, err
} }
var maxStorage int64 var mailMax, driveMax, photosMax int64
if err := s.db.QueryRow(ctx, ` if err := s.db.QueryRow(ctx, `
SELECT COALESCE((preferences->>'max_storage')::bigint, 5368709120) 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 FROM settings WHERE user_id = $1
`, userID).Scan(&maxStorage); err != nil && !errors.Is(err, pgx.ErrNoRows) { `, userID).Scan(&mailMax, &driveMax, &photosMax); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, err
} }
return map[string]any{ return map[string]any{
"id": id, "external_id": extID, "email": email, "name": name, "id": id,
"external_id": extID,
"email": email,
"name": name,
"status": status,
"invited_at": invitedAt,
"disabled_at": disabledAt,
"created_at": createdAt, "created_at": createdAt,
"updated_at": updatedAt,
"quota": map[string]any{ "quota": map[string]any{
"mail_count": mailCount, "mail": map[string]any{
"storage_used_bytes": int64(0), "count": mailCount,
"max_storage_bytes": maxStorage, "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 }, nil
} }
func (s *Service) SetQuota(ctx context.Context, actorSub, userID string, maxStorageBytes int64) error { func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUserRequest) (map[string]any, error) {
_, err := s.db.Exec(ctx, ` var id string
INSERT INTO settings (user_id, preferences) if err := s.db.QueryRow(ctx, `
VALUES ($1, jsonb_build_object('max_storage', $2::text)) INSERT INTO users (external_id, email, name, status)
ON CONFLICT (user_id) DO UPDATE VALUES ($1, $2, $3, 'active')
SET preferences = settings.preferences || jsonb_build_object('max_storage', $2::text), 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() updated_at = NOW()
`, userID, maxStorageBytes) 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 { if err != nil {
return err return err
} }
s.logAudit(ctx, actorSub, "set_quota", map[string]any{ s.logAudit(ctx, actorSub, "set_quota", map[string]any{
"target_user": userID, "max_storage_bytes": maxStorageBytes, "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 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 { func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error {
result, err := s.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) result, err := s.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
if err != nil { if err != nil {
@ -141,6 +328,14 @@ func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error
return nil 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 { type AuditLogsList struct {
Logs []map[string]any `json:"logs"` Logs []map[string]any `json:"logs"`
Pagination query.PaginationMeta `json:"pagination,omitempty"` Pagination query.PaginationMeta `json:"pagination,omitempty"`
@ -171,8 +366,11 @@ func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (A
return AuditLogsList{}, err return AuditLogsList{}, err
} }
logs = append(logs, map[string]any{ logs = append(logs, map[string]any{
"id": id, "actor": actor, "action": action, "id": id,
"details": json.RawMessage(details), "created_at": createdAt, "actor": actor,
"action": action,
"details": json.RawMessage(details),
"created_at": createdAt,
}) })
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -185,23 +383,183 @@ func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (A
}, nil }, 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) { func (s *Service) GetStats(ctx context.Context) (map[string]any, error) {
stats := map[string]any{} stats := map[string]any{}
var userCount, mailCount, accountCount int64 var totalUsers, activeUsers, disabledUsers, invitedUsers int64
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers); err != nil {
return nil, err 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 { if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM messages`).Scan(&mailCount); err != nil {
return nil, err return nil, err
} }
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM mail_accounts`).Scan(&accountCount); err != nil { if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM mail_accounts`).Scan(&accountCount); err != nil {
return nil, err 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
}
stats["total_users"] = userCount type topActor struct {
stats["total_messages"] = mailCount Actor string `json:"actor"`
stats["total_accounts"] = accountCount 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 return stats, nil
} }

View File

@ -1,6 +1,9 @@
package admin package admin
import ( import (
"net/mail"
"slices"
"strconv"
"strings" "strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
@ -9,23 +12,117 @@ import (
const maxQuotaRequestBody = 4 << 10 const maxQuotaRequestBody = 4 << 10
type setQuotaRequest struct { type setQuotaRequest struct {
MaxStorageBytes int64 `json:"max_storage_bytes"` 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 { func validateSetQuota(req *setQuotaRequest) *apivalidate.ValidationError {
if req.MaxStorageBytes < 0 { if req.MailMaxStorageBytes == nil && req.DriveMaxStorageBytes == nil && req.PhotosMaxStorageBytes == nil {
return apivalidate.NewValidationError(apivalidate.FieldDetail{ return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "quota", Message: "at least one quota field is required"})
Field: "max_storage_bytes", Message: "must be non-negative", }
}) 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 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 { func validateUserID(userID string) *apivalidate.ValidationError {
if strings.TrimSpace(userID) == "" { if strings.TrimSpace(userID) == "" {
return apivalidate.NewValidationError(apivalidate.FieldDetail{ return apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "userID", Message: "required"})
Field: "userID", Message: "required",
})
} }
return nil return nil
} }

View File

@ -78,6 +78,25 @@ func Auth(verifier *auth.Verifier, db *pgxpool.Pool, audit *securityaudit.Logger
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil) apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil)
return return
} }
var disabled bool
if err := db.QueryRow(r.Context(), `
SELECT status = 'disabled' FROM users WHERE external_id = $1
`, claims.Sub).Scan(&disabled); err != nil {
slog.Error("read user status", "sub", claims.Sub, "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to read user status", nil)
return
}
if disabled {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "account disabled", nil)
if audit != nil {
audit.Log(r.Context(), claims.Sub, securityaudit.ActionTokenRejected, map[string]any{
"reason": "account_disabled",
"path": r.URL.Path,
"method": r.Method,
})
}
return
}
} }
if audit != nil { if audit != nil {

View File

@ -40,3 +40,24 @@ func RequirePermission(resource permission.Resource, level permission.Level) fun
}) })
} }
} }
func RequireAdminScope(scope permission.AdminScope) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := ClaimsFromContext(r.Context())
if claims == nil {
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
return
}
if !permission.HasRole(claims.Groups, permission.RoleAdmin) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
return
}
if !permission.HasAdminScope(claims.Groups, scope) {
apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "forbidden", nil)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -60,6 +60,75 @@ func levelRank(l Level) int {
return int(l) return int(l)
} }
// AdminScope is a fine-grained admin API permission with read < write ordering.
type AdminScope int
const (
AdminScopeRead AdminScope = iota + 1
AdminScopeWrite
)
// DefaultAdminScope is the scope assumed when an endpoint requires full admin API access.
const DefaultAdminScope = AdminScopeWrite
const (
GroupAdminRead = "admin:read"
GroupAdminWrite = "admin:write"
)
func (s AdminScope) String() string {
switch s {
case AdminScopeRead:
return "read"
case AdminScopeWrite:
return "write"
default:
return "unknown"
}
}
func ParseAdminScope(s string) (AdminScope, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "read":
return AdminScopeRead, true
case "write":
return AdminScopeWrite, true
default:
return 0, false
}
}
func adminScopeRank(s AdminScope) int {
return int(s)
}
// HasAdminScope reports whether groups grant at least the required admin API scope.
// Platform admins (admin or role:admin) satisfy any scope for backwards compatibility.
// admin:write implies admin:read; admin:read does not imply write.
func HasAdminScope(groups []string, required AdminScope) bool {
if HasRole(groups, RoleAdmin) {
return true
}
max := AdminScope(0)
for _, g := range groups {
g = strings.ToLower(strings.TrimSpace(g))
switch g {
case GroupAdminRead:
if adminScopeRank(AdminScopeRead) > adminScopeRank(max) {
max = AdminScopeRead
}
case GroupAdminWrite:
if adminScopeRank(AdminScopeWrite) > adminScopeRank(max) {
max = AdminScopeWrite
}
}
}
return adminScopeRank(max) >= adminScopeRank(required)
}
// HasRole reports whether groups grant the given platform role. // HasRole reports whether groups grant the given platform role.
func HasRole(groups []string, role Role) bool { func HasRole(groups []string, role Role) bool {
want := string(role) want := string(role)

View File

@ -67,3 +67,134 @@ func TestHasPermissionIsolation(t *testing.T) {
t.Fatal("contacts permission must not grant drive access") t.Fatal("contacts permission must not grant drive access")
} }
} }
func TestAdminScopeString(t *testing.T) {
tests := []struct {
scope AdminScope
want string
}{
{AdminScopeRead, "read"},
{AdminScopeWrite, "write"},
{AdminScope(0), "unknown"},
}
for _, tt := range tests {
if got := tt.scope.String(); got != tt.want {
t.Fatalf("AdminScope(%d).String() = %q, want %q", tt.scope, got, tt.want)
}
}
}
func TestParseAdminScope(t *testing.T) {
tests := []struct {
in string
want AdminScope
ok bool
}{
{"read", AdminScopeRead, true},
{"write", AdminScopeWrite, true},
{" READ ", AdminScopeRead, true},
{"Write", AdminScopeWrite, true},
{"admin", 0, false},
{"", 0, false},
}
for _, tt := range tests {
got, ok := ParseAdminScope(tt.in)
if ok != tt.ok || got != tt.want {
t.Fatalf("ParseAdminScope(%q) = (%v, %v), want (%v, %v)", tt.in, got, ok, tt.want, tt.ok)
}
}
}
func TestHasAdminScopeReadOnly(t *testing.T) {
groups := []string{GroupAdminRead}
if !HasAdminScope(groups, AdminScopeRead) {
t.Fatal("admin:read should satisfy read scope")
}
if HasAdminScope(groups, AdminScopeWrite) {
t.Fatal("admin:read should not satisfy write scope")
}
}
func TestHasAdminScopeWriteImpliesRead(t *testing.T) {
groups := []string{GroupAdminWrite}
if !HasAdminScope(groups, AdminScopeRead) {
t.Fatal("admin:write should satisfy read scope")
}
if !HasAdminScope(groups, AdminScopeWrite) {
t.Fatal("admin:write should satisfy write scope")
}
}
func TestHasAdminScopePlatformAdminBypass(t *testing.T) {
tests := []struct {
name string
groups []string
}{
{"bare admin role", []string{"admin"}},
{"prefixed admin role", []string{"role:admin"}},
{"trimmed prefixed admin role", []string{" role:admin "}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !HasAdminScope(tt.groups, AdminScopeRead) {
t.Fatal("platform admin should satisfy read scope")
}
if !HasAdminScope(tt.groups, AdminScopeWrite) {
t.Fatal("platform admin should satisfy write scope")
}
if !HasAdminScope(tt.groups, DefaultAdminScope) {
t.Fatal("platform admin should satisfy default admin scope")
}
})
}
}
func TestHasAdminScopeNoAccess(t *testing.T) {
tests := []struct {
name string
groups []string
}{
{"empty groups", nil},
{"unrelated user role", []string{"role:user"}},
{"resource permission only", []string{"drive:admin"}},
{"unknown admin suffix", []string{"admin:unknown"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if HasAdminScope(tt.groups, AdminScopeRead) {
t.Fatal("expected no admin read scope")
}
if HasAdminScope(tt.groups, AdminScopeWrite) {
t.Fatal("expected no admin write scope")
}
})
}
}
func TestHasAdminScopeMaxWins(t *testing.T) {
groups := []string{GroupAdminRead, GroupAdminWrite}
if !HasAdminScope(groups, AdminScopeRead) {
t.Fatal("combined scopes should satisfy read")
}
if !HasAdminScope(groups, AdminScopeWrite) {
t.Fatal("combined scopes should satisfy write")
}
}
func TestHasAdminScopeNormalization(t *testing.T) {
groups := []string{" Admin:Write "}
if !HasAdminScope(groups, AdminScopeWrite) {
t.Fatal("admin scope groups should be case- and whitespace-normalized")
}
if !HasAdminScope(groups, AdminScopeRead) {
t.Fatal("normalized admin:write should still imply read")
}
}

View File

@ -0,0 +1,9 @@
DROP INDEX IF EXISTS idx_users_status;
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_status_valid_chk;
ALTER TABLE users
DROP COLUMN IF EXISTS disabled_at,
DROP COLUMN IF EXISTS invited_at,
DROP COLUMN IF EXISTS status;

View File

@ -0,0 +1,19 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS invited_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'users_status_valid_chk'
) THEN
ALTER TABLE users
ADD CONSTRAINT users_status_valid_chk
CHECK (status IN ('active', 'disabled', 'invited'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);

View File

@ -173,10 +173,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon
#### Admin #### Admin
- [ ] Ajouter RBAC admin strict. - [x] Ajouter RBAC admin strict.
- [ ] Ajouter CRUD utilisateurs complet (create/invite/disable/reactivate). - [x] Ajouter CRUD utilisateurs complet (create/invite/disable/reactivate).
- [ ] Ajouter quotas multi-service (mail/drive/photos). - [x] Ajouter quotas multi-service (mail/drive/photos).
- [ ] Ajouter pages stats exploitables + export audit. - [x] Ajouter pages stats exploitables + export audit.
## 3) Frontend web (`gmail-interface-clone`) ## 3) Frontend web (`gmail-interface-clone`)