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:
@@ -1396,7 +1396,7 @@
|
|||||||
"actions": { "showMore": "Show more" },
|
"actions": { "showMore": "Show more" },
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Export Audit Log",
|
"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" },
|
"fields": { "from": "From", "to": "To" },
|
||||||
"actions": { "export": "Export", "exporting": "Exporting..." },
|
"actions": { "export": "Export", "exporting": "Exporting..." },
|
||||||
"messages": {
|
"messages": {
|
||||||
@@ -1424,7 +1424,7 @@
|
|||||||
"provisioningEventHistory": "Provisioning Event History",
|
"provisioningEventHistory": "Provisioning Event History",
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Export SCIM Events",
|
"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" },
|
"fields": { "from": "From", "to": "To" },
|
||||||
"actions": { "export": "Export", "exporting": "Exporting..." },
|
"actions": { "export": "Export", "exporting": "Exporting..." },
|
||||||
"messages": {
|
"messages": {
|
||||||
|
|||||||
@@ -2544,7 +2544,7 @@
|
|||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Exporter le journal d'audit",
|
"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" },
|
"fields": { "from": "Du", "to": "Au" },
|
||||||
"actions": { "export": "Exporter", "exporting": "Exportation..." },
|
"actions": { "export": "Exporter", "exporting": "Exportation..." },
|
||||||
"messages": {
|
"messages": {
|
||||||
@@ -2593,7 +2593,7 @@
|
|||||||
"provisioningEventHistory": "Historique des événements de provisionnement",
|
"provisioningEventHistory": "Historique des événements de provisionnement",
|
||||||
"export": {
|
"export": {
|
||||||
"title": "Exporter les événements SCIM",
|
"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" },
|
"fields": { "from": "Du", "to": "Au" },
|
||||||
"actions": { "export": "Exporter", "exporting": "Exportation..." },
|
"actions": { "export": "Exporter", "exporting": "Exportation..." },
|
||||||
"messages": {
|
"messages": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ All notable changes to the `prb` CLI will be documented in this file.
|
|||||||
|
|
||||||
### Added
|
### 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
|
## [0.205.0] - 2026-07-28
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ All notable changes to `probod` (the server, including the bundled `@probo/conso
|
|||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
- 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
|
- Subprocessor cards truncate long country/region lists behind a "+N" popover
|
||||||
|
|||||||
@@ -387,3 +387,48 @@ WHERE
|
|||||||
|
|
||||||
return count, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -495,6 +495,88 @@ WHERE
|
|||||||
return nil
|
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(
|
func (p *MembershipProfiles) LoadByOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
|
|||||||
@@ -301,3 +301,47 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
459
pkg/iam/log_export_csv.go
Normal 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
|
||||||
|
}
|
||||||
58
pkg/iam/log_export_csv_test.go
Normal file
58
pkg/iam/log_export_csv_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ package iam
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,7 +35,6 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/filemanager"
|
"go.probo.inc/probo/pkg/filemanager"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/mail"
|
"go.probo.inc/probo/pkg/mail"
|
||||||
"go.probo.inc/probo/pkg/page"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogExportService struct {
|
type LogExportService struct {
|
||||||
@@ -88,7 +87,7 @@ func (s *LogExportService) BuildAndUploadExport(
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
fileKey := uuid.MustNewV4().String()
|
fileKey := uuid.MustNewV4().String()
|
||||||
fileName := fmt.Sprintf(
|
fileName := fmt.Sprintf(
|
||||||
"%s-export-%s-to-%s.jsonl",
|
"%s-export-%s-to-%s.csv",
|
||||||
typeName,
|
typeName,
|
||||||
args.FromTime.Format("2006-01-02"),
|
args.FromTime.Format("2006-01-02"),
|
||||||
args.ToTime.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),
|
ID: gid.New(exportJob.ID.TenantID(), coredata.FileEntityType),
|
||||||
OrganizationID: exportJob.OrganizationID,
|
OrganizationID: exportJob.OrganizationID,
|
||||||
BucketName: s.bucket,
|
BucketName: s.bucket,
|
||||||
MimeType: "application/octet-stream",
|
MimeType: "text/csv",
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
FileKey: fileKey,
|
FileKey: fileKey,
|
||||||
Visibility: coredata.FileVisibilityPrivate,
|
Visibility: coredata.FileVisibilityPrivate,
|
||||||
@@ -131,7 +130,7 @@ func (s *LogExportService) BuildAndUploadExport(
|
|||||||
_ = pr.CloseWithError(uploadErr)
|
_ = pr.CloseWithError(uploadErr)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
writeErr := s.streamJSONL(ctx, exportJob, args, scope, pw)
|
writeErr := s.streamCSV(ctx, exportJob, args, scope, pw)
|
||||||
if writeErr != nil {
|
if writeErr != nil {
|
||||||
_ = pw.CloseWithError(writeErr)
|
_ = pw.CloseWithError(writeErr)
|
||||||
} else {
|
} else {
|
||||||
@@ -141,7 +140,7 @@ func (s *LogExportService) BuildAndUploadExport(
|
|||||||
<-uploadDone
|
<-uploadDone
|
||||||
|
|
||||||
if writeErr != nil {
|
if writeErr != nil {
|
||||||
return nil, fmt.Errorf("cannot write JSONL: %w", writeErr)
|
return nil, fmt.Errorf("cannot write CSV: %w", writeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if uploadErr != nil {
|
if uploadErr != nil {
|
||||||
@@ -214,7 +213,7 @@ func (s *LogExportService) SendExportEmail(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LogExportService) streamJSONL(
|
func (s *LogExportService) streamCSV(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
exportJob *coredata.ExportJob,
|
exportJob *coredata.ExportJob,
|
||||||
args *coredata.LogExportArguments,
|
args *coredata.LogExportArguments,
|
||||||
@@ -224,106 +223,37 @@ func (s *LogExportService) streamJSONL(
|
|||||||
return s.pg.WithConn(
|
return s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
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 {
|
switch exportJob.Type {
|
||||||
case coredata.ExportJobTypeAuditLog:
|
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:
|
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:
|
default:
|
||||||
return fmt.Errorf("unsupported log export type: %q", exportJob.Type)
|
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14610,7 +14610,7 @@ tools:
|
|||||||
$ref: "#/components/schemas/ListAuditLogEntriesOutput"
|
$ref: "#/components/schemas/ListAuditLogEntriesOutput"
|
||||||
- name: requestAuditLogExport
|
- name: requestAuditLogExport
|
||||||
title: Request Audit Log Export
|
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:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
destructive: false
|
destructive: false
|
||||||
@@ -14622,7 +14622,7 @@ tools:
|
|||||||
$ref: "#/components/schemas/RequestAuditLogExportOutput"
|
$ref: "#/components/schemas/RequestAuditLogExportOutput"
|
||||||
- name: requestSCIMEventExport
|
- name: requestSCIMEventExport
|
||||||
title: Request SCIM Event Export
|
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:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
destructive: false
|
destructive: false
|
||||||
|
|||||||
Reference in New Issue
Block a user