Resolve SCIM export profiles from event path IDs

SCIM events store user_name, not identity_id; profile GIDs
in /Users/{id} paths drive LoadExistingByIDs and identity
email lookup instead of batching by user_name.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-07-30 07:50:22 +00:00
parent 39b00bc4a7
commit 5ea732a97f
3 changed files with 105 additions and 70 deletions

View File

@@ -495,12 +495,11 @@ WHERE
return nil return nil
} }
func (p *MembershipProfiles) LoadByOrganizationIDAndUserNames( func (p *MembershipProfiles) LoadExistingByIDs(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
scope Scoper, scope Scoper,
organizationID gid.GID, profileIDs []gid.GID,
userNames []string,
) error { ) error {
q := ` q := `
SELECT SELECT
@@ -542,26 +541,22 @@ FROM
iam_membership_profiles p iam_membership_profiles p
WHERE WHERE
p.%s p.%s
AND p.organization_id = @organization_id AND p.id = ANY(@profile_ids)
AND p.user_name = ANY(@user_names::citext[])
` `
q = fmt.Sprintf(q, scope.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{ args := pgx.NamedArgs{"profile_ids": profileIDs}
"organization_id": organizationID,
"user_names": userNames,
}
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args) rows, err := conn.Query(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot query profiles by user names: %w", err) return fmt.Errorf("cannot query profiles by ids: %w", err)
} }
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile]) profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil { if err != nil {
return fmt.Errorf("cannot collect profiles by user names: %w", err) return fmt.Errorf("cannot collect profiles by ids: %w", err)
} }
*p = profiles *p = profiles

View File

@@ -40,6 +40,11 @@ type (
fullName string fullName string
} }
scimProfileExportLookup struct {
byProfileID map[gid.GID]scimProfileExportInfo
byUserName map[string]scimProfileExportInfo
}
auditLogActorExportInfo struct { auditLogActorExportInfo struct {
email string email string
name string name string
@@ -172,19 +177,13 @@ func (s *LogExportService) streamSCIMEventCSV(
return events, nil return events, nil
}, },
func(events coredata.SCIMEvents) error { func(events coredata.SCIMEvents) error {
profilesByUserName, err := loadSCIMProfileExportInfo( profileLookup, err := loadSCIMProfileExportInfo(ctx, conn, scope, events)
ctx,
conn,
scope,
organizationID,
events,
)
if err != nil { if err != nil {
return err return err
} }
for _, event := range events { for _, event := range events {
row := scimEventCSVRow(organizationName, event, profilesByUserName) row := scimEventCSVRow(organizationName, event, profileLookup)
if err := w.Write(row); err != nil { if err := w.Write(row); err != nil {
return fmt.Errorf("cannot write SCIM event CSV row: %w", err) return fmt.Errorf("cannot write SCIM event CSV row: %w", err)
} }
@@ -223,9 +222,9 @@ func auditLogEntryCSVRow(
func scimEventCSVRow( func scimEventCSVRow(
organizationName string, organizationName string,
event *coredata.SCIMEvent, event *coredata.SCIMEvent,
profilesByUserName map[string]scimProfileExportInfo, lookup scimProfileExportLookup,
) []string { ) []string {
profile := profilesByUserName[strings.ToLower(event.UserName)] profile := lookup.forEvent(event)
return []string{ return []string{
organizationName, organizationName,
@@ -293,23 +292,19 @@ func loadSCIMProfileExportInfo(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
scope coredata.Scoper, scope coredata.Scoper,
organizationID gid.GID,
events coredata.SCIMEvents, events coredata.SCIMEvents,
) (map[string]scimProfileExportInfo, error) { ) (scimProfileExportLookup, error) {
userNames := uniqueNonEmptyStrings(scimEventUserNames(events)) profileIDs := scimEventProfileIDs(events)
if len(userNames) == 0 { if len(profileIDs) == 0 {
return map[string]scimProfileExportInfo{}, nil return scimProfileExportLookup{
byProfileID: map[gid.GID]scimProfileExportInfo{},
byUserName: map[string]scimProfileExportInfo{},
}, nil
} }
var profiles coredata.MembershipProfiles var profiles coredata.MembershipProfiles
if err := profiles.LoadByOrganizationIDAndUserNames( if err := profiles.LoadExistingByIDs(ctx, conn, scope, profileIDs); err != nil {
ctx, return scimProfileExportLookup{}, fmt.Errorf("cannot load SCIM export profiles: %w", err)
conn,
scope,
organizationID,
userNames,
); err != nil {
return nil, fmt.Errorf("cannot load SCIM profile export info: %w", err)
} }
identityIDs := make([]gid.GID, 0, len(profiles)) identityIDs := make([]gid.GID, 0, len(profiles))
@@ -319,7 +314,7 @@ func loadSCIMProfileExportInfo(
var identities coredata.Identities var identities coredata.Identities
if err := identities.LoadByIDs(ctx, conn, identityIDs); err != nil { if err := identities.LoadByIDs(ctx, conn, identityIDs); err != nil {
return nil, fmt.Errorf("cannot load SCIM profile identity emails: %w", err) return scimProfileExportLookup{}, fmt.Errorf("cannot load SCIM profile identity emails: %w", err)
} }
emailByIdentityID := make(map[gid.GID]string, len(identities)) emailByIdentityID := make(map[gid.GID]string, len(identities))
@@ -327,52 +322,85 @@ func loadSCIMProfileExportInfo(
emailByIdentityID[identity.ID] = identity.EmailAddress.String() emailByIdentityID[identity.ID] = identity.EmailAddress.String()
} }
result := make(map[string]scimProfileExportInfo, len(profiles)) lookup := scimProfileExportLookup{
for _, profile := range profiles { byProfileID: make(map[gid.GID]scimProfileExportInfo, len(profiles)),
if profile.UserName == nil { byUserName: make(map[string]scimProfileExportInfo, len(profiles)),
continue
} }
for _, profile := range profiles {
key := strings.ToLower(*profile.UserName) info := scimProfileExportInfo{
result[key] = scimProfileExportInfo{
email: emailByIdentityID[profile.IdentityID], email: emailByIdentityID[profile.IdentityID],
fullName: profileFullName(profile), fullName: profileFullName(profile),
} }
lookup.byProfileID[profile.ID] = info
if profile.UserName != nil {
lookup.byUserName[strings.ToLower(*profile.UserName)] = info
}
} }
return result, nil return lookup, nil
} }
func scimEventUserNames(events coredata.SCIMEvents) []string { func (l scimProfileExportLookup) forEvent(event *coredata.SCIMEvent) scimProfileExportInfo {
userNames := make([]string, 0, len(events)) if profileID, ok := scimProfileIDFromEventPath(event.Path); ok {
if info, ok := l.byProfileID[profileID]; ok {
return info
}
}
if event.UserName != "" {
return l.byUserName[strings.ToLower(event.UserName)]
}
return scimProfileExportInfo{}
}
const scimUsersResourcePathPrefix = "/Users/"
func scimProfileIDFromEventPath(path string) (gid.GID, bool) {
if !strings.HasPrefix(path, scimUsersResourcePathPrefix) {
return gid.GID{}, false
}
rest := strings.TrimPrefix(path, scimUsersResourcePathPrefix)
if rest == "" {
return gid.GID{}, false
}
idPart, _, _ := strings.Cut(rest, "?")
idPart, _, _ = strings.Cut(idPart, "/")
if idPart == "" {
return gid.GID{}, false
}
profileID, err := gid.ParseGID(idPart)
if err != nil {
return gid.GID{}, false
}
return profileID, true
}
func scimEventProfileIDs(events coredata.SCIMEvents) []gid.GID {
seen := gid.NewSet()
for _, event := range events { for _, event := range events {
userNames = append(userNames, event.UserName) profileID, ok := scimProfileIDFromEventPath(event.Path)
} if !ok {
return userNames
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue continue
} }
key := strings.ToLower(value) seen[profileID] = struct{}{}
if _, ok := seen[key]; ok {
continue
} }
seen[key] = struct{}{} profileIDs := make([]gid.GID, 0, len(seen))
for profileID := range seen {
out = append(out, value) profileIDs = append(profileIDs, profileID)
} }
return out return profileIDs
} }
func profileFullName(profile *coredata.MembershipProfile) string { func profileFullName(profile *coredata.MembershipProfile) string {

View File

@@ -24,11 +24,23 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
) )
func TestUniqueNonEmptyStrings(t *testing.T) { func TestScimProfileIDFromEventPath(t *testing.T) {
t.Parallel() t.Parallel()
got := uniqueNonEmptyStrings([]string{"a", "A", "", "b", "a"}) profileID := gid.New(gid.NewTenantID(), coredata.MembershipProfileEntityType)
assert.Equal(t, []string{"a", "b"}, got)
got, ok := scimProfileIDFromEventPath("/Users/" + profileID.String())
require.True(t, ok)
assert.Equal(t, profileID, got)
_, ok = scimProfileIDFromEventPath("/Users")
assert.False(t, ok)
_, ok = scimProfileIDFromEventPath("/Users?filter=userName eq \"a\"")
assert.False(t, ok)
} }