diff --git a/cmd/lfx-v1-sync-helper/auth0_identity_test.go b/cmd/lfx-v1-sync-helper/auth0_identity_test.go index 3c88dad..91c0c82 100644 --- a/cmd/lfx-v1-sync-helper/auth0_identity_test.go +++ b/cmd/lfx-v1-sync-helper/auth0_identity_test.go @@ -49,6 +49,11 @@ type fakeAuth0Users struct { // created tracks users passed to Create. created []*management.User + // updateErr is returned by Update. + updateErr error + // updated tracks (id, user) pairs passed to Update. + updated []*management.User + // linkErr is returned by Link. linkErr error // linked tracks (primaryID, provider, userID) tuples. @@ -94,7 +99,11 @@ func (f *fakeAuth0Users) Create(_ context.Context, u *management.User, _ ...mana return nil } -func (f *fakeAuth0Users) Update(_ context.Context, _ string, _ *management.User, _ ...management.RequestOption) error { +func (f *fakeAuth0Users) Update(_ context.Context, _ string, u *management.User, _ ...management.RequestOption) error { + if f.updateErr != nil { + return f.updateErr + } + f.updated = append(f.updated, u) return nil } @@ -186,6 +195,78 @@ func TestLinkEmailIdentity(t *testing.T) { } }) + t.Run("email matches primary account's own email is skipped", func(t *testing.T) { + fake := &fakeAuth0Users{ + users: map[string]*management.User{ + "auth0|primary": { + ID: auth0.String("auth0|primary"), + Email: auth0.String("alt@example.com"), + }, + }, + } + cleanup := setupLinkTest(t, fake) + defer cleanup() + + err := linkEmailIdentity(context.Background(), "auth0|primary", "alt@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fake.created) != 0 { + t.Errorf("expected no create calls, got %d", len(fake.created)) + } + if len(fake.linked) != 0 { + t.Errorf("expected no link calls, got %d", len(fake.linked)) + } + }) + + t.Run("email matches primary account's own email case-insensitive is skipped", func(t *testing.T) { + fake := &fakeAuth0Users{ + users: map[string]*management.User{ + "auth0|primary": { + ID: auth0.String("auth0|primary"), + Email: auth0.String("Alt@Example.COM"), + }, + }, + } + cleanup := setupLinkTest(t, fake) + defer cleanup() + + err := linkEmailIdentity(context.Background(), "auth0|primary", "alt@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fake.created) != 0 { + t.Errorf("expected no create calls, got %d", len(fake.created)) + } + if len(fake.linked) != 0 { + t.Errorf("expected no link calls, got %d", len(fake.linked)) + } + }) + + t.Run("blocked primary account is skipped", func(t *testing.T) { + fake := &fakeAuth0Users{ + users: map[string]*management.User{ + "auth0|primary": { + ID: auth0.String("auth0|primary"), + Blocked: auth0.Bool(true), + }, + }, + } + cleanup := setupLinkTest(t, fake) + defer cleanup() + + err := linkEmailIdentity(context.Background(), "auth0|primary", "alt@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fake.created) != 0 { + t.Errorf("expected no create calls, got %d", len(fake.created)) + } + if len(fake.linked) != 0 { + t.Errorf("expected no link calls, got %d", len(fake.linked)) + } + }) + t.Run("already linked case-insensitive", func(t *testing.T) { fake := &fakeAuth0Users{ users: map[string]*management.User{ diff --git a/cmd/lfx-v1-sync-helper/auth0_mgmt.go b/cmd/lfx-v1-sync-helper/auth0_mgmt.go index 7db84ce..2845554 100644 --- a/cmd/lfx-v1-sync-helper/auth0_mgmt.go +++ b/cmd/lfx-v1-sync-helper/auth0_mgmt.go @@ -173,6 +173,14 @@ func syncProfileToAuth0(ctx context.Context, auth0UserID string, v1Data map[stri return fmt.Errorf("failed to read Auth0 user %s: %w", auth0UserID, err) } + // Blocked accounts are treated as inactive/deprovisioned: don't push new + // profile data to them. This is a no-op skip, not an error. + if existing.GetBlocked() { + logger.With("auth0_user_id", auth0UserID). + WarnContext(ctx, "Auth0 user is blocked, skipping profile sync") + return nil + } + // Start from existing user_metadata (or empty map) for diffing. existingMetadata := make(map[string]interface{}) if existing.UserMetadata != nil { @@ -225,6 +233,25 @@ func linkEmailIdentity(ctx context.Context, primaryAuth0ID, email string) error if err != nil { return fmt.Errorf("failed to read primary user %s: %w", primaryAuth0ID, err) } + + // Blocked accounts are treated as inactive/deprovisioned: don't link new + // secondary identities to them. This is a no-op skip, not an error. + if primaryUser.GetBlocked() { + logger.With("auth0_user_id", primaryAuth0ID, "email", email). + WarnContext(ctx, "Auth0 user is blocked, skipping email link") + return nil + } + + // If the email matches the primary account's own login email, there is + // nothing to link: the account already has this email as its top-level + // identity. Linking it as a secondary "email" identity would just create + // a redundant duplicate. + if strings.EqualFold(primaryUser.GetEmail(), email) { + logger.With("auth0_user_id", primaryAuth0ID, "email", email). + WarnContext(ctx, "email matches primary account's own email, skipping link") + return nil + } + for _, identity := range primaryUser.Identities { if identity.GetProvider() == "email" && identity.GetConnection() == "email" { if profileEmail, _ := identity.GetProfileData()["email"].(string); strings.EqualFold(profileEmail, email) { diff --git a/cmd/lfx-v1-sync-helper/auth0_mgmt_test.go b/cmd/lfx-v1-sync-helper/auth0_mgmt_test.go index 990d7d8..df0a8fb 100644 --- a/cmd/lfx-v1-sync-helper/auth0_mgmt_test.go +++ b/cmd/lfx-v1-sync-helper/auth0_mgmt_test.go @@ -9,8 +9,32 @@ import ( "fmt" "net" "testing" + + "github.com/auth0/go-auth0" + "github.com/auth0/go-auth0/management" ) +func TestSyncProfileToAuth0Blocked(t *testing.T) { + fake := &fakeAuth0Users{ + users: map[string]*management.User{ + "auth0|blocked": { + ID: auth0.String("auth0|blocked"), + Blocked: auth0.Bool(true), + }, + }, + } + cleanup := setupLinkTest(t, fake) + defer cleanup() + + err := syncProfileToAuth0(context.Background(), "auth0|blocked", map[string]any{"title": "Engineer"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fake.updated) != 0 { + t.Errorf("expected no update calls, got %d", len(fake.updated)) + } +} + func TestBuildAuth0Metadata(t *testing.T) { tests := []struct { name string