Skip to content

Commit dfe9d6a

Browse files
refactor(users): address username scrub review feedback
Drop redundant revision confirm before side effects, unify scrubMaxRetries, rename scrub helpers, document no-email tradeoff, remove email from WARN logs, and expand shouldScrubSettingsUsername test coverage. Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Andres Tobon <andrest2455@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4884168 commit dfe9d6a

2 files changed

Lines changed: 134 additions & 48 deletions

File tree

internal/service/project_subscriber.go

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ const notificationTimeout = 5 * time.Second
3535
// many sequential KV reads under load.
3636
const settingsScanTimeout = 2 * time.Minute
3737

38-
// scrubSideEffectMaxRetries is the number of attempts for indexer/FGA publishes after a
39-
// successful KV write. Retries are independent of the username match so a transient NATS
40-
// failure does not leave access tuples stale after the settings record was already scrubbed.
41-
const scrubSideEffectMaxRetries = 4
38+
// scrubMaxRetries is the number of attempts for settings KV writes and indexer/FGA publishes
39+
// after a successful scrub. Retries are independent of the username match so a transient
40+
// conflict or NATS failure does not leave access tuples stale after settings were scrubbed.
41+
const scrubMaxRetries = 4
4242

4343
// serviceAuthBearer is the static JWT audience token used for background NATS handler
4444
// side effects (indexer/FGA) that have no originating HTTP request context.
@@ -568,7 +568,7 @@ func (s *ProjectsService) HandleUserDeleted(ctx context.Context, msg domain.Mess
568568
continue
569569
}
570570
scrubCtx, scrubCancel := context.WithTimeout(ctx, settingsScanTimeout)
571-
s.scrubUsernameFromProjectSettings(scrubCtx, candidate.UID, event.Username)
571+
s.scrubProjectSettingsUsername(scrubCtx, candidate.UID, event.Username)
572572
scrubCancel()
573573
}
574574

@@ -607,9 +607,9 @@ func projectSettingsHasUsername(s *models.ProjectSettings, username string) bool
607607
return false
608608
}
609609

610-
// scrubUsernameInProjectSettings clears username on every matching entry in settings
610+
// clearUsernameInSettings clears username on every matching entry in settings
611611
// that still represents the deleted account. Returns true when at least one field was changed.
612-
func (s *ProjectsService) scrubUsernameInProjectSettings(ctx context.Context, settings *models.ProjectSettings, username string) bool {
612+
func (s *ProjectsService) clearUsernameInSettings(ctx context.Context, settings *models.ProjectSettings, username string) bool {
613613
changed := false
614614
clearIfMatch := func(u *models.UserInfo) {
615615
if u == nil || u.Username != username {
@@ -642,6 +642,11 @@ func (s *ProjectsService) scrubUsernameInProjectSettings(ctx context.Context, se
642642
// shouldScrubSettingsUsername reports whether a settings entry carrying deletedUsername should
643643
// be cleared. When the entry has an email, auth is consulted so a reassigned LFID reused by a
644644
// new account (same username string, different lifecycle) is not scrubbed.
645+
//
646+
// Entries without email always scrub when the username matches. That is intentional: M2M and
647+
// legacy email-less settings cannot be disambiguated via auth lookup. Downstream scrub is gated
648+
// by the v1-sync-helper user.deleted publish ACL (project-api subscribes only from trusted
649+
// service accounts); carrying email in the event would be a follow-up hardening step.
645650
func (s *ProjectsService) shouldScrubSettingsUsername(ctx context.Context, u models.UserInfo, deletedUsername string) bool {
646651
email := strings.ToLower(strings.TrimSpace(u.Email))
647652
if email == "" {
@@ -660,17 +665,16 @@ func (s *ProjectsService) shouldScrubSettingsUsername(ctx context.Context, u mod
660665
return true
661666
}
662667
slog.WarnContext(ctx, "project_subscriber: auth lookup failed during username scrub — skipping entry",
663-
constants.ErrKey, err, "email", u.Email)
668+
constants.ErrKey, err)
664669
return false
665670
}
666671
return resolved != deletedUsername
667672
}
668673

669-
// scrubUsernameFromProjectSettings fetches settings for a single project, clears the
674+
// scrubProjectSettingsUsername fetches settings for a single project, clears the
670675
// username on any matching entry, persists, and reindexes. Retries on revision conflicts.
671-
func (s *ProjectsService) scrubUsernameFromProjectSettings(ctx context.Context, projectUID, username string) {
672-
const maxRetries = 4
673-
for attempt := 0; attempt < maxRetries; attempt++ {
676+
func (s *ProjectsService) scrubProjectSettingsUsername(ctx context.Context, projectUID, username string) {
677+
for attempt := 0; attempt < scrubMaxRetries; attempt++ {
674678
settings, revision, err := s.ProjectRepository.GetProjectSettingsWithRevision(ctx, projectUID)
675679
if err != nil {
676680
slog.DebugContext(ctx, "project_subscriber: failed to get settings for username scrub — skipping",
@@ -681,7 +685,7 @@ func (s *ProjectsService) scrubUsernameFromProjectSettings(ctx context.Context,
681685
return
682686
}
683687

684-
if !s.scrubUsernameInProjectSettings(ctx, settings, username) {
688+
if !s.clearUsernameInSettings(ctx, settings, username) {
685689
return
686690
}
687691

@@ -692,7 +696,7 @@ func (s *ProjectsService) scrubUsernameFromProjectSettings(ctx context.Context,
692696
s.publishProjectSettingsScrubSideEffects(ctx, projectUID)
693697
return
694698
}
695-
if !errors.Is(updateErr, domain.ErrRevisionMismatch) || attempt == maxRetries-1 {
699+
if !errors.Is(updateErr, domain.ErrRevisionMismatch) || attempt == scrubMaxRetries-1 {
696700
slog.WarnContext(ctx, "project_subscriber: failed to clear username from project settings",
697701
constants.ErrKey, updateErr, "project_uid", projectUID)
698702
return
@@ -703,17 +707,17 @@ func (s *ProjectsService) scrubUsernameFromProjectSettings(ctx context.Context,
703707
}
704708

705709
// publishProjectSettingsScrubSideEffects reindexes scrubbed settings and refreshes OpenFGA
706-
// access tuples. Each attempt reloads the current KV record and confirms the revision has
707-
// not advanced before publishing, so a concurrent settings write cannot be overwritten by a
708-
// stale snapshot. ProjectSettingsUpdatedSubject is intentionally omitted to avoid role-change emails.
710+
// access tuples. Each attempt reloads the current KV record before publishing; indexer and FGA
711+
// projections are full-state and idempotent. ProjectSettingsUpdatedSubject is intentionally
712+
// omitted to avoid role-change emails.
709713
func (s *ProjectsService) publishProjectSettingsScrubSideEffects(ctx context.Context, projectUID string) {
710714
ctx = ctxWithServiceAuth(ctx)
711-
for attempt := 0; attempt < scrubSideEffectMaxRetries; attempt++ {
712-
settings, revision, err := s.ProjectRepository.GetProjectSettingsWithRevision(ctx, projectUID)
715+
for attempt := 0; attempt < scrubMaxRetries; attempt++ {
716+
settings, _, err := s.ProjectRepository.GetProjectSettingsWithRevision(ctx, projectUID)
713717
if err != nil {
714-
if attempt == scrubSideEffectMaxRetries-1 {
718+
if attempt == scrubMaxRetries-1 {
715719
slog.WarnContext(ctx, "project_subscriber: failed to reload settings for scrub side effects",
716-
constants.ErrKey, err, "project_uid", projectUID, "attempts", scrubSideEffectMaxRetries)
720+
constants.ErrKey, err, "project_uid", projectUID, "attempts", scrubMaxRetries)
717721
return
718722
}
719723
slog.DebugContext(ctx, "project_subscriber: retrying scrub side effects after settings reload failure",
@@ -728,31 +732,14 @@ func (s *ProjectsService) publishProjectSettingsScrubSideEffects(ctx context.Con
728732
if baseErr != nil {
729733
slog.WarnContext(ctx, "project_subscriber: failed to load project for FGA refresh after username scrub",
730734
constants.ErrKey, baseErr, "project_uid", projectUID)
731-
if attempt == scrubSideEffectMaxRetries-1 {
735+
if attempt == scrubMaxRetries-1 {
732736
return
733737
}
734738
slog.DebugContext(ctx, "project_subscriber: retrying scrub side effects after project load failure",
735739
"attempt", attempt+1, "project_uid", projectUID)
736740
continue
737741
}
738742

739-
_, confirmRevision, confirmErr := s.ProjectRepository.GetProjectSettingsWithRevision(ctx, projectUID)
740-
if confirmErr != nil {
741-
if attempt == scrubSideEffectMaxRetries-1 {
742-
slog.WarnContext(ctx, "project_subscriber: failed to confirm settings revision for scrub side effects",
743-
constants.ErrKey, confirmErr, "project_uid", projectUID, "attempts", scrubSideEffectMaxRetries)
744-
return
745-
}
746-
slog.DebugContext(ctx, "project_subscriber: retrying scrub side effects after revision confirm failure",
747-
"attempt", attempt+1, "project_uid", projectUID)
748-
continue
749-
}
750-
if confirmRevision != revision {
751-
slog.DebugContext(ctx, "project_subscriber: settings revision advanced before side-effect publish — retrying",
752-
"expected_revision", revision, "current_revision", confirmRevision, "project_uid", projectUID)
753-
continue
754-
}
755-
756743
indexMsg := indexerTypes.IndexerMessageEnvelope{
757744
Action: indexerConstants.ActionUpdated,
758745
Data: *settings,
@@ -767,14 +754,14 @@ func (s *ProjectsService) publishProjectSettingsScrubSideEffects(ctx context.Con
767754
return
768755
}
769756

770-
if attempt == scrubSideEffectMaxRetries-1 {
757+
if attempt == scrubMaxRetries-1 {
771758
if indexErr != nil {
772759
slog.WarnContext(ctx, "project_subscriber: failed to reindex project settings after username scrub",
773-
constants.ErrKey, indexErr, "project_uid", projectUID, "attempts", scrubSideEffectMaxRetries)
760+
constants.ErrKey, indexErr, "project_uid", projectUID, "attempts", scrubMaxRetries)
774761
}
775762
if accessErr != nil {
776763
slog.WarnContext(ctx, "project_subscriber: failed to publish FGA update after username scrub",
777-
constants.ErrKey, accessErr, "project_uid", projectUID, "attempts", scrubSideEffectMaxRetries)
764+
constants.ErrKey, accessErr, "project_uid", projectUID, "attempts", scrubMaxRetries)
778765
}
779766
return
780767
}

internal/service/project_subscriber_test.go

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,7 +1201,7 @@ func TestHandleUserDeleted(t *testing.T) {
12011201
Writers: []models.UserInfo{{Username: deletedUsername, Email: "deleted@example.com"}},
12021202
}
12031203
r.On("ListAllProjectsSettings", mock.Anything).Return([]*models.ProjectSettings{settings}, nil)
1204-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(3)
1204+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(2)
12051205
r.On("UpdateProjectSettings", mock.Anything, mock.MatchedBy(func(s *models.ProjectSettings) bool {
12061206
return len(s.Writers) == 1 && s.Writers[0].Username == "" && s.Writers[0].Email == "deleted@example.com"
12071207
}), uint64(1)).Return(nil)
@@ -1243,7 +1243,7 @@ func TestHandleUserDeleted(t *testing.T) {
12431243
r.On("UpdateProjectSettings", mock.Anything, mock.MatchedBy(func(s *models.ProjectSettings) bool {
12441244
return len(s.Auditors) == 1 && s.Auditors[0].Username == ""
12451245
}), uint64(2)).Return(nil).Once()
1246-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(secondRead, uint64(2), nil).Times(2)
1246+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(secondRead, uint64(2), nil).Once()
12471247
r.On("GetProjectBase", mock.Anything, projectUID).Return(&models.ProjectBase{UID: projectUID}, nil)
12481248
},
12491249
setupMsg: func(m *domain.MockMessageBuilder) {
@@ -1285,7 +1285,7 @@ func TestHandleUserDeleted(t *testing.T) {
12851285
OpportunityOwner: &models.UserInfo{Username: deletedUsername, Email: "oo@example.com"},
12861286
}
12871287
r.On("ListAllProjectsSettings", mock.Anything).Return([]*models.ProjectSettings{settings}, nil)
1288-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(3)
1288+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(2)
12891289
r.On("UpdateProjectSettings", mock.Anything, mock.MatchedBy(func(s *models.ProjectSettings) bool {
12901290
return len(s.MeetingCoordinators) == 1 && s.MeetingCoordinators[0].Username == "" &&
12911291
s.ExecutiveDirector != nil && s.ExecutiveDirector.Username == "" &&
@@ -1308,7 +1308,7 @@ func TestHandleUserDeleted(t *testing.T) {
13081308
Writers: []models.UserInfo{{Username: deletedUsername, Email: "deleted@example.com"}},
13091309
}
13101310
r.On("ListAllProjectsSettings", mock.Anything).Return([]*models.ProjectSettings{settings}, nil)
1311-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(4)
1311+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(3)
13121312
r.On("UpdateProjectSettings", mock.Anything, mock.Anything, uint64(1)).Return(nil)
13131313
r.On("GetProjectBase", mock.Anything, projectUID).
13141314
Return((*models.ProjectBase)(nil), errors.New("transient read failure")).Once()
@@ -1329,7 +1329,7 @@ func TestHandleUserDeleted(t *testing.T) {
13291329
Writers: []models.UserInfo{{Username: deletedUsername, Email: "deleted@example.com"}},
13301330
}
13311331
r.On("ListAllProjectsSettings", mock.Anything).Return([]*models.ProjectSettings{settings}, nil)
1332-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(5)
1332+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(settings, uint64(1), nil).Times(3)
13331333
r.On("UpdateProjectSettings", mock.Anything, mock.Anything, uint64(1)).Return(nil)
13341334
r.On("GetProjectBase", mock.Anything, projectUID).Return(&models.ProjectBase{UID: projectUID}, nil).Times(2)
13351335
},
@@ -1354,7 +1354,7 @@ func TestHandleUserDeleted(t *testing.T) {
13541354
Writers: []models.UserInfo{{Username: "other", Email: "other@example.com"}},
13551355
}
13561356
r.On("ListAllProjectsSettings", mock.Anything).Return([]*models.ProjectSettings{match, other}, nil)
1357-
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(match, uint64(1), nil).Times(3)
1357+
r.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).Return(match, uint64(1), nil).Times(2)
13581358
r.On("UpdateProjectSettings", mock.Anything, mock.Anything, uint64(1)).Return(nil)
13591359
r.On("GetProjectBase", mock.Anything, projectUID).Return(&models.ProjectBase{UID: projectUID}, nil)
13601360
},
@@ -1406,6 +1406,105 @@ func TestHandleUserDeleted(t *testing.T) {
14061406
}
14071407
}
14081408

1409+
func TestShouldScrubSettingsUsername(t *testing.T) {
1410+
const deletedUsername = "deleted.user"
1411+
ctx := context.Background()
1412+
1413+
tests := []struct {
1414+
name string
1415+
entry models.UserInfo
1416+
setupUser func(*domain.MockUserReader)
1417+
userReader bool
1418+
want bool
1419+
}{
1420+
{
1421+
name: "no email — always scrub",
1422+
entry: models.UserInfo{Username: deletedUsername},
1423+
want: true,
1424+
},
1425+
{
1426+
name: "nil user reader with email — scrub",
1427+
entry: models.UserInfo{Username: deletedUsername, Email: "a@example.com"},
1428+
want: true,
1429+
},
1430+
{
1431+
name: "email maps to same username — do not scrub",
1432+
entry: models.UserInfo{Username: deletedUsername, Email: "active@example.com"},
1433+
setupUser: func(u *domain.MockUserReader) {
1434+
u.On("UsernameByEmail", mock.Anything, "active@example.com").Return(deletedUsername, nil)
1435+
},
1436+
userReader: true,
1437+
want: false,
1438+
},
1439+
{
1440+
name: "email maps to different username — scrub",
1441+
entry: models.UserInfo{Username: deletedUsername, Email: "reassigned@example.com"},
1442+
setupUser: func(u *domain.MockUserReader) {
1443+
u.On("UsernameByEmail", mock.Anything, "reassigned@example.com").Return("new.user", nil)
1444+
},
1445+
userReader: true,
1446+
want: true,
1447+
},
1448+
{
1449+
name: "email not found in auth — scrub",
1450+
entry: models.UserInfo{Username: deletedUsername, Email: "gone@example.com"},
1451+
setupUser: func(u *domain.MockUserReader) {
1452+
u.On("UsernameByEmail", mock.Anything, "gone@example.com").Return("", domain.ErrUserNotFound)
1453+
},
1454+
userReader: true,
1455+
want: true,
1456+
},
1457+
{
1458+
name: "auth lookup error — skip entry",
1459+
entry: models.UserInfo{Username: deletedUsername, Email: "err@example.com"},
1460+
setupUser: func(u *domain.MockUserReader) {
1461+
u.On("UsernameByEmail", mock.Anything, "err@example.com").Return("", errors.New("auth unavailable"))
1462+
},
1463+
userReader: true,
1464+
want: false,
1465+
},
1466+
}
1467+
1468+
for _, tt := range tests {
1469+
t.Run(tt.name, func(t *testing.T) {
1470+
svc := &ProjectsService{}
1471+
if tt.userReader {
1472+
mockUser := &domain.MockUserReader{}
1473+
if tt.setupUser != nil {
1474+
tt.setupUser(mockUser)
1475+
}
1476+
svc.UserReader = mockUser
1477+
t.Cleanup(func() { mockUser.AssertExpectations(t) })
1478+
}
1479+
got := svc.shouldScrubSettingsUsername(ctx, tt.entry, deletedUsername)
1480+
assert.Equal(t, tt.want, got)
1481+
})
1482+
}
1483+
}
1484+
1485+
func TestScrubProjectSettingsUsernameRetryExhaustion(t *testing.T) {
1486+
const (
1487+
deletedUsername = "deleted.user"
1488+
projectUID = "proj-1"
1489+
)
1490+
mockRepo := &domain.MockProjectRepository{}
1491+
for range scrubMaxRetries {
1492+
mockRepo.On("GetProjectSettingsWithRevision", mock.Anything, projectUID).
1493+
Return(&models.ProjectSettings{
1494+
UID: projectUID,
1495+
Writers: []models.UserInfo{{Username: deletedUsername, Email: "deleted@example.com"}},
1496+
}, uint64(1), nil).Once()
1497+
}
1498+
mockRepo.On("UpdateProjectSettings", mock.Anything, mock.Anything, uint64(1)).
1499+
Return(domain.ErrRevisionMismatch)
1500+
1501+
svc := &ProjectsService{ProjectRepository: mockRepo}
1502+
svc.scrubProjectSettingsUsername(context.Background(), projectUID, deletedUsername)
1503+
1504+
mockRepo.AssertNumberOfCalls(t, "UpdateProjectSettings", scrubMaxRetries)
1505+
mockRepo.AssertExpectations(t)
1506+
}
1507+
14091508
func TestProjectSettingsHasUsername(t *testing.T) {
14101509
const username = "alice"
14111510
tests := []struct {

0 commit comments

Comments
 (0)