Wrap encoding/csv with formula-safe cell sanitization and use it from log export streaming. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
412 lines
9.5 KiB
Go
412 lines
9.5 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package iam
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/mail"
|
|
"go.probo.inc/probo/pkg/page"
|
|
"go.probo.inc/probo/pkg/safecsv"
|
|
)
|
|
|
|
type (
|
|
scimProfileExportInfo struct {
|
|
email string
|
|
fullName string
|
|
}
|
|
|
|
auditLogActorExportInfo struct {
|
|
email string
|
|
name string
|
|
}
|
|
)
|
|
|
|
var (
|
|
auditLogExportCSVHeader = []string{
|
|
"organization_name",
|
|
"id",
|
|
"created_at",
|
|
"actor_type",
|
|
"actor_id",
|
|
"actor_email",
|
|
"actor_name",
|
|
"action",
|
|
"resource_type",
|
|
"resource_id",
|
|
}
|
|
|
|
scimEventExportCSVHeader = []string{
|
|
"organization_name",
|
|
"id",
|
|
"created_at",
|
|
"method",
|
|
"path",
|
|
"user_name",
|
|
"email",
|
|
"full_name",
|
|
"status_code",
|
|
"error_message",
|
|
"ip_address",
|
|
}
|
|
|
|
auditLogExportOrderBy = page.OrderBy[coredata.AuditLogEntryOrderField]{
|
|
Field: coredata.AuditLogEntryOrderFieldCreatedAt,
|
|
Direction: page.OrderDirectionAsc,
|
|
}
|
|
|
|
scimEventExportOrderBy = page.OrderBy[coredata.SCIMEventOrderField]{
|
|
Field: coredata.SCIMEventOrderFieldCreatedAt,
|
|
Direction: page.OrderDirectionAsc,
|
|
}
|
|
)
|
|
|
|
func (s *LogExportService) streamAuditLogCSV(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope coredata.Scoper,
|
|
organizationID gid.GID,
|
|
organizationName string,
|
|
filter *coredata.AuditLogEntryFilter,
|
|
w *safecsv.Writer,
|
|
) error {
|
|
if err := w.Write(auditLogExportCSVHeader); err != nil {
|
|
return fmt.Errorf("cannot write audit log CSV header: %w", err)
|
|
}
|
|
|
|
return page.WalkAll(
|
|
ctx,
|
|
auditLogExportOrderBy,
|
|
func(ctx context.Context, cursor *page.Cursor[coredata.AuditLogEntryOrderField]) (coredata.AuditLogEntries, error) {
|
|
var logs coredata.AuditLogEntries
|
|
if err := logs.LoadByOrganizationID(
|
|
ctx,
|
|
conn,
|
|
scope,
|
|
organizationID,
|
|
cursor,
|
|
filter,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("cannot load audit log entries: %w", err)
|
|
}
|
|
|
|
return logs, nil
|
|
},
|
|
func(entries coredata.AuditLogEntries) error {
|
|
actorsByID, err := loadAuditLogActorExportInfo(ctx, conn, entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
row := auditLogEntryCSVRow(organizationName, entry, actorsByID[entry.ActorID])
|
|
if err := w.Write(row); err != nil {
|
|
return fmt.Errorf("cannot write audit log CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
w.Flush()
|
|
|
|
if err := w.Error(); err != nil {
|
|
return fmt.Errorf("cannot flush audit log CSV writer: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func (s *LogExportService) streamSCIMEventCSV(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope coredata.Scoper,
|
|
organizationID gid.GID,
|
|
organizationName string,
|
|
filter *coredata.SCIMEventFilter,
|
|
w *safecsv.Writer,
|
|
) error {
|
|
if err := w.Write(scimEventExportCSVHeader); err != nil {
|
|
return fmt.Errorf("cannot write SCIM event CSV header: %w", err)
|
|
}
|
|
|
|
return page.WalkAll(
|
|
ctx,
|
|
scimEventExportOrderBy,
|
|
func(ctx context.Context, cursor *page.Cursor[coredata.SCIMEventOrderField]) (coredata.SCIMEvents, error) {
|
|
var events coredata.SCIMEvents
|
|
if err := events.LoadByOrganizationID(
|
|
ctx,
|
|
conn,
|
|
scope,
|
|
organizationID,
|
|
cursor,
|
|
filter,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("cannot load SCIM events: %w", err)
|
|
}
|
|
|
|
return events, nil
|
|
},
|
|
func(events coredata.SCIMEvents) error {
|
|
profilesByUserName, err := loadSCIMProfileExportInfo(
|
|
ctx,
|
|
conn,
|
|
scope,
|
|
organizationID,
|
|
events,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, event := range events {
|
|
row := scimEventCSVRow(organizationName, event, profilesByUserName)
|
|
if err := w.Write(row); err != nil {
|
|
return fmt.Errorf("cannot write SCIM event CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
w.Flush()
|
|
|
|
if err := w.Error(); err != nil {
|
|
return fmt.Errorf("cannot flush SCIM event CSV writer: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func auditLogEntryCSVRow(
|
|
organizationName string,
|
|
entry *coredata.AuditLogEntry,
|
|
actor auditLogActorExportInfo,
|
|
) []string {
|
|
return []string{
|
|
organizationName,
|
|
entry.ID.String(),
|
|
entry.CreatedAt.Format(time.RFC3339),
|
|
string(entry.ActorType),
|
|
entry.ActorID.String(),
|
|
actor.email,
|
|
actor.name,
|
|
entry.Action,
|
|
entry.ResourceType,
|
|
entry.ResourceID.String(),
|
|
}
|
|
}
|
|
|
|
func scimEventCSVRow(
|
|
organizationName string,
|
|
event *coredata.SCIMEvent,
|
|
profilesByUserName map[string]scimProfileExportInfo,
|
|
) []string {
|
|
profile := profilesByUserName[strings.ToLower(event.UserName)]
|
|
|
|
email := profile.email
|
|
if email == "" {
|
|
email = scimEmailFromUserName(event.UserName)
|
|
}
|
|
|
|
return []string{
|
|
organizationName,
|
|
event.ID.String(),
|
|
event.CreatedAt.Format(time.RFC3339),
|
|
event.Method,
|
|
event.Path,
|
|
event.UserName,
|
|
email,
|
|
profile.fullName,
|
|
strconv.Itoa(event.StatusCode),
|
|
stringPtrValue(event.ErrorMessage),
|
|
event.IPAddress.String(),
|
|
}
|
|
}
|
|
|
|
func loadAuditLogActorExportInfo(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
entries coredata.AuditLogEntries,
|
|
) (map[gid.GID]auditLogActorExportInfo, error) {
|
|
identityIDs := make([]gid.GID, 0)
|
|
apiKeyIDs := make([]gid.GID, 0)
|
|
|
|
for _, entry := range entries {
|
|
switch entry.ActorType {
|
|
case coredata.AuditLogActorTypeUser:
|
|
identityIDs = append(identityIDs, entry.ActorID)
|
|
case coredata.AuditLogActorTypeAPIKey:
|
|
apiKeyIDs = append(apiKeyIDs, entry.ActorID)
|
|
case coredata.AuditLogActorTypeSystem:
|
|
default:
|
|
}
|
|
}
|
|
|
|
result := make(map[gid.GID]auditLogActorExportInfo)
|
|
|
|
var identities coredata.Identities
|
|
if err := identities.LoadByIDs(ctx, conn, identityIDs); err != nil {
|
|
return nil, fmt.Errorf("cannot load audit log actor identities: %w", err)
|
|
}
|
|
|
|
for _, identity := range identities {
|
|
result[identity.ID] = auditLogActorExportInfo{
|
|
email: identity.EmailAddress.String(),
|
|
name: identity.FullName,
|
|
}
|
|
}
|
|
|
|
var apiKeys coredata.PersonalAPIKeys
|
|
if err := apiKeys.LoadByIDs(ctx, conn, apiKeyIDs); err != nil {
|
|
return nil, fmt.Errorf("cannot load audit log actor API keys: %w", err)
|
|
}
|
|
|
|
for _, apiKey := range apiKeys {
|
|
result[apiKey.ID] = auditLogActorExportInfo{
|
|
name: apiKey.Name,
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func loadSCIMProfileExportInfo(
|
|
ctx context.Context,
|
|
conn pg.Querier,
|
|
scope coredata.Scoper,
|
|
organizationID gid.GID,
|
|
events coredata.SCIMEvents,
|
|
) (map[string]scimProfileExportInfo, error) {
|
|
userNames := uniqueNonEmptyStrings(scimEventUserNames(events))
|
|
if len(userNames) == 0 {
|
|
return map[string]scimProfileExportInfo{}, nil
|
|
}
|
|
|
|
var profiles coredata.MembershipProfiles
|
|
if err := profiles.LoadByOrganizationIDAndUserNames(
|
|
ctx,
|
|
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))
|
|
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))
|
|
for _, profile := range profiles {
|
|
if profile.UserName == nil {
|
|
continue
|
|
}
|
|
|
|
key := strings.ToLower(*profile.UserName)
|
|
result[key] = scimProfileExportInfo{
|
|
email: emailByIdentityID[profile.IdentityID],
|
|
fullName: profileFullName(profile),
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func scimEventUserNames(events coredata.SCIMEvents) []string {
|
|
userNames := make([]string, 0, len(events))
|
|
for _, event := range events {
|
|
userNames = append(userNames, event.UserName)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
key := strings.ToLower(value)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
out = append(out, value)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
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 {
|
|
if profile.FormattedName != nil && *profile.FormattedName != "" {
|
|
return *profile.FormattedName
|
|
}
|
|
|
|
return profile.FullName
|
|
}
|
|
|
|
func stringPtrValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
|
|
return *value
|
|
}
|