- 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.
154 lines
4.8 KiB
Go
154 lines
4.8 KiB
Go
package provision
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/hosted"
|
|
"github.com/ultisuite/ulti-backend/internal/migration"
|
|
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
|
"github.com/ultisuite/ulti-backend/internal/users"
|
|
)
|
|
|
|
type Handler struct {
|
|
secret string
|
|
platformDomain string
|
|
hosted *hosted.Service
|
|
nc *nextcloud.Client
|
|
db *pgxpool.Pool
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewHandler(secret, platformDomain string, hostedSvc *hosted.Service, nc *nextcloud.Client, db *pgxpool.Pool) *Handler {
|
|
return &Handler{
|
|
secret: strings.TrimSpace(secret),
|
|
platformDomain: strings.ToLower(strings.TrimSpace(platformDomain)),
|
|
hosted: hostedSvc,
|
|
nc: nc,
|
|
db: db,
|
|
logger: slog.Default().With("component", "provision"),
|
|
}
|
|
}
|
|
|
|
type provisionUserRequest struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
Password string `json:"password"`
|
|
ExternalID string `json:"external_id"`
|
|
}
|
|
|
|
func (h *Handler) ProvisionUser(w http.ResponseWriter, r *http.Request) {
|
|
if h.secret == "" {
|
|
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "provision_disabled", "provisioning webhook not configured", nil)
|
|
return
|
|
}
|
|
if !authorizeProvision(r, h.secret) {
|
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, "unauthorized", "invalid provision secret", nil)
|
|
return
|
|
}
|
|
|
|
req, err := decodeProvisionBody(r)
|
|
if err != nil {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_json", "invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
if email == "" {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "email required", nil)
|
|
return
|
|
}
|
|
|
|
if h.platformDomain != "" && !strings.Contains(email, "@") {
|
|
email = email + "@" + h.platformDomain
|
|
} else if h.platformDomain != "" && !strings.HasSuffix(email, "@"+h.platformDomain) {
|
|
local := strings.Split(email, "@")[0]
|
|
email = local + "@" + h.platformDomain
|
|
}
|
|
|
|
ctx := r.Context()
|
|
externalID := strings.TrimSpace(req.ExternalID)
|
|
userID, err := users.ResolveProvisionUser(ctx, h.db, externalID, email, req.Name)
|
|
if err != nil {
|
|
h.logger.Error("resolve user", "error", err, "email", email)
|
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to provision user", nil)
|
|
return
|
|
}
|
|
|
|
skipMailbox, err := migration.HasPendingMigrationInvite(ctx, h.db, email)
|
|
if err != nil {
|
|
h.logger.Error("check migration invite", "error", err, "email", email)
|
|
apiresponse.WriteError(w, r, http.StatusInternalServerError, "internal_error", "failed to check migration invite", nil)
|
|
return
|
|
}
|
|
|
|
var result hosted.ProvisionMailboxResult
|
|
if !skipMailbox {
|
|
result, err = h.hosted.EnsureMailboxProvisioned(ctx, hosted.ProvisionMailboxInput{
|
|
UserID: userID,
|
|
Email: email,
|
|
DisplayName: req.Name,
|
|
Password: req.Password,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, hosted.ErrAddressTaken) {
|
|
apiresponse.WriteError(w, r, http.StatusConflict, "address_taken", err.Error(), nil)
|
|
return
|
|
}
|
|
h.logger.Error("provision mailbox", "error", err, "email", email)
|
|
apiresponse.WriteError(w, r, http.StatusConflict, "provision_failed", err.Error(), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
_ = migration.LinkHostedMailboxByEmail(ctx, h.db, userID, email)
|
|
|
|
if h.nc != nil && userID != "" && externalID != "" {
|
|
if _, err := h.nc.EnsurePrincipal(ctx, email, externalID, req.Name); err != nil {
|
|
h.logger.Warn("nextcloud provision", "error", err)
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"user_id": userID,
|
|
"email": email,
|
|
}
|
|
if !skipMailbox {
|
|
resp["mailbox_id"] = result.Mailbox.ID
|
|
resp["mail_account_id"] = result.MailAccountID
|
|
}
|
|
if skipMailbox {
|
|
resp["mailbox_deferred"] = true
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// CheckAddress validates local part availability (Authentik expression policy or public API).
|
|
func (h *Handler) CheckAddress(w http.ResponseWriter, r *http.Request) {
|
|
if h.hosted == nil {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": true})
|
|
return
|
|
}
|
|
local := strings.TrimSpace(r.URL.Query().Get("local"))
|
|
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
|
|
if domain == "" {
|
|
domain = h.platformDomain
|
|
}
|
|
if local == "" || domain == "" {
|
|
apiresponse.WriteError(w, r, http.StatusBadRequest, "validation_error", "local and domain required", nil)
|
|
return
|
|
}
|
|
available, err := h.hosted.IsAddressAvailable(r.Context(), domain, local)
|
|
if err != nil {
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": false, "reason": err.Error()})
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"available": available})
|
|
}
|