[LFXV2-2645] feat(users): scrub username from committee data on user deletion - #133
Conversation
…V2-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 <andrest2455@gmail.com>
…test 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 <andrest2455@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds committee username cleanup when deleting merged users.
Changes:
- Adds query-service configuration and client.
- Scrubs committee members and settings with reuse guards.
- Adds unit tests for cleanup behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
client_query.go |
Adds query-service resource lookup. |
config.go |
Adds query-service URL configuration. |
client_committees.go |
Adds committee fetch/update helpers. |
handlers_users.go |
Implements username scrubbing. |
handlers_users_test.go |
Tests deletion and scrub flows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com>
…lete (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 <andrest2455@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
cmd/lfx-v1-sync-helper/handlers_users.go:153
- The scrub is skipped in the normal deletion order documented immediately above:
handleAlternateEmailDeleteremoves each email SFID from this user's mapping (handlers_users.go:301-303), whilegetPrimaryEmailForUserrequires that mapping and returns an error when it is absent or empty. If alternate-email rows are deleted before the merged-user row, no event is published and the committee username remains. Source the guard email from data that survives this ordering, or emit/capture the scrub event before the email mapping is removed.
if username != "" {
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")
cmd/lfx-v1-sync-helper/handlers_users.go:184
- A core NATS
Publishis not a durable handoff: success only means the message was queued on this client. If the committee-service subscriber is unavailable, or this process exits before the socket flushes, the event is lost; the delete handler still returnsfalse, so the source JetStream message is ACKed and never retried. Use a persisted/acknowledged delivery mechanism and propagate publication failure so the source deletion can be retried.
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
- 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 <andrest2455@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
cmd/lfx-v1-sync-helper/handlers_users.go:153
- This lookup depends on the alternate-email mapping and live alternate-email records, but
handleAlternateEmailDeleteremoves each SFID from that mapping before returning (lines 301-315), andgetV1ObjectDatatreats soft-deleted rows as absent (lfx_v1_client.go:1229-1236). The preceding comment notes those rows are normally deleted before or alongside the user, so the normal ordering leaves no primary email, skips this event, and never scrubs committee data. Preserve the primary email independently until the merged-user delete is handled (or resolve it from another durable source) so the event can always be emitted.
if username != "" {
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")
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 <andrest2455@gmail.com>
…leanup 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.<sfid>). 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 <andrest2455@gmail.com>
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 <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
cmd/lfx-v1-sync-helper/handlers_users.go:163
getPrimaryEmailForUserreads the alternate-email mapping, buthandleAlternateEmailDeletenormally removes those entries before the merged-user deletion. This therefore commonly returns an error; the error is discarded and an event withoutemailis still published. The linked committee consumer only applies its LFID-reuse guard whenemailis non-empty, so a delayed event can clear a reassigned user's committee username. Persist the primary email before alternate-email cleanup (as the earlier cache implementation did) and publish only with that guard value; the added test should also exercise lookup failure instead of always returning success.
email, _ := getPrimaryEmailForUserFn(ctx, userSfid)
publishUserDeletedEventFn(ctx, key, normalizedUsername, email)
cmd/lfx-v1-sync-helper/handlers_users.go:61
- The PR description still claims
go.mod/go.sumupgradex/textto v0.40.0 andx/cryptoto v0.54.0, but the current PR changes only these two handler files and the repository remains on v0.37.0/v0.52.0. Thus the advertised dependency/CVE remediation is not part of this change. Update the PR description to note that the bumps were reverted, or restore a compliant remediation in a separate dependency change.
publishUserDeletedEventFn = publishUserDeletedEvent
getPrimaryEmailForUserFn = getPrimaryEmailForUser
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 <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
cmd/lfx-v1-sync-helper/handlers_users.go:163
- Do not publish a username-only deletion when this lookup fails.
getPrimaryEmailForUserreads the alternate-email mapping and live rows, buthandleAlternateEmailDeletenormally removes that mapping before the merged-user delete arrives. The linked committee consumer only applies its LFID-reuse guard whenemailis non-empty, so this fallback can scrub a newly reassigned user's committee identity/access. Persist the primary email before alternate-email cleanup (as the prior fix described), use that durable value here, and cover lookup failure in the test.
email, _ := getPrimaryEmailForUserFn(ctx, userSfid)
publishUserDeletedEventFn(ctx, key, normalizedUsername, email)
go.mod:25
- The dependency changes do not match the PR's stated security update: this sets
x/textto v0.39.0 rather than v0.40.0, whilex/cryptoremains at v0.52.0 instead of the described v0.54.0. Regeneratego.mod/go.sumwith the intended versions, or correct the PR description and vulnerability claims if these versions are deliberate.
golang.org/x/text v0.39.0
dealako
left a comment
There was a problem hiding this comment.
Hey @andrest50 👋 — thanks for the quick turnaround on my last pass. This is a follow-up review after the three new commits (e92d7d1, 688db4a, 02e5766) that landed and dismissed my prior "Approved with comments."
👏 Nice work
- Question #1 (raw-vs-normalized username contract) — resolved.
e92d7d1now normalizes the published username vianormalizeKVSegment(TrimSpace → ToLower → NFC), so decomposed-Unicode / case / whitespace variants can't silently miss the downstream scrub. Good call routing the shared pipeline through one helper. - Prior Nit (untested publish wrapper) — resolved.
TestPublishUserDeletedEvent+ the injectablenatsPublishBytesFnseam now cover the publish path, including the error-swallowed case. Clean use of the existingFn-var pattern. - Question #2 (username reuse) — partially mitigated by carrying the primary
emailin the payload so downstream scrubbers can distinguish LFID reuse. Good instinct — see the caveat below on whether it actually populates. - All CI green (MegaLinter, CodeQL, License, DCO), targeted tests pass locally.
Revision tracking
| Prior item | Status |
|---|---|
| Q1 — raw vs normalized username contract | ✅ Resolved (e92d7d1) |
| Nit — publish wrapper untested | ✅ Resolved (TestPublishUserDeletedEvent) |
Nit — inaccurate x/crypto bump rationale |
✅ Moot — net change vs main is a clean forward bump (x/text 0.37.0 → 0.39.0; x/crypto/x/sys unchanged). No CVE regression. |
| Q2 — best-effort durability + username reuse | email added for reuse detection, but see 🔴 #1 (it's usually empty); durability tradeoff still documented-as-accepted |
New findings this round
🔴 Blocking
1. Primary-email enrichment is usually empty in the common delete path — undermining the reuse-detection it was added for (handlers_users.go:161-163)
getPrimaryEmailForUserFn(ctx, userSfid) reconstructs the primary email by reading the v1-merged-user.alternate-emails.<sfid> mapping and the per-email rows. But your own TODO at lines 155-159 states those alternate-email rows are "deleted before or alongside the user row." So at user-delete time this lookup will frequently hit ErrKeyNotFound → email == "". Since 688db4a added email specifically so downstream can distinguish LFID reuse (the exact risk from my Q2), an empty email in the common case means a delayed event after LFID reassignment could still scrub a new owner. Please either capture the primary email earlier (e.g. from the user payload / a value written on update, which is what 0caa31c attempted before it was reverted), or drop the email field if it can't be reliably populated — shipping a field that's empty in the common path is worse than not having it. A design confirmation would close this out.
🟡 Minor
2. Doc comment detached from toKVKey, now misdescribes normalizeKVSegment (handlers_users.go:64-70)
e92d7d1 inserted normalizeKVSegment directly beneath the existing toKVKey godoc block, so that comment ("...encodes it as a URL-safe base64 key segment ... RawURLEncoding keeps keys opaque and short") is now the godoc for normalizeKVSegment, which does none of the base64 encoding. Meanwhile toKVKey is left undocumented. Give normalizeKVSegment its own one-liner (TrimSpace→ToLower→NFC) and move the base64/RawURLEncoding sentence back down onto toKVKey.
3. getPrimaryEmailForUser error silently discarded (handlers_users.go:162)
email, _ := getPrimaryEmailForUserFn(...) drops the error, so an empty email is indistinguishable from a lookup failure in production logs. Combined with #1, a one-line DebugContext on the error would make the (expected) empty-email case observable.
4. Test gap: empty/failed-email path through handleMergedUserDelete not covered (handlers_users_test.go)
TestHandleMergedUserDeleteScrub always stubs getPrimaryEmailForUserFn → "deleted@example.com", nil. Given #1 makes empty-email the expected production path, add a case where the lookup returns "", err and assert wantPublished: true, wantEmail: "". (TestPublishUserDeletedEvent exercises "" but bypasses the handler.)
5. Primary email logged at DEBUG, now reachable from the delete path (lfx_v1_client.go:295)
getPrimaryEmailForUser logs the full fallbackEmail. Pre-existing and DEBUG-gated, but the new delete flow widens its exposure — with DEBUG=true every deleted user's primary email now hits logs. Consider masking. (Good that publishUserDeletedEvent itself keeps email out of its INFO line.)
⚪ Nit
6. Redundant double-normalization (handlers_users.go:145 & 161) — usernameToKVKey(username) and normalizeKVSegment(username) both run the NFC/lower/trim pipeline on the same input. Compute norm := normalizeKVSegment(username) once and derive the index key from it. Not worth churn on its own.
AI reconciliation
Copilot has 0 active inline comments — its earlier notes were against removed code (primary-email cache / query-service client) and are stale. No new bot findings to reconcile.
Issue count (carried + new)
- 🔴 Blocking: 1 — email enrichment empty in common delete path
- 🟡 Minor: 4 — detached godoc; swallowed error; empty-email test gap; DEBUG email log
- ⚪ Nit: 1 — redundant double-normalization
🔴 Final decision: Needs changes before approval
No mechanical bugs — tests are green and the username scrub itself is sound. The blocker is a design/correctness question: the email field added this round is likely empty in the common case, so it doesn't yet deliver the reuse-detection it was meant to. Confirm the ordering assumption and either populate email reliably or drop it, then knock out the doc/test/logging minors. Happy to re-approve quickly once #1 is settled.
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 <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
cmd/lfx-v1-sync-helper/handlers_users.go:325
- This cache is populated only when a primary-email row is processed after this deployment. The existing durable KV consumer will not replay already-ACKed rows, and
--rebuild-user-secondary-indexescurrently writes onlyv1-user.email.*, so existing users generally have no primary-email cache. With the documented deletion ordering, the live fallback is then gone and the event omitsemail; the linked committee consumer disables its reuse guard when email is empty and scrubs solely by username, which can clear a reassigned LFID. Backfill these keys and sequence that backfill before enabling the publisher, or make email-less events safe in the consumer.
// 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 {
cmd/lfx-v1-sync-helper/handlers_users.go:331
- A transient failure here is logged and then the handler returns
false, ACKing the only row event that would populate this cache. Because the primary row is normally removed before the merged-user deletion, the later fallback cannot recover the address, permanently dropping the email reuse guard. Request redelivery when this cache write fails, as the preceding mapping write already does.
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")
}
cmd/lfx-v1-sync-helper/handlers_users.go:176
- The producer applies NFC here, but the linked committee-service consumer builds its username index with only
TrimSpace+ToLowerand compares settings withEqualFold. For historical decomposed Unicode usernames (which this repository explicitly supports), the event contains the composed form, so the consumer hashes a different value and canonical-equivalent settings also fail to match; those usernames are never scrubbed. Define one normalization contract and apply NFC in both producer and consumer index/comparison paths.
if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" {
go.mod:25
- The PR description claims
x/textis bumped to v0.40.0 andx/cryptoto v0.54.0 to resolve GO-2026-5970/GO-2026-5932, but this diff setsx/textv0.39.0 and leaves indirectx/cryptoat v0.52.0. The official advisory says v0.39.0 does fix GO-2026-5970, while GO-2026-5932 has no fixedx/crypto/openpgpversion, so the stated security remediation cannot match these changes. Reconcile the PR’s security claim and dependency plan.
golang.org/x/text v0.39.0
cmd/lfx-v1-sync-helper/handlers_users.go:190
- The primary-email cache is deleted only inside the non-empty-username branch. A soft-deleted user with a missing/blank username (explicitly supported by the new tests), and every hard delete that returns earlier, leaves the newly persisted email in
v1-mappingsindefinitely even thoughuserSfidis sufficient to address the cache key. Move cache cleanup so every deletion path attempts it, independently of whether an event can be published.
// 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")
}
dealako
left a comment
There was a problem hiding this comment.
Hey @andrest50 👋 — thanks for the thorough turnaround on this. This follow-up review covers the single new commit 3047696 ("restore primary-email cache for user.deleted event"), and it cleanly closes out my blocker from the last round.
👏 Nice work
- 🔴 Blocker resolved — email enrichment now actually populates.
getCachedPrimaryEmailForUserreads av1-user.primary-email.<sfid>cache written athandleAlternateEmailUpdatetime (which survives the alternate-email-row cleanup that races ahead of the user delete), with a live-lookup fallback. This is exactly the ordering fix that was needed — and you wired in best-effort cache cleanup on delete plus a doc comment explaining the rationale. - Godoc fixed —
normalizeKVSegment(TrimSpace→ToLower→NFC) andtoKVKey(RawURLEncoding) now each carry their own accurate comment. - Error handling fixed — the lookup error and the empty-email case are now logged at Debug before publishing without email.
- Strong test additions —
TestGetCachedPrimaryEmailForUser(hit + miss→live) and the extended scrub test asserting the email-error path and cache-key cleanup. Fixtures are synthetic (cached@/live@/deleted@example.com). Tests green,go vetclean.
Revision tracking (prior round)
| Prior item | Status |
|---|---|
| 🔴 Email enrichment empty in common delete path | ✅ Resolved (3047696 — cache restore) |
🟡 Detached godoc on normalizeKVSegment/toKVKey |
✅ Resolved |
🟡 getPrimaryEmailForUser error silently discarded |
✅ Resolved (Debug logs on error + empty) |
| 🟡 Test gap: empty/failed-email path | ✅ Resolved (email lookup error → still publish without email case) |
🟡 Primary email logged at DEBUG (lfx_v1_client.go) |
|
| ⚪ Redundant double-normalization | ⚪ Not addressed (nit, carried) |
New findings this round
Both my parallel security + code-quality passes agree there are no blocking issues — the mechanics are correct and well-tested. Remaining items cluster around the new cache's lifecycle:
🟡 Minor
- Stale cached email on demote / primary-row delete (
handlers_users.go:325,:444) — the cache is written only whenprimary_email__c=trueand cleaned only on full user delete. If a user's primary email is demoted (true→false) or the primary alt-email row is deleted (whichhandleAlternateEmailDeleteexplicitly skips at line 444), the cache is neither updated nor removed. SincegetCachedPrimaryEmailForUseris cache-first, a later delete would emit a stale address, and the PII copy is retained indefinitely for a demoted-but-never-deleted user. For a PR whose whole purpose is scrubbing PII, that retention gap is worth closing — add a cache delete before the line-444 early return (and/or on demote). If you'd rather defer, a one-line comment at the write site noting the demote/delete path is intentionally uncovered + a follow-up ticket would satisfy me. - Cache key orphaned when a deleted user has an empty username (
handlers_users.go:176-188) — thedeleteIndexKeyFn(...primary-email...)cleanup sits inside theif normalizedUsername != ""guard, so a user with a cached primary email but blank username never gets the cache key removed. Consider hoisting the cache cleanup outside the username guard. - Cache write path untested (
handlers_users.go:327) — reads go through the swappablereadMappingsKVValueFn(tested), but themappingsKV.Putwrite inhandleAlternateEmailUpdatehas no coverage ("primary email arrives → cache key written"). Asymmetric testability. - Missing empty/whitespace cache-value test branch —
getCachedPrimaryEmailForUsercorrectlyTrimSpaces and falls through to live lookup on a blank cached value, but no test exercises that branch.
⚪ Nit
- Const naming (
handlers_users.go:26) —kvKeyPrimaryEmailByUserSfiddrops thePrefixsuffix its three siblings use (kvKeyUsernamePrefix, etc.); rename tokvKeyPrimaryEmailPrefixfor consistency. - Repeated
kvKeyPrimaryEmailByUserSfid + userSfidacross write/read/delete (3×) — a tinyprimaryEmailCacheKey(sfid)helper would centralize it. Optional.
AI reconciliation
Copilot has no new active inline comments this round. No external bot findings to reconcile.
Issue count
- 🔴 Blocking: 0
- 🟡 Minor: 4 — stale/retained cache on demote/primary-delete; orphaned cache on empty-username delete; untested cache write; missing empty-value test branch (+1 carried: DEBUG email log)
- ⚪ Nit: 3 — const naming; repeated key expression; carried double-normalization
🔴 Final decision: Needs changes before approval
This is very close — the blocker is gone and the feature is correct and well-tested. I'm holding on a formal approval only for the cache-lifecycle cluster (item #1 in particular, since stale/retained PII is thematically at odds with a scrub feature). Address #1 (fix or consciously defer with a follow-up ticket) and, ideally, the quick #2/#3, and I'll re-approve promptly. #4–#6 I'm happy to take as follow-ups.
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 <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Andres Tobon <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
cmd/lfx-v1-sync-helper/handlers_users.go:468
- The normal user-delete ordering removes alternate-email rows before the merged-user row, but this branch deletes the primary-email cache at that first step. Because the mapping was already pruned above,
getCachedPrimaryEmailForUserlater has neither the cache nor the live primary row, so the event is emitted without the email the reuse guard needs. Preserve or replace this value until the merged-user deletion has published.
clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr)
cmd/lfx-v1-sync-helper/handlers_users.go:185
- Publishing after an email lookup failure disables the linked committee consumer's reuse guard: PR #161 only compares stored email when
event.Emailis non-empty. A delayed deletion can therefore clear the username of a new owner with the same LFID. Require a durable identity before publishing, or make all consumers reject email-less events.
publishUserDeletedEventFn(ctx, key, normalizedUsername, email)
cmd/lfx-v1-sync-helper/handlers_users.go:352
- This new cache is populated only when a primary-email update is processed after deployment, and a failed
Putis ACKed instead of retried. The existing durable consumer does not replay already-ACKed records, so unchanged users—and users hit by one transient write failure—will have no cache when later deleted. Backfill existing primary emails and request redelivery when this required cache write fails.
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")
}
cmd/lfx-v1-sync-helper/handlers_users.go:176
- The producer normalizes usernames to NFC, but the linked committee consumer's
BuildUsernameIndexKeyandusernameMatchesuse only trim/lowercase. For decomposed Unicode LFIDs (explicitly supported by this repository), the event hashes and compares differently, so neither members nor settings are scrubbed. Use the same NFC normalization in every producer and consumer.
if normalizedUsername := normalizeKVSegment(username); normalizedUsername != "" {
go.mod:25
- The PR description says this update sets
x/textto v0.40.0 andx/cryptoto v0.54.0, but the diff setsx/textto v0.39.0 and leavesx/cryptoat v0.52.0. Update the dependency/security claims to match the committed modules, or commit the intended versions.
golang.org/x/text v0.39.0
dealako
left a comment
There was a problem hiding this comment.
Hey @andrest50 👋 — thanks for hardening the cache lifecycle so quickly. Both of my prior minors are cleanly resolved and the design is right. We're down to the last mile: one formatting fix and one test that actually asserts the new behavior.
👏 Nice work
- Stale/retained cache on demote & primary-row delete — resolved. The new
clearPrimaryEmailCacheIfMatchedhelper is a nice design: it only deletes the cache when the stored value still matches the affected address (EqualFold+TrimSpace), so a legitimate newer primary (A→B) is never wiped. Wired into both the demotion branch (handleAlternateEmailUpdate, non-primary) and the primary-row-delete branch (handleAlternateEmailDelete). Both my parallel passes confirmed the ordering is correct and the value-match protects against false clears. - Blank-username orphan — resolved. Cache cleanup hoisted outside the
if normalizedUsername != ""guard, and you addedwhitespace-only/no usernametest cases that assert the cache key is still deleted. - Const rename — done (
kvKeyPrimaryEmailPrefix, matches siblings).
Revision tracking (prior round)
| Prior item | Status |
|---|---|
| 🟡 Stale cache on demote / primary-row delete | ✅ Resolved (09bf1c2 — clearPrimaryEmailCacheIfMatched) |
| 🟡 Cache cleanup orphaned on blank-username delete | ✅ Resolved (hoisted + tested) |
⚪ Const naming ...ByUserSfid → ...Prefix |
✅ Resolved (but see 🟡 #1 below — introduced a gofmt miss) |
| 🟡 Cache write path untested | ❌ Still open — no TestHandleAlternateEmailUpdate; write + demote-clear uncovered |
| 🟡 Empty/whitespace cache-value test branch | ❌ Still open — clearPrimaryEmailCacheIfMatched not directly tested |
⚪ Repeated key expression / DEBUG email log (lfx_v1_client.go) |
⚪ Carried nits |
New findings this round
🟡 Minor
gofmtnot clean — const block misaligned by the rename (handlers_users.go:26). The rename shortened the longest identifier, so the wholeconstblock needs to collapse one column, but the padding wasn't updated —gofmt -lflags the file andmake checkwill reformat it. (Interestingly CI MegaLinter is green, so it won't hard-block the gate, but it violates the repo's gofmt standard.) One-liner fix:gofmt -w cmd/lfx-v1-sync-helper/handlers_users.go- The new clear-on-match logic ships with no test that asserts a deletion (
handlers_users.go:214,:357,:468). This is the substantive one:- There's no
TestHandleAlternateEmailUpdateat all, so the demotion clear path (:357) — the behavior this round adds — has zero coverage. - In
TestHandleAlternateEmailDelete, the newreadMappingsKVValueFnstub always returnserrors.New("cache miss"), so the primary-delete clear (:468) hits the read-error early-return and no-ops — nothing asserts a cache delete. clearPrimaryEmailCacheIfMatchedisn't unit-tested for its four documented branches (match→delete, no-match→skip, empty-email→skip, read-error→skip).
A small direct table test onclearPrimaryEmailCacheIfMatched(assertingdeleteIndexKeyFnis/ isn't called per branch) would lock in the logic and is the cleanest way to close this.
- There's no
⚪ Nit
- Read-error silently leaves stale cache (
handlers_users.go:217) — reasonable (you can't value-match without the value, and blind-deleting risks dropping a different valid primary), but a one-line comment noting the trade-off, or aDebugContext, would help. A transient read error during a genuine demotion leaves the cache stale until the user-delete path cleans it. - Hard-delete of a primary row doesn't clear the cache (
handlers_users.go—handleAlternateEmailDeletereturns early onv1Data == nilbefore the clear). Edge case only (soft deletes always carry a payload); the unconditional user-delete cleanup still catches it. - Redundant
userSfid == ""guard inclearPrimaryEmailCacheIfMatched— both call sites already guarantee non-empty. Harmless defense-in-depth.
AI reconciliation
No new external bot inline comments this round. Author replies confirm the fixes map to 09bf1c2. Nothing to reconcile.
Issue count
- 🔴 Blocking: 0
- 🟡 Minor: 2 — gofmt not clean; clear-on-match logic has no asserting test
- ⚪ Nit: 3 — silent read-error; hard-delete edge case; redundant guard (+ carried nits)
🔴 Final decision: Needs changes before approval
Genuinely the last mile — the feature is correct and the cache lifecycle is now sound. Holding formal approval only for the two 🟡 items: run gofmt -w, and add a test that actually asserts clearPrimaryEmailCacheIfMatched deletes on a value match (ideally covering the demotion path too). Do those and I'll approve on the next pass; the nits are take-it-or-leave-it.
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 <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
cmd/lfx-v1-sync-helper/handlers_users.go:189
- The linked committee-service consumer only applies the email reuse guard when
emailis non-empty. Publishing here after a lookup error or empty result therefore creates a username-only event that can clear a reassigned LFID's committee identity and access. Do not publish an unguarded event; skip it until an immutable email is available (while retaining the cache for recovery), or make every consumer reject email-less events.
publishUserDeletedEventFn(ctx, key, normalizedUsername, email)
cmd/lfx-v1-sync-helper/handlers_users.go:472
- This deletes the cache on the exact ordering it was introduced to handle: primary alternate-email rows are normally deleted before the merged-user row. The mapping array has already been cleaned above, so the later merged-user handler cannot recover the email; existing users also will not have received a cache write from this new version. Persist
emailAddrinto the primary-email cache from this deletion payload (and retry a failed write) rather than clearing it, then delete it only after the merged-user event is handled.
clearPrimaryEmailCacheIfMatched(ctx, key, userSfid, emailAddr)
go.mod:25
- The dependency changes do not match the PR's stated security update: this lands
x/textv0.39.0 rather than v0.40.0, whilex/cryptoremains v0.52.0 rather than v0.54.0. Also, GO-2026-5932 has no fixedx/cryptoversion and applies to the deprecatedopenpgppackages, which this repository does not import. Aligngo.mod/go.sumwith the intended update and correct the security rationale so the PR does not claim remediation that is absent or inapplicable.
golang.org/x/text v0.39.0
dealako
left a comment
There was a problem hiding this comment.
Hey @andrest50 👋 — that's the last mile done. Both items from my previous review are resolved, and you proactively went a step further on PII. Approving. 🎉
👏 Nice work
- gofmt — fixed. Const block realigned;
gofmt -landgo vetare clean, full package test suite green. - Clear-on-match now has real coverage.
TestClearPrimaryEmailCacheIfMatchedis a proper table test: match→delete, case-insensitive match→delete, no-match→skip, empty-email→skip, empty-sfid→skip, read-error→skip — each asserting exactly one delete (of the right key) or zero. This pins down the exact behavior I was worried about last round. With the function directly unit-tested, the thin demotion/delete call-sites no longer need handler-level coverage. - Proactive PII hardening (above and beyond):
- Removed
usernamefrom the publish-successInfolog — the LFID is the very thing being scrubbed, so keeping it out of logs is the right call. - Added primary-email cache cleanup to the hard-KV-delete (
v1Data == nil) branch (my prior nit), and asserted it inTestHandleMergedUserDeleteScrub— closes the last cache-orphan path.
- Removed
Revision tracking — all resolved
| Prior item | Status |
|---|---|
🟡 gofmt not clean (const misalignment) |
✅ Resolved (f185b27) |
| 🟡 Clear-on-match logic had no asserting test | ✅ Resolved (TestClearPrimaryEmailCacheIfMatched) |
| ⚪ Hard-delete of primary row didn't clear cache | ✅ Resolved (proactive) |
⚪ username in publish log |
✅ Resolved (proactive) |
| ⚪ Silent read-error / redundant sfid guard | ⚪ Left as-is — fine, take-it-or-leave-it |
Remaining items (all optional — none blocking)
- ⚪ Nit — the
clearPrimaryEmailCacheIfMatchedWarn reads "failed to delete stale primary email cache," but at the hard-delete/demote call-sites the entry is a matched live cache being cleaned, not necessarily stale. Cosmetic wording only. - ⚪ Nit —
TestClearPrimaryEmailCacheIfMatchedcould add an empty/whitespace cached value case, but theTrimSpace+EqualFoldbehavior is already correct. - 📌 Follow-up (out of scope for this PR) — the pre-existing Auth0 link/unlink paths (
handlers_users.go~499/503/529/533) still log realemailvalues at Warn/Error. Not touched by this PR, but worth a follow-up ticket to align them with the PII-hardening direction this change establishes.
AI reconciliation
Copilot's latest review carried no actionable inline comments. Nothing to reconcile.
Issue count
- 🔴 Blocking: 0
- 🟡 Minor: 0
- ⚪ Nit: 2 (both optional) + 1 out-of-scope follow-up
✅ Final decision: Approved with minor comments
Clean, correct, well-tested, and MERGEABLE. Thanks for the thorough iteration through the cache-lifecycle and PII edge cases — nicely done. Ship it once you're happy with the optional nits (or file the Auth0-logging follow-up separately).
Summary
handleMergedUserDelete()to publish alfx.v1-sync-helper.user.deletedNATS event carrying the deleted user's username and primary emailQUERY_SERVICE_URLconfig, and all direct committee scrub logic from the sync-helper — that responsibility now lives entirely in the committee serviceTicket
LFXV2-2645
Changes
Modified files
cmd/lfx-v1-sync-helper/handlers_users.go— extendshandleMergedUserDeleteto publish a user-deleted NATS event; addspublishUserDeletedEvent,getPrimaryEmailForUserFn, andpublishUserDeletedEventFninjectable varscmd/lfx-v1-sync-helper/handlers_users_test.go—TestHandleMergedUserDeleteScrubcovering event publish, no-username, email-lookup-failure, and hard-KV-delete casesgo.mod/go.sum— bumpsgolang.org/x/text→ v0.40.0 andgolang.org/x/crypto→ v0.54.0 to resolve CVE GO-2026-5970 and GO-2026-5932Deleted files
cmd/lfx-v1-sync-helper/client_query.go— query-service HTTP client (no longer needed)Notes
v1Data == nil) cannot be scrubbed — the payload is gone so the username is unrecoverable. This path logs a warning. Hard deletes are abnormal and should not occur in normal operation.