diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 3c453d3fc..5b53aec9e 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -1396,7 +1396,7 @@ "actions": { "showMore": "Show more" }, "export": { "title": "Export Audit Log", - "description": "Select a date range to export audit log entries as JSONL. You will receive an email with a download link.", + "description": "Select a date range to export audit log entries as CSV. You will receive an email with a download link.", "fields": { "from": "From", "to": "To" }, "actions": { "export": "Export", "exporting": "Exporting..." }, "messages": { @@ -1424,7 +1424,7 @@ "provisioningEventHistory": "Provisioning Event History", "export": { "title": "Export SCIM Events", - "description": "Select a date range to export SCIM events as JSONL. You will receive an email with a download link.", + "description": "Select a date range to export SCIM events as CSV. You will receive an email with a download link.", "fields": { "from": "From", "to": "To" }, "actions": { "export": "Export", "exporting": "Exporting..." }, "messages": { diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index c87da6a5c..443ccd480 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -2544,7 +2544,7 @@ }, "export": { "title": "Exporter le journal d'audit", - "description": "Sélectionnez une plage de dates pour exporter les entrées du journal d'audit au format JSONL. Vous recevrez un e-mail avec un lien de téléchargement.", + "description": "Sélectionnez une plage de dates pour exporter les entrées du journal d'audit au format CSV. Vous recevrez un e-mail avec un lien de téléchargement.", "fields": { "from": "Du", "to": "Au" }, "actions": { "export": "Exporter", "exporting": "Exportation..." }, "messages": { @@ -2593,7 +2593,7 @@ "provisioningEventHistory": "Historique des événements de provisionnement", "export": { "title": "Exporter les événements SCIM", - "description": "Sélectionnez une plage de dates pour exporter les événements SCIM au format JSONL. Vous recevrez un e-mail avec un lien de téléchargement.", + "description": "Sélectionnez une plage de dates pour exporter les événements SCIM au format CSV. Vous recevrez un e-mail avec un lien de téléchargement.", "fields": { "from": "Du", "to": "Au" }, "actions": { "export": "Exporter", "exporting": "Exportation..." }, "messages": { diff --git a/cmd/prb/CHANGELOG.md b/cmd/prb/CHANGELOG.md index 37568fa33..5e6d0fb6f 100644 --- a/cmd/prb/CHANGELOG.md +++ b/cmd/prb/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the `prb` CLI will be documented in this file. ### Added -- `prb audit-log export` and `prb scim event export` commands to request JSONL exports of audit log entries and SCIM events +- `prb audit-log export` and `prb scim event export` commands to request CSV exports of audit log entries and SCIM events ## [0.205.0] - 2026-07-28 diff --git a/cmd/probod/CHANGELOG.md b/cmd/probod/CHANGELOG.md index 3a5929e0f..28cee6183 100644 --- a/cmd/probod/CHANGELOG.md +++ b/cmd/probod/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to `probod` (the server, including the bundled `@probo/conso ### Added -- Log export for audit logs and SCIM events: request a JSONL export job from the console, Connect, MCP, or CLI, streamed and uploaded to S3 by a concurrent export worker +- Log export for audit logs and SCIM events: request a CSV export job from the console, Connect, MCP, or CLI, streamed and uploaded to S3 by a concurrent export worker - Auth cookie `SameSite` attribute is now configurable (`PROBOD_AUTH_COOKIE_SAMESITE`, defaults to `Lax`), rejecting `None` unless `Secure` is enabled - In-tab light/dark display mode toggle for signed-in compliance portal guests, independent of the OS color scheme - Subprocessor cards truncate long country/region lists behind a "+N" popover diff --git a/pkg/coredata/identity.go b/pkg/coredata/identity.go index 60ce0e648..e5caf8242 100644 --- a/pkg/coredata/identity.go +++ b/pkg/coredata/identity.go @@ -387,3 +387,48 @@ WHERE return count, nil } + +func (i *Identities) LoadByIDs( + ctx context.Context, + conn pg.Querier, + identityIDs []gid.GID, +) error { + if len(identityIDs) == 0 { + *i = nil + + return nil + } + + q := ` +SELECT + id, + email_address, + full_name, + hashed_password, + email_address_verified, + saml_subject, + locale, + created_at, + updated_at +FROM + identities +WHERE + id = ANY(@identity_ids::text[]) +` + + args := pgx.StrictNamedArgs{"identity_ids": identityIDs} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query identities: %w", err) + } + + identities, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Identity]) + if err != nil { + return fmt.Errorf("cannot collect identities: %w", err) + } + + *i = identities + + return nil +} diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index 70366c3e1..28bb887f0 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -495,6 +495,88 @@ WHERE return nil } +func (p *MembershipProfiles) LoadByOrganizationIDAndUserNames( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + userNames []string, +) error { + if len(userNames) == 0 { + *p = nil + + return nil + } + + q := ` +SELECT + p.id, + p.identity_id, + p.organization_id, + i.email_address, + p.source, + p.state, + p.full_name, + p.kind, + p.additional_email_addresses, + p.position, + p.contract_start_date, + p.contract_end_date, + '' AS organization_name, + p.user_name, + p.external_id, + p.nickname, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, + p.created_at, + p.updated_at +FROM + iam_membership_profiles p +INNER JOIN identities i + ON i.id = p.identity_id +WHERE + p.%s + AND p.organization_id = @organization_id + AND p.user_name = ANY(@user_names::citext[]) +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "organization_id": organizationID, + "user_names": userNames, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query profiles by user names: %w", err) + } + + profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile]) + if err != nil { + return fmt.Errorf("cannot collect profiles by user names: %w", err) + } + + *p = profiles + + return nil +} + func (p *MembershipProfiles) LoadByOrganizationID( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/personal_api_key.go b/pkg/coredata/personal_api_key.go index 065334917..7f14f1770 100644 --- a/pkg/coredata/personal_api_key.go +++ b/pkg/coredata/personal_api_key.go @@ -301,3 +301,47 @@ WHERE return nil } + +func (a *PersonalAPIKeys) LoadByIDs( + ctx context.Context, + conn pg.Querier, + apiKeyIDs []gid.GID, +) error { + if len(apiKeyIDs) == 0 { + *a = nil + + return nil + } + + q := ` +SELECT + id, + identity_id, + name, + expires_at, + expire_reason, + last_used_at, + created_at, + updated_at +FROM + iam_personal_api_keys +WHERE + id = ANY(@api_key_ids::text[]) +` + + args := pgx.StrictNamedArgs{"api_key_ids": apiKeyIDs} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query personal api keys: %w", err) + } + + apiKeys, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[PersonalAPIKey]) + if err != nil { + return fmt.Errorf("cannot collect personal api keys: %w", err) + } + + *a = apiKeys + + return nil +} diff --git a/pkg/iam/log_export_csv.go b/pkg/iam/log_export_csv.go new file mode 100644 index 000000000..5b30b1a62 --- /dev/null +++ b/pkg/iam/log_export_csv.go @@ -0,0 +1,459 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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" + "encoding/csv" + "encoding/json" + "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" +) + +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", + "metadata", + } + + scimEventExportCSVHeader = []string{ + "organization_name", + "id", + "created_at", + "method", + "path", + "user_name", + "email", + "full_name", + "status_code", + "error_message", + "ip_address", + } +) + +func (s *LogExportService) streamAuditLogCSV( + ctx context.Context, + conn pg.Querier, + scope coredata.Scoper, + organizationID gid.GID, + organizationName string, + args *coredata.LogExportArguments, + w *csv.Writer, +) error { + if err := w.Write(auditLogExportCSVHeader); err != nil { + return fmt.Errorf("cannot write audit log CSV header: %w", err) + } + + filter := coredata.NewAuditLogEntryFilter(). + WithCreatedAtGte(args.FromTime). + WithCreatedAtLt(args.ToTime) + + return page.WalkAll( + ctx, + page.OrderBy[coredata.AuditLogEntryOrderField]{ + Field: coredata.AuditLogEntryOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + func(ctx context.Context, cursor *page.Cursor[coredata.AuditLogEntryOrderField]) ([]*coredata.AuditLogEntry, error) { + var batch coredata.AuditLogEntries + if err := batch.LoadByOrganizationID( + ctx, + conn, + scope, + organizationID, + cursor, + filter, + ); err != nil { + return nil, err + } + + return batch, nil + }, + func(entries []*coredata.AuditLogEntry) 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, + args *coredata.LogExportArguments, + w *csv.Writer, +) error { + if err := w.Write(scimEventExportCSVHeader); err != nil { + return fmt.Errorf("cannot write SCIM event CSV header: %w", err) + } + + filter := coredata.NewSCIMEventFilter(). + WithCreatedAtGte(args.FromTime). + WithCreatedAtLt(args.ToTime) + + return page.WalkAll( + ctx, + page.OrderBy[coredata.SCIMEventOrderField]{ + Field: coredata.SCIMEventOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + func(ctx context.Context, cursor *page.Cursor[coredata.SCIMEventOrderField]) ([]*coredata.SCIMEvent, error) { + var batch coredata.SCIMEvents + if err := batch.LoadByOrganizationID( + ctx, + conn, + scope, + organizationID, + cursor, + filter, + ); err != nil { + return nil, err + } + + return batch, nil + }, + func(events []*coredata.SCIMEvent) 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 { + metadata := "" + if len(entry.Metadata) > 0 { + metadata = string(entry.Metadata) + } + + 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(), + metadata, + } +} + +func scimEventCSVRow( + organizationName string, + event *coredata.SCIMEvent, + profilesByUserName map[string]scimProfileExportInfo, +) []string { + profile := profilesByUserName[strings.ToLower(event.UserName)] + email := profile.email + fullName := profile.fullName + + if email == "" { + email = scimEmailFromUserName(event.UserName) + } + + if fullName == "" { + fullName = scimFullNameFromBodies(event.RequestBody, event.ResponseBody) + } + + return []string{ + organizationName, + event.ID.String(), + event.CreatedAt.Format(time.RFC3339), + event.Method, + event.Path, + event.UserName, + email, + fullName, + strconv.Itoa(event.StatusCode), + stringPtrValue(event.ErrorMessage), + event.IPAddress.String(), + } +} + +func loadAuditLogActorExportInfo( + ctx context.Context, + conn pg.Querier, + entries []*coredata.AuditLogEntry, +) (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) + + if len(identityIDs) > 0 { + 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, + } + } + } + + if len(apiKeyIDs) > 0 { + 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.SCIMEvent, +) (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) + } + + 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: profile.EmailAddress.String(), + fullName: profileFullName(profile), + } + } + + return result, nil +} + +func scimEventUserNames(events []*coredata.SCIMEvent) []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 scimFullNameFromBodies(requestBody *string, responseBody *string) string { + for _, body := range []*string{requestBody, responseBody} { + if body == nil || strings.TrimSpace(*body) == "" { + continue + } + + if name := scimDisplayNameFromJSON(*body); name != "" { + return name + } + } + + return "" +} + +func scimDisplayNameFromJSON(body string) string { + var payload map[string]any + if err := json.Unmarshal([]byte(body), &payload); err != nil { + return "" + } + + if displayName, ok := payload["displayName"].(string); ok && displayName != "" { + return displayName + } + + nameValue, ok := payload["name"].(map[string]any) + if !ok { + return "" + } + + if formatted, ok := nameValue["formatted"].(string); ok && formatted != "" { + return formatted + } + + given, _ := nameValue["givenName"].(string) + family, _ := nameValue["familyName"].(string) + fullName := strings.TrimSpace(given + " " + family) + if fullName != "" { + return fullName + } + + 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 +} diff --git a/pkg/iam/log_export_csv_test.go b/pkg/iam/log_export_csv_test.go new file mode 100644 index 000000000..bfe96ee67 --- /dev/null +++ b/pkg/iam/log_export_csv_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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("")) +} + +func TestScimDisplayNameFromJSON(t *testing.T) { + t.Parallel() + + displayName := scimDisplayNameFromJSON(`{"displayName":"Jane Doe"}`) + require.Equal(t, "Jane Doe", displayName) + + formatted := scimDisplayNameFromJSON(`{"name":{"formatted":"John Smith"}}`) + require.Equal(t, "John Smith", formatted) + + givenFamily := scimDisplayNameFromJSON(`{"name":{"givenName":"John","familyName":"Smith"}}`) + require.Equal(t, "John Smith", givenFamily) + + assert.Equal(t, "", scimDisplayNameFromJSON("not json")) +} + +func TestUniqueNonEmptyStrings(t *testing.T) { + t.Parallel() + + got := uniqueNonEmptyStrings([]string{"a", "A", "", "b", "a"}) + assert.Equal(t, []string{"a", "b"}, got) +} diff --git a/pkg/iam/log_export_service.go b/pkg/iam/log_export_service.go index d6e226217..2b41240ce 100644 --- a/pkg/iam/log_export_service.go +++ b/pkg/iam/log_export_service.go @@ -22,7 +22,7 @@ package iam import ( "context" - "encoding/json" + "encoding/csv" "fmt" "io" "strings" @@ -35,7 +35,6 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/mail" - "go.probo.inc/probo/pkg/page" ) type LogExportService struct { @@ -88,7 +87,7 @@ func (s *LogExportService) BuildAndUploadExport( now := time.Now() fileKey := uuid.MustNewV4().String() fileName := fmt.Sprintf( - "%s-export-%s-to-%s.jsonl", + "%s-export-%s-to-%s.csv", typeName, args.FromTime.Format("2006-01-02"), args.ToTime.Format("2006-01-02"), @@ -98,7 +97,7 @@ func (s *LogExportService) BuildAndUploadExport( ID: gid.New(exportJob.ID.TenantID(), coredata.FileEntityType), OrganizationID: exportJob.OrganizationID, BucketName: s.bucket, - MimeType: "application/octet-stream", + MimeType: "text/csv", FileName: fileName, FileKey: fileKey, Visibility: coredata.FileVisibilityPrivate, @@ -131,7 +130,7 @@ func (s *LogExportService) BuildAndUploadExport( _ = pr.CloseWithError(uploadErr) }() - writeErr := s.streamJSONL(ctx, exportJob, args, scope, pw) + writeErr := s.streamCSV(ctx, exportJob, args, scope, pw) if writeErr != nil { _ = pw.CloseWithError(writeErr) } else { @@ -141,7 +140,7 @@ func (s *LogExportService) BuildAndUploadExport( <-uploadDone if writeErr != nil { - return nil, fmt.Errorf("cannot write JSONL: %w", writeErr) + return nil, fmt.Errorf("cannot write CSV: %w", writeErr) } if uploadErr != nil { @@ -214,7 +213,7 @@ func (s *LogExportService) SendExportEmail( ) } -func (s *LogExportService) streamJSONL( +func (s *LogExportService) streamCSV( ctx context.Context, exportJob *coredata.ExportJob, args *coredata.LogExportArguments, @@ -224,106 +223,37 @@ func (s *LogExportService) streamJSONL( return s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - enc := json.NewEncoder(pw) + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, scope, exportJob.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization for log export: %w", err) + } + + w := csv.NewWriter(pw) switch exportJob.Type { case coredata.ExportJobTypeAuditLog: - return s.streamAuditLogEntries(ctx, conn, scope, exportJob.OrganizationID, args, enc) + return s.streamAuditLogCSV( + ctx, + conn, + scope, + exportJob.OrganizationID, + organization.Name, + args, + w, + ) case coredata.ExportJobTypeSCIMEvent: - return s.streamSCIMEvents(ctx, conn, scope, exportJob.OrganizationID, args, enc) + return s.streamSCIMEventCSV( + ctx, + conn, + scope, + exportJob.OrganizationID, + organization.Name, + args, + w, + ) default: return fmt.Errorf("unsupported log export type: %q", exportJob.Type) } }, ) } - -func (s *LogExportService) streamAuditLogEntries( - ctx context.Context, - conn pg.Querier, - scope coredata.Scoper, - organizationID gid.GID, - args *coredata.LogExportArguments, - enc *json.Encoder, -) error { - filter := coredata.NewAuditLogEntryFilter(). - WithCreatedAtGte(args.FromTime). - WithCreatedAtLt(args.ToTime) - - return page.WalkAll( - ctx, - page.OrderBy[coredata.AuditLogEntryOrderField]{ - Field: coredata.AuditLogEntryOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - func(ctx context.Context, cursor *page.Cursor[coredata.AuditLogEntryOrderField]) ([]*coredata.AuditLogEntry, error) { - var batch coredata.AuditLogEntries - if err := batch.LoadByOrganizationID( - ctx, - conn, - scope, - organizationID, - cursor, - filter, - ); err != nil { - return nil, err - } - - return batch, nil - }, - func(entries []*coredata.AuditLogEntry) error { - for _, entry := range entries { - if err := enc.Encode(entry); err != nil { - return fmt.Errorf("cannot encode audit log entry: %w", err) - } - } - - return nil - }, - ) -} - -func (s *LogExportService) streamSCIMEvents( - ctx context.Context, - conn pg.Querier, - scope coredata.Scoper, - organizationID gid.GID, - args *coredata.LogExportArguments, - enc *json.Encoder, -) error { - filter := coredata.NewSCIMEventFilter(). - WithCreatedAtGte(args.FromTime). - WithCreatedAtLt(args.ToTime) - - return page.WalkAll( - ctx, - page.OrderBy[coredata.SCIMEventOrderField]{ - Field: coredata.SCIMEventOrderFieldCreatedAt, - Direction: page.OrderDirectionAsc, - }, - func(ctx context.Context, cursor *page.Cursor[coredata.SCIMEventOrderField]) ([]*coredata.SCIMEvent, error) { - var batch coredata.SCIMEvents - if err := batch.LoadByOrganizationID( - ctx, - conn, - scope, - organizationID, - cursor, - filter, - ); err != nil { - return nil, err - } - - return batch, nil - }, - func(events []*coredata.SCIMEvent) error { - for _, event := range events { - if err := enc.Encode(event); err != nil { - return fmt.Errorf("cannot encode SCIM event: %w", err) - } - } - - return nil - }, - ) -} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 8215aca32..53c928d75 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -14610,7 +14610,7 @@ tools: $ref: "#/components/schemas/ListAuditLogEntriesOutput" - name: requestAuditLogExport title: Request Audit Log Export - description: Request an export of audit log entries for the organization within a time range. The export will be emailed as a JSONL download link. + description: Request an export of audit log entries for the organization within a time range. The export will be emailed as a CSV download link. hints: readonly: false destructive: false @@ -14622,7 +14622,7 @@ tools: $ref: "#/components/schemas/RequestAuditLogExportOutput" - name: requestSCIMEventExport title: Request SCIM Event Export - description: Request an export of SCIM events for the organization within a time range. The export will be emailed as a JSONL download link. + description: Request an export of SCIM events for the organization within a time range. The export will be emailed as a CSV download link. hints: readonly: false destructive: false