@@ -35,10 +35,10 @@ const notificationTimeout = 5 * time.Second
3535// many sequential KV reads under load.
3636const 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.
645650func (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.
709713func (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 }
0 commit comments