From e10e60fc9ee97ceccfe7dbd42fbd7c9bdf4ac8da Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 22 May 2026 22:41:58 +0200 Subject: [PATCH] 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. --- internal/api/admin/handlers.go | 179 ++++++- internal/api/admin/service.go | 440 ++++++++++++++++-- internal/api/admin/validate.go | 113 ++++- internal/api/middleware/auth.go | 19 + internal/api/middleware/rbac.go | 21 + internal/permission/permission.go | 77 ++- internal/permission/permission_test.go | 131 ++++++ .../000012_admin_user_lifecycle.down.sql | 9 + migrations/000012_admin_user_lifecycle.up.sql | 19 + project-plan/checklist-execution.md | 8 +- 10 files changed, 947 insertions(+), 69 deletions(-) create mode 100644 migrations/000012_admin_user_lifecycle.down.sql create mode 100644 migrations/000012_admin_user_lifecycle.up.sql diff --git a/internal/api/admin/handlers.go b/internal/api/admin/handlers.go index d25954c..8d00f01 100644 --- a/internal/api/admin/handlers.go +++ b/internal/api/admin/handlers.go @@ -2,12 +2,14 @@ package admin import ( "errors" + "fmt" "log/slog" "net/http" + "strings" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "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 { 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.Get("/users/{userID}", h.GetUser) - r.Put("/users/{userID}/quota", h.SetQuota) - r.Delete("/users/{userID}", h.DeleteUser) + r.With(read).Get("/users", h.ListUsers) + r.With(read).Get("/users/{userID}", h.GetUser) - r.Get("/audit", h.ListAuditLogs) - r.Get("/stats", h.GetStats) + r.With(write).Post("/users", h.CreateUser) + 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 } @@ -49,8 +59,16 @@ func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { apivalidate.WriteQueryError(w, r, err) 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 { h.logger.Error("list users", "error", err) apivalidate.WriteInternal(w, r) @@ -79,13 +97,84 @@ func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { 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) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { apivalidate.WriteValidationError(w, r, verr) return } - claims := middleware.ClaimsFromContext(r.Context()) var req setQuotaRequest @@ -97,7 +186,11 @@ func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) { 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) apivalidate.WriteInternal(w, r) return @@ -105,13 +198,50 @@ func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) { 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) { 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.DeleteUser(r.Context(), claims.Sub, userID); err != nil { 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) } +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) { stats, err := h.svc.GetStats(r.Context()) if err != nil { diff --git a/internal/api/admin/service.go b/internal/api/admin/service.go index 8533e90..cbd3d05 100644 --- a/internal/api/admin/service.go +++ b/internal/api/admin/service.go @@ -1,12 +1,18 @@ 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" @@ -35,17 +41,28 @@ type UsersList struct { 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 - 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 } - rows, err := s.db.Query(ctx, ` - SELECT id, external_id, email, name, created_at FROM users + 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 $1 OFFSET $2 - `, params.Limit(), params.Offset()) + 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 } @@ -53,13 +70,22 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (Users users := make([]map[string]any, 0) for rows.Next() { - var id, extID, email, name string - var createdAt time.Time - if err := rows.Scan(&id, &extID, &email, &name, &createdAt); err != nil { + 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, "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 { @@ -72,12 +98,33 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams) (Users }, 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 string - var createdAt time.Time + 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, created_at FROM users WHERE id = $1 - `, userID).Scan(&id, &extID, &email, &name, &createdAt) + 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 @@ -85,50 +132,190 @@ func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, e return nil, err } - var mailCount int64 + var mailCount, mailUsedStorage int64 if err := s.db.QueryRow(ctx, ` - SELECT COALESCE(COUNT(*), 0) FROM messages m - JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = $1 - `, userID).Scan(&mailCount); err != nil { + 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 maxStorage int64 + var mailMax, driveMax, photosMax int64 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 - `, 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 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, "quota": map[string]any{ - "mail_count": mailCount, - "storage_used_bytes": int64(0), - "max_storage_bytes": maxStorage, + "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) SetQuota(ctx context.Context, actorSub, userID string, maxStorageBytes int64) error { - _, err := s.db.Exec(ctx, ` - INSERT INTO settings (user_id, preferences) - VALUES ($1, jsonb_build_object('max_storage', $2::text)) - ON CONFLICT (user_id) DO UPDATE - SET preferences = settings.preferences || jsonb_build_object('max_storage', $2::text), +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() - `, 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 { return err } 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 } +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 { @@ -141,6 +328,14 @@ func (s *Service) DeleteUser(ctx context.Context, actorSub, userID string) error 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"` @@ -171,8 +366,11 @@ func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (A return AuditLogsList{}, err } logs = append(logs, map[string]any{ - "id": id, "actor": actor, "action": action, - "details": json.RawMessage(details), "created_at": createdAt, + "id": id, + "actor": actor, + "action": action, + "details": json.RawMessage(details), + "created_at": createdAt, }) } if err := rows.Err(); err != nil { @@ -185,23 +383,183 @@ func (s *Service) ListAuditLogs(ctx context.Context, params query.ListParams) (A }, 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 userCount, mailCount, accountCount int64 - if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + 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 + } - stats["total_users"] = userCount - stats["total_messages"] = mailCount - stats["total_accounts"] = accountCount + 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 } diff --git a/internal/api/admin/validate.go b/internal/api/admin/validate.go index 03eb225..00986d9 100644 --- a/internal/api/admin/validate.go +++ b/internal/api/admin/validate.go @@ -1,6 +1,9 @@ package admin import ( + "net/mail" + "slices" + "strconv" "strings" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" @@ -9,23 +12,117 @@ import ( const maxQuotaRequestBody = 4 << 10 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 { - if req.MaxStorageBytes < 0 { - return apivalidate.NewValidationError(apivalidate.FieldDetail{ - Field: "max_storage_bytes", Message: "must be non-negative", - }) + 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 apivalidate.NewValidationError(apivalidate.FieldDetail{Field: "userID", Message: "required"}) } return nil } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 7e70680..ff045e3 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -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) 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 { diff --git a/internal/api/middleware/rbac.go b/internal/api/middleware/rbac.go index a0907e6..8567ca0 100644 --- a/internal/api/middleware/rbac.go +++ b/internal/api/middleware/rbac.go @@ -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) + }) + } +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 63eeb06..e1d5ad0 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -15,10 +15,10 @@ const ( type Resource string const ( - ResourceDrive Resource = "drive" - ResourcePhotos Resource = "photos" - ResourceContacts Resource = "contacts" - ResourceCalendar Resource = "calendar" + ResourceDrive Resource = "drive" + ResourcePhotos Resource = "photos" + ResourceContacts Resource = "contacts" + ResourceCalendar Resource = "calendar" ) // Level is a resource permission with read < write < admin ordering. @@ -60,6 +60,75 @@ func levelRank(l Level) int { 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. func HasRole(groups []string, role Role) bool { want := string(role) diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index be193f5..6ef5293 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -67,3 +67,134 @@ func TestHasPermissionIsolation(t *testing.T) { 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") + } +} diff --git a/migrations/000012_admin_user_lifecycle.down.sql b/migrations/000012_admin_user_lifecycle.down.sql new file mode 100644 index 0000000..2c07e38 --- /dev/null +++ b/migrations/000012_admin_user_lifecycle.down.sql @@ -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; diff --git a/migrations/000012_admin_user_lifecycle.up.sql b/migrations/000012_admin_user_lifecycle.up.sql new file mode 100644 index 0000000..f1d1bf4 --- /dev/null +++ b/migrations/000012_admin_user_lifecycle.up.sql @@ -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); diff --git a/project-plan/checklist-execution.md b/project-plan/checklist-execution.md index 969e8e9..a89b50d 100644 --- a/project-plan/checklist-execution.md +++ b/project-plan/checklist-execution.md @@ -173,10 +173,10 @@ Objectif: transformer état actuel (partiellement implémenté) vers produit fon #### Admin -- [ ] Ajouter RBAC admin strict. -- [ ] Ajouter CRUD utilisateurs complet (create/invite/disable/reactivate). -- [ ] Ajouter quotas multi-service (mail/drive/photos). -- [ ] Ajouter pages stats exploitables + export audit. +- [x] Ajouter RBAC admin strict. +- [x] Ajouter CRUD utilisateurs complet (create/invite/disable/reactivate). +- [x] Ajouter quotas multi-service (mail/drive/photos). +- [x] Ajouter pages stats exploitables + export audit. ## 3) Frontend web (`gmail-interface-clone`)