Harden log export CSV and fix profile loader boundary

Load SCIM profile emails via Identities in the export
layer, drop the cross-table join, sanitize formula-leading
cells, and fall back to validated userName when profile email
is missing. Drop redundant gid array casts in LoadByIDs.

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-29 22:04:35 +00:00
parent 45a7a428bf
commit c5b1f210d5
5 changed files with 83 additions and 11 deletions

View File

@@ -407,7 +407,7 @@ SELECT
FROM FROM
identities identities
WHERE WHERE
id = ANY(@identity_ids::text[]) id = ANY(@identity_ids)
` `
args := pgx.StrictNamedArgs{"identity_ids": identityIDs} args := pgx.StrictNamedArgs{"identity_ids": identityIDs}

View File

@@ -507,7 +507,7 @@ SELECT
p.id, p.id,
p.identity_id, p.identity_id,
p.organization_id, p.organization_id,
i.email_address, ''::citext AS email_address,
p.source, p.source,
p.state, p.state,
p.full_name, p.full_name,
@@ -540,8 +540,6 @@ SELECT
p.updated_at p.updated_at
FROM FROM
iam_membership_profiles p iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
WHERE WHERE
p.%s p.%s
AND p.organization_id = @organization_id AND p.organization_id = @organization_id

View File

@@ -320,7 +320,7 @@ SELECT
FROM FROM
iam_personal_api_keys iam_personal_api_keys
WHERE WHERE
id = ANY(@api_key_ids::text[]) id = ANY(@api_key_ids)
` `
args := pgx.StrictNamedArgs{"api_key_ids": apiKeyIDs} args := pgx.StrictNamedArgs{"api_key_ids": apiKeyIDs}

View File

@@ -31,6 +31,7 @@ import (
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
) )
@@ -206,7 +207,7 @@ func auditLogEntryCSVRow(
entry *coredata.AuditLogEntry, entry *coredata.AuditLogEntry,
actor auditLogActorExportInfo, actor auditLogActorExportInfo,
) []string { ) []string {
return []string{ return csvExportRow(
organizationName, organizationName,
entry.ID.String(), entry.ID.String(),
entry.CreatedAt.Format(time.RFC3339), entry.CreatedAt.Format(time.RFC3339),
@@ -217,7 +218,7 @@ func auditLogEntryCSVRow(
entry.Action, entry.Action,
entry.ResourceType, entry.ResourceType,
entry.ResourceID.String(), entry.ResourceID.String(),
} )
} }
func scimEventCSVRow( func scimEventCSVRow(
@@ -227,19 +228,24 @@ func scimEventCSVRow(
) []string { ) []string {
profile := profilesByUserName[strings.ToLower(event.UserName)] profile := profilesByUserName[strings.ToLower(event.UserName)]
return []string{ email := profile.email
if email == "" {
email = scimEmailFromUserName(event.UserName)
}
return csvExportRow(
organizationName, organizationName,
event.ID.String(), event.ID.String(),
event.CreatedAt.Format(time.RFC3339), event.CreatedAt.Format(time.RFC3339),
event.Method, event.Method,
event.Path, event.Path,
event.UserName, event.UserName,
profile.email, email,
profile.fullName, profile.fullName,
strconv.Itoa(event.StatusCode), strconv.Itoa(event.StatusCode),
stringPtrValue(event.ErrorMessage), stringPtrValue(event.ErrorMessage),
event.IPAddress.String(), event.IPAddress.String(),
} )
} }
func loadAuditLogActorExportInfo( func loadAuditLogActorExportInfo(
@@ -312,6 +318,21 @@ func loadSCIMProfileExportInfo(
return nil, fmt.Errorf("cannot load SCIM profile export info: %w", err) return nil, fmt.Errorf("cannot load SCIM profile export info: %w", err)
} }
identityIDs := make([]gid.GID, 0, len(profiles))
for _, profile := range profiles {
identityIDs = append(identityIDs, profile.IdentityID)
}
var identities coredata.Identities
if err := identities.LoadByIDs(ctx, conn, identityIDs); err != nil {
return nil, fmt.Errorf("cannot load SCIM profile identity emails: %w", err)
}
emailByIdentityID := make(map[gid.GID]string, len(identities))
for _, identity := range identities {
emailByIdentityID[identity.ID] = identity.EmailAddress.String()
}
result := make(map[string]scimProfileExportInfo, len(profiles)) result := make(map[string]scimProfileExportInfo, len(profiles))
for _, profile := range profiles { for _, profile := range profiles {
if profile.UserName == nil { if profile.UserName == nil {
@@ -320,7 +341,7 @@ func loadSCIMProfileExportInfo(
key := strings.ToLower(*profile.UserName) key := strings.ToLower(*profile.UserName)
result[key] = scimProfileExportInfo{ result[key] = scimProfileExportInfo{
email: profile.EmailAddress.String(), email: emailByIdentityID[profile.IdentityID],
fullName: profileFullName(profile), fullName: profileFullName(profile),
} }
} }
@@ -360,6 +381,41 @@ func uniqueNonEmptyStrings(values []string) []string {
return out return out
} }
func csvExportRow(fields ...string) []string {
row := make([]string, len(fields))
for i, field := range fields {
row[i] = csvSafeCell(field)
}
return row
}
func csvSafeCell(value string) string {
if value == "" {
return value
}
switch value[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + value
default:
return value
}
}
func scimEmailFromUserName(userName string) string {
userName = strings.TrimSpace(userName)
if userName == "" {
return ""
}
if _, err := mail.ParseAddr(userName); err == nil {
return userName
}
return ""
}
func profileFullName(profile *coredata.MembershipProfile) string { func profileFullName(profile *coredata.MembershipProfile) string {
if profile.FormattedName != nil && *profile.FormattedName != "" { if profile.FormattedName != nil && *profile.FormattedName != "" {
return *profile.FormattedName return *profile.FormattedName

View File

@@ -32,3 +32,21 @@ func TestUniqueNonEmptyStrings(t *testing.T) {
got := uniqueNonEmptyStrings([]string{"a", "A", "", "b", "a"}) got := uniqueNonEmptyStrings([]string{"a", "A", "", "b", "a"})
assert.Equal(t, []string{"a", "b"}, got) assert.Equal(t, []string{"a", "b"}, got)
} }
func TestCsvSafeCell(t *testing.T) {
t.Parallel()
assert.Equal(t, "plain", csvSafeCell("plain"))
assert.Equal(t, "'=1+1", csvSafeCell("=1+1"))
assert.Equal(t, "'+cmd", csvSafeCell("+cmd"))
assert.Equal(t, "'-2", csvSafeCell("-2"))
assert.Equal(t, "'@sum", csvSafeCell("@sum"))
}
func TestScimEmailFromUserName(t *testing.T) {
t.Parallel()
assert.Equal(t, "user@example.com", scimEmailFromUserName("user@example.com"))
assert.Equal(t, "", scimEmailFromUserName("not-an-email"))
assert.Equal(t, "", scimEmailFromUserName(""))
}