Emit audit and SCIM log exports as CSV

JSONL was awkward in spreadsheets and SIEM imports. Write
tab-separated-friendly CSV with organization name on every row,
resolve audit actors to email or API key name, and enrich SCIM rows
with profile email and display name when available.

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 18:14:25 +00:00
parent 0448c25d8b
commit 5cd407bd86
11 changed files with 726 additions and 108 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}

459
pkg/iam/log_export_csv.go Normal file
View File

@@ -0,0 +1,459 @@
// 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"
"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
}

View File

@@ -0,0 +1,58 @@
// 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 (
"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)
}

View File

@@ -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
},
)
}

View File

@@ -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