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:
parent
f0f0b31043
commit
e10e60fc9e
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
migrations/000012_admin_user_lifecycle.down.sql
Normal file
9
migrations/000012_admin_user_lifecycle.down.sql
Normal 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;
|
||||||
19
migrations/000012_admin_user_lifecycle.up.sql
Normal file
19
migrations/000012_admin_user_lifecycle.up.sql
Normal 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);
|
||||||
@ -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`)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user