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 } // LookupIdentityByEmail returns the OIDC subject and stored email for a user row. func LookupIdentityByEmail(ctx context.Context, db *pgxpool.Pool, email string) (externalID, storedEmail string, err error) { if db == nil { return "", "", fmt.Errorf("database not configured") } email = strings.ToLower(strings.TrimSpace(email)) if email == "" { return "", "", pgx.ErrNoRows } err = db.QueryRow(ctx, ` SELECT external_id, email FROM users WHERE lower(email) = $1 `, email).Scan(&externalID, &storedEmail) return externalID, storedEmail, 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, }) }