From 204865e466f246b80b9b2e4a9d2c68e7bfdc22a1 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 14 Jul 2026 14:32:32 -0400 Subject: [PATCH 01/15] feat(users): scrub username from committee data on user deletion (LFXV2-2645) When a merged_user is soft-deleted in the v1-objects KV bucket, handleMergedUserDelete now clears the deleted user's username from: - committee_member records (via query-service tag lookup + UpdateCommitteeMember) - committee_settings writers and auditors entries (via query-service tag lookup + UpdateCommitteeSettings); entries are retained, only username is cleared A new query-service HTTP client (client_query.go) issues GET /query/resources?v=1&type={type}&tags={tagKey}:{username} to discover affected resources. QUERY_SERVICE_URL configures the endpoint (optional; scrub is skipped with a warning when absent). The handler is idempotent: records already cleared or reassigned to a different user are skipped. The existing v1 username-index cleanup is unchanged. Unit tests cover member scrub (7 cases), settings scrub (7 cases), and the handleMergedUserDelete wiring (3 cases). Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/client_committees.go | 55 +++ cmd/lfx-v1-sync-helper/client_query.go | 83 ++++ cmd/lfx-v1-sync-helper/config.go | 9 + cmd/lfx-v1-sync-helper/handlers_users.go | 196 ++++++++- cmd/lfx-v1-sync-helper/handlers_users_test.go | 407 ++++++++++++++++++ 5 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 cmd/lfx-v1-sync-helper/client_query.go diff --git a/cmd/lfx-v1-sync-helper/client_committees.go b/cmd/lfx-v1-sync-helper/client_committees.go index 8665d82..729ee90 100644 --- a/cmd/lfx-v1-sync-helper/client_committees.go +++ b/cmd/lfx-v1-sync-helper/client_committees.go @@ -230,6 +230,61 @@ func deleteCommitteeMember(ctx context.Context, committeeUID, memberUID string, return nil } +// fetchCommitteeSettings fetches the current settings for a committee and returns +// the settings object and its ETag. +func fetchCommitteeSettings(ctx context.Context, committeeUID string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { + token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") + if err != nil { + return nil, "", err + } + + result, err := committeeClient.GetCommitteeSettings(ctx, &committeeservice.GetCommitteeSettingsPayload{ + BearerToken: &token, + UID: stringToStringPtr(committeeUID), + Version: stringToStringPtr("1"), + }) + if err != nil { + return nil, "", fmt.Errorf("failed to get committee settings: %w", err) + } + + etag := "" + if result.Etag != nil { + etag = *result.Etag + } + + return result.CommitteeSettings, etag, nil +} + +// applyCommitteeSettingsUpdate calls UpdateCommitteeSettings on the committee client. +// The caller is responsible for populating all required fields (including UID and +// the current ETag in IfMatch) and for setting only the fields that should change. +func applyCommitteeSettingsUpdate(ctx context.Context, payload *committeeservice.UpdateCommitteeSettingsPayload) error { + token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") + if err != nil { + return err + } + payload.BearerToken = &token + if _, err := committeeClient.UpdateCommitteeSettings(ctx, payload); err != nil { + return fmt.Errorf("failed to update committee settings: %w", err) + } + return nil +} + +// applyCommitteeMemberUpdate calls UpdateCommitteeMember on the committee client +// directly, bypassing the fetch-compare cycle in updateCommitteeMember. The caller +// must provide a current ETag in payload.IfMatch and a fully-populated payload. +func applyCommitteeMemberUpdate(ctx context.Context, payload *committeeservice.UpdateCommitteeMemberPayload) error { + token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") + if err != nil { + return err + } + payload.BearerToken = &token + if _, err := committeeClient.UpdateCommitteeMember(ctx, payload); err != nil { + return fmt.Errorf("failed to update committee member: %w", err) + } + return nil +} + // committeeMembersEqual compares a committee member with an update payload for equality. func committeeMembersEqual(current *committeeservice.CommitteeMemberFullWithReadonlyAttributes, update *committeeservice.UpdateCommitteeMemberPayload) bool { // Compare basic fields. diff --git a/cmd/lfx-v1-sync-helper/client_query.go b/cmd/lfx-v1-sync-helper/client_query.go new file mode 100644 index 0000000..7f6ed7e --- /dev/null +++ b/cmd/lfx-v1-sync-helper/client_query.go @@ -0,0 +1,83 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// The lfx-v1-sync-helper service. +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + queryServiceAudience = "lfx-v2-query-service" +) + +// queryResource is a resource returned by the query-service GET /query/resources endpoint. +// For committee_member resources, UID is the member UID and ParentUID is the committee UID. +// For committee_settings resources, UID is the committee UID. +type queryResource struct { + UID string `json:"uid"` + Type string `json:"type"` + ParentUID *string `json:"parent_uid,omitempty"` +} + +// queryResourcesResponse is the JSON body returned by GET /query/resources. +type queryResourcesResponse struct { + Resources []queryResource `json:"resources"` +} + +// queryResourcesByTag queries the query-service for resources of the given type +// tagged with tagKey:tagValue (e.g. type=committee_member, tagKey=username, tagValue=alice). +// All matching resources are returned in a single call; the caller must not assume ordering. +func queryResourcesByTag(ctx context.Context, resourceType, tagKey, tagValue string) ([]queryResource, error) { + if cfg.QueryServiceURL == nil { + return nil, fmt.Errorf("QUERY_SERVICE_URL is not configured") + } + + token, err := generateCachedJWTToken(ctx, queryServiceAudience, "") + if err != nil { + return nil, fmt.Errorf("failed to generate JWT token for query-service: %w", err) + } + + reqURL := fmt.Sprintf("%s/query/resources?v=1&type=%s&tags=%s:%s", + cfg.QueryServiceURL.String(), + url.QueryEscape(resourceType), + url.QueryEscape(tagKey), + url.QueryEscape(tagValue), + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create query-service request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET /query/resources request failed: %w", err) + } + body, readErr := io.ReadAll(resp.Body) + if closeErr := resp.Body.Close(); closeErr != nil { + logger.With(errKey, closeErr).WarnContext(ctx, "failed to close query-service response body") + } + if readErr != nil { + return nil, fmt.Errorf("failed to read query-service response: %w", readErr) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET /query/resources returned status %d: %s", resp.StatusCode, body) + } + + var result queryResourcesResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal query-service response: %w", err) + } + + return result.Resources, nil +} diff --git a/cmd/lfx-v1-sync-helper/config.go b/cmd/lfx-v1-sync-helper/config.go index bb36a94..d2f668f 100644 --- a/cmd/lfx-v1-sync-helper/config.go +++ b/cmd/lfx-v1-sync-helper/config.go @@ -36,6 +36,7 @@ type Config struct { ProjectServiceURL *url.URL CommitteeServiceURL *url.URL MemberServiceURL *url.URL // Optional; required only for --backfill-acs-org pass + QueryServiceURL *url.URL // Optional; required for username scrub on user deletion // NATS configuration NATSURL string @@ -281,6 +282,14 @@ func LoadConfig() (*Config, error) { cfg.MemberServiceURL = memberServiceURL } + if queryServiceURLStr := os.Getenv("QUERY_SERVICE_URL"); queryServiceURLStr != "" { + queryServiceURL, err := url.Parse(queryServiceURLStr) + if err != nil { + return nil, fmt.Errorf("failed to parse QUERY_SERVICE_URL: %w", err) + } + cfg.QueryServiceURL = queryServiceURL + } + return cfg, nil } diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 53dedde..13448f7 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -12,6 +12,7 @@ import ( "strings" "time" + committeeservice "github.com/linuxfoundation/lfx-v2-committee-service/gen/committee_service" "github.com/nats-io/nats.go/jetstream" "github.com/vmihailenco/msgpack/v5" "golang.org/x/text/unicode/norm" @@ -53,6 +54,15 @@ var ( deleteIndexKeyFn = deleteIndexKey ) +// Committee username scrub dependencies — injected in tests to avoid live HTTP. +var ( + queryResourcesByTagFn = queryResourcesByTag + fetchCommitteeMemberFn = fetchCommitteeMember + applyMemberUpdateFn = applyCommitteeMemberUpdate + fetchCommitteeSettingsFn = fetchCommitteeSettings + applySettingsUpdateFn = applyCommitteeSettingsUpdate +) + // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. // NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic @@ -116,8 +126,8 @@ func handleMergedUserUpdate(ctx context.Context, key string, v1Data map[string]a // handleMergedUserDelete processes deletion of a merged user record: deletes // the username -> SFID secondary index so future lookups do not resolve a -// deleted user. Soft deletes and hard KV deletes both arrive here; v1Data is -// nil for a hard KV delete. +// deleted user, then scrubs the username from v2 committee data. +// Soft deletes and hard KV deletes both arrive here; v1Data is nil for a hard KV delete. // Returns true if the operation should be retried, false otherwise. func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data map[string]any) bool { if v1Data == nil { @@ -145,9 +155,191 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // In practice the alternate email rows are deleted before or alongside the user row, so // handleAlternateEmailDelete cleans those up individually — but if the user is deleted // without its alternate emails being deleted first, those entries will be orphaned. + + if username != "" { + scrubCommitteeMembersUsername(ctx, key, username) + scrubCommitteeSettingsUsername(ctx, key, username) + } + return false } +// scrubCommitteeMembersUsername clears the username field from all v2 committee_member +// records that still carry the given username. It is idempotent: records whose username +// has already been cleared or reassigned to another user are left untouched. +func scrubCommitteeMembersUsername(ctx context.Context, key, username string) { + if committeeClient == nil { + logger.With("key", key, "username", username). + WarnContext(ctx, "committee client not configured; skipping committee member username scrub") + return + } + + members, err := queryResourcesByTagFn(ctx, "committee_member", "username", username) + if err != nil { + logger.With(errKey, err, "key", key, "username", username). + ErrorContext(ctx, "failed to query committee members by username tag; member scrub incomplete") + return + } + + logger.With("key", key, "username", username, "count", len(members)). + InfoContext(ctx, "scrubbing username from committee members") + + for _, res := range members { + if res.ParentUID == nil || *res.ParentUID == "" { + logger.With("key", key, "member_uid", res.UID). + WarnContext(ctx, "committee_member resource missing parent_uid; skipping") + continue + } + committeeUID := *res.ParentUID + memberUID := res.UID + + current, etag, err := fetchCommitteeMemberFn(ctx, committeeUID, memberUID) + if err != nil { + logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "member_uid", memberUID). + ErrorContext(ctx, "failed to fetch committee member for username scrub; skipping") + continue + } + + // Reuse guard + idempotency: only clear if the record still carries this exact username. + if stringPtrToString(current.Username) != username { + logger.With("key", key, "committee_uid", committeeUID, "member_uid", memberUID). + DebugContext(ctx, "committee member username already cleared or reassigned; skipping") + continue + } + + emptyUsername := "" + payload := memberToUpdatePayload(committeeUID, memberUID, etag, current) + payload.Username = &emptyUsername + + if err := applyMemberUpdateFn(ctx, payload); err != nil { + logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "member_uid", memberUID). + ErrorContext(ctx, "failed to clear username from committee member") + } else { + logger.With("key", key, "committee_uid", committeeUID, "member_uid", memberUID, "username", username). + InfoContext(ctx, "cleared username from committee member") + } + } +} + +// memberToUpdatePayload builds an UpdateCommitteeMemberPayload that mirrors all +// current fields of a CommitteeMemberFullWithReadonlyAttributes. The caller adjusts +// specific fields (e.g. Username) before submitting the update. +func memberToUpdatePayload(committeeUID, memberUID, etag string, m *committeeservice.CommitteeMemberFullWithReadonlyAttributes) *committeeservice.UpdateCommitteeMemberPayload { + return &committeeservice.UpdateCommitteeMemberPayload{ + UID: committeeUID, + MemberUID: memberUID, + IfMatch: &etag, + Version: "1", + Email: stringPtrToString(m.Email), + Username: m.Username, + FirstName: m.FirstName, + LastName: m.LastName, + JobTitle: m.JobTitle, + LinkedinProfile: m.LinkedinProfile, + AppointedBy: m.AppointedBy, + Status: m.Status, + Role: m.Role, + Voting: m.Voting, + Organization: m.Organization, + } +} + +// scrubCommitteeSettingsUsername clears the username field from all committee_settings +// writers and auditors entries that carry the given username. The entries themselves are +// retained; only the username field is cleared. Idempotent; guarded against reuse. +func scrubCommitteeSettingsUsername(ctx context.Context, key, username string) { + if committeeClient == nil { + logger.With("key", key, "username", username). + WarnContext(ctx, "committee client not configured; skipping committee settings username scrub") + return + } + + writerResults, writerErr := queryResourcesByTagFn(ctx, "committee_settings", "writer", username) + if writerErr != nil { + logger.With(errKey, writerErr, "key", key, "username", username). + ErrorContext(ctx, "failed to query committee_settings by writer tag; settings writer scrub may be incomplete") + } + + auditorResults, auditorErr := queryResourcesByTagFn(ctx, "committee_settings", "auditor", username) + if auditorErr != nil { + logger.With(errKey, auditorErr, "key", key, "username", username). + ErrorContext(ctx, "failed to query committee_settings by auditor tag; settings auditor scrub may be incomplete") + } + + // Deduplicate by committee UID — the same committee may appear in both writer and auditor results. + seen := make(map[string]struct{}) + var committeeUIDs []string + for _, res := range append(writerResults, auditorResults...) { + if _, ok := seen[res.UID]; !ok { + seen[res.UID] = struct{}{} + committeeUIDs = append(committeeUIDs, res.UID) + } + } + + logger.With("key", key, "username", username, "count", len(committeeUIDs)). + InfoContext(ctx, "scrubbing username from committee settings") + + for _, committeeUID := range committeeUIDs { + if err := scrubOneCommitteeSettingsUsername(ctx, key, committeeUID, username); err != nil { + logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "username", username). + ErrorContext(ctx, "failed to scrub username from committee settings") + } + } +} + +// scrubOneCommitteeSettingsUsername fetches settings for a single committee, clears +// the username on any matching writer/auditor entries, and updates the settings if changed. +func scrubOneCommitteeSettingsUsername(ctx context.Context, key, committeeUID, username string) error { + settings, etag, err := fetchCommitteeSettingsFn(ctx, committeeUID) + if err != nil { + return fmt.Errorf("fetching committee settings: %w", err) + } + if settings == nil { + return nil + } + + changed := false + for _, w := range settings.Writers { + if w != nil && stringPtrToString(w.Username) == username { + w.Username = nil + changed = true + } + } + for _, a := range settings.Auditors { + if a != nil && stringPtrToString(a.Username) == username { + a.Username = nil + changed = true + } + } + + if !changed { + logger.With("key", key, "committee_uid", committeeUID, "username", username). + DebugContext(ctx, "no matching username in committee settings writers/auditors; skipping update") + return nil + } + + payload := &committeeservice.UpdateCommitteeSettingsPayload{ + UID: stringToStringPtr(committeeUID), + Version: stringToStringPtr("1"), + IfMatch: &etag, + BusinessEmailRequired: settings.BusinessEmailRequired, + LastReviewedAt: settings.LastReviewedAt, + LastReviewedBy: settings.LastReviewedBy, + MemberVisibility: settings.MemberVisibility, + ShowMeetingAttendees: settings.ShowMeetingAttendees, + Writers: settings.Writers, + Auditors: settings.Auditors, + } + + if err := applySettingsUpdateFn(ctx, payload); err != nil { + return fmt.Errorf("updating committee settings: %w", err) + } + + logger.With("key", key, "committee_uid", committeeUID, "username", username). + InfoContext(ctx, "cleared username from committee settings writers/auditors") + return nil +} + // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns // true if the error is retryable so the caller can NACK the JetStream message. func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool { diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index cd9faa8..4fc9ae4 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -10,6 +10,8 @@ import ( "io" "log/slog" "testing" + + committeeservice "github.com/linuxfoundation/lfx-v2-committee-service/gen/committee_service" ) func TestToKVKey(t *testing.T) { @@ -320,6 +322,411 @@ func TestExtractUsernameIndex(t *testing.T) { } } +// TestHandleMergedUserDeleteScrub verifies that handleMergedUserDelete triggers the +// committee scrub functions when a username is present in the payload. +func TestHandleMergedUserDeleteScrub(t *testing.T) { + origLogger := logger + origDeleteIndex := deleteIndexKeyFn + origQueryTag := queryResourcesByTagFn + origFetchMember := fetchCommitteeMemberFn + origApplyMember := applyMemberUpdateFn + origFetchSettings := fetchCommitteeSettingsFn + origApplySettings := applySettingsUpdateFn + origCommitteeClient := committeeClient + t.Cleanup(func() { + logger = origLogger + deleteIndexKeyFn = origDeleteIndex + queryResourcesByTagFn = origQueryTag + fetchCommitteeMemberFn = origFetchMember + applyMemberUpdateFn = origApplyMember + fetchCommitteeSettingsFn = origFetchSettings + applySettingsUpdateFn = origApplySettings + committeeClient = origCommitteeClient + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + committeeClient = &committeeservice.Client{} + + tests := []struct { + name string + v1Data map[string]any + wantScrubCalled bool + }{ + { + name: "nil v1Data (hard delete) → no scrub", + v1Data: nil, + }, + { + name: "empty username → no scrub", + v1Data: map[string]any{"username__c": "", "sfid": "003ABC"}, + }, + { + name: "username present → scrub called", + v1Data: map[string]any{"username__c": "alice", "sfid": "003ABC"}, + wantScrubCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var queryCalls []string + queryResourcesByTagFn = func(_ context.Context, resourceType, _, _ string) ([]queryResource, error) { + queryCalls = append(queryCalls, resourceType) + return nil, nil + } + fetchCommitteeMemberFn = func(_ context.Context, _, _ string) (*committeeservice.CommitteeMemberFullWithReadonlyAttributes, string, error) { + return nil, "", errors.New("should not be called") + } + fetchCommitteeSettingsFn = func(_ context.Context, _ string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { + return nil, "", errors.New("should not be called") + } + + handleMergedUserDelete(context.Background(), "test-key", "003ABC", tt.v1Data) + + if tt.wantScrubCalled { + if len(queryCalls) == 0 { + t.Error("expected query-service calls for scrub, got none") + } + } else { + if len(queryCalls) != 0 { + t.Errorf("expected no query-service calls, got %v", queryCalls) + } + } + }) + } +} + +// TestScrubCommitteeMembersUsername covers the committee member username scrub logic. +func TestScrubCommitteeMembersUsername(t *testing.T) { + origLogger := logger + origQueryTag := queryResourcesByTagFn + origFetchMember := fetchCommitteeMemberFn + origApplyMember := applyMemberUpdateFn + origCommitteeClient := committeeClient + t.Cleanup(func() { + logger = origLogger + queryResourcesByTagFn = origQueryTag + fetchCommitteeMemberFn = origFetchMember + applyMemberUpdateFn = origApplyMember + committeeClient = origCommitteeClient + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + // Use a non-nil sentinel so committeeClient != nil checks pass. + committeeClient = &committeeservice.Client{} + + const ( + username = "alice" + committeeUID = "comm-uuid-1" + memberUID = "mem-uuid-1" + ) + parentUID := committeeUID + + alice := username + bob := "bob" + email := "alice@example.com" + + tests := []struct { + name string + queryResources []queryResource + queryErr error + fetchResult *committeeservice.CommitteeMemberFullWithReadonlyAttributes + fetchErr error + wantUpdateCalls int + // wantClearedUsername is the username value the update payload should carry. + wantClearedUsername *string // nil = empty string pointer expected + }{ + { + name: "query returns empty → no updates", + queryErr: nil, + }, + { + name: "query error → no fetch, no update", + queryErr: errors.New("query service unavailable"), + }, + { + name: "resource missing parent_uid → skip", + queryResources: []queryResource{ + {UID: memberUID, Type: "committee_member"}, + }, + }, + { + name: "fetch error → skip that resource", + queryResources: []queryResource{ + {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, + }, + fetchErr: errors.New("not found"), + }, + { + name: "member username already empty (idempotency) → no update", + queryResources: []queryResource{ + {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, + }, + fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ + Username: nil, + Email: &email, + Status: "Active", + }, + }, + { + name: "member username belongs to another user (reuse guard) → no update", + queryResources: []queryResource{ + {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, + }, + fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ + Username: &bob, + Email: &email, + Status: "Active", + }, + }, + { + name: "member has matching username → username cleared, other fields preserved", + queryResources: []queryResource{ + {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, + }, + fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ + Username: &alice, + Email: &email, + Status: "Active", + }, + wantUpdateCalls: 1, + wantClearedUsername: strPtr(""), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queryResourcesByTagFn = func(_ context.Context, _, _, _ string) ([]queryResource, error) { + return tt.queryResources, tt.queryErr + } + fetchCommitteeMemberFn = func(_ context.Context, gotCommitteeUID, gotMemberUID string) (*committeeservice.CommitteeMemberFullWithReadonlyAttributes, string, error) { + if gotCommitteeUID != committeeUID || gotMemberUID != memberUID { + t.Errorf("fetchCommitteeMember called with (%q, %q), want (%q, %q)", gotCommitteeUID, gotMemberUID, committeeUID, memberUID) + } + return tt.fetchResult, "etag-1", tt.fetchErr + } + + var updatePayloads []*committeeservice.UpdateCommitteeMemberPayload + applyMemberUpdateFn = func(_ context.Context, payload *committeeservice.UpdateCommitteeMemberPayload) error { + updatePayloads = append(updatePayloads, payload) + return nil + } + + scrubCommitteeMembersUsername(context.Background(), "test-key", username) + + if len(updatePayloads) != tt.wantUpdateCalls { + t.Errorf("applyMemberUpdate called %d times, want %d", len(updatePayloads), tt.wantUpdateCalls) + } + + if tt.wantUpdateCalls > 0 && len(updatePayloads) > 0 { + p := updatePayloads[0] + gotUsername := stringPtrToString(p.Username) + if gotUsername != "" { + t.Errorf("update payload username = %q, want empty string", gotUsername) + } + // Verify other fields are preserved from the fetched member. + if p.Email != email { + t.Errorf("update payload email = %q, want %q", p.Email, email) + } + if p.Status != "Active" { + t.Errorf("update payload status = %q, want %q", p.Status, "Active") + } + if p.UID != committeeUID || p.MemberUID != memberUID { + t.Errorf("update payload IDs = (%q, %q), want (%q, %q)", p.UID, p.MemberUID, committeeUID, memberUID) + } + } + }) + } +} + +// TestScrubCommitteeSettingsUsername covers the committee settings writer/auditor username scrub. +func TestScrubCommitteeSettingsUsername(t *testing.T) { + origLogger := logger + origQueryTag := queryResourcesByTagFn + origFetchSettings := fetchCommitteeSettingsFn + origApplySettings := applySettingsUpdateFn + origCommitteeClient := committeeClient + t.Cleanup(func() { + logger = origLogger + queryResourcesByTagFn = origQueryTag + fetchCommitteeSettingsFn = origFetchSettings + applySettingsUpdateFn = origApplySettings + committeeClient = origCommitteeClient + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + committeeClient = &committeeservice.Client{} + + const ( + username = "alice" + committeeUID = "comm-uuid-1" + ) + + alice := username + bob := "bob" + aliceEmail := "alice@example.com" + bobEmail := "bob@example.com" + + makeResource := func(uid string) queryResource { return queryResource{UID: uid, Type: "committee_settings"} } + + tests := []struct { + name string + writerResults []queryResource + writerErr error + auditorResults []queryResource + auditorErr error + fetchResult *committeeservice.CommitteeSettingsWithReadonlyAttributes + fetchErr error + wantUpdateCalls int + wantWritersClear bool + wantAuditorsClear bool + }{ + { + name: "both queries return empty → no updates", + }, + { + name: "writer query returns result, username already empty (idempotency) → no update", + writerResults: []queryResource{makeResource(committeeUID)}, + fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ + Writers: []*committeeservice.CommitteeUser{ + {Email: &aliceEmail, Username: nil}, + }, + }, + }, + { + name: "writer query returns result with matching username → update clears writer username", + writerResults: []queryResource{makeResource(committeeUID)}, + fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ + Writers: []*committeeservice.CommitteeUser{ + {Email: &aliceEmail, Username: &alice}, + }, + Auditors: []*committeeservice.CommitteeUser{ + {Email: &bobEmail, Username: &bob}, + }, + }, + wantUpdateCalls: 1, + wantWritersClear: true, + }, + { + name: "auditor query returns result with matching username → update clears auditor username", + auditorResults: []queryResource{makeResource(committeeUID)}, + fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ + Writers: []*committeeservice.CommitteeUser{ + {Email: &bobEmail, Username: &bob}, + }, + Auditors: []*committeeservice.CommitteeUser{ + {Email: &aliceEmail, Username: &alice}, + }, + }, + wantUpdateCalls: 1, + wantAuditorsClear: true, + }, + { + name: "same committee UID in both writer and auditor results → fetched and updated once", + writerResults: []queryResource{makeResource(committeeUID)}, + auditorResults: []queryResource{makeResource(committeeUID)}, + fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ + Writers: []*committeeservice.CommitteeUser{{Email: &aliceEmail, Username: &alice}}, + Auditors: []*committeeservice.CommitteeUser{{Email: &aliceEmail, Username: &alice}}, + }, + wantUpdateCalls: 1, + wantWritersClear: true, + wantAuditorsClear: true, + }, + { + name: "unrelated writer (different username) → not cleared", + writerResults: []queryResource{makeResource(committeeUID)}, + fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ + Writers: []*committeeservice.CommitteeUser{ + {Email: &bobEmail, Username: &bob}, + }, + }, + }, + { + name: "fetch error → no update", + writerResults: []queryResource{makeResource(committeeUID)}, + fetchErr: errors.New("not found"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writerCallCount := 0 + auditorCallCount := 0 + queryResourcesByTagFn = func(_ context.Context, _, tagKey, _ string) ([]queryResource, error) { + switch tagKey { + case "writer": + writerCallCount++ + return tt.writerResults, tt.writerErr + case "auditor": + auditorCallCount++ + return tt.auditorResults, tt.auditorErr + } + return nil, nil + } + + fetchCalls := 0 + fetchCommitteeSettingsFn = func(_ context.Context, gotUID string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { + fetchCalls++ + if gotUID != committeeUID { + t.Errorf("fetchCommitteeSettings called with uid %q, want %q", gotUID, committeeUID) + } + return tt.fetchResult, "etag-1", tt.fetchErr + } + + var updatePayloads []*committeeservice.UpdateCommitteeSettingsPayload + applySettingsUpdateFn = func(_ context.Context, payload *committeeservice.UpdateCommitteeSettingsPayload) error { + updatePayloads = append(updatePayloads, payload) + return nil + } + + scrubCommitteeSettingsUsername(context.Background(), "test-key", username) + + if len(updatePayloads) != tt.wantUpdateCalls { + t.Errorf("applySettingsUpdate called %d times, want %d", len(updatePayloads), tt.wantUpdateCalls) + } + + if tt.wantUpdateCalls > 0 && len(updatePayloads) > 0 { + p := updatePayloads[0] + + if tt.wantWritersClear { + for _, w := range p.Writers { + if w != nil && w.Username != nil && *w.Username == username { + t.Errorf("writer username %q should have been cleared", username) + } + } + } + + if tt.wantAuditorsClear { + for _, a := range p.Auditors { + if a != nil && a.Username != nil && *a.Username == username { + t.Errorf("auditor username %q should have been cleared", username) + } + } + } + + // Verify unrelated entries are preserved. + for _, w := range p.Writers { + if w != nil && stringPtrToString(w.Username) == bob { + // bob's username should be unchanged + } + } + } + + // Verify deduplication: if same UID in both results, fetch called once. + if len(tt.writerResults) > 0 && len(tt.auditorResults) > 0 { + firstWriterUID := tt.writerResults[0].UID + firstAuditorUID := tt.auditorResults[0].UID + if firstWriterUID == firstAuditorUID && fetchCalls > 1 { + t.Errorf("same committee UID in both results but fetchCommitteeSettings called %d times, want 1", fetchCalls) + } + } + }) + } +} + +// strPtr returns a pointer to the given string; used in test helpers. +func strPtr(s string) *string { return &s } + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct { From c0b0c38696f948c232a78acce875973cc1c85fba Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 14 Jul 2026 14:38:19 -0400 Subject: [PATCH 02/15] test(scrub): remove dead wantClearedUsername field from member scrub test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field was populated in one test case but never read in the assertion body — the check hardcoded `!= ""` directly. Removing it avoids misleading struct fields that appear to carry meaning but have no effect. Generated with [Claude Code](https://claude.ai/claude-code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/handlers_users_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 4fc9ae4..4066df7 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -433,8 +433,6 @@ func TestScrubCommitteeMembersUsername(t *testing.T) { fetchResult *committeeservice.CommitteeMemberFullWithReadonlyAttributes fetchErr error wantUpdateCalls int - // wantClearedUsername is the username value the update payload should carry. - wantClearedUsername *string // nil = empty string pointer expected }{ { name: "query returns empty → no updates", @@ -489,8 +487,7 @@ func TestScrubCommitteeMembersUsername(t *testing.T) { Email: &email, Status: "Active", }, - wantUpdateCalls: 1, - wantClearedUsername: strPtr(""), + wantUpdateCalls: 1, }, } From 5c3e493a17f21272e2c140095593e95fddfb284f Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Fri, 17 Jul 2026 16:28:15 -0400 Subject: [PATCH 03/15] fix(lint): remove empty block flagged by revive in settings scrub test Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/handlers_users_test.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 4066df7..ef733a0 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -701,12 +701,7 @@ func TestScrubCommitteeSettingsUsername(t *testing.T) { } } - // Verify unrelated entries are preserved. - for _, w := range p.Writers { - if w != nil && stringPtrToString(w.Username) == bob { - // bob's username should be unchanged - } - } + } // Verify deduplication: if same UID in both results, fetch called once. From ba1d24b4fba95e5a2261807eb2a2b4f13f6e30e1 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 09:44:10 -0700 Subject: [PATCH 04/15] refactor(users): replace committee scrub with NATS publish on user delete (LFXV2-2645) Remove the query-service dependency introduced for username scrubbing. Instead, publish a lfx.v1-sync-helper.user.deleted NATS event carrying the deleted user's username and primary email. The committee service subscribes to this event and performs the scrub using its own KV secondary index, keeping the sync-helper free of any query-service coupling. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/client_committees.go | 55 --- cmd/lfx-v1-sync-helper/client_query.go | 83 ---- cmd/lfx-v1-sync-helper/config.go | 9 - cmd/lfx-v1-sync-helper/handlers_users.go | 207 ++------- cmd/lfx-v1-sync-helper/handlers_users_test.go | 397 ------------------ 5 files changed, 27 insertions(+), 724 deletions(-) delete mode 100644 cmd/lfx-v1-sync-helper/client_query.go diff --git a/cmd/lfx-v1-sync-helper/client_committees.go b/cmd/lfx-v1-sync-helper/client_committees.go index 729ee90..8665d82 100644 --- a/cmd/lfx-v1-sync-helper/client_committees.go +++ b/cmd/lfx-v1-sync-helper/client_committees.go @@ -230,61 +230,6 @@ func deleteCommitteeMember(ctx context.Context, committeeUID, memberUID string, return nil } -// fetchCommitteeSettings fetches the current settings for a committee and returns -// the settings object and its ETag. -func fetchCommitteeSettings(ctx context.Context, committeeUID string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { - token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") - if err != nil { - return nil, "", err - } - - result, err := committeeClient.GetCommitteeSettings(ctx, &committeeservice.GetCommitteeSettingsPayload{ - BearerToken: &token, - UID: stringToStringPtr(committeeUID), - Version: stringToStringPtr("1"), - }) - if err != nil { - return nil, "", fmt.Errorf("failed to get committee settings: %w", err) - } - - etag := "" - if result.Etag != nil { - etag = *result.Etag - } - - return result.CommitteeSettings, etag, nil -} - -// applyCommitteeSettingsUpdate calls UpdateCommitteeSettings on the committee client. -// The caller is responsible for populating all required fields (including UID and -// the current ETag in IfMatch) and for setting only the fields that should change. -func applyCommitteeSettingsUpdate(ctx context.Context, payload *committeeservice.UpdateCommitteeSettingsPayload) error { - token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") - if err != nil { - return err - } - payload.BearerToken = &token - if _, err := committeeClient.UpdateCommitteeSettings(ctx, payload); err != nil { - return fmt.Errorf("failed to update committee settings: %w", err) - } - return nil -} - -// applyCommitteeMemberUpdate calls UpdateCommitteeMember on the committee client -// directly, bypassing the fetch-compare cycle in updateCommitteeMember. The caller -// must provide a current ETag in payload.IfMatch and a fully-populated payload. -func applyCommitteeMemberUpdate(ctx context.Context, payload *committeeservice.UpdateCommitteeMemberPayload) error { - token, err := generateCachedJWTToken(ctx, committeeServiceAudience, "") - if err != nil { - return err - } - payload.BearerToken = &token - if _, err := committeeClient.UpdateCommitteeMember(ctx, payload); err != nil { - return fmt.Errorf("failed to update committee member: %w", err) - } - return nil -} - // committeeMembersEqual compares a committee member with an update payload for equality. func committeeMembersEqual(current *committeeservice.CommitteeMemberFullWithReadonlyAttributes, update *committeeservice.UpdateCommitteeMemberPayload) bool { // Compare basic fields. diff --git a/cmd/lfx-v1-sync-helper/client_query.go b/cmd/lfx-v1-sync-helper/client_query.go deleted file mode 100644 index 7f6ed7e..0000000 --- a/cmd/lfx-v1-sync-helper/client_query.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -// The lfx-v1-sync-helper service. -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" -) - -const ( - queryServiceAudience = "lfx-v2-query-service" -) - -// queryResource is a resource returned by the query-service GET /query/resources endpoint. -// For committee_member resources, UID is the member UID and ParentUID is the committee UID. -// For committee_settings resources, UID is the committee UID. -type queryResource struct { - UID string `json:"uid"` - Type string `json:"type"` - ParentUID *string `json:"parent_uid,omitempty"` -} - -// queryResourcesResponse is the JSON body returned by GET /query/resources. -type queryResourcesResponse struct { - Resources []queryResource `json:"resources"` -} - -// queryResourcesByTag queries the query-service for resources of the given type -// tagged with tagKey:tagValue (e.g. type=committee_member, tagKey=username, tagValue=alice). -// All matching resources are returned in a single call; the caller must not assume ordering. -func queryResourcesByTag(ctx context.Context, resourceType, tagKey, tagValue string) ([]queryResource, error) { - if cfg.QueryServiceURL == nil { - return nil, fmt.Errorf("QUERY_SERVICE_URL is not configured") - } - - token, err := generateCachedJWTToken(ctx, queryServiceAudience, "") - if err != nil { - return nil, fmt.Errorf("failed to generate JWT token for query-service: %w", err) - } - - reqURL := fmt.Sprintf("%s/query/resources?v=1&type=%s&tags=%s:%s", - cfg.QueryServiceURL.String(), - url.QueryEscape(resourceType), - url.QueryEscape(tagKey), - url.QueryEscape(tagValue), - ) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create query-service request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("GET /query/resources request failed: %w", err) - } - body, readErr := io.ReadAll(resp.Body) - if closeErr := resp.Body.Close(); closeErr != nil { - logger.With(errKey, closeErr).WarnContext(ctx, "failed to close query-service response body") - } - if readErr != nil { - return nil, fmt.Errorf("failed to read query-service response: %w", readErr) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET /query/resources returned status %d: %s", resp.StatusCode, body) - } - - var result queryResourcesResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal query-service response: %w", err) - } - - return result.Resources, nil -} diff --git a/cmd/lfx-v1-sync-helper/config.go b/cmd/lfx-v1-sync-helper/config.go index d2f668f..bb36a94 100644 --- a/cmd/lfx-v1-sync-helper/config.go +++ b/cmd/lfx-v1-sync-helper/config.go @@ -36,7 +36,6 @@ type Config struct { ProjectServiceURL *url.URL CommitteeServiceURL *url.URL MemberServiceURL *url.URL // Optional; required only for --backfill-acs-org pass - QueryServiceURL *url.URL // Optional; required for username scrub on user deletion // NATS configuration NATSURL string @@ -282,14 +281,6 @@ func LoadConfig() (*Config, error) { cfg.MemberServiceURL = memberServiceURL } - if queryServiceURLStr := os.Getenv("QUERY_SERVICE_URL"); queryServiceURLStr != "" { - queryServiceURL, err := url.Parse(queryServiceURLStr) - if err != nil { - return nil, fmt.Errorf("failed to parse QUERY_SERVICE_URL: %w", err) - } - cfg.QueryServiceURL = queryServiceURL - } - return cfg, nil } diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 13448f7..454057e 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -12,7 +12,6 @@ import ( "strings" "time" - committeeservice "github.com/linuxfoundation/lfx-v2-committee-service/gen/committee_service" "github.com/nats-io/nats.go/jetstream" "github.com/vmihailenco/msgpack/v5" "golang.org/x/text/unicode/norm" @@ -54,15 +53,6 @@ var ( deleteIndexKeyFn = deleteIndexKey ) -// Committee username scrub dependencies — injected in tests to avoid live HTTP. -var ( - queryResourcesByTagFn = queryResourcesByTag - fetchCommitteeMemberFn = fetchCommitteeMember - applyMemberUpdateFn = applyCommitteeMemberUpdate - fetchCommitteeSettingsFn = fetchCommitteeSettings - applySettingsUpdateFn = applyCommitteeSettingsUpdate -) - // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. // NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic @@ -157,187 +147,44 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // without its alternate emails being deleted first, those entries will be orphaned. if username != "" { - scrubCommitteeMembersUsername(ctx, key, username) - scrubCommitteeSettingsUsername(ctx, key, username) - } - - return false -} - -// scrubCommitteeMembersUsername clears the username field from all v2 committee_member -// records that still carry the given username. It is idempotent: records whose username -// has already been cleared or reassigned to another user are left untouched. -func scrubCommitteeMembersUsername(ctx context.Context, key, username string) { - if committeeClient == nil { - logger.With("key", key, "username", username). - WarnContext(ctx, "committee client not configured; skipping committee member username scrub") - return - } - - members, err := queryResourcesByTagFn(ctx, "committee_member", "username", username) - if err != nil { - logger.With(errKey, err, "key", key, "username", username). - ErrorContext(ctx, "failed to query committee members by username tag; member scrub incomplete") - return - } - - logger.With("key", key, "username", username, "count", len(members)). - InfoContext(ctx, "scrubbing username from committee members") - - for _, res := range members { - if res.ParentUID == nil || *res.ParentUID == "" { - logger.With("key", key, "member_uid", res.UID). - WarnContext(ctx, "committee_member resource missing parent_uid; skipping") - continue - } - committeeUID := *res.ParentUID - memberUID := res.UID - - current, etag, err := fetchCommitteeMemberFn(ctx, committeeUID, memberUID) - if err != nil { - logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "member_uid", memberUID). - ErrorContext(ctx, "failed to fetch committee member for username scrub; skipping") - continue - } - - // Reuse guard + idempotency: only clear if the record still carries this exact username. - if stringPtrToString(current.Username) != username { - logger.With("key", key, "committee_uid", committeeUID, "member_uid", memberUID). - DebugContext(ctx, "committee member username already cleared or reassigned; skipping") - continue - } - - emptyUsername := "" - payload := memberToUpdatePayload(committeeUID, memberUID, etag, current) - payload.Username = &emptyUsername - - if err := applyMemberUpdateFn(ctx, payload); err != nil { - logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "member_uid", memberUID). - ErrorContext(ctx, "failed to clear username from committee member") + email, emailErr := getPrimaryEmailForUser(ctx, userSfid) + if emailErr != nil { + logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped") } else { - logger.With("key", key, "committee_uid", committeeUID, "member_uid", memberUID, "username", username). - InfoContext(ctx, "cleared username from committee member") + publishUserDeletedEvent(ctx, key, username, email) } } -} -// memberToUpdatePayload builds an UpdateCommitteeMemberPayload that mirrors all -// current fields of a CommitteeMemberFullWithReadonlyAttributes. The caller adjusts -// specific fields (e.g. Username) before submitting the update. -func memberToUpdatePayload(committeeUID, memberUID, etag string, m *committeeservice.CommitteeMemberFullWithReadonlyAttributes) *committeeservice.UpdateCommitteeMemberPayload { - return &committeeservice.UpdateCommitteeMemberPayload{ - UID: committeeUID, - MemberUID: memberUID, - IfMatch: &etag, - Version: "1", - Email: stringPtrToString(m.Email), - Username: m.Username, - FirstName: m.FirstName, - LastName: m.LastName, - JobTitle: m.JobTitle, - LinkedinProfile: m.LinkedinProfile, - AppointedBy: m.AppointedBy, - Status: m.Status, - Role: m.Role, - Voting: m.Voting, - Organization: m.Organization, - } + return false } -// scrubCommitteeSettingsUsername clears the username field from all committee_settings -// writers and auditors entries that carry the given username. The entries themselves are -// retained; only the username field is cleared. Idempotent; guarded against reuse. -func scrubCommitteeSettingsUsername(ctx context.Context, key, username string) { - if committeeClient == nil { - logger.With("key", key, "username", username). - WarnContext(ctx, "committee client not configured; skipping committee settings username scrub") - return - } - - writerResults, writerErr := queryResourcesByTagFn(ctx, "committee_settings", "writer", username) - if writerErr != nil { - logger.With(errKey, writerErr, "key", key, "username", username). - ErrorContext(ctx, "failed to query committee_settings by writer tag; settings writer scrub may be incomplete") - } - - auditorResults, auditorErr := queryResourcesByTagFn(ctx, "committee_settings", "auditor", username) - if auditorErr != nil { - logger.With(errKey, auditorErr, "key", key, "username", username). - ErrorContext(ctx, "failed to query committee_settings by auditor tag; settings auditor scrub may be incomplete") - } - - // Deduplicate by committee UID — the same committee may appear in both writer and auditor results. - seen := make(map[string]struct{}) - var committeeUIDs []string - for _, res := range append(writerResults, auditorResults...) { - if _, ok := seen[res.UID]; !ok { - seen[res.UID] = struct{}{} - committeeUIDs = append(committeeUIDs, res.UID) - } - } - - logger.With("key", key, "username", username, "count", len(committeeUIDs)). - InfoContext(ctx, "scrubbing username from committee settings") - - for _, committeeUID := range committeeUIDs { - if err := scrubOneCommitteeSettingsUsername(ctx, key, committeeUID, username); err != nil { - logger.With(errKey, err, "key", key, "committee_uid", committeeUID, "username", username). - ErrorContext(ctx, "failed to scrub username from committee settings") - } - } +// userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a +// merged user is soft-deleted. The committee service subscribes to this subject and scrubs +// the username from committee members and settings writers/auditors. +type userDeletedEvent struct { + Username string `json:"username"` + Email string `json:"email"` } -// scrubOneCommitteeSettingsUsername fetches settings for a single committee, clears -// the username on any matching writer/auditor entries, and updates the settings if changed. -func scrubOneCommitteeSettingsUsername(ctx context.Context, key, committeeUID, username string) error { - settings, etag, err := fetchCommitteeSettingsFn(ctx, committeeUID) - if err != nil { - return fmt.Errorf("fetching committee settings: %w", err) - } - if settings == nil { - return nil - } - - changed := false - for _, w := range settings.Writers { - if w != nil && stringPtrToString(w.Username) == username { - w.Username = nil - changed = true - } - } - for _, a := range settings.Auditors { - if a != nil && stringPtrToString(a.Username) == username { - a.Username = nil - changed = true - } - } - - if !changed { - logger.With("key", key, "committee_uid", committeeUID, "username", username). - DebugContext(ctx, "no matching username in committee settings writers/auditors; skipping update") - return nil - } +const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" - payload := &committeeservice.UpdateCommitteeSettingsPayload{ - UID: stringToStringPtr(committeeUID), - Version: stringToStringPtr("1"), - IfMatch: &etag, - BusinessEmailRequired: settings.BusinessEmailRequired, - LastReviewedAt: settings.LastReviewedAt, - LastReviewedBy: settings.LastReviewedBy, - MemberVisibility: settings.MemberVisibility, - ShowMeetingAttendees: settings.ShowMeetingAttendees, - Writers: settings.Writers, - Auditors: settings.Auditors, +// publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish +// errors are logged and do not affect the delete handler's return value. +func publishUserDeletedEvent(ctx context.Context, key, username, email string) { + payload, err := json.Marshal(userDeletedEvent{Username: username, Email: email}) + if err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped") + return } - - if err := applySettingsUpdateFn(ctx, payload); err != nil { - return fmt.Errorf("updating committee settings: %w", err) + if err := natsConn.Publish(v1SyncHelperUserDeletedSubject, payload); err != nil { + logger.With(errKey, err, "key", key). + ErrorContext(ctx, "failed to publish user-deleted event; committee username scrub skipped") + return } - - logger.With("key", key, "committee_uid", committeeUID, "username", username). - InfoContext(ctx, "cleared username from committee settings writers/auditors") - return nil + logger.With("key", key, "username", username). + InfoContext(ctx, "published user-deleted event for committee username scrub") } // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index ef733a0..c4d2c3f 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -10,8 +10,6 @@ import ( "io" "log/slog" "testing" - - committeeservice "github.com/linuxfoundation/lfx-v2-committee-service/gen/committee_service" ) func TestToKVKey(t *testing.T) { @@ -324,401 +322,6 @@ func TestExtractUsernameIndex(t *testing.T) { // TestHandleMergedUserDeleteScrub verifies that handleMergedUserDelete triggers the // committee scrub functions when a username is present in the payload. -func TestHandleMergedUserDeleteScrub(t *testing.T) { - origLogger := logger - origDeleteIndex := deleteIndexKeyFn - origQueryTag := queryResourcesByTagFn - origFetchMember := fetchCommitteeMemberFn - origApplyMember := applyMemberUpdateFn - origFetchSettings := fetchCommitteeSettingsFn - origApplySettings := applySettingsUpdateFn - origCommitteeClient := committeeClient - t.Cleanup(func() { - logger = origLogger - deleteIndexKeyFn = origDeleteIndex - queryResourcesByTagFn = origQueryTag - fetchCommitteeMemberFn = origFetchMember - applyMemberUpdateFn = origApplyMember - fetchCommitteeSettingsFn = origFetchSettings - applySettingsUpdateFn = origApplySettings - committeeClient = origCommitteeClient - }) - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } - committeeClient = &committeeservice.Client{} - - tests := []struct { - name string - v1Data map[string]any - wantScrubCalled bool - }{ - { - name: "nil v1Data (hard delete) → no scrub", - v1Data: nil, - }, - { - name: "empty username → no scrub", - v1Data: map[string]any{"username__c": "", "sfid": "003ABC"}, - }, - { - name: "username present → scrub called", - v1Data: map[string]any{"username__c": "alice", "sfid": "003ABC"}, - wantScrubCalled: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var queryCalls []string - queryResourcesByTagFn = func(_ context.Context, resourceType, _, _ string) ([]queryResource, error) { - queryCalls = append(queryCalls, resourceType) - return nil, nil - } - fetchCommitteeMemberFn = func(_ context.Context, _, _ string) (*committeeservice.CommitteeMemberFullWithReadonlyAttributes, string, error) { - return nil, "", errors.New("should not be called") - } - fetchCommitteeSettingsFn = func(_ context.Context, _ string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { - return nil, "", errors.New("should not be called") - } - - handleMergedUserDelete(context.Background(), "test-key", "003ABC", tt.v1Data) - - if tt.wantScrubCalled { - if len(queryCalls) == 0 { - t.Error("expected query-service calls for scrub, got none") - } - } else { - if len(queryCalls) != 0 { - t.Errorf("expected no query-service calls, got %v", queryCalls) - } - } - }) - } -} - -// TestScrubCommitteeMembersUsername covers the committee member username scrub logic. -func TestScrubCommitteeMembersUsername(t *testing.T) { - origLogger := logger - origQueryTag := queryResourcesByTagFn - origFetchMember := fetchCommitteeMemberFn - origApplyMember := applyMemberUpdateFn - origCommitteeClient := committeeClient - t.Cleanup(func() { - logger = origLogger - queryResourcesByTagFn = origQueryTag - fetchCommitteeMemberFn = origFetchMember - applyMemberUpdateFn = origApplyMember - committeeClient = origCommitteeClient - }) - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - - // Use a non-nil sentinel so committeeClient != nil checks pass. - committeeClient = &committeeservice.Client{} - - const ( - username = "alice" - committeeUID = "comm-uuid-1" - memberUID = "mem-uuid-1" - ) - parentUID := committeeUID - - alice := username - bob := "bob" - email := "alice@example.com" - - tests := []struct { - name string - queryResources []queryResource - queryErr error - fetchResult *committeeservice.CommitteeMemberFullWithReadonlyAttributes - fetchErr error - wantUpdateCalls int - }{ - { - name: "query returns empty → no updates", - queryErr: nil, - }, - { - name: "query error → no fetch, no update", - queryErr: errors.New("query service unavailable"), - }, - { - name: "resource missing parent_uid → skip", - queryResources: []queryResource{ - {UID: memberUID, Type: "committee_member"}, - }, - }, - { - name: "fetch error → skip that resource", - queryResources: []queryResource{ - {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, - }, - fetchErr: errors.New("not found"), - }, - { - name: "member username already empty (idempotency) → no update", - queryResources: []queryResource{ - {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, - }, - fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ - Username: nil, - Email: &email, - Status: "Active", - }, - }, - { - name: "member username belongs to another user (reuse guard) → no update", - queryResources: []queryResource{ - {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, - }, - fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ - Username: &bob, - Email: &email, - Status: "Active", - }, - }, - { - name: "member has matching username → username cleared, other fields preserved", - queryResources: []queryResource{ - {UID: memberUID, Type: "committee_member", ParentUID: &parentUID}, - }, - fetchResult: &committeeservice.CommitteeMemberFullWithReadonlyAttributes{ - Username: &alice, - Email: &email, - Status: "Active", - }, - wantUpdateCalls: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - queryResourcesByTagFn = func(_ context.Context, _, _, _ string) ([]queryResource, error) { - return tt.queryResources, tt.queryErr - } - fetchCommitteeMemberFn = func(_ context.Context, gotCommitteeUID, gotMemberUID string) (*committeeservice.CommitteeMemberFullWithReadonlyAttributes, string, error) { - if gotCommitteeUID != committeeUID || gotMemberUID != memberUID { - t.Errorf("fetchCommitteeMember called with (%q, %q), want (%q, %q)", gotCommitteeUID, gotMemberUID, committeeUID, memberUID) - } - return tt.fetchResult, "etag-1", tt.fetchErr - } - - var updatePayloads []*committeeservice.UpdateCommitteeMemberPayload - applyMemberUpdateFn = func(_ context.Context, payload *committeeservice.UpdateCommitteeMemberPayload) error { - updatePayloads = append(updatePayloads, payload) - return nil - } - - scrubCommitteeMembersUsername(context.Background(), "test-key", username) - - if len(updatePayloads) != tt.wantUpdateCalls { - t.Errorf("applyMemberUpdate called %d times, want %d", len(updatePayloads), tt.wantUpdateCalls) - } - - if tt.wantUpdateCalls > 0 && len(updatePayloads) > 0 { - p := updatePayloads[0] - gotUsername := stringPtrToString(p.Username) - if gotUsername != "" { - t.Errorf("update payload username = %q, want empty string", gotUsername) - } - // Verify other fields are preserved from the fetched member. - if p.Email != email { - t.Errorf("update payload email = %q, want %q", p.Email, email) - } - if p.Status != "Active" { - t.Errorf("update payload status = %q, want %q", p.Status, "Active") - } - if p.UID != committeeUID || p.MemberUID != memberUID { - t.Errorf("update payload IDs = (%q, %q), want (%q, %q)", p.UID, p.MemberUID, committeeUID, memberUID) - } - } - }) - } -} - -// TestScrubCommitteeSettingsUsername covers the committee settings writer/auditor username scrub. -func TestScrubCommitteeSettingsUsername(t *testing.T) { - origLogger := logger - origQueryTag := queryResourcesByTagFn - origFetchSettings := fetchCommitteeSettingsFn - origApplySettings := applySettingsUpdateFn - origCommitteeClient := committeeClient - t.Cleanup(func() { - logger = origLogger - queryResourcesByTagFn = origQueryTag - fetchCommitteeSettingsFn = origFetchSettings - applySettingsUpdateFn = origApplySettings - committeeClient = origCommitteeClient - }) - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - committeeClient = &committeeservice.Client{} - - const ( - username = "alice" - committeeUID = "comm-uuid-1" - ) - - alice := username - bob := "bob" - aliceEmail := "alice@example.com" - bobEmail := "bob@example.com" - - makeResource := func(uid string) queryResource { return queryResource{UID: uid, Type: "committee_settings"} } - - tests := []struct { - name string - writerResults []queryResource - writerErr error - auditorResults []queryResource - auditorErr error - fetchResult *committeeservice.CommitteeSettingsWithReadonlyAttributes - fetchErr error - wantUpdateCalls int - wantWritersClear bool - wantAuditorsClear bool - }{ - { - name: "both queries return empty → no updates", - }, - { - name: "writer query returns result, username already empty (idempotency) → no update", - writerResults: []queryResource{makeResource(committeeUID)}, - fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ - Writers: []*committeeservice.CommitteeUser{ - {Email: &aliceEmail, Username: nil}, - }, - }, - }, - { - name: "writer query returns result with matching username → update clears writer username", - writerResults: []queryResource{makeResource(committeeUID)}, - fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ - Writers: []*committeeservice.CommitteeUser{ - {Email: &aliceEmail, Username: &alice}, - }, - Auditors: []*committeeservice.CommitteeUser{ - {Email: &bobEmail, Username: &bob}, - }, - }, - wantUpdateCalls: 1, - wantWritersClear: true, - }, - { - name: "auditor query returns result with matching username → update clears auditor username", - auditorResults: []queryResource{makeResource(committeeUID)}, - fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ - Writers: []*committeeservice.CommitteeUser{ - {Email: &bobEmail, Username: &bob}, - }, - Auditors: []*committeeservice.CommitteeUser{ - {Email: &aliceEmail, Username: &alice}, - }, - }, - wantUpdateCalls: 1, - wantAuditorsClear: true, - }, - { - name: "same committee UID in both writer and auditor results → fetched and updated once", - writerResults: []queryResource{makeResource(committeeUID)}, - auditorResults: []queryResource{makeResource(committeeUID)}, - fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ - Writers: []*committeeservice.CommitteeUser{{Email: &aliceEmail, Username: &alice}}, - Auditors: []*committeeservice.CommitteeUser{{Email: &aliceEmail, Username: &alice}}, - }, - wantUpdateCalls: 1, - wantWritersClear: true, - wantAuditorsClear: true, - }, - { - name: "unrelated writer (different username) → not cleared", - writerResults: []queryResource{makeResource(committeeUID)}, - fetchResult: &committeeservice.CommitteeSettingsWithReadonlyAttributes{ - Writers: []*committeeservice.CommitteeUser{ - {Email: &bobEmail, Username: &bob}, - }, - }, - }, - { - name: "fetch error → no update", - writerResults: []queryResource{makeResource(committeeUID)}, - fetchErr: errors.New("not found"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - writerCallCount := 0 - auditorCallCount := 0 - queryResourcesByTagFn = func(_ context.Context, _, tagKey, _ string) ([]queryResource, error) { - switch tagKey { - case "writer": - writerCallCount++ - return tt.writerResults, tt.writerErr - case "auditor": - auditorCallCount++ - return tt.auditorResults, tt.auditorErr - } - return nil, nil - } - - fetchCalls := 0 - fetchCommitteeSettingsFn = func(_ context.Context, gotUID string) (*committeeservice.CommitteeSettingsWithReadonlyAttributes, string, error) { - fetchCalls++ - if gotUID != committeeUID { - t.Errorf("fetchCommitteeSettings called with uid %q, want %q", gotUID, committeeUID) - } - return tt.fetchResult, "etag-1", tt.fetchErr - } - - var updatePayloads []*committeeservice.UpdateCommitteeSettingsPayload - applySettingsUpdateFn = func(_ context.Context, payload *committeeservice.UpdateCommitteeSettingsPayload) error { - updatePayloads = append(updatePayloads, payload) - return nil - } - - scrubCommitteeSettingsUsername(context.Background(), "test-key", username) - - if len(updatePayloads) != tt.wantUpdateCalls { - t.Errorf("applySettingsUpdate called %d times, want %d", len(updatePayloads), tt.wantUpdateCalls) - } - - if tt.wantUpdateCalls > 0 && len(updatePayloads) > 0 { - p := updatePayloads[0] - - if tt.wantWritersClear { - for _, w := range p.Writers { - if w != nil && w.Username != nil && *w.Username == username { - t.Errorf("writer username %q should have been cleared", username) - } - } - } - - if tt.wantAuditorsClear { - for _, a := range p.Auditors { - if a != nil && a.Username != nil && *a.Username == username { - t.Errorf("auditor username %q should have been cleared", username) - } - } - } - - - } - - // Verify deduplication: if same UID in both results, fetch called once. - if len(tt.writerResults) > 0 && len(tt.auditorResults) > 0 { - firstWriterUID := tt.writerResults[0].UID - firstAuditorUID := tt.auditorResults[0].UID - if firstWriterUID == firstAuditorUID && fetchCalls > 1 { - t.Errorf("same committee UID in both results but fetchCommitteeSettings called %d times, want 1", fetchCalls) - } - } - }) - } -} - -// strPtr returns a pointer to the given string; used in test helpers. -func strPtr(s string) *string { return &s } - // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct { From a54ea152402329f0490e145f181d653e5351336e Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 09:58:50 -0700 Subject: [PATCH 05/15] fix(deps): upgrade golang.org/x/text and x/crypto to fix grype CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golang.org/x/text v0.37.0 → v0.40.0 (fixes GO-2026-5970, High) - golang.org/x/crypto v0.52.0 → v0.54.0 (fixes GO-2026-5932) - golang.org/x/sys v0.45.0 → v0.47.0 (transitive) Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- go.mod | 6 +++--- go.sum | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 248f3d7..2607fad 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 goa.design/goa/v3 v3.26.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -56,6 +56,6 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.devnw.com/structs v1.0.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 7aeb9ca..bc2ee6a 100644 --- a/go.sum +++ b/go.sum @@ -105,21 +105,21 @@ go.devnw.com/structs v1.0.0 h1:FFkBoBOkapCdxFEIkpOZRmMOMr9b9hxjKTD3bJYl9lk= go.devnw.com/structs v1.0.0/go.mod h1:wHBkdQpNeazdQHszJ2sxwVEpd8zGTEsKkeywDLGbrmg= goa.design/goa/v3 v3.26.0 h1:lDHpqvhYpRGWcyAznXmU5m3LOZ1VFuP3r35XuL+hlbc= goa.design/goa/v3 v3.26.0/go.mod h1:afBmJ7gfwPSXociyFfVzcKGVCqS2DlGn7F6Olf+9yog= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 429b5a4c04ae8378a4128e8fa78a82a672c0fda0 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 10:16:32 -0700 Subject: [PATCH 06/15] fix(review): add TestHandleMergedUserDeleteScrub and injectable fn vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce getPrimaryEmailForUserFn and publishUserDeletedEventFn package-level vars so tests can stub them without a live NATS connection, matching the existing pattern for deleteIndexKeyFn and lookupMergedUserFn. Replace the dangling // TestHandleMergedUserDeleteScrub comment (which referenced a non-existent function) with the actual test, covering: - username present + email resolved → event published - no username → no publish - email lookup failure → warn, no publish - nil v1Data (hard KV delete) → no publish Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/handlers_users.go | 11 +- cmd/lfx-v1-sync-helper/handlers_users_test.go | 100 +++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 454057e..f0c51a1 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -53,6 +53,13 @@ var ( deleteIndexKeyFn = deleteIndexKey ) +// handleMergedUserDelete dependencies, split out so tests can inject fakes +// without needing a live NATS connection. +var ( + getPrimaryEmailForUserFn = getPrimaryEmailForUser + publishUserDeletedEventFn = publishUserDeletedEvent +) + // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. // NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic @@ -147,12 +154,12 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // without its alternate emails being deleted first, those entries will be orphaned. if username != "" { - email, emailErr := getPrimaryEmailForUser(ctx, userSfid) + email, emailErr := getPrimaryEmailForUserFn(ctx, userSfid) if emailErr != nil { logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid). WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped") } else { - publishUserDeletedEvent(ctx, key, username, email) + publishUserDeletedEventFn(ctx, key, username, email) } } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index c4d2c3f..a722335 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -321,7 +321,105 @@ func TestExtractUsernameIndex(t *testing.T) { } // TestHandleMergedUserDeleteScrub verifies that handleMergedUserDelete triggers the -// committee scrub functions when a username is present in the payload. +// committee username scrub (NATS publish) when a username is present in the payload. +func TestHandleMergedUserDeleteScrub(t *testing.T) { + origLogger := logger + origDeleteIndex := deleteIndexKeyFn + origGetEmail := getPrimaryEmailForUserFn + origPublish := publishUserDeletedEventFn + t.Cleanup(func() { + logger = origLogger + deleteIndexKeyFn = origDeleteIndex + getPrimaryEmailForUserFn = origGetEmail + publishUserDeletedEventFn = origPublish + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + + const ( + userSfid = "003ABC" + username = "alice" + email = "alice@example.com" + ) + + tests := []struct { + name string + v1Data map[string]any + emailErr error + wantPublished bool + wantUsername string + wantEmail string + }{ + { + name: "username present, email resolved → publish event", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": username, + }, + wantPublished: true, + wantUsername: username, + wantEmail: email, + }, + { + name: "no username → no publish", + v1Data: map[string]any{ + "sfid": userSfid, + }, + wantPublished: false, + }, + { + name: "email lookup fails → no publish", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": username, + }, + emailErr: errors.New("email lookup failed"), + wantPublished: false, + }, + { + name: "nil v1Data (hard KV delete) → no publish", + v1Data: nil, + wantPublished: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var publishedUsername, publishedEmail string + var publishCalled bool + + getPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { + if tc.emailErr != nil { + return "", tc.emailErr + } + return email, nil + } + publishUserDeletedEventFn = func(_ context.Context, _, u, e string) { + publishCalled = true + publishedUsername = u + publishedEmail = e + } + + got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data) + + if got { + t.Errorf("handleMergedUserDelete() = true, want false") + } + if publishCalled != tc.wantPublished { + t.Errorf("publishCalled = %v, want %v", publishCalled, tc.wantPublished) + } + if tc.wantPublished { + if publishedUsername != tc.wantUsername { + t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) + } + if publishedEmail != tc.wantEmail { + t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail) + } + } + }) + } +} + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct { From 0caa31c792618732360681994c10861a255face4 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 10:41:50 -0700 Subject: [PATCH 07/15] fix(scrub): cache primary email at update time to survive email-row cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleMergedUserDelete calls getPrimaryEmailForUser to resolve the user's email for the committee scrub event. That lookup reads the alternate-emails mapping array, which handleAlternateEmailDelete clears before the merged-user row is deleted — causing the email lookup to always fail and the scrub event to never be published. Fix: when handleAlternateEmailUpdate processes a primary email record (primary_email__c=true), write the address to a dedicated KV cache key (v1-user.primary-email.). getCachedPrimaryEmailForUser checks this key first and falls back to getPrimaryEmailForUser; handleMergedUserDelete uses getCachedPrimaryEmailForUser via the injectable getPrimaryEmailForUserFn var and deletes the cache key after publishing the event. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/handlers_users.go | 42 ++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index f0c51a1..4336aac 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -19,9 +19,10 @@ import ( const ( // KV key prefixes for secondary indexes written to v1-mappings. - kvKeyUsernamePrefix = "v1-user.username." - kvKeyEmailPrefix = "v1-user.email." - kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyUsernamePrefix = "v1-user.username." + kvKeyEmailPrefix = "v1-user.email." + kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyPrimaryEmailByUserSfid = "v1-user.primary-email." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -56,7 +57,7 @@ var ( // handleMergedUserDelete dependencies, split out so tests can inject fakes // without needing a live NATS connection. var ( - getPrimaryEmailForUserFn = getPrimaryEmailForUser + getPrimaryEmailForUserFn = getCachedPrimaryEmailForUser publishUserDeletedEventFn = publishUserDeletedEvent ) @@ -160,6 +161,11 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped") } else { publishUserDeletedEventFn(ctx, key, username, email) + // Best-effort cleanup of the cached primary-email key. + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after user-deleted event") + } } } @@ -194,6 +200,21 @@ func publishUserDeletedEvent(ctx context.Context, key, username, email string) { InfoContext(ctx, "published user-deleted event for committee username scrub") } +// getCachedPrimaryEmailForUser returns the primary email for a user. It first +// checks the v1-mappings cache written by handleAlternateEmailUpdate (which +// survives alternate-email row deletion), then falls back to the live KV +// lookup. The cache avoids an ordering problem where alternate-email rows are +// cleaned up before the merged-user deletion event is processed. +func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) { + cacheKey := kvKeyPrimaryEmailByUserSfid + userSfid + if entry, err := mappingsKV.Get(ctx, cacheKey); err == nil { + if email := strings.TrimSpace(string(entry.Value())); email != "" { + return email, nil + } + } + return getPrimaryEmailForUser(ctx, userSfid) +} + // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns // true if the error is retryable so the caller can NACK the JetStream message. func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool { @@ -252,9 +273,18 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri } } - // Skip primary emails — the primary email is the Auth0 user's own email - // field, not a linked identity, so it is out of scope for this handler. + // Primary emails are not linked as Auth0 identities (they are the Auth0 + // user's own email). Cache the address in mappings so handleMergedUserDelete + // can supply it to the committee scrub even after the alternate-email rows + // have been cleaned up ahead of the merged-user row. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { + if emailAddr != "" { + cacheKey := kvKeyPrimaryEmailByUserSfid + leadorcontactid + if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid). + WarnContext(ctx, "failed to cache primary email for user deletion scrub") + } + } return shouldRetry } From e5f41c38ab29e568973eb03988e2e9debe5a9562 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 11:14:16 -0700 Subject: [PATCH 08/15] refactor(users): replace primary-email cache with username KV index (LFXV2-2645) The committee service now indexes members by username, so the user-deleted event no longer needs to carry an email address. Remove the v1-user.primary-email.* cache written by handleAlternateEmailUpdate, the getCachedPrimaryEmailForUser fallback wrapper, and the email lookup from handleMergedUserDelete. publishUserDeletedEvent now publishes only {username}; the committee service uses its new username secondary index to find affected members directly. Generated with [Claude Code](https://claude.ai/claude-code) Signed-off-by: Andres Tobon --- cmd/lfx-v1-sync-helper/handlers_users.go | 57 +++---------------- cmd/lfx-v1-sync-helper/handlers_users_test.go | 39 ++----------- 2 files changed, 14 insertions(+), 82 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 1b9b03e..fb9ded8 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -20,10 +20,9 @@ import ( const ( // KV key prefixes for secondary indexes written to v1-mappings. - kvKeyUsernamePrefix = "v1-user.username." - kvKeyEmailPrefix = "v1-user.email." - kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." - kvKeyPrimaryEmailByUserSfid = "v1-user.primary-email." + kvKeyUsernamePrefix = "v1-user.username." + kvKeyEmailPrefix = "v1-user.email." + kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -57,10 +56,7 @@ var ( // handleMergedUserDelete dependencies, split out so tests can inject fakes // without needing a live NATS connection. -var ( - getPrimaryEmailForUserFn = getCachedPrimaryEmailForUser - publishUserDeletedEventFn = publishUserDeletedEvent -) +var publishUserDeletedEventFn = publishUserDeletedEvent // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. @@ -156,18 +152,7 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // without its alternate emails being deleted first, those entries will be orphaned. if username != "" { - email, emailErr := getPrimaryEmailForUserFn(ctx, userSfid) - if emailErr != nil { - logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid). - WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped") - } else { - publishUserDeletedEventFn(ctx, key, username, email) - // Best-effort cleanup of the cached primary-email key. - if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil { - logger.With(errKey, err, "key", key, "user_sfid", userSfid). - WarnContext(ctx, "failed to delete cached primary email after user-deleted event") - } - } + publishUserDeletedEventFn(ctx, key, username) } return false @@ -178,15 +163,14 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // the username from committee members and settings writers/auditors. type userDeletedEvent struct { Username string `json:"username"` - Email string `json:"email"` } const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" // publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish // errors are logged and do not affect the delete handler's return value. -func publishUserDeletedEvent(ctx context.Context, key, username, email string) { - payload, err := json.Marshal(userDeletedEvent{Username: username, Email: email}) +func publishUserDeletedEvent(ctx context.Context, key, username string) { + payload, err := json.Marshal(userDeletedEvent{Username: username}) if err != nil { logger.With(errKey, err, "key", key). ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped") @@ -201,21 +185,6 @@ func publishUserDeletedEvent(ctx context.Context, key, username, email string) { InfoContext(ctx, "published user-deleted event for committee username scrub") } -// getCachedPrimaryEmailForUser returns the primary email for a user. It first -// checks the v1-mappings cache written by handleAlternateEmailUpdate (which -// survives alternate-email row deletion), then falls back to the live KV -// lookup. The cache avoids an ordering problem where alternate-email rows are -// cleaned up before the merged-user deletion event is processed. -func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) { - cacheKey := kvKeyPrimaryEmailByUserSfid + userSfid - if entry, err := mappingsKV.Get(ctx, cacheKey); err == nil { - if email := strings.TrimSpace(string(entry.Value())); email != "" { - return email, nil - } - } - return getPrimaryEmailForUser(ctx, userSfid) -} - // syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns // true if the error is retryable so the caller can NACK the JetStream message. func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool { @@ -292,18 +261,8 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri } } - // Primary emails are not linked as Auth0 identities (they are the Auth0 - // user's own email). Cache the address in mappings so handleMergedUserDelete - // can supply it to the committee scrub even after the alternate-email rows - // have been cleaned up ahead of the merged-user row. + // Primary emails are not linked as Auth0 identities (they are the Auth0 user's own email). if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { - if emailAddr != "" { - cacheKey := kvKeyPrimaryEmailByUserSfid + leadorcontactid - if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil { - logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid). - WarnContext(ctx, "failed to cache primary email for user deletion scrub") - } - } return false } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index e118d60..26488dc 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -325,12 +325,10 @@ func TestExtractUsernameIndex(t *testing.T) { func TestHandleMergedUserDeleteScrub(t *testing.T) { origLogger := logger origDeleteIndex := deleteIndexKeyFn - origGetEmail := getPrimaryEmailForUserFn origPublish := publishUserDeletedEventFn t.Cleanup(func() { logger = origLogger deleteIndexKeyFn = origDeleteIndex - getPrimaryEmailForUserFn = origGetEmail publishUserDeletedEventFn = origPublish }) logger = slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -339,26 +337,22 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { const ( userSfid = "003ABC" username = "alice" - email = "alice@example.com" ) tests := []struct { name string v1Data map[string]any - emailErr error wantPublished bool wantUsername string - wantEmail string }{ { - name: "username present, email resolved → publish event", + name: "username present → publish event", v1Data: map[string]any{ - "sfid": userSfid, + "sfid": userSfid, "username__c": username, }, wantPublished: true, wantUsername: username, - wantEmail: email, }, { name: "no username → no publish", @@ -367,15 +361,6 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { }, wantPublished: false, }, - { - name: "email lookup fails → no publish", - v1Data: map[string]any{ - "sfid": userSfid, - "username__c": username, - }, - emailErr: errors.New("email lookup failed"), - wantPublished: false, - }, { name: "nil v1Data (hard KV delete) → no publish", v1Data: nil, @@ -385,19 +370,12 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - var publishedUsername, publishedEmail string + var publishedUsername string var publishCalled bool - getPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { - if tc.emailErr != nil { - return "", tc.emailErr - } - return email, nil - } - publishUserDeletedEventFn = func(_ context.Context, _, u, e string) { + publishUserDeletedEventFn = func(_ context.Context, _, u string) { publishCalled = true publishedUsername = u - publishedEmail = e } got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data) @@ -408,13 +386,8 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { if publishCalled != tc.wantPublished { t.Errorf("publishCalled = %v, want %v", publishCalled, tc.wantPublished) } - if tc.wantPublished { - if publishedUsername != tc.wantUsername { - t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) - } - if publishedEmail != tc.wantEmail { - t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail) - } + if tc.wantPublished && publishedUsername != tc.wantUsername { + t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) } }) } From e92d7d1640bb5049795e00c2abffb7060c324dc7 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 16:20:11 -0700 Subject: [PATCH 09/15] fix(users): normalize username in user.deleted publish Align event payload with username index normalization, document best-effort publish tradeoff, add publishUserDeletedEvent test, and note x/crypto bump. Co-authored-by: Cursor Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users.go | 20 ++++++-- cmd/lfx-v1-sync-helper/handlers_users_test.go | 48 +++++++++++++++++++ go.mod | 2 +- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index fb9ded8..43a2a3c 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -62,8 +62,12 @@ var publishUserDeletedEventFn = publishUserDeletedEvent // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. // NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic // transposition. RawURLEncoding (no padding) keeps keys opaque and short. +func normalizeKVSegment(s string) string { + return norm.NFC.String(strings.ToLower(strings.TrimSpace(s))) +} + func toKVKey(s string) string { - s = norm.NFC.String(strings.ToLower(strings.TrimSpace(s))) + s = normalizeKVSegment(s) if s == "" { return "" } @@ -151,8 +155,8 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // handleAlternateEmailDelete cleans those up individually — but if the user is deleted // without its alternate emails being deleted first, those entries will be orphaned. - if username != "" { - publishUserDeletedEventFn(ctx, key, username) + if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" { + publishUserDeletedEventFn(ctx, key, normalizedUsername) } return false @@ -168,7 +172,13 @@ type userDeletedEvent struct { const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" // publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish -// errors are logged and do not affect the delete handler's return value. +// errors are logged and do not affect the delete handler's return value (the JetStream +// KV delete is already ACKed). A failed publish can leave username PII in v2 settings +// until a manual re-sync; scrub subscribers treat the event as idempotent. +var natsPublishBytesFn = func(subject string, data []byte) error { + return natsConn.Publish(subject, data) +} + func publishUserDeletedEvent(ctx context.Context, key, username string) { payload, err := json.Marshal(userDeletedEvent{Username: username}) if err != nil { @@ -176,7 +186,7 @@ func publishUserDeletedEvent(ctx context.Context, key, username string) { ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped") return } - if err := natsConn.Publish(v1SyncHelperUserDeletedSubject, payload); err != nil { + if err := natsPublishBytesFn(v1SyncHelperUserDeletedSubject, payload); err != nil { logger.With(errKey, err, "key", key). ErrorContext(ctx, "failed to publish user-deleted event; committee username scrub skipped") return diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 26488dc..fd3e635 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -5,6 +5,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -345,6 +346,15 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { wantPublished bool wantUsername string }{ + { + name: "username present → publish normalized event", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": " Alice ", + }, + wantPublished: true, + wantUsername: "alice", + }, { name: "username present → publish event", v1Data: map[string]any{ @@ -393,6 +403,44 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { } } +func TestPublishUserDeletedEvent(t *testing.T) { + origLogger := logger + origPublish := natsPublishBytesFn + t.Cleanup(func() { + logger = origLogger + natsPublishBytesFn = origPublish + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + t.Run("publishes normalized payload on subject", func(t *testing.T) { + var gotSubject string + var gotPayload userDeletedEvent + natsPublishBytesFn = func(subject string, data []byte) error { + gotSubject = subject + if err := json.Unmarshal(data, &gotPayload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return nil + } + + publishUserDeletedEvent(context.Background(), "test-key", "alice") + + if gotSubject != v1SyncHelperUserDeletedSubject { + t.Fatalf("subject = %q, want %q", gotSubject, v1SyncHelperUserDeletedSubject) + } + if gotPayload.Username != "alice" { + t.Fatalf("username = %q, want alice", gotPayload.Username) + } + }) + + t.Run("publish error is swallowed", func(_ *testing.T) { + natsPublishBytesFn = func(_ string, _ []byte) error { + return errors.New("nats unavailable") + } + publishUserDeletedEvent(context.Background(), "test-key", "alice") + }) +} + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct { diff --git a/go.mod b/go.mod index 2607fad..4c2c07e 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,6 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.devnw.com/structs v1.0.0 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.54.0 // indirect; go mod tidy — GO-2026-5932 has no upstream fix yet golang.org/x/sys v0.47.0 // indirect ) From 688db4a070579618219a1be1038e53ae17d6e8b1 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 16:35:14 -0700 Subject: [PATCH 10/15] fix(users): carry primary email in user.deleted event Include deleted account email in the scrub event payload for downstream reuse guards, reject whitespace-only usernames, and revert x/* bumps that failed license compliance. Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users.go | 17 +++++++---- cmd/lfx-v1-sync-helper/handlers_users_test.go | 30 +++++++++++++++++-- go.mod | 6 ++-- go.sum | 16 +++++----- 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 43a2a3c..cfac85b 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -56,7 +56,10 @@ var ( // handleMergedUserDelete dependencies, split out so tests can inject fakes // without needing a live NATS connection. -var publishUserDeletedEventFn = publishUserDeletedEvent +var ( + publishUserDeletedEventFn = publishUserDeletedEvent + getPrimaryEmailForUserFn = getPrimaryEmailForUser +) // toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 // key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. @@ -156,17 +159,19 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // without its alternate emails being deleted first, those entries will be orphaned. if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" { - publishUserDeletedEventFn(ctx, key, normalizedUsername) + email, _ := getPrimaryEmailForUserFn(ctx, userSfid) + publishUserDeletedEventFn(ctx, key, normalizedUsername, email) } return false } // userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a -// merged user is soft-deleted. The committee service subscribes to this subject and scrubs -// the username from committee members and settings writers/auditors. +// merged user is soft-deleted. Username is the normalized LFID; Email is the deleted +// account's primary email when available so downstream scrubbers can distinguish LFID reuse. type userDeletedEvent struct { Username string `json:"username"` + Email string `json:"email,omitempty"` } const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted" @@ -179,8 +184,8 @@ var natsPublishBytesFn = func(subject string, data []byte) error { return natsConn.Publish(subject, data) } -func publishUserDeletedEvent(ctx context.Context, key, username string) { - payload, err := json.Marshal(userDeletedEvent{Username: username}) +func publishUserDeletedEvent(ctx context.Context, key, username, email string) { + payload, err := json.Marshal(userDeletedEvent{Username: username, Email: email}) if err != nil { logger.With(errKey, err, "key", key). ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped") diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index fd3e635..42b6552 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -327,13 +327,18 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { origLogger := logger origDeleteIndex := deleteIndexKeyFn origPublish := publishUserDeletedEventFn + origEmail := getPrimaryEmailForUserFn t.Cleanup(func() { logger = origLogger deleteIndexKeyFn = origDeleteIndex publishUserDeletedEventFn = origPublish + getPrimaryEmailForUserFn = origEmail }) logger = slog.New(slog.NewTextHandler(io.Discard, nil)) deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + getPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { + return "deleted@example.com", nil + } const ( userSfid = "003ABC" @@ -345,6 +350,7 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { v1Data map[string]any wantPublished bool wantUsername string + wantEmail string }{ { name: "username present → publish normalized event", @@ -354,6 +360,7 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { }, wantPublished: true, wantUsername: "alice", + wantEmail: "deleted@example.com", }, { name: "username present → publish event", @@ -363,6 +370,15 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { }, wantPublished: true, wantUsername: username, + wantEmail: "deleted@example.com", + }, + { + name: "whitespace-only username → no publish", + v1Data: map[string]any{ + "sfid": userSfid, + "username__c": " ", + }, + wantPublished: false, }, { name: "no username → no publish", @@ -381,11 +397,13 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var publishedUsername string + var publishedEmail string var publishCalled bool - publishUserDeletedEventFn = func(_ context.Context, _, u string) { + publishUserDeletedEventFn = func(_ context.Context, _, u, e string) { publishCalled = true publishedUsername = u + publishedEmail = e } got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data) @@ -399,6 +417,9 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { if tc.wantPublished && publishedUsername != tc.wantUsername { t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername) } + if tc.wantPublished && publishedEmail != tc.wantEmail { + t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail) + } }) } } @@ -423,7 +444,7 @@ func TestPublishUserDeletedEvent(t *testing.T) { return nil } - publishUserDeletedEvent(context.Background(), "test-key", "alice") + publishUserDeletedEvent(context.Background(), "test-key", "alice", "alice@example.com") if gotSubject != v1SyncHelperUserDeletedSubject { t.Fatalf("subject = %q, want %q", gotSubject, v1SyncHelperUserDeletedSubject) @@ -431,13 +452,16 @@ func TestPublishUserDeletedEvent(t *testing.T) { if gotPayload.Username != "alice" { t.Fatalf("username = %q, want alice", gotPayload.Username) } + if gotPayload.Email != "alice@example.com" { + t.Fatalf("email = %q, want alice@example.com", gotPayload.Email) + } }) t.Run("publish error is swallowed", func(_ *testing.T) { natsPublishBytesFn = func(_ string, _ []byte) error { return errors.New("nats unavailable") } - publishUserDeletedEvent(context.Background(), "test-key", "alice") + publishUserDeletedEvent(context.Background(), "test-key", "alice", "") }) } diff --git a/go.mod b/go.mod index 4c2c07e..248f3d7 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 goa.design/goa/v3 v3.26.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -56,6 +56,6 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.devnw.com/structs v1.0.0 // indirect - golang.org/x/crypto v0.54.0 // indirect; go mod tidy — GO-2026-5932 has no upstream fix yet - golang.org/x/sys v0.47.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index bc2ee6a..7aeb9ca 100644 --- a/go.sum +++ b/go.sum @@ -105,21 +105,21 @@ go.devnw.com/structs v1.0.0 h1:FFkBoBOkapCdxFEIkpOZRmMOMr9b9hxjKTD3bJYl9lk= go.devnw.com/structs v1.0.0/go.mod h1:wHBkdQpNeazdQHszJ2sxwVEpd8zGTEsKkeywDLGbrmg= goa.design/goa/v3 v3.26.0 h1:lDHpqvhYpRGWcyAznXmU5m3LOZ1VFuP3r35XuL+hlbc= goa.design/goa/v3 v3.26.0/go.mod h1:afBmJ7gfwPSXociyFfVzcKGVCqS2DlGn7F6Olf+9yog= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 02e57668cdcec04ad97ecd9c12a907598e7888fb Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 27 Jul 2026 16:45:23 -0700 Subject: [PATCH 11/15] fix(deps): bump x/text, pip, and uv for grype CVEs Upgrade golang.org/x/text to v0.39.0 (GO-2026-5970) and refresh pip/uv lock constraints to clear MegaLinter grype failures. Signed-off-by: Andres Tobon Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 ++-- pyproject.toml | 4 ++-- uv.lock | 58 ++++++++++++++++++++++++++------------------------ 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 248f3d7..6836383 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 goa.design/goa/v3 v3.26.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.39.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7aeb9ca..cca2b00 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pyproject.toml b/pyproject.toml index 5110d0e..a054e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,6 @@ dependencies = [ [tool.uv] constraint-dependencies = [ - "pip>=26.1", - "uv>=0.11.6", + "pip>=26.1.2", + "uv>=0.11.15", ] diff --git a/uv.lock b/uv.lock index d413004..0b06ad1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,11 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" [manifest] constraints = [ - { name = "pip", specifier = ">=26.1" }, - { name = "uv", specifier = ">=0.11.6" }, + { name = "pip", specifier = ">=26.1.2" }, + { name = "uv", specifier = ">=0.11.15" }, ] [[package]] @@ -190,7 +190,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -370,11 +372,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.1" +version = "26.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, ] [[package]] @@ -686,28 +688,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/a3/be4a946c7c2fc4094c020c8f7d8bd0a739bad55ebe4e2817d6e2b1bc6bff/uv-0.11.14.tar.gz", hash = "sha256:0ea006a117b586b2681b6dfd9703a540d2ad2a136ec0f48d272767e599cc3dfb", size = 4130699, upload-time = "2026-05-12T18:00:37.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/15/9b2138b16eb1fa8c2cd84b1037ad10c38b3acc36ce96c6d27000bfb7e716/uv-0.11.14-py3-none-linux_armv6l.whl", hash = "sha256:78411a883f230a710af19f2ac6e6f0ba8eae90f0e5af4605f923fd367539fff4", size = 23545199, upload-time = "2026-05-12T18:01:34.526Z" }, - { url = "https://files.pythonhosted.org/packages/75/81/c678e8b9a8e624f9c338c66cd57dd9cfc6b5a0501ad3c87fd0cc0bf8850a/uv-0.11.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:078f2e63da89c8fcf6d578f02156045c5990c57d76464aab3f3f798d3fff95cd", size = 22957064, upload-time = "2026-05-12T18:00:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ad/95fbd15b23f26f36d0cfb0ddf159b9602a1b1c0feced60a7f98385e919f1/uv-0.11.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdad43d52c130e3159e84ab1844e04d819d2c4a2495a687d27f80d560a3650e", size = 21678307, upload-time = "2026-05-12T18:00:57.132Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cb/b3da1c4d95d6dd507896bca16dbd643118013b2b151f5f35a08d3391728c/uv-0.11.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9923da7c63d70de9fe71829503d7e7ebfd6304e804d7232aad5f716e190db25b", size = 23353409, upload-time = "2026-05-12T18:01:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/78c6b8d6bcc04c5043b50631e9b413422a03a0bd7c4a997748f8e9cbac25/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b0759ca504e48dcd4fafb1a61ef69aeb24c5a60fbf5f504a7873c8db1b24718", size = 23103964, upload-time = "2026-05-12T18:01:31.094Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/acb66e09bc54a74e4288e996d841af04d88588fd6bdbfbab2468ab7169a7/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78b51b117549ee4db7197ea5ece0848cecd443e464fb9dff9f254cdc1e4ed96f", size = 23104638, upload-time = "2026-05-12T18:01:10.093Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/8497be61accdb8e56d02e11edd3ac471466259420e0bd9c05c1966df134a/uv-0.11.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1ddbe8a2ab160affc179e9c3a40913b23a08cdf55254e1f3829cc22a51a0d8d", size = 24625888, upload-time = "2026-05-12T18:01:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/95/91/f730799fd20a45777b255e20cf9f648a4e4e0979bf65e87a8633197cf7d9/uv-0.11.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3005a2db1e8d72e125630d4f22ac4ceddb2c033e1f9b94b7f3ea38ebac46dd6", size = 25445231, upload-time = "2026-05-12T18:00:40.012Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4d/106463fc27e63e402aec2e791774dac2db5bd5e1c36cdcf38125aa97ab1c/uv-0.11.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5c8f9ea36274ef2f9d24f0522085e280844172e901d9213f66a21b212266706", size = 24571961, upload-time = "2026-05-12T18:00:43.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/4d/163fe746b97bd1129627e8b1f943e17583ddc143eaab532d56a799a9ba5a/uv-0.11.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:379e64b236cf55f762a8308d7efe4365d5296ba29f3a4868761bc45b4e915a71", size = 24718523, upload-time = "2026-05-12T18:01:06.587Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/7a3673494a0cf70267559166398f9c50c4925ff20122f99a28d6c5a80d83/uv-0.11.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:29c12a562441fc2d604e6920c558cacce74a55f889468708683a79b35a6e18a1", size = 23454821, upload-time = "2026-05-12T18:00:51.166Z" }, - { url = "https://files.pythonhosted.org/packages/bb/43/6358394a567d865f3a5ce27b1e0d939549911e36d9b59f0c545a167f92f7/uv-0.11.14-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:e84069681c0334e07cbc7f114eb09d7fe1335e1db0297a66dbca80a1b393fe6d", size = 24087843, upload-time = "2026-05-12T18:00:47.272Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f6/7d0ae1e1f52b85057ca24d8876d6a4cc87b541ea6aca627fe36594c06099/uv-0.11.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b15bf7c146e38d7c938d3a207115d5fdd8ef764fe1f866c225b1bed27e88da1e", size = 24147611, upload-time = "2026-05-12T18:01:20.499Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a2/511ad0c5da5697fd990b99569425b62b81cbc3458c35acc845211b55d6b5/uv-0.11.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ddda5c5e41097814adac535c74851bae55e8097b9afc79aeae7fcffd8d86c06d", size = 23920348, upload-time = "2026-05-12T18:01:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b6/7084e3401b1f1020f215a125136eec1ed2bd541e10a5fea1625515579599/uv-0.11.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e54326703f1eca83a6fd73275e0f398b16b7d3f81531bf58899c2869bc403f6c", size = 24928981, upload-time = "2026-05-12T18:01:13.961Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6a/7e81729fe729889c8cc63bbf64291734359bd7f6ba84852dc0504453511d/uv-0.11.14-py3-none-win32.whl", hash = "sha256:b384d873d0d18552c7524226125efd3965d921b7134c2f476c333771beb733e1", size = 22573503, upload-time = "2026-05-12T18:00:34.36Z" }, - { url = "https://files.pythonhosted.org/packages/94/5d/f8905f9af5cd46af2a688b2246dbb5a4d95b8557eeffd7f241e037659d9e/uv-0.11.14-py3-none-win_amd64.whl", hash = "sha256:f0a8b58b38e984241bca5d7a5a47bf9ffe1ca2ab392a640887db8a04c4a9ec95", size = 25175590, upload-time = "2026-05-12T18:01:00.38Z" }, - { url = "https://files.pythonhosted.org/packages/04/cb/7333d08d944f3018eb89242cd5e646e7b37faa1b567faeaf9254a8b59d53/uv-0.11.14-py3-none-win_arm64.whl", hash = "sha256:6a13e7e064563050c6606b3fd77091d427cdbdc5938b6f134baf8d8ec79bfdb7", size = 23594775, upload-time = "2026-05-12T18:01:03.55Z" }, +version = "0.11.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/7c/29fad91a39d7926681f20245d60815b14b481f300cef9c37f1e5ae3cd2a7/uv-0.11.32.tar.gz", hash = "sha256:5359a7b0de78ba99b2519d33c173e004c39111c4baebe1b7c3d111a2de2011e1", size = 5790510, upload-time = "2026-07-23T23:05:52.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/ca/5ea42012c0ecbaac481f099f3c90ee4db737b3ec0bb6f51fa571e234e740/uv-0.11.32-py3-none-linux_armv6l.whl", hash = "sha256:26d61d1b640d2dcfd7c3b64e75ada14c8eb477accba57f414c1a3759d6ab2253", size = 25933056, upload-time = "2026-07-23T23:05:06.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/85/ef4d0f02f469cfad1d73c11bf31ac0a8f24da3c62d7f3305c50a5e51e814/uv-0.11.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c82954b1bd507e767b1d996c2865ea3eb7d464a479ec0e4d60f0b8e2e07dee45", size = 24918625, upload-time = "2026-07-23T23:05:09.299Z" }, + { url = "https://files.pythonhosted.org/packages/60/43/ec2de11ef008e76a8d01c34d573c939c565694f72c0d008d200aa3a5eac3/uv-0.11.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a56d1abf16337350e878b2c869b256b8448ecc4a722b98f8d41e1b3680a587b", size = 23519325, upload-time = "2026-07-23T23:05:11.877Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/0d6a8ee5066385fce65a5ef57939b1f63e9ff955afa07fa423c3e7e0abdb/uv-0.11.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:125c142363d0842c8506a057da56bae182e2aa3957344f57dd9ef20ea10f06b0", size = 25450318, upload-time = "2026-07-23T23:05:14.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/ec/fee4f0d12c7c42bf3b86eda615185860a76505c19e5b0e82afee8bf1c1a3/uv-0.11.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:96625d832fe5b94ed0a029764cef0879ff47e3a525aec5c99d360a959e2f4aea", size = 25421693, upload-time = "2026-07-23T23:05:17.083Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/cd77d7d87411030fb183f0e444634d7ba9803cd6ab64bc240d73ce50a071/uv-0.11.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ffc8322ce88b29dc0a905c102ea7d32c9021ee0353b641674e350c65fa930a", size = 25451542, upload-time = "2026-07-23T23:05:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/78/3b/3127e224c05101a9675b579978c02d3b500f4362004abb177acdc6294264/uv-0.11.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e36fe8ff79ab7d13e9ad45d24bb9a52c1024caa6897cf62933bb3f760643d13", size = 26809886, upload-time = "2026-07-23T23:05:22.35Z" }, + { url = "https://files.pythonhosted.org/packages/4e/59/82c05d4f1ce1de47667efa6548a14240a9c409388a09b788386317e190ca/uv-0.11.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:19fb9f2dba53851c0ee6e7e5582bd355fcaa9b1387b5d688f5bb9efc543dc605", size = 27608113, upload-time = "2026-07-23T23:05:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/649b350677a1fb07938e1b5b33ac7564339f5e1f295a4a958b7181eb9924/uv-0.11.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f030a91a94655af11e41dafe1b41e1a90e1f2641bf13ff7ed7d4f5fb4f031d", size = 26788162, upload-time = "2026-07-23T23:05:27.585Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/114463d056b6b328d45557001e848b8ab15539bd8f4fa7a457ccb83e2b5d/uv-0.11.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3da76cd4e2697de30928b8a8524bd39183ac1e08cb7e72833807c022b7cba6c4", size = 27013687, upload-time = "2026-07-23T23:05:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/6ee5278036c57bcc7ee9c99cd890bac20d4431e63416ffe4080ba8f92767/uv-0.11.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be0799f1ad70c755d10de5aaf46af94199d4f16a992f90278f2662350cd3f4fe", size = 25582781, upload-time = "2026-07-23T23:05:32.623Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/2055d3db9ee3e4f59a14912bfcfacb3ec537d60d8b51a8477e4ddfc11e5c/uv-0.11.32-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f35b3b8b65ba4579f8b23645a19394f7e5f90b20b07376ecff18b3bb656a81ae", size = 26291488, upload-time = "2026-07-23T23:05:35.219Z" }, + { url = "https://files.pythonhosted.org/packages/dc/59/999a5e83f927539a5b90b708abf77642f07faccf04f37c1f933864c403c6/uv-0.11.32-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cd085addb35f074561d2c5659a088f5f10361502bd3ad4c8f5105f5a4b9d2817", size = 26419928, upload-time = "2026-07-23T23:05:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/7575563aeac458053d220a95eb1c5aeda14870414bd6db00e9eb673c3793/uv-0.11.32-py3-none-musllinux_1_1_i686.whl", hash = "sha256:43360834111ae917808a70b36f4fb0e53de8e0be422e4cd4445876ffae8decc2", size = 26077451, upload-time = "2026-07-23T23:05:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/02/58/0a92505cfe2b02b1c2d6ded23e83763fe98b43fbe1a4e5cbd6866da3bdf7/uv-0.11.32-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:77f4356548ee8dc47efae154efd4e930c65570e7d4971c57bdef592f6eefb39c", size = 27223406, upload-time = "2026-07-23T23:05:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/1f7cf58780bb1c5d6ea89126438971d8e61ad04d290e1c34ce1e8b136a83/uv-0.11.32-py3-none-win32.whl", hash = "sha256:bb1ae8189e315499a77f3cc27cac5985ab1d8cb79baa82e368b6c1e574a7d9cf", size = 24681569, upload-time = "2026-07-23T23:05:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/60/a645cca710448004268b7731c4dc6fc7d084a7c620fc73ecc2127cb61661/uv-0.11.32-py3-none-win_amd64.whl", hash = "sha256:855632ee1d2a8491986efa54c562b806cd32477761889a1e13624b519d98a45e", size = 27780686, upload-time = "2026-07-23T23:05:48.141Z" }, + { url = "https://files.pythonhosted.org/packages/13/68/efb90a3e46c9f93e577b34e82467f232308a0ff3b12f807226caca6f72e2/uv-0.11.32-py3-none-win_arm64.whl", hash = "sha256:bf988bf510772785eac1edfc7cdedca074e8936b45e755b58f7f3e1e9ca86424", size = 25882589, upload-time = "2026-07-23T23:05:50.723Z" }, ] [[package]] From 3047696cca3167f0832f58998ce7db507ab49fc9 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 28 Jul 2026 08:44:16 -0700 Subject: [PATCH 12/15] fix(users): restore primary-email cache for user.deleted event Reintroduce v1-user.primary-email.* cache written on primary alternate-email updates so handleMergedUserDelete can populate email after alternate-email rows are cleaned up; add debug logging on lookup miss and split normalizeKVSegment/toKVKey godoc. Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users.go | 70 ++++++++++-- cmd/lfx-v1-sync-helper/handlers_users_test.go | 100 ++++++++++++++++-- 2 files changed, 154 insertions(+), 16 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index cfac85b..ccb349b 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -20,9 +20,10 @@ import ( const ( // KV key prefixes for secondary indexes written to v1-mappings. - kvKeyUsernamePrefix = "v1-user.username." - kvKeyEmailPrefix = "v1-user.email." - kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyUsernamePrefix = "v1-user.username." + kvKeyEmailPrefix = "v1-user.email." + kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyPrimaryEmailByUserSfid = "v1-user.primary-email." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -58,17 +59,31 @@ var ( // without needing a live NATS connection. var ( publishUserDeletedEventFn = publishUserDeletedEvent - getPrimaryEmailForUserFn = getPrimaryEmailForUser + getPrimaryEmailForUserFn = getCachedPrimaryEmailForUser ) -// toKVKey normalizes a user-provided string and encodes it as a URL-safe base64 -// key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding. -// NFC unifies decomposed/precomposed Unicode (e.g. n\u0303 ≡ ñ) without semantic -// transposition. RawURLEncoding (no padding) keeps keys opaque and short. +// readMappingsKVValueFn reads a raw value from v1-mappings. Swappable in tests. +var readMappingsKVValueFn = func(ctx context.Context, key string) ([]byte, error) { + entry, err := mappingsKV.Get(ctx, key) + if err != nil { + return nil, err + } + return entry.Value(), nil +} + +// lookupPrimaryEmailForUserFn is the live alternate-email lookup used when the +// primary-email cache misses. Swappable in tests. +var lookupPrimaryEmailForUserFn = getPrimaryEmailForUser + +// normalizeKVSegment normalizes a user-provided string for NATS KV key segments: +// TrimSpace → ToLower → NFC. NFC unifies decomposed/precomposed Unicode +// (e.g. n\u0303 ≡ ñ) without semantic transposition. func normalizeKVSegment(s string) string { return norm.NFC.String(strings.ToLower(strings.TrimSpace(s))) } +// toKVKey encodes a normalized string as a URL-safe base64 key segment +// (RawURLEncoding, no padding) safe for NATS KV. func toKVKey(s string) string { s = normalizeKVSegment(s) if s == "" { @@ -159,13 +174,40 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // without its alternate emails being deleted first, those entries will be orphaned. if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" { - email, _ := getPrimaryEmailForUserFn(ctx, userSfid) + email, emailErr := getPrimaryEmailForUserFn(ctx, userSfid) + if emailErr != nil { + logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid). + DebugContext(ctx, "failed to look up primary email for deleted user; publishing user-deleted event without email") + } else if email == "" { + logger.With("key", key, "user_sfid", userSfid). + DebugContext(ctx, "no primary email found for deleted user; publishing user-deleted event without email") + } publishUserDeletedEventFn(ctx, key, normalizedUsername, email) + // Best-effort cleanup of the cached primary-email key. + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after user-deleted event") + } } return false } +// getCachedPrimaryEmailForUser returns the primary email for a user. It first +// checks the v1-mappings cache written by handleAlternateEmailUpdate (which +// survives alternate-email row deletion), then falls back to the live KV +// lookup. The cache avoids an ordering problem where alternate-email rows are +// cleaned up before the merged-user deletion event is processed. +func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) { + cacheKey := kvKeyPrimaryEmailByUserSfid + userSfid + if raw, err := readMappingsKVValueFn(ctx, cacheKey); err == nil { + if email := strings.TrimSpace(string(raw)); email != "" { + return email, nil + } + } + return lookupPrimaryEmailForUserFn(ctx, userSfid) +} + // userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a // merged user is soft-deleted. Username is the normalized LFID; Email is the deleted // account's primary email when available so downstream scrubbers can distinguish LFID reuse. @@ -277,7 +319,17 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri } // Primary emails are not linked as Auth0 identities (they are the Auth0 user's own email). + // Cache the address in mappings so handleMergedUserDelete can supply it to downstream + // scrubbers even after the alternate-email rows have been cleaned up ahead of the + // merged-user row. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { + if emailAddr != "" { + cacheKey := kvKeyPrimaryEmailByUserSfid + leadorcontactid + if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid). + WarnContext(ctx, "failed to cache primary email for user deletion scrub") + } + } return false } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 42b6552..e266879 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -335,10 +335,6 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { getPrimaryEmailForUserFn = origEmail }) logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } - getPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { - return "deleted@example.com", nil - } const ( userSfid = "003ABC" @@ -348,29 +344,42 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { tests := []struct { name string v1Data map[string]any + emailResult string + emailErr error wantPublished bool wantUsername string wantEmail string + wantDeleted []string }{ { - name: "username present → publish normalized event", + name: "username present → publish normalized event and clear primary-email cache", v1Data: map[string]any{ "sfid": userSfid, "username__c": " Alice ", }, + emailResult: "deleted@example.com", wantPublished: true, wantUsername: "alice", wantEmail: "deleted@example.com", + wantDeleted: []string{ + kvKeyUsernamePrefix + usernameToKVKey(" Alice "), + kvKeyPrimaryEmailByUserSfid + userSfid, + }, }, { - name: "username present → publish event", + name: "email lookup error → still publish without email", v1Data: map[string]any{ "sfid": userSfid, "username__c": username, }, + emailErr: errors.New("alternate emails mapping gone"), wantPublished: true, wantUsername: username, - wantEmail: "deleted@example.com", + wantEmail: "", + wantDeleted: []string{ + kvKeyUsernamePrefix + usernameToKVKey(username), + kvKeyPrimaryEmailByUserSfid + userSfid, + }, }, { name: "whitespace-only username → no publish", @@ -396,6 +405,18 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + var deletedKeys []string + deleteIndexKeyFn = func(_ context.Context, key string) error { + deletedKeys = append(deletedKeys, key) + return nil + } + getPrimaryEmailForUserFn = func(_ context.Context, gotSfid string) (string, error) { + if gotSfid != userSfid { + t.Errorf("getPrimaryEmailForUserFn called with sfid %q, want %q", gotSfid, userSfid) + } + return tc.emailResult, tc.emailErr + } + var publishedUsername string var publishedEmail string var publishCalled bool @@ -420,10 +441,75 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { if tc.wantPublished && publishedEmail != tc.wantEmail { t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail) } + if tc.wantDeleted != nil { + if len(deletedKeys) != len(tc.wantDeleted) { + t.Fatalf("deletedKeys = %v, want %v", deletedKeys, tc.wantDeleted) + } + for i, want := range tc.wantDeleted { + if deletedKeys[i] != want { + t.Errorf("deletedKeys[%d] = %q, want %q", i, deletedKeys[i], want) + } + } + } else if len(deletedKeys) != 0 { + t.Errorf("expected no index deletes, got %v", deletedKeys) + } }) } } +// TestGetCachedPrimaryEmailForUser verifies cache-first lookup with live fallback. +func TestGetCachedPrimaryEmailForUser(t *testing.T) { + origRead := readMappingsKVValueFn + origLive := lookupPrimaryEmailForUserFn + t.Cleanup(func() { + readMappingsKVValueFn = origRead + lookupPrimaryEmailForUserFn = origLive + }) + + const userSfid = "003ABC" + + t.Run("cache hit → no live lookup", func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, key string) ([]byte, error) { + if key == kvKeyPrimaryEmailByUserSfid+userSfid { + return []byte("cached@example.com"), nil + } + return nil, errors.New("unexpected key") + } + lookupPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) { + t.Fatal("live lookup should not run on cache hit") + return "", nil + } + + email, err := getCachedPrimaryEmailForUser(context.Background(), userSfid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if email != "cached@example.com" { + t.Fatalf("email = %q, want cached@example.com", email) + } + }) + + t.Run("cache miss → live lookup", func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, _ string) ([]byte, error) { + return nil, errors.New("cache miss") + } + lookupPrimaryEmailForUserFn = func(_ context.Context, gotSfid string) (string, error) { + if gotSfid != userSfid { + t.Errorf("lookupPrimaryEmailForUserFn sfid = %q, want %q", gotSfid, userSfid) + } + return "live@example.com", nil + } + + email, err := getCachedPrimaryEmailForUser(context.Background(), userSfid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if email != "live@example.com" { + t.Fatalf("email = %q, want live@example.com", email) + } + }) +} + func TestPublishUserDeletedEvent(t *testing.T) { origLogger := logger origPublish := natsPublishBytesFn From 09bf1c2f4791e101080ccaa3aac63625de809e0d Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 28 Jul 2026 10:54:54 -0700 Subject: [PATCH 13/15] fix(users): harden primary-email cache lifecycle Hoist cache delete outside the username guard, clear cache on primary-email delete/demote, and rename kvKeyPrimaryEmailPrefix for naming consistency. Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users.go | 40 +++++++++++++++---- cmd/lfx-v1-sync-helper/handlers_users_test.go | 16 +++++--- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index ccb349b..4b656ee 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -23,7 +23,7 @@ const ( kvKeyUsernamePrefix = "v1-user.username." kvKeyEmailPrefix = "v1-user.email." kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." - kvKeyPrimaryEmailByUserSfid = "v1-user.primary-email." + kvKeyPrimaryEmailPrefix = "v1-user.primary-email." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -183,11 +183,12 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma DebugContext(ctx, "no primary email found for deleted user; publishing user-deleted event without email") } publishUserDeletedEventFn(ctx, key, normalizedUsername, email) - // Best-effort cleanup of the cached primary-email key. - if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil { - logger.With(errKey, err, "key", key, "user_sfid", userSfid). - WarnContext(ctx, "failed to delete cached primary email after user-deleted event") - } + } + + // Best-effort cleanup of the cached primary-email key (even when username is blank). + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailPrefix+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after user delete") } return false @@ -199,7 +200,7 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // lookup. The cache avoids an ordering problem where alternate-email rows are // cleaned up before the merged-user deletion event is processed. func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) { - cacheKey := kvKeyPrimaryEmailByUserSfid + userSfid + cacheKey := kvKeyPrimaryEmailPrefix + userSfid if raw, err := readMappingsKVValueFn(ctx, cacheKey); err == nil { if email := strings.TrimSpace(string(raw)); email != "" { return email, nil @@ -208,6 +209,26 @@ func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, return lookupPrimaryEmailForUserFn(ctx, userSfid) } +// clearPrimaryEmailCacheIfMatched deletes the cached primary email when it still +// points at emailAddr (e.g. after a primary row is demoted or deleted). +func clearPrimaryEmailCacheIfMatched(ctx context.Context, key, userSfid, emailAddr string) { + if emailAddr == "" || userSfid == "" { + return + } + cacheKey := kvKeyPrimaryEmailPrefix + userSfid + raw, err := readMappingsKVValueFn(ctx, cacheKey) + if err != nil { + return + } + if !strings.EqualFold(strings.TrimSpace(string(raw)), strings.TrimSpace(emailAddr)) { + return + } + if err := deleteIndexKeyFn(ctx, cacheKey); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete stale primary email cache") + } +} + // userDeletedEvent is the payload published to "lfx.v1-sync-helper.user.deleted" when a // merged user is soft-deleted. Username is the normalized LFID; Email is the deleted // account's primary email when available so downstream scrubbers can distinguish LFID reuse. @@ -324,7 +345,7 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri // merged-user row. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { if emailAddr != "" { - cacheKey := kvKeyPrimaryEmailByUserSfid + leadorcontactid + cacheKey := kvKeyPrimaryEmailPrefix + leadorcontactid if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil { logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid). WarnContext(ctx, "failed to cache primary email for user deletion scrub") @@ -333,6 +354,8 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri return false } + clearPrimaryEmailCacheIfMatched(ctx, key, leadorcontactid, emailAddr) + // If this is the user's only qualifying alternate email, treat it as // though it were flagged primary (see isSoleQualifyingAlternateEmail): // v1 lazy-sync may not have created/synced the primary row yet. @@ -442,6 +465,7 @@ func handleAlternateEmailDelete(ctx context.Context, key, emailSfid string, v1Da // Skip primary emails — the primary email is the Auth0 user's own email // field, not a linked identity, so it is out of scope for this handler. if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary { + clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr) return false } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index e266879..42ef4a5 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -363,7 +363,7 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { wantEmail: "deleted@example.com", wantDeleted: []string{ kvKeyUsernamePrefix + usernameToKVKey(" Alice "), - kvKeyPrimaryEmailByUserSfid + userSfid, + kvKeyPrimaryEmailPrefix + userSfid, }, }, { @@ -378,23 +378,29 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { wantEmail: "", wantDeleted: []string{ kvKeyUsernamePrefix + usernameToKVKey(username), - kvKeyPrimaryEmailByUserSfid + userSfid, + kvKeyPrimaryEmailPrefix + userSfid, }, }, { - name: "whitespace-only username → no publish", + name: "whitespace-only username → no publish but clear primary-email cache", v1Data: map[string]any{ "sfid": userSfid, "username__c": " ", }, wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, }, { - name: "no username → no publish", + name: "no username → no publish but still clear primary-email cache", v1Data: map[string]any{ "sfid": userSfid, }, wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, }, { name: "nil v1Data (hard KV delete) → no publish", @@ -470,7 +476,7 @@ func TestGetCachedPrimaryEmailForUser(t *testing.T) { t.Run("cache hit → no live lookup", func(t *testing.T) { readMappingsKVValueFn = func(_ context.Context, key string) ([]byte, error) { - if key == kvKeyPrimaryEmailByUserSfid+userSfid { + if key == kvKeyPrimaryEmailPrefix+userSfid { return []byte("cached@example.com"), nil } return nil, errors.New("unexpected key") From 976350c0a1e09bbf5589beb60b5bf93044c16e24 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 28 Jul 2026 10:55:30 -0700 Subject: [PATCH 14/15] test(users): stub primary-email cache read in delete tests Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 42ef4a5..98d60b2 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -613,12 +613,14 @@ func TestHandleAlternateEmailDelete(t *testing.T) { origUnlink := unlinkEmailIdentityFn origUpdateEmails := updateContactEmailMappingIndexFn origDeleteIndex := deleteIndexKeyFn + origReadCache := readMappingsKVValueFn t.Cleanup(func() { logger = origLogger lookupMergedUserFn = origLookup unlinkEmailIdentityFn = origUnlink updateContactEmailMappingIndexFn = origUpdateEmails deleteIndexKeyFn = origDeleteIndex + readMappingsKVValueFn = origReadCache }) logger = slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -627,6 +629,9 @@ func TestHandleAlternateEmailDelete(t *testing.T) { // so return nil to simulate the normal successful case. updateContactEmailMappingIndexFn = func(_ context.Context, _, _ string, _ bool) error { return nil } deleteIndexKeyFn = func(_ context.Context, _ string) error { return nil } + readMappingsKVValueFn = func(_ context.Context, _ string) ([]byte, error) { + return nil, errors.New("cache miss") + } const ( userSfid = "003DEF" From f185b2767a340be7b11d2c27f6d14ff2c8c95b17 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Tue, 28 Jul 2026 11:42:21 -0700 Subject: [PATCH 15/15] fix(users): gofmt, cache-clear tests, and proactive PII hardening Run gofmt on KV prefix const block, add TestClearPrimaryEmailCacheIfMatched, drop raw LFID from publish INFO log, and clear primary-email cache on hard delete. Signed-off-by: Andres Tobon Co-authored-by: Cursor --- cmd/lfx-v1-sync-helper/handlers_users.go | 14 ++- cmd/lfx-v1-sync-helper/handlers_users_test.go | 109 +++++++++++++++++- 2 files changed, 117 insertions(+), 6 deletions(-) diff --git a/cmd/lfx-v1-sync-helper/handlers_users.go b/cmd/lfx-v1-sync-helper/handlers_users.go index 4b656ee..7db6e0c 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users.go +++ b/cmd/lfx-v1-sync-helper/handlers_users.go @@ -20,10 +20,10 @@ import ( const ( // KV key prefixes for secondary indexes written to v1-mappings. - kvKeyUsernamePrefix = "v1-user.username." - kvKeyEmailPrefix = "v1-user.email." - kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." - kvKeyPrimaryEmailPrefix = "v1-user.primary-email." + kvKeyUsernamePrefix = "v1-user.username." + kvKeyEmailPrefix = "v1-user.email." + kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails." + kvKeyPrimaryEmailPrefix = "v1-user.primary-email." // v1-objects KV key prefixes as replicated by Meltano. v1MergedUserKVPrefix = "salesforce-merged_user." @@ -153,6 +153,10 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma // always carry a payload, so log a warning if we see this. logger.With("key", key, "user_sfid", userSfid). WarnContext(ctx, "merged_user hard-deleted with no payload; cannot clean up username index") + if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailPrefix+userSfid); err != nil { + logger.With(errKey, err, "key", key, "user_sfid", userSfid). + WarnContext(ctx, "failed to delete cached primary email after hard user delete") + } return false } @@ -259,7 +263,7 @@ func publishUserDeletedEvent(ctx context.Context, key, username, email string) { ErrorContext(ctx, "failed to publish user-deleted event; committee username scrub skipped") return } - logger.With("key", key, "username", username). + logger.With("key", key). InfoContext(ctx, "published user-deleted event for committee username scrub") } diff --git a/cmd/lfx-v1-sync-helper/handlers_users_test.go b/cmd/lfx-v1-sync-helper/handlers_users_test.go index 98d60b2..f363763 100644 --- a/cmd/lfx-v1-sync-helper/handlers_users_test.go +++ b/cmd/lfx-v1-sync-helper/handlers_users_test.go @@ -403,9 +403,12 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) { }, }, { - name: "nil v1Data (hard KV delete) → no publish", + name: "nil v1Data (hard KV delete) → no publish but clear primary-email cache", v1Data: nil, wantPublished: false, + wantDeleted: []string{ + kvKeyPrimaryEmailPrefix + userSfid, + }, }, } @@ -557,6 +560,110 @@ func TestPublishUserDeletedEvent(t *testing.T) { }) } +// TestClearPrimaryEmailCacheIfMatched verifies conditional cache cleanup on demote/delete paths. +func TestClearPrimaryEmailCacheIfMatched(t *testing.T) { + origLogger := logger + origRead := readMappingsKVValueFn + origDelete := deleteIndexKeyFn + t.Cleanup(func() { + logger = origLogger + readMappingsKVValueFn = origRead + deleteIndexKeyFn = origDelete + }) + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + const ( + key = "test-key" + userSfid = "003ABC" + ) + cacheKey := kvKeyPrimaryEmailPrefix + userSfid + + tests := []struct { + name string + sfid string + emailAddr string + readResult []byte + readErr error + wantDelete bool + }{ + { + name: "match → delete", + sfid: userSfid, + emailAddr: "a@example.com", + readResult: []byte("a@example.com"), + wantDelete: true, + }, + { + name: "case-insensitive match → delete", + sfid: userSfid, + emailAddr: "A@Example.COM", + readResult: []byte("a@example.com"), + wantDelete: true, + }, + { + name: "no match → skip", + sfid: userSfid, + emailAddr: "a@example.com", + readResult: []byte("b@example.com"), + wantDelete: false, + }, + { + name: "empty email → skip", + sfid: userSfid, + emailAddr: "", + readResult: []byte("a@example.com"), + wantDelete: false, + }, + { + name: "empty user sfid → skip", + sfid: "", + emailAddr: "a@example.com", + readResult: []byte("a@example.com"), + wantDelete: false, + }, + { + name: "read error → skip", + sfid: userSfid, + emailAddr: "a@example.com", + readErr: errors.New("cache miss"), + wantDelete: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readMappingsKVValueFn = func(_ context.Context, gotKey string) ([]byte, error) { + if tt.sfid == "" { + t.Fatal("read should not run when user sfid is empty") + } + if gotKey != cacheKey { + t.Fatalf("read key = %q, want %q", gotKey, cacheKey) + } + return tt.readResult, tt.readErr + } + + var deletedKeys []string + deleteIndexKeyFn = func(_ context.Context, gotKey string) error { + deletedKeys = append(deletedKeys, gotKey) + return nil + } + + clearPrimaryEmailCacheIfMatched(context.Background(), key, tt.sfid, tt.emailAddr) + + if tt.wantDelete { + if len(deletedKeys) != 1 { + t.Fatalf("deletedKeys = %v, want one delete", deletedKeys) + } + if deletedKeys[0] != cacheKey { + t.Fatalf("deleted key = %q, want %q", deletedKeys[0], cacheKey) + } + } else if len(deletedKeys) != 0 { + t.Fatalf("expected no delete, got %v", deletedKeys) + } + }) + } +} + // TestExtractEmailIndex covers the field extraction for the alternate_email reindex phase. func TestExtractEmailIndex(t *testing.T) { tests := []struct {