Skip to content

Commit 56a355f

Browse files
authored
Merge pull request #161 from linuxfoundation/feat/LFXV2-2645-user-delete-username-scrub
[LFXV2-2645] feat(users): scrub deleted user's username from committee data on user delete
2 parents 253c358 + a48d003 commit 56a355f

23 files changed

Lines changed: 1236 additions & 17 deletions

.claude/skills/committee-service-dev/references/nats-messaging.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Repo-local inventory of NATS subjects, queue groups, KV buckets, Object Stores,
2626
```go
2727
"lfx.mailing-list-api.committee_mailing_list.changed" // handled by internal/service/message_handler.go (updates has_mailing_list + re-index)
2828
"lfx.invite-service.invite_accepted" // published by the invite service after it processes a self-serve acceptance (enriched event embedding the invite record); handled by HandleInviteAccepted (owned by lfx-v2-invite-service: inviteapi.InviteServiceAcceptedSubject)
29+
"lfx.v1-sync-helper.user.deleted" // published by v1-sync-helper when a merged user record is soft-deleted; handled by HandleUserDeleted to scrub the username from committee members and settings writers/auditors (pkg/constants/subjects.go: V1SyncHelperUserDeletedSubject)
2930
```
3031

3132
### Self-consumed event subjects

cmd/committee-api/service/committee_handler.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func (mhs *MessageHandlerService) HandleMessage(ctx context.Context, msg port.Tr
4040
constants.CommitteeLinkCreatedSubject: mhs.handleCommitteeLinkCreated,
4141
constants.CommitteeApplicationSubmittedSubject: mhs.handleCommitteeApplicationSubmitted,
4242
constants.CommitteeApplicationUpdatedSubject: mhs.handleCommitteeApplicationUpdated,
43+
constants.V1SyncHelperUserDeletedSubject: mhs.handleUserDeleted,
4344
}
4445

4546
handler, ok := handlers[subject]
@@ -125,6 +126,10 @@ func (mhs *MessageHandlerService) handleCommitteeGetProject(ctx context.Context,
125126
return mhs.messageHandler.HandleCommitteeGetProject(ctx, msg)
126127
}
127128

129+
func (mhs *MessageHandlerService) handleUserDeleted(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {
130+
return mhs.messageHandler.HandleUserDeleted(ctx, msg)
131+
}
132+
128133
func (mhs *MessageHandlerService) respondWithError(ctx context.Context, msg port.TransportMessenger, errorMsg string) {
129134
errResponse := []byte(fmt.Sprintf(`{"error":"%s"}`, errorMsg))
130135
if err := msg.Respond(errResponse); err != nil {

cmd/committee-api/service/group_weekly_brief_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ func (r *stubCommitteeReader) ListAllInvites(_ context.Context) ([]*model.Commit
7575
panic("not used")
7676
}
7777

78+
func (r *stubCommitteeReader) ListMembersByEmail(_ context.Context, _ string) ([]*model.CommitteeMember, error) {
79+
panic("not used")
80+
}
81+
82+
func (r *stubCommitteeReader) ListMembersByUsername(_ context.Context, _ string) ([]*model.CommitteeMember, error) {
83+
panic("not used")
84+
}
85+
7886
// ensure the stub satisfies the interface at compile time
7987
var _ internalsvc.CommitteeReader = (*stubCommitteeReader)(nil)
8088

cmd/committee-api/service/providers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,7 @@ func QueueSubscriptions(ctx context.Context, committeeReader port.CommitteeReade
910910
constants.CommitteeLinkCreatedSubject: messageHandlerService.HandleMessage,
911911
constants.CommitteeApplicationSubmittedSubject: messageHandlerService.HandleMessage,
912912
constants.CommitteeApplicationUpdatedSubject: messageHandlerService.HandleMessage,
913+
constants.V1SyncHelperUserDeletedSubject: messageHandlerService.HandleMessage,
913914
}
914915

915916
for subject, handler := range subjects {

cmd/committee-cli/commands/sync/members_by_committee_index_test.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ import (
2424
// recording (indexed, orgIndexed, emailIndexed, updated) and error simulation
2525
// (indexError, updateError) support those tests.
2626
type mockMemberWriter struct {
27-
indexed []string // committee_uid+"."+member_uid keys that were written
28-
orgIndexed []string // org_sfid+"."+member_uid keys that were written
29-
emailIndexed []string // email_hash+"."+member_uid keys that were written
30-
updated []*model.CommitteeMember // members passed to UpdateMember (member-project-attribute repair)
31-
indexError error
32-
updateError error
27+
indexed []string // committee_uid+"."+member_uid keys that were written
28+
orgIndexed []string // org_sfid+"."+member_uid keys that were written
29+
emailIndexed []string // email_hash+"."+member_uid keys that were written
30+
usernameIndexed []string // username_hash+"."+member_uid keys that were written
31+
updated []*model.CommitteeMember // members passed to UpdateMember (member-project-attribute repair)
32+
indexError error
33+
updateError error
3334
}
3435

3536
func (w *mockMemberWriter) CreateMember(_ context.Context, _ *model.CommitteeMember) error {
@@ -80,6 +81,19 @@ func (w *mockMemberWriter) IndexMemberByEmail(ctx context.Context, m *model.Comm
8081
return key, nil
8182
}
8283

84+
func (w *mockMemberWriter) IndexMemberByUsername(ctx context.Context, m *model.CommitteeMember) (string, error) {
85+
if w.indexError != nil {
86+
return "", w.indexError
87+
}
88+
hash := m.BuildUsernameIndexKey(ctx)
89+
if hash == "" {
90+
return "", nil
91+
}
92+
key := fmt.Sprintf(constants.KVLookupMembersByUsernamePrefix, hash, m.UID)
93+
w.usernameIndexed = append(w.usernameIndexed, key)
94+
return key, nil
95+
}
96+
8397
// newBackfillRC builds a RunContext wired with the provided reader and writer mocks.
8498
func newBackfillRC(r *mockReader, w *mockMemberWriter, args ...string) commands.RunContext {
8599
return commands.RunContext{
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
package sync
5+
6+
import (
7+
"context"
8+
"flag"
9+
"fmt"
10+
"log/slog"
11+
"time"
12+
13+
"github.com/linuxfoundation/lfx-v2-committee-service/cmd/committee-cli/commands"
14+
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/model"
15+
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/constants"
16+
"github.com/linuxfoundation/lfx-v2-committee-service/pkg/errors"
17+
)
18+
19+
// membersByUsernameIndexSubcommand backfills the secondary index
20+
// "lookup/committee-members-by-username/<username_hash>.<member_uid>" for all existing members
21+
// that carry a username. Members without a username are skipped. Idempotent: re-running is safe.
22+
type membersByUsernameIndexSubcommand struct{}
23+
24+
func (s *membersByUsernameIndexSubcommand) Name() string { return "members-by-username-index" }
25+
26+
func (s *membersByUsernameIndexSubcommand) Help() string {
27+
return "backfill the username→member secondary index for existing members (LFXV2-2645)"
28+
}
29+
30+
func (s *membersByUsernameIndexSubcommand) Run(ctx context.Context, rc commands.RunContext) error {
31+
slog.DebugContext(ctx, "starting subcommand", "subcommand", s.Name(), "args", rc.Args)
32+
33+
fs := flag.NewFlagSet("members-by-username-index", flag.ContinueOnError)
34+
fs.Usage = func() {
35+
_, _ = fmt.Fprintf(fs.Output(), "usage: committee-cli sync members-by-username-index [flags]\n\nflags:\n")
36+
fs.PrintDefaults()
37+
}
38+
sleep := fs.Duration("sleep", 0, "wait between each member write (e.g. 200ms, 1s)")
39+
dryRun := fs.Bool("dry-run", true, "compute what would be written without actually writing (default true; pass --dry-run=false to write)")
40+
if err := fs.Parse(rc.Args); err != nil {
41+
if err == flag.ErrHelp {
42+
return nil
43+
}
44+
return err
45+
}
46+
47+
rc.DryRun = *dryRun
48+
49+
if rc.CommitteeReader == nil {
50+
return errors.NewUnexpected("CommitteeReader is not wired in RunContext")
51+
}
52+
if rc.CommitteeMemberWriter == nil {
53+
return errors.NewUnexpected("CommitteeMemberWriter is not wired in RunContext")
54+
}
55+
56+
ctx = context.WithValue(ctx, constants.AuthorizationContextID, "Bearer lfx-v2-committee-service")
57+
58+
stats := commands.NewStats()
59+
stats.DryRun = rc.DryRun
60+
61+
errEach := rc.CommitteeReader.EachMember(ctx, func(member *model.CommitteeMember) error {
62+
stats.Total++
63+
64+
if member.BuildUsernameIndexKey(ctx) == "" {
65+
stats.Skipped++
66+
return nil
67+
}
68+
69+
if rc.DryRun {
70+
slog.DebugContext(ctx, "dry-run: would write username member index",
71+
"member_uid", member.UID,
72+
)
73+
stats.Updated++
74+
return nil
75+
}
76+
77+
key, errIdx := rc.CommitteeMemberWriter.IndexMemberByUsername(ctx, member)
78+
if errIdx != nil {
79+
slog.WarnContext(ctx, "failed to index member by username",
80+
"error", errIdx,
81+
"member_uid", member.UID,
82+
)
83+
stats.Failed++
84+
return nil
85+
}
86+
if key == "" {
87+
stats.Skipped++
88+
return nil
89+
}
90+
91+
slog.DebugContext(ctx, "indexed member by username",
92+
"member_uid", member.UID,
93+
)
94+
stats.Updated++
95+
96+
if *sleep > 0 {
97+
timer := time.NewTimer(*sleep)
98+
select {
99+
case <-ctx.Done():
100+
if !timer.Stop() {
101+
<-timer.C
102+
}
103+
return ctx.Err()
104+
case <-timer.C:
105+
}
106+
}
107+
return nil
108+
})
109+
if errEach != nil {
110+
return fmt.Errorf("failed to stream members: %w", errEach)
111+
}
112+
113+
stats.Log(ctx, "sync members-by-username-index")
114+
115+
if stats.Failed > 0 {
116+
return fmt.Errorf("%d member(s) failed to index", stats.Failed)
117+
}
118+
return nil
119+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
package sync
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/linuxfoundation/lfx-v2-committee-service/internal/domain/model"
15+
)
16+
17+
func usernameBackfillMember(uid, username string) *model.CommitteeMember {
18+
return &model.CommitteeMember{CommitteeMemberBase: model.CommitteeMemberBase{
19+
UID: uid,
20+
CommitteeUID: "c1",
21+
Username: username,
22+
}}
23+
}
24+
25+
func TestMembersByUsernameIndex_BackfillAll_SkipsMembersWithoutUsername(t *testing.T) {
26+
r := &mockReader{
27+
members: map[string][]*model.CommitteeMember{
28+
"c1": {
29+
usernameBackfillMember("m1", "jdoe"),
30+
usernameBackfillMember("m2", "asmith"),
31+
usernameBackfillMember("m3", ""), // no username → skipped
32+
},
33+
},
34+
}
35+
w := &mockMemberWriter{}
36+
37+
err := (&membersByUsernameIndexSubcommand{}).Run(context.Background(), newBackfillRC(r, w, "--dry-run=false"))
38+
require.NoError(t, err)
39+
assert.Len(t, w.usernameIndexed, 2, "only members with a username are indexed")
40+
}
41+
42+
func TestMembersByUsernameIndex_BackfillAll_SkipsWhitespaceOnlyUsername(t *testing.T) {
43+
r := &mockReader{
44+
members: map[string][]*model.CommitteeMember{
45+
"c1": {
46+
usernameBackfillMember("m1", "jdoe"),
47+
usernameBackfillMember("m2", " "), // whitespace-only → skipped
48+
},
49+
},
50+
}
51+
w := &mockMemberWriter{}
52+
53+
err := (&membersByUsernameIndexSubcommand{}).Run(context.Background(), newBackfillRC(r, w, "--dry-run=false"))
54+
require.NoError(t, err)
55+
assert.Len(t, w.usernameIndexed, 1, "whitespace-only username must be skipped like empty username")
56+
}
57+
58+
func TestMembersByUsernameIndex_DryRun(t *testing.T) {
59+
r := &mockReader{
60+
members: map[string][]*model.CommitteeMember{
61+
"c1": {usernameBackfillMember("m1", "jdoe")},
62+
},
63+
}
64+
w := &mockMemberWriter{}
65+
66+
err := (&membersByUsernameIndexSubcommand{}).Run(context.Background(), newBackfillRC(r, w, "--dry-run"))
67+
require.NoError(t, err)
68+
assert.Empty(t, w.usernameIndexed, "dry-run must not write")
69+
}
70+
71+
func TestMembersByUsernameIndex_IndexError_ReturnsError(t *testing.T) {
72+
r := &mockReader{
73+
members: map[string][]*model.CommitteeMember{
74+
"c1": {usernameBackfillMember("m1", "jdoe")},
75+
},
76+
}
77+
w := &mockMemberWriter{indexError: fmt.Errorf("nats unavailable")}
78+
79+
err := (&membersByUsernameIndexSubcommand{}).Run(context.Background(), newBackfillRC(r, w, "--dry-run=false"))
80+
require.Error(t, err)
81+
assert.Contains(t, err.Error(), "failed to index")
82+
}

cmd/committee-cli/commands/sync/sync.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func (c *command) Subcommands() map[string]commands.Subcommand {
2222
"members-by-committee-index": &membersByCommitteeIndexSubcommand{},
2323
"members-by-organization-index": &membersByOrganizationIndexSubcommand{},
2424
"members-by-email-index": &membersByEmailIndexSubcommand{},
25+
"members-by-username-index": &membersByUsernameIndexSubcommand{},
2526
"reindex-invites": &reindexInvitesSubcommand{},
2627
"member-cdp-org-id": &memberCDPOrgIDSubcommand{},
2728
}

cmd/committee-cli/commands/sync/total_members_attribute_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ func (r *mockReader) ListMembersByEmail(_ context.Context, _ string) ([]*model.C
6565
return nil, nil
6666
}
6767

68+
func (r *mockReader) ListMembersByUsername(_ context.Context, _ string) ([]*model.CommitteeMember, error) {
69+
return nil, nil
70+
}
71+
6872
// Stub methods required to satisfy port.CommitteeReader.
6973

7074
func (r *mockReader) GetMember(_ context.Context, uid string) (*model.CommitteeMember, uint64, error) {

internal/domain/model/committee_member.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ func (cm *CommitteeMember) BuildEmailIndexKey(ctx context.Context) string {
118118
return key
119119
}
120120

121+
// BuildUsernameIndexKey returns the SHA-256 hex digest of the member's normalized username, used
122+
// as the dot-free username segment of the lookup/committee-members-by-username/<hash>.<member_uid>
123+
// secondary index. Returns "" when the member has no username (callers treat that as a no-op).
124+
func (cm *CommitteeMember) BuildUsernameIndexKey(ctx context.Context) string {
125+
username := strings.TrimSpace(strings.ToLower(cm.Username))
126+
if username == "" {
127+
return ""
128+
}
129+
hash := sha256.Sum256([]byte(username))
130+
key := hex.EncodeToString(hash[:])
131+
slog.DebugContext(ctx, "member username index key built",
132+
"username", redaction.Redact(cm.Username),
133+
"key", key,
134+
)
135+
return key
136+
}
137+
121138
// Tags generates a consistent set of tags for the committee member.
122139
// IMPORTANT: If you modify this method, please update the Committee Tags documentation in the README.md
123140
// to ensure consumers understand how to use these tags for searching.

0 commit comments

Comments
 (0)