ultisuite-backend/internal/api/admin/handlers_mail_domains.go
R3D347HR4Y 1ffd0817d8
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(migration): enhance migration API with roster and audit export features
- Added endpoints for listing and importing migration rosters.
- Introduced audit export functionality for migration jobs in CSV and NDJSON formats.
- Implemented tenant mismatch validation for Microsoft migration claims.
- Enhanced error handling for email claiming and migration processes.
- Added integration tests for roster import and claim workflows.
2026-06-13 13:11:30 +02:00

609 lines
19 KiB
Go

package admin
import (
"encoding/csv"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/query"
migr "github.com/ultisuite/ulti-backend/internal/migration"
)
const maxAdminMailRequestBody = 1 << 20
func (h *Handler) registerMailAdminRoutes(r chi.Router, read, write func(http.Handler) http.Handler) {
if h.svc.hosted == nil && h.svc.migration == nil {
return
}
r.Route("/mail", func(r chi.Router) {
if h.svc.hosted != nil {
r.With(read).Get("/domains", h.ListMailDomains)
r.With(write).Post("/domains", h.CreateMailDomain)
r.With(read).Get("/domains/{domainID}", h.GetMailDomain)
r.With(write).Post("/domains/{domainID}/verify-txt", h.VerifyMailDomainTXT)
r.With(write).Post("/domains/{domainID}/verify-mx", h.VerifyMailDomainMX)
}
})
r.Route("/migration", func(r chi.Router) {
if h.svc.migration == nil {
return
}
r.With(read).Get("/projects", h.ListMigrationProjects)
r.With(write).Post("/projects", h.CreateMigrationProject)
r.With(write).Post("/projects/{projectID}/activate", h.ActivateMigrationProject)
r.With(read).Get("/projects/{projectID}/cutover-dns", h.PreflightMigrationCutoverDNS)
r.With(write).Post("/projects/{projectID}/cutover", h.StartMigrationCutover)
r.With(write).Post("/projects/{projectID}/invites", h.CreateMigrationInvite)
r.With(write).Post("/projects/{projectID}/invites/import", h.ImportMigrationInvites)
r.With(read).Get("/projects/{projectID}/roster", h.ListMigrationRoster)
r.With(write).Post("/projects/{projectID}/roster", h.ImportMigrationRoster)
r.With(read).Get("/projects/{projectID}/jobs", h.ListMigrationProjectJobs)
r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit", h.ListMigrationJobAudit)
r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit/summary", h.MigrationJobAuditSummary)
r.With(read).Get("/projects/{projectID}/jobs/{jobID}/audit/export", h.ExportMigrationJobAudit)
r.With(read).Get("/projects/{projectID}/audit/export", h.ExportMigrationProjectAudit)
r.With(write).Post("/projects/{projectID}/jobs/retry-failed", h.RetryMigrationFailedJobs)
r.With(write).Post("/projects/{projectID}/jobs/{jobID}/retry", h.RetryMigrationJob)
r.With(write).Post("/projects/{projectID}/jobs/{jobID}/reset-cursor", h.ResetMigrationJobCursor)
r.With(write).Patch("/projects/{projectID}/shared-drive-mode", h.UpdateMigrationSharedDriveMode)
r.With(read).Get("/projects/{projectID}/shared-drives", h.ListMigrationSharedDrives)
r.With(write).Post("/projects/{projectID}/shared-drives/{driveID}/approve", h.ApproveMigrationSharedDrive)
r.With(write).Post("/projects/{projectID}/shared-drives/{driveID}/reject", h.RejectMigrationSharedDrive)
r.With(read).Get("/microsoft/admin-consent-url", h.MicrosoftMigrationAdminConsentURL)
r.With(read).Get("/microsoft/admin-consents", h.ListMicrosoftAdminConsents)
})
}
func (h *Handler) ListMailDomains(w http.ResponseWriter, r *http.Request) {
rows, err := h.svc.hosted.ListDomains(r.Context())
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domains": rows})
}
type createMailDomainRequest struct {
Name string `json:"name"`
}
func (h *Handler) CreateMailDomain(w http.ResponseWriter, r *http.Request) {
var req createMailDomainRequest
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
return
}
row, err := h.svc.hosted.CreateDomain(r.Context(), req.Name, false)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, row)
}
func (h *Handler) GetMailDomain(w http.ResponseWriter, r *http.Request) {
row, err := h.svc.hosted.GetDomain(r.Context(), chi.URLParam(r, "domainID"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) VerifyMailDomainTXT(w http.ResponseWriter, r *http.Request) {
domainID := chi.URLParam(r, "domainID")
row, report, err := h.svc.hosted.VerifyDomainTXTRecord(r.Context(), domainID)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_txt_not_verified", err.Error(), map[string]any{"dns": report})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report})
}
func (h *Handler) VerifyMailDomainMX(w http.ResponseWriter, r *http.Request) {
domainID := chi.URLParam(r, "domainID")
expected := h.migrationCutoverConfig().ExpectedMXHosts
row, report, err := h.svc.hosted.VerifyDomainMXRecord(r.Context(), domainID, expected)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "dns_mx_not_verified", err.Error(), map[string]any{"dns": report})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"domain": row, "dns": report})
}
type createMigrationProjectRequest struct {
Name string `json:"name"`
SourceProvider string `json:"source_provider"`
DomainID string `json:"domain_id"`
AuthMode string `json:"auth_mode"`
}
func (h *Handler) CreateMigrationProject(w http.ResponseWriter, r *http.Request) {
var req createMigrationProjectRequest
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
return
}
row, err := h.svc.migration.CreateProject(r.Context(), req.Name, req.SourceProvider, req.DomainID, req.AuthMode)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, row)
}
func (h *Handler) ListMigrationProjects(w http.ResponseWriter, r *http.Request) {
rows, err := h.svc.migration.ListProjects(r.Context())
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"projects": rows})
}
func (h *Handler) ActivateMigrationProject(w http.ResponseWriter, r *http.Request) {
row, err := h.svc.migration.ActivateProject(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) PreflightMigrationCutoverDNS(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
report, err := h.svc.migration.PreflightCutoverDNS(r.Context(), chi.URLParam(r, "projectID"), h.migrationCutoverConfig())
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"dns": report})
}
func (h *Handler) StartMigrationCutover(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.migration.StartCutover(r.Context(), chi.URLParam(r, "projectID"))
if errors.Is(err, migr.ErrCutoverMXNotReady) {
apiresponse.WriteError(w, r, http.StatusConflict, "migration_cutover_mx_not_ready", err.Error(), map[string]any{
"dns": result.DNS,
"project": result.Project,
})
return
}
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) migrationCutoverConfig() migr.CutoverConfig {
if h.svc.cfg == nil {
return migr.CutoverConfig{}
}
return migr.CutoverConfig{
ExpectedMXHosts: migr.ParseCutoverMXHosts(
h.svc.cfg.MigrationCutoverMXHosts,
h.svc.cfg.PlatformMailDomain,
h.svc.cfg.StalwartIMAPHost,
),
RequireMX: h.svc.cfg.MigrationCutoverRequireMX,
}
}
type createMigrationInviteRequest struct {
Email string `json:"email"`
AlternateEmails []string `json:"alternate_emails,omitempty"`
}
func (h *Handler) CreateMigrationInvite(w http.ResponseWriter, r *http.Request) {
var req createMigrationInviteRequest
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
return
}
row, err := h.svc.migration.CreateInvite(r.Context(), chi.URLParam(r, "projectID"), req.Email, req.AlternateEmails)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, row)
}
func (h *Handler) ImportMigrationInvites(w http.ResponseWriter, r *http.Request) {
var emails []string
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
file, _, err := r.FormFile("file")
if err == nil {
defer file.Close()
reader := csv.NewReader(file)
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "file", Message: "invalid csv",
}))
return
}
if len(record) > 0 {
emails = append(emails, record[0])
}
}
}
}
if len(emails) == 0 {
var body struct {
Emails []string `json:"emails"`
}
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &body); err != nil {
return
}
emails = body.Emails
}
count, err := h.svc.migration.ImportInvites(r.Context(), chi.URLParam(r, "projectID"), emails)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"imported": count})
}
func (h *Handler) ListMigrationRoster(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
rows, err := h.svc.migration.ListRoster(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
if rows == nil {
rows = []migr.RosterEntry{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"roster": rows})
}
func (h *Handler) ImportMigrationRoster(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
projectID := chi.URLParam(r, "projectID")
var inputs []migr.RosterRowInput
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
file, _, err := r.FormFile("file")
if err == nil {
defer file.Close()
parsed, err := migr.ParseRosterCSV(file)
if err != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "file", Message: err.Error(),
}))
return
}
inputs = parsed
}
}
if len(inputs) == 0 {
var body struct {
CSV string `json:"csv"`
Rows []migr.RosterRowInput `json:"rows"`
}
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &body); err != nil {
return
}
if len(body.Rows) > 0 {
inputs = body.Rows
} else if strings.TrimSpace(body.CSV) != "" {
parsed, err := migr.ParseRosterCSV(strings.NewReader(body.CSV))
if err != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "csv", Message: err.Error(),
}))
return
}
inputs = parsed
}
}
if len(inputs) == 0 {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "csv", Message: "roster csv or rows required",
}))
return
}
result, err := h.svc.migration.ImportRoster(r.Context(), projectID, inputs)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}
func (h *Handler) MicrosoftMigrationAdminConsentURL(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
consentURL, err := h.svc.migration.MicrosoftAdminConsentURL(
r.URL.Query().Get("tenant"),
r.URL.Query().Get("project_id"),
)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, "admin_consent_unavailable", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"url": consentURL})
}
func (h *Handler) ListMicrosoftAdminConsents(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
rows, err := h.svc.migration.ListMicrosoftAdminConsents(r.Context())
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"consents": rows})
}
func (h *Handler) ListMigrationProjectJobs(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
rows, err := h.svc.migration.ListProjectJobs(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"jobs": rows})
}
func (h *Handler) RetryMigrationJob(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
row, err := h.svc.migration.RetryJob(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"))
if err != nil {
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_retryable", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) ResetMigrationJobCursor(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
row, err := h.svc.migration.ResetJobCursor(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "jobID"))
if err != nil {
status := http.StatusNotFound
code := "migration_job_not_resettable"
if strings.Contains(err.Error(), "running") {
status = http.StatusConflict
} else if strings.Contains(err.Error(), "not found") {
code = "migration_job_not_found"
}
apiresponse.WriteError(w, r, status, code, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) RetryMigrationFailedJobs(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
count, err := h.svc.migration.RetryFailedJobs(r.Context(), chi.URLParam(r, "projectID"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"retried": count})
}
func (h *Handler) ListMigrationJobAudit(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
params, err := query.ParseListRequest(r)
if err != nil {
apivalidate.WriteQueryError(w, r, err)
return
}
items, pagination, err := h.svc.migration.ListJobAudit(
r.Context(),
chi.URLParam(r, "projectID"),
chi.URLParam(r, "jobID"),
r.URL.Query().Get("status"),
params,
)
if err != nil {
if strings.Contains(err.Error(), "not found") {
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil)
return
}
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
"items": items,
"pagination": pagination,
})
}
func (h *Handler) MigrationJobAuditSummary(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
summary, err := h.svc.migration.JobAuditSummary(
r.Context(),
chi.URLParam(r, "projectID"),
chi.URLParam(r, "jobID"),
)
if err != nil {
if strings.Contains(err.Error(), "not found") {
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil)
return
}
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, summary)
}
func (h *Handler) ExportMigrationJobAudit(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
format, verr := validateExportFormat(r.URL.Query().Get("format"))
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
projectID := chi.URLParam(r, "projectID")
jobID := chi.URLParam(r, "jobID")
meta, err := h.svc.migration.PrepareJobAuditExport(r.Context(), projectID, jobID, format)
if err != nil {
if strings.Contains(err.Error(), "not found") {
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_job_not_found", err.Error(), nil)
return
}
apivalidate.WriteInternal(w, r)
return
}
w.Header().Set("Content-Type", meta.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.FileName))
w.WriteHeader(http.StatusOK)
if err := h.svc.migration.WriteJobAuditExport(
r.Context(),
projectID,
jobID,
r.URL.Query().Get("status"),
format,
w,
); err != nil {
h.logger.Error("export migration job audit", "error", err)
}
}
func (h *Handler) ExportMigrationProjectAudit(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
format, verr := validateExportFormat(r.URL.Query().Get("format"))
if verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
projectID := chi.URLParam(r, "projectID")
meta, err := h.svc.migration.PrepareProjectAuditExport(r.Context(), projectID, format)
if err != nil {
if strings.Contains(err.Error(), "not found") {
apiresponse.WriteError(w, r, http.StatusNotFound, "migration_project_not_found", err.Error(), nil)
return
}
apivalidate.WriteInternal(w, r)
return
}
w.Header().Set("Content-Type", meta.ContentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, meta.FileName))
w.WriteHeader(http.StatusOK)
if err := h.svc.migration.WriteProjectAuditExport(
r.Context(),
projectID,
r.URL.Query().Get("status"),
format,
w,
); err != nil {
h.logger.Error("export migration project audit", "error", err)
}
}
type updateSharedDriveModeRequest struct {
Mode string `json:"shared_drive_mode"`
}
func (h *Handler) UpdateMigrationSharedDriveMode(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
var req updateSharedDriveModeRequest
if err := apivalidate.DecodeJSON(w, r, maxAdminMailRequestBody, &req); err != nil {
return
}
row, err := h.svc.migration.UpdateSharedDriveMode(r.Context(), chi.URLParam(r, "projectID"), req.Mode)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) ListMigrationSharedDrives(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
rows, err := h.svc.migration.ListSharedDrives(r.Context(), chi.URLParam(r, "projectID"), r.URL.Query().Get("status"))
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"shared_drives": rows})
}
func (h *Handler) ApproveMigrationSharedDrive(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
row, err := h.svc.migration.ApproveSharedDrive(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "driveID"))
if err != nil {
apiresponse.WriteError(w, r, http.StatusNotFound, "shared_drive_not_found", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}
func (h *Handler) RejectMigrationSharedDrive(w http.ResponseWriter, r *http.Request) {
if h.svc.migration == nil {
apivalidate.WriteInternal(w, r)
return
}
row, err := h.svc.migration.RejectSharedDrive(r.Context(), chi.URLParam(r, "projectID"), chi.URLParam(r, "driveID"))
if err != nil {
apiresponse.WriteError(w, r, http.StatusNotFound, "shared_drive_not_found", err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, row)
}