- 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.
126 lines
3.5 KiB
Go
126 lines
3.5 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/auth"
|
|
)
|
|
|
|
// ProvisionEmail returns the email stored for a newly provisioned user.
|
|
func ProvisionEmail(claims *auth.Claims) string {
|
|
if claims == nil {
|
|
return ""
|
|
}
|
|
email := strings.TrimSpace(claims.Email)
|
|
if email != "" {
|
|
return email
|
|
}
|
|
return claims.Sub + "@unknown.ultimail.local"
|
|
}
|
|
|
|
// EnsureUser inserts or updates the Ultimail user row for OIDC claims and returns the internal UUID.
|
|
func EnsureUser(ctx context.Context, db *pgxpool.Pool, claims *auth.Claims) (string, error) {
|
|
if db == nil {
|
|
return "", fmt.Errorf("database not configured")
|
|
}
|
|
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
|
|
return "", fmt.Errorf("missing subject claim")
|
|
}
|
|
|
|
email := ProvisionEmail(claims)
|
|
name := strings.TrimSpace(claims.Name)
|
|
|
|
var userCount int64
|
|
if err := db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
|
return "", fmt.Errorf("count users: %w", err)
|
|
}
|
|
isFirstUser := userCount == 0
|
|
|
|
var userID string
|
|
err := db.QueryRow(ctx, `
|
|
INSERT INTO users (external_id, email, name, platform_admin)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (external_id) DO UPDATE SET
|
|
email = EXCLUDED.email,
|
|
name = EXCLUDED.name,
|
|
updated_at = NOW()
|
|
RETURNING id
|
|
`, claims.Sub, email, name, isFirstUser).Scan(&userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("provision user: %w", err)
|
|
}
|
|
if isFirstUser {
|
|
if err := bootstrapFirstUserAdmin(ctx, db, userID); err != nil {
|
|
return "", fmt.Errorf("bootstrap platform admin: %w", err)
|
|
}
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
// LookupUserID returns the internal user UUID for an OIDC subject.
|
|
func LookupUserID(ctx context.Context, db *pgxpool.Pool, externalID string) (string, error) {
|
|
if db == nil {
|
|
return "", fmt.Errorf("database not configured")
|
|
}
|
|
externalID = strings.TrimSpace(externalID)
|
|
if externalID == "" {
|
|
return "", pgx.ErrNoRows
|
|
}
|
|
var userID string
|
|
err := db.QueryRow(ctx, `SELECT id::text FROM users WHERE external_id = $1`, externalID).Scan(&userID)
|
|
return userID, err
|
|
}
|
|
|
|
// LookupUserIDByEmail returns the internal user UUID for a stored email address.
|
|
func LookupUserIDByEmail(ctx context.Context, db *pgxpool.Pool, email string) (string, error) {
|
|
if db == nil {
|
|
return "", fmt.Errorf("database not configured")
|
|
}
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if email == "" {
|
|
return "", pgx.ErrNoRows
|
|
}
|
|
var userID string
|
|
err := db.QueryRow(ctx, `SELECT id::text FROM users WHERE lower(email) = $1`, email).Scan(&userID)
|
|
return userID, err
|
|
}
|
|
|
|
// ResolveProvisionUser finds an existing user by external_id or email, or creates one from Authentik enrollment data.
|
|
func ResolveProvisionUser(ctx context.Context, db *pgxpool.Pool, externalID, email, name string) (string, error) {
|
|
externalID = strings.TrimSpace(externalID)
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
|
|
if externalID != "" {
|
|
userID, err := LookupUserID(ctx, db, externalID)
|
|
if err == nil {
|
|
return userID, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return "", err
|
|
}
|
|
}
|
|
if email != "" {
|
|
userID, err := LookupUserIDByEmail(ctx, db, email)
|
|
if err == nil {
|
|
return userID, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return "", err
|
|
}
|
|
}
|
|
if externalID == "" {
|
|
return "", fmt.Errorf("cannot provision user without external_id or existing email")
|
|
}
|
|
return EnsureUser(ctx, db, &auth.Claims{
|
|
Sub: externalID,
|
|
Email: email,
|
|
Name: name,
|
|
})
|
|
}
|