diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 67604c918..0a18b3034 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -1393,7 +1393,21 @@ "empty": "No audit log entries yet.", "showing": "Showing {{shown}} of {{total}} entries", "columns": { "date": "Date", "actor": "Actor", "action": "Action", "resource": "Resource" }, - "actions": { "showMore": "Show more" } + "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.", + "fields": { "from": "From", "to": "To" }, + "actions": { "export": "Export", "exporting": "Exporting..." }, + "messages": { + "successTitle": "Success", + "success": "Export started. You will receive an email with a download link when it is ready." + }, + "errors": { + "title": "Error", + "request": "Failed to request audit log export" + } + } }, "generalSettingsPage": { "messages": { "deleted": "Organization deleted successfully." }, @@ -1407,7 +1421,21 @@ }, "scimSettingsPage": { "manualScim": { "title": "Manual SCIM", "description": "Configure SCIM manually if your identity provider is not listed above. This requires setting up the SCIM endpoint URL and bearer token in your identity provider." }, - "provisioningEventHistory": "Provisioning Event History" + "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.", + "fields": { "from": "From", "to": "To" }, + "actions": { "export": "Export", "exporting": "Exporting..." }, + "messages": { + "successTitle": "Success", + "success": "Export started. You will receive an email with a download link when it is ready." + }, + "errors": { + "title": "Error", + "request": "Failed to request SCIM event export" + } + } }, "settingsLayout": { "title": "Settings", "tabs": { "general": "General", "samlSso": "SAML SSO", "scim": "SCIM", "webhooks": "Webhooks", "auditLog": "Audit Log" } }, "connectorList": { "title": "Identity Provider", "description": "Connect your identity provider to automatically sync users to your organization. Once connected, you don't need to configure SCIM manually." }, diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index c89721032..8b7e1f109 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -2539,6 +2539,20 @@ }, "actions": { "showMore": "Afficher plus" + }, + "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.", + "fields": { "from": "Du", "to": "Au" }, + "actions": { "export": "Exporter", "exporting": "Exportation..." }, + "messages": { + "successTitle": "Succès", + "success": "L'export a démarré. Vous recevrez un e-mail avec un lien de téléchargement lorsqu'il sera prêt." + }, + "errors": { + "title": "Erreur", + "request": "Échec de la demande d'export du journal d'audit" + } } }, "generalSettingsPage": { @@ -2574,7 +2588,21 @@ "title": "SCIM manuel", "description": "Configurez SCIM manuellement si votre fournisseur d’identité n’est pas listé ci-dessus. Cela nécessite de configurer l’URL du point de terminaison SCIM et le jeton porteur dans votre fournisseur d’identité." }, - "provisioningEventHistory": "Historique des événements de provisionnement" + "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.", + "fields": { "from": "Du", "to": "Au" }, + "actions": { "export": "Exporter", "exporting": "Exportation..." }, + "messages": { + "successTitle": "Succès", + "success": "Export démarré. Vous recevrez un e-mail avec un lien de téléchargement lorsqu’il sera prêt." + }, + "errors": { + "title": "Erreur", + "request": "Échec de la demande d’export des événements SCIM" + } + } }, "settingsLayout": { "title": "Paramètres", diff --git a/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx index dac3508fb..c868df253 100644 --- a/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx +++ b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx @@ -18,11 +18,18 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { formatError } from "@probo/helpers"; import { dateFormat } from "@probo/i18n"; import { Badge, Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconArrowDown, IconChevronDown, + Input, Spinner, Table, Tbody, @@ -30,16 +37,21 @@ import { Th, Thead, Tr, + useDialogRef, + useToast, } from "@probo/ui"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { graphql, type PreloadedQuery, useFragment, + useMutation, usePaginationFragment, usePreloadedQuery, } from "react-relay"; +import type { AuditLogSettingsPageExportMutation } from "#/__generated__/iam/AuditLogSettingsPageExportMutation.graphql"; import type { AuditLogSettingsPageFragment$key } from "#/__generated__/iam/AuditLogSettingsPageFragment.graphql"; import type { AuditLogSettingsPageQuery } from "#/__generated__/iam/AuditLogSettingsPageQuery.graphql"; import type { AuditLogSettingsPageRefetchQuery } from "#/__generated__/iam/AuditLogSettingsPageRefetchQuery.graphql"; @@ -50,6 +62,8 @@ export const auditLogSettingsPageQuery = graphql` organization: node(id: $organizationId) @required(action: THROW) { __typename ... on Organization { + id + canExportAuditLog: permission(action: "iam:audit-log:export") ...AuditLogSettingsPageFragment } } @@ -95,6 +109,16 @@ const auditLogEntryRowFragment = graphql` } `; +const exportMutation = graphql` + mutation AuditLogSettingsPageExportMutation( + $input: RequestAuditLogExportInput! + ) { + requestAuditLogExport(input: $input) { + exportJobId + } + } +`; + function ActorTypeBadge({ type }: { type: string }) { switch (type) { case "USER": @@ -178,6 +202,112 @@ function AuditLogEntryRow({ ); } +function ExportAuditLogDialog({ + organizationId, +}: { + organizationId: string; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [commitExport, isExporting] = useMutation(exportMutation); + + const handleExport = () => { + if (!fromDate || !toDate) return; + + commitExport({ + variables: { + input: { + organizationId, + fromTime: new Date(`${fromDate}T00:00:00Z`).toISOString(), + toTime: new Date(Date.parse(`${toDate}T00:00:00Z`) + 24 * 60 * 60 * 1000).toISOString(), + }, + }, + onCompleted: (_response, errors) => { + if (errors) { + toast({ + title: t("auditLogSettingsPage.export.errors.title"), + description: formatError(t("auditLogSettingsPage.export.errors.request"), errors), + variant: "error", + }); + return; + } + toast({ + title: t("auditLogSettingsPage.export.messages.successTitle"), + description: t("auditLogSettingsPage.export.messages.success"), + variant: "success", + }); + dialogRef.current?.close(); + setFromDate(""); + setToDate(""); + }, + onError: (error) => { + toast({ + title: t("auditLogSettingsPage.export.errors.title"), + description: formatError(t("auditLogSettingsPage.export.errors.request"), error), + variant: "error", + }); + }, + }); + }; + + return ( + <> + + + +

+ {t("auditLogSettingsPage.export.description")} +

+ + setFromDate(e.target.value)} + required + /> + + + setToDate(e.target.value)} + required + /> + +
+ + + +
+ + ); +} + export function AuditLogSettingsPage(props: { queryRef: PreloadedQuery; }) { @@ -202,11 +332,16 @@ export function AuditLogSettingsPage(props: { return (
-
-

{t("auditLogSettingsPage.title")}

-

- {t("auditLogSettingsPage.description")} -

+
+
+

{t("auditLogSettingsPage.title")}

+

+ {t("auditLogSettingsPage.description")} +

+
+ {organization.canExportAuditLog && ( + + )}
{entries.length === 0 diff --git a/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx index b02f75007..9a3d07570 100644 --- a/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx +++ b/apps/console/src/pages/iam/organizations/settings/SCIMSettingsPage.tsx @@ -18,14 +18,31 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { Spinner } from "@probo/ui"; -import { useEffect, useRef } from "react"; +import { formatError } from "@probo/helpers"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconArrowDown, + Input, + Spinner, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { graphql, type PreloadedQuery, - useMutation, usePreloadedQuery } from "react-relay"; +import { + graphql, + type PreloadedQuery, + useMutation, + usePreloadedQuery, +} from "react-relay"; import { useSearchParams } from "react-router"; import type { SCIMSettingsPageCreateSCIMConfigurationMutation } from "#/__generated__/iam/SCIMSettingsPageCreateSCIMConfigurationMutation.graphql"; +import type { SCIMSettingsPageExportMutation } from "#/__generated__/iam/SCIMSettingsPageExportMutation.graphql"; import type { SCIMSettingsPageQuery } from "#/__generated__/iam/SCIMSettingsPageQuery.graphql"; import { ConnectorList } from "./_components/ConnectorList"; @@ -38,6 +55,7 @@ export const scimSettingsPageQuery = graphql` __typename ... on Organization { id + canExportSCIMEvents: permission(action: "iam:scim-event:export") scimConfiguration { id @@ -69,6 +87,122 @@ const createSCIMConfigurationMutation = graphql` } `; +const exportMutation = graphql` + mutation SCIMSettingsPageExportMutation( + $input: RequestSCIMEventExportInput! + ) { + requestSCIMEventExport(input: $input) { + exportJobId + } + } +`; + +function ExportSCIMEventsDialog({ + organizationId, +}: { + organizationId: string; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [commitExport, isExporting] = useMutation(exportMutation); + + const handleExport = () => { + if (!fromDate || !toDate) return; + + commitExport({ + variables: { + input: { + organizationId, + fromTime: new Date(`${fromDate}T00:00:00Z`).toISOString(), + toTime: new Date(Date.parse(`${toDate}T00:00:00Z`) + 24 * 60 * 60 * 1000).toISOString(), + }, + }, + onCompleted: (_response, errors) => { + if (errors) { + toast({ + title: t("scimSettingsPage.export.errors.title"), + description: formatError(t("scimSettingsPage.export.errors.request"), errors), + variant: "error", + }); + return; + } + toast({ + title: t("scimSettingsPage.export.messages.successTitle"), + description: t("scimSettingsPage.export.messages.success"), + variant: "success", + }); + dialogRef.current?.close(); + setFromDate(""); + setToDate(""); + }, + onError: (error) => { + toast({ + title: t("scimSettingsPage.export.errors.title"), + description: formatError(t("scimSettingsPage.export.errors.request"), error), + variant: "error", + }); + }, + }); + }; + + return ( + <> + + + +

+ {t("scimSettingsPage.export.description")} +

+ + setFromDate(e.target.value)} + required + /> + + + setToDate(e.target.value)} + required + /> + +
+ + + +
+ + ); +} + export function SCIMSettingsPage(props: { queryRef: PreloadedQuery; }) { @@ -180,9 +314,14 @@ export function SCIMSettingsPage(props: { {showProvisioningEvents && (
-

- {t("scimSettingsPage.provisioningEventHistory")} -

+
+

+ {t("scimSettingsPage.provisioningEventHistory")} +

+ {organization.canExportSCIMEvents && ( + + )} +
)} diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index a883ec741..a5e0fadf5 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -123,7 +123,7 @@ The method name signals whether the result set is bounded: ### Loading every row without an unbounded query -When a caller genuinely needs all rows, expose a cursor-paginated `LoadBy*` and walk it with the generic `page.LoadAll` helper ([`pkg/page/load_all.go`](../../pkg/page/load_all.go)). It repeatedly fetches forward pages of `MaxCursorSize` until the result is exhausted, and returns an error past `MaxLoadAllPages` (20) pages so a genuinely unbounded set fails loudly instead of exhausting memory. +When a caller genuinely needs all rows, expose a cursor-paginated `LoadBy*` and walk it with the generic `page.LoadAll` / `page.WalkAll` helpers ([`pkg/page/load_all.go`](../../pkg/page/load_all.go)). They repeatedly fetch forward pages of `MaxCursorSize` until the result is exhausted. `LoadAll` materialises the concatenated slice and errors past `MaxLoadAllPages` pages so a genuinely unbounded set fails loudly instead of exhausting memory. `WalkAll` streams each page to a callback with no page cap — use it when you can process rows as they arrive (e.g. streaming an export). ```go things, err := page.LoadAll( @@ -143,7 +143,29 @@ things, err := page.LoadAll( ) ``` -The order field passed to `page.LoadAll` must have a `CursorKey` case on the entity. `CursorKey` panics at runtime (not compile time) on an unhandled field, so add the case when introducing the order field. +```go +err := page.WalkAll( + ctx, + page.OrderBy[coredata.ThingOrderField]{ + Field: coredata.ThingOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + }, + func(ctx context.Context, cursor *page.Cursor[coredata.ThingOrderField]) ([]*coredata.Thing, error) { + var batch coredata.Things + if err := batch.LoadByParentID(ctx, conn, scope, parentID, cursor); err != nil { + return nil, err + } + + return batch, nil + }, + func(things []*coredata.Thing) error { + // process one page + return nil + }, +) +``` + +The order field passed to `page.LoadAll` / `page.WalkAll` must have a `CursorKey` case on the entity. `CursorKey` panics at runtime (not compile time) on an unhandled field, so add the case when introducing the order field. ## No cross-entity JOINs diff --git a/e2e/console/audit_log_test.go b/e2e/console/audit_log_test.go index 45ad75703..50b816e02 100644 --- a/e2e/console/audit_log_test.go +++ b/e2e/console/audit_log_test.go @@ -259,6 +259,74 @@ func TestAuditLog_RBAC(t *testing.T) { }) } +func TestAuditLog_Export(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + const mutation = ` + mutation($input: RequestAuditLogExportInput!) { + requestAuditLogExport(input: $input) { + exportJobId + } + } + ` + + t.Run("owner can request export", func(t *testing.T) { + t.Parallel() + + var result struct { + RequestAuditLogExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestAuditLogExport"` + } + + err := owner.Execute(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.RequestAuditLogExport.ExportJobID) + }) + + t.Run("admin can request export", func(t *testing.T) { + t.Parallel() + admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) + + var result struct { + RequestAuditLogExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestAuditLogExport"` + } + + err := admin.Execute(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": admin.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.RequestAuditLogExport.ExportJobID) + }) + + t.Run("viewer cannot request export", func(t *testing.T) { + t.Parallel() + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _, err := viewer.Do(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": viewer.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer cannot request audit log export") + }) +} + func TestAuditLog_TenantIsolation(t *testing.T) { t.Parallel() diff --git a/e2e/console/scim_event_test.go b/e2e/console/scim_event_test.go new file mode 100644 index 000000000..8034fc573 --- /dev/null +++ b/e2e/console/scim_event_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestSCIMEvent_Export(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + const mutation = ` + mutation($input: RequestSCIMEventExportInput!) { + requestSCIMEventExport(input: $input) { + exportJobId + } + } + ` + + t.Run("owner can request export", func(t *testing.T) { + t.Parallel() + + var result struct { + RequestSCIMEventExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestSCIMEventExport"` + } + + err := owner.ExecuteConnect(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.RequestSCIMEventExport.ExportJobID) + }) + + t.Run("admin can request export", func(t *testing.T) { + t.Parallel() + admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) + + var result struct { + RequestSCIMEventExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestSCIMEventExport"` + } + + err := admin.ExecuteConnect(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": admin.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.RequestSCIMEventExport.ExportJobID) + }) + + t.Run("viewer cannot request export", func(t *testing.T) { + t.Parallel() + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + _, err := viewer.DoConnect(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": viewer.GetOrganizationID().String(), + "fromTime": "2026-01-01T00:00:00Z", + "toTime": "2026-03-24T00:00:00Z", + }, + }) + testutil.RequireForbiddenError(t, err, "viewer cannot request SCIM event export") + }) +} diff --git a/go.mod b/go.mod index a37c91aa4..c732c47a4 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ require ( fyne.io/systray v1.12.2 github.com/99designs/gqlgen v0.17.94 github.com/anthropics/anthropic-sdk-go v1.58.1 - github.com/aws/aws-sdk-go-v2 v1.43.0 - github.com/aws/aws-sdk-go-v2/config v1.32.31 - github.com/aws/aws-sdk-go-v2/credentials v1.19.30 + github.com/aws/aws-sdk-go-v2 v1.43.1 + github.com/aws/aws-sdk-go-v2/config v1.32.32 + github.com/aws/aws-sdk-go-v2/credentials v1.19.31 + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.6 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.56.0 - github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.0 github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0 github.com/brianvoe/gofakeit/v7 v7.15.0 @@ -60,11 +61,11 @@ require ( github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.32 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -164,15 +165,15 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect - github.com/aws/smithy-go v1.27.4 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.33 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.33 // indirect + github.com/aws/smithy-go v1.27.5 github.com/beevik/etree v1.7.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitfield/gotestdox v0.2.2 // indirect diff --git a/go.sum b/go.sum index 1ff329f7d..0c8968b23 100644 --- a/go.sum +++ b/go.sum @@ -61,50 +61,52 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= -github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= -github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= -github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= -github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2 v1.43.1 h1:t6AQIB1uQ7HJA+0CDRWjOYG5MfwnOyyDsN4vRDHcwIY= +github.com/aws/aws-sdk-go-v2 v1.43.1/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15 h1:rq/p1VNFfygoKEQ9hHMKsKBE98lspPvT8IxaFs5mFhw= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.15/go.mod h1:bELIhlPfW8OkpDhP1MvCjHDtvv8NhiBTz+K4o26zrXA= +github.com/aws/aws-sdk-go-v2/config v1.32.32 h1:CcYdrcIjulT7xbTSqeEInh/PqUWv10LMznfdbNRwHBM= +github.com/aws/aws-sdk-go-v2/config v1.32.32/go.mod h1:Rk+LRPrR2oLWLOqUbhNOKYOJ8chU2aZ/R+Sdi/Bc/+4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.31 h1:olhkNt+ZMx+X40XxeyrflmdBx145TP+3DqaS4s8GsbI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.31/go.mod h1:twHrsQY+gUmkTOsDqYwBBrdP41FvzO86qjiRbj7GXcw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.32 h1:zheY8iDNNzOHGS2aBJ5GWjeRbhsGuSg4+s20NZ0AywQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.32/go.mod h1:bkw+ZoqafHSo/3lQBm+xzWf4kh79hqP9M2kPtmOFZIY= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.6 h1:Z8V0WPxiFJyRP2JPN8kY5Knjf16R8qfTFyWsB3jc690= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.6/go.mod h1:MJqRc0Esq7Kok2+n2u2hp+O8oaWk7gvn053fnS1c4iA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 h1:PqiNS1QVFVctiMX30IwaY6pM3cUUfZFe+HPKmlZVY4I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32/go.mod h1:jqisrvz2jliDnF3dW4wO9ZT1lHz1d6pXjftFyyZyqjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 h1:bWzam6cUCb/BRiZYmmdwPg+FvZy3/QygB9u6z7BIlnM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32/go.mod h1:VGTWlYbW4qvz9djYt2lj35gSAZS5aZ2xKn85wXziw6A= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.33 h1:J4GttOtoayrtx24b8NODgSvJTAQ2qj/E5YPEeaYrYh0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.33/go.mod h1:G8G6DL9QyBO/vuHXJ/JG29qy2hBNYrQxDYJmqMStWJ4= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.56.0 h1:CWw8zDpnMJLwSvZd41Ncf/eJPeZ5t74UxGAu4HbM3S4= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.56.0/go.mod h1:dGxTgK2ZKWrbZv5o/8oCeO3Uch3n0w2rtSFroeJoLcE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 h1:SA43nfaY7+1jjMNIc2ywu99JLJLButtIdLP6j+bT870= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14/go.mod h1:Du3llKcwbQvHsTXSLzTOGQz0DTDBMEzdg7DAGu7inrY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.25 h1:t1pBmM7qO2pzE8sQ/00T+PnWfKNuQAbCw0cdChNfoMM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.25/go.mod h1:YDz7QcfWKH760WBWlw3zi2m/oawz6M55I02taW1X6oA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.32 h1:dWFHhQpbf7Yui4fsy+bJUy54JrmpJHIyAuUkcadqwMo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.32/go.mod h1:oN4Iix8rAbyTx6tFMP9mS8RFLJnDeZSbSsHwXYSs3tE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.33 h1:WWevlLzmBqgzRy/rrTUHEmLXnLMNuSZkrQWdlGiLPYY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.33/go.mod h1:lRlrKO4OuKlBZsSGioD2lprkdvu2dI7SAATcC1CYt7U= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.1 h1:LE9F8L9PXkboje/lJrvthQGsvbhi3SPZZidPgYuNBxk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.1/go.mod h1:BC1zJ0lDLKkzEJDsF8kyimsmMoear7ZcfUzzEFscQrk= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.0 h1:pFFG4fjjuxCrCnAQJg/O33h947MBR8dvQb+FX93Ed+k= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.0/go.mod h1:62iixi6C/4RcKklwtnn+LATl9ZyisVjf6ahGmITBpYA= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.1 h1:i7p1pinRrWxJp+sD+u2pCWYdcB9vL1VNIPKWssNOp4o= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.1/go.mod h1:gtQTy/o93W5Sx0IFdAkn7Usa+Qg7ydG2+9GC7MoKqPU= github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0 h1:8AE9z5vMHNC7tQuaje8fSsNZyvj+0ttiQ2Ed/8rLBsc= github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0/go.mod h1:004bP6yJs8vdEpZwBT3H25GzleBVJYgeT2pPXkU4t4g= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= -github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= -github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.1 h1:GBLnqpnDEn/+vnBGlxAJ4+jopfzW7vVT0++jLPgWN9A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.1/go.mod h1:RK3AEzTbSEbGLQDd3qPA5ZLXm6mfB9shq5sbiuaF7AU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.1 h1:1DWsRED+rRzFQ1rDWpwkipw67tp4kyQByQg9POWYl80= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.1/go.mod h1:nadMS3uTrTANeD214BBuPg/SJpJ/UhBiNuqN1Z2Ay4c= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.1 h1:JugCuomdxnZwjp5xvqSuPgeWRecAvkno7EwXmR/ZXWE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.1/go.mod h1:dtViDu/XqU2gq1eeTFz7Ijb7xCHoso8CaBOqYVshoqc= +github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= +github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 1077897cf..6e81c8227 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -113,6 +113,8 @@ const ( subjectMailingListSubscription = "%s – Confirm Your Compliance Updates Subscription" subjectMailingListUnsubscription = "%s – You've been unsubscribed" subjectMailingListUpdates = "%s – %s" + subjectAuditLogExport = "Your audit log export is ready" + subjectSCIMEventExport = "Your SCIM event export is ready" ) var ( @@ -144,6 +146,8 @@ var ( mailingListUnsubscriptionTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-unsubscription.txt.tmpl")) mailingListUpdatesHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/mailing-list-updates.html.tmpl")) mailingListUpdatesTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-updates.txt.tmpl")) + logExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/log-export.html.tmpl")) + logExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/log-export.txt.tmpl")) ) func (p *Presenter) getCommonVariables() (*CommonVariables, error) { @@ -334,6 +338,37 @@ func (p *Presenter) RenderFrameworkExport(ctx context.Context, downloadUrl strin return subjectFrameworkExport, textBody, htmlBody, err } +func (p *Presenter) RenderLogExport(ctx context.Context, downloadUrl string, isSCIMEvent bool) (subject string, textBody string, htmlBody *string, err error) { + vars, err := p.getCommonVariables() + if err != nil { + return "", "", nil, fmt.Errorf("cannot get common variables: %w", err) + } + + subject = subjectAuditLogExport + exportLabel := "audit log" + + if isSCIMEvent { + subject = subjectSCIMEventExport + exportLabel = "SCIM event" + } + + data := struct { + *CommonVariables + Subject string + ExportLabel string + DownloadUrl string + }{ + CommonVariables: vars, + Subject: subject, + ExportLabel: exportLabel, + DownloadUrl: downloadUrl, + } + + textBody, htmlBody, err = renderEmail(logExportTextTemplate, logExportHTMLTemplate, data) + + return subject, textBody, htmlBody, err +} + func (p *Presenter) RenderCompliancePortalAccess(ctx context.Context, organizationName string) (subject string, textBody string, htmlBody *string, err error) { vars, err := p.getCommonVariables() if err != nil { diff --git a/packages/emails/scripts/build.ts b/packages/emails/scripts/build.ts index 519511682..4ca3eb172 100644 --- a/packages/emails/scripts/build.ts +++ b/packages/emails/scripts/build.ts @@ -37,6 +37,7 @@ import CompliancePortalDocumentAccessRejected from "../src/CompliancePortalDocum import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate"; import MailingListSubscription from "../src/MailingListSubscription"; import MailingListUnsubscription from "../src/MailingListUnsubscription"; +import LogExport from "../src/LogExport"; import MagicLink from "../src/MagicLink"; const __filename = fileURLToPath(import.meta.url); @@ -104,6 +105,10 @@ const templates: TemplateConfig[] = [ name: "mailing-list-updates", render: () => MailingListUpdates(), }, + { + name: "log-export", + render: () => LogExport(), + }, ]; async function build() { diff --git a/packages/emails/src/LogExport.tsx b/packages/emails/src/LogExport.tsx new file mode 100644 index 000000000..f2c13a158 --- /dev/null +++ b/packages/emails/src/LogExport.tsx @@ -0,0 +1,41 @@ +// 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. + +import { Button, Section, Text } from 'react-email'; +import * as React from 'react'; +import EmailLayout, { bodyText, button, buttonContainer } from './components/EmailLayout'; + +export const LogExport = () => { + return ( + + + Your {'{{.ExportLabel}}'} export has been completed successfully. Click the button below to download it: + + +
+ +
+
+ ); +}; + +export default LogExport; diff --git a/packages/emails/templates/log-export.txt b/packages/emails/templates/log-export.txt new file mode 100644 index 000000000..da56c4e95 --- /dev/null +++ b/packages/emails/templates/log-export.txt @@ -0,0 +1,10 @@ +Probo + +Hi {{.RecipientFullName}}, + +Your {{.ExportLabel}} export has been completed successfully. Click the link below to download it: + +{{.DownloadUrl}} + +{{.SenderCompanyHeadquarterAddress}} +Powered By Probo diff --git a/pkg/cmd/auditlog/audit_log.go b/pkg/cmd/auditlog/audit_log.go index dab36dd25..73329333e 100644 --- a/pkg/cmd/auditlog/audit_log.go +++ b/pkg/cmd/auditlog/audit_log.go @@ -22,6 +22,7 @@ package auditlog import ( "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/auditlog/export" "go.probo.inc/probo/pkg/cmd/auditlog/list" "go.probo.inc/probo/pkg/cmd/auditlog/view" "go.probo.inc/probo/pkg/cmd/cmdutil" @@ -33,6 +34,7 @@ func NewCmdAuditLog(f *cmdutil.Factory) *cobra.Command { Short: "Manage audit log entries", } + cmd.AddCommand(export.NewCmdExport(f)) cmd.AddCommand(list.NewCmdList(f)) cmd.AddCommand(view.NewCmdView(f)) diff --git a/pkg/cmd/auditlog/export/export.go b/pkg/cmd/auditlog/export/export.go new file mode 100644 index 000000000..ce983aff7 --- /dev/null +++ b/pkg/cmd/auditlog/export/export.go @@ -0,0 +1,126 @@ +// 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 export + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const exportMutation = ` +mutation($input: RequestAuditLogExportInput!) { + requestAuditLogExport(input: $input) { + exportJobId + } +} +` + +func NewCmdExport(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagFrom string + flagTo string + ) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export audit log entries", + Example: ` prb audit-log export --org --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z + prb audit-log export --from 2026-03-01T00:00:00Z --to 2026-03-24T00:00:00Z`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + if flagFrom == "" { + return fmt.Errorf("--from is required (RFC3339 timestamp)") + } + + if flagTo == "" { + return fmt.Errorf("--to is required (RFC3339 timestamp)") + } + + variables := map[string]any{ + "input": map[string]any{ + "organizationId": flagOrg, + "fromTime": flagFrom, + "toTime": flagTo, + }, + } + + data, err := client.Do(exportMutation, variables) + if err != nil { + return err + } + + var resp struct { + RequestAuditLogExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestAuditLogExport"` + } + + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Audit log export requested %s\nYou will receive an email with a download link when the export is ready.\n", + resp.RequestAuditLogExport.ExportJobID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagFrom, "from", "", "Start time in RFC3339 format (e.g. 2026-01-01T00:00:00Z)") + cmd.Flags().StringVar(&flagTo, "to", "", "End time in RFC3339 format (e.g. 2026-02-01T00:00:00Z)") + + return cmd +} diff --git a/pkg/cmd/scim/event/event.go b/pkg/cmd/scim/event/event.go index 6f016091d..12d641d0e 100644 --- a/pkg/cmd/scim/event/event.go +++ b/pkg/cmd/scim/event/event.go @@ -23,6 +23,7 @@ package event import ( "github.com/spf13/cobra" "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/scim/event/export" "go.probo.inc/probo/pkg/cmd/scim/event/list" ) @@ -33,6 +34,7 @@ func NewCmdEvent(f *cmdutil.Factory) *cobra.Command { } cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(export.NewCmdExport(f)) return cmd } diff --git a/pkg/cmd/scim/event/export/export.go b/pkg/cmd/scim/event/export/export.go new file mode 100644 index 000000000..217fef35e --- /dev/null +++ b/pkg/cmd/scim/event/export/export.go @@ -0,0 +1,126 @@ +// 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 export + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const exportMutation = ` +mutation($input: RequestSCIMEventExportInput!) { + requestSCIMEventExport(input: $input) { + exportJobId + } +} +` + +func NewCmdExport(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagFrom string + flagTo string + ) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export SCIM events", + Example: ` prb scim event export --org --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z + prb scim event export --from 2026-03-01T00:00:00Z --to 2026-03-24T00:00:00Z`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + if flagFrom == "" { + return fmt.Errorf("--from is required (RFC3339 timestamp)") + } + + if flagTo == "" { + return fmt.Errorf("--to is required (RFC3339 timestamp)") + } + + variables := map[string]any{ + "input": map[string]any{ + "organizationId": flagOrg, + "fromTime": flagFrom, + "toTime": flagTo, + }, + } + + data, err := client.Do(exportMutation, variables) + if err != nil { + return err + } + + var resp struct { + RequestSCIMEventExport struct { + ExportJobID string `json:"exportJobId"` + } `json:"requestSCIMEventExport"` + } + + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "SCIM event export requested %s\nYou will receive an email with a download link when the export is ready.\n", + resp.RequestSCIMEventExport.ExportJobID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagFrom, "from", "", "Start time in RFC3339 format (e.g. 2026-01-01T00:00:00Z)") + cmd.Flags().StringVar(&flagTo, "to", "", "End time in RFC3339 format (e.g. 2026-02-01T00:00:00Z)") + + return cmd +} diff --git a/pkg/coredata/audit_log_entry.go b/pkg/coredata/audit_log_entry.go index 28cd242dd..9f929ca9d 100644 --- a/pkg/coredata/audit_log_entry.go +++ b/pkg/coredata/audit_log_entry.go @@ -255,7 +255,7 @@ LIMIT 1; return nil } -func (es *AuditLogEntries) LoadAllByOrganizationID( +func (es *AuditLogEntries) LoadByOrganizationID( ctx context.Context, conn pg.Querier, scope Scoper, diff --git a/pkg/coredata/audit_log_entry_filter.go b/pkg/coredata/audit_log_entry_filter.go index 6926c9236..dfbb3d1ea 100644 --- a/pkg/coredata/audit_log_entry_filter.go +++ b/pkg/coredata/audit_log_entry_filter.go @@ -21,6 +21,8 @@ package coredata import ( + "time" + "github.com/jackc/pgx/v5" "go.probo.inc/probo/pkg/gid" ) @@ -30,6 +32,8 @@ type AuditLogEntryFilter struct { actorID *gid.GID resourceType *string resourceID *gid.GID + createdAtGte *time.Time + createdAtLt *time.Time } func NewAuditLogEntryFilter() *AuditLogEntryFilter { @@ -56,6 +60,16 @@ func (f *AuditLogEntryFilter) WithResourceID(resourceID gid.GID) *AuditLogEntryF return f } +func (f *AuditLogEntryFilter) WithCreatedAtGte(t time.Time) *AuditLogEntryFilter { + f.createdAtGte = &t + return f +} + +func (f *AuditLogEntryFilter) WithCreatedAtLt(t time.Time) *AuditLogEntryFilter { + f.createdAtLt = &t + return f +} + func (f *AuditLogEntryFilter) SQLFragment() string { return ` ( @@ -82,15 +96,29 @@ func (f *AuditLogEntryFilter) SQLFragment() string { resource_id = @filter_resource_id::text ELSE TRUE END + AND + CASE + WHEN @filter_created_at_gte::timestamptz IS NOT NULL THEN + created_at >= @filter_created_at_gte::timestamptz + ELSE TRUE + END + AND + CASE + WHEN @filter_created_at_lt::timestamptz IS NOT NULL THEN + created_at < @filter_created_at_lt::timestamptz + ELSE TRUE + END )` } func (f *AuditLogEntryFilter) SQLArguments() pgx.StrictNamedArgs { args := pgx.StrictNamedArgs{ - "filter_action": nil, - "filter_actor_id": nil, - "filter_resource_type": nil, - "filter_resource_id": nil, + "filter_action": nil, + "filter_actor_id": nil, + "filter_resource_type": nil, + "filter_resource_id": nil, + "filter_created_at_gte": nil, + "filter_created_at_lt": nil, } if f.action != nil { @@ -109,5 +137,13 @@ func (f *AuditLogEntryFilter) SQLArguments() pgx.StrictNamedArgs { args["filter_resource_id"] = *f.resourceID } + if f.createdAtGte != nil { + args["filter_created_at_gte"] = *f.createdAtGte + } + + if f.createdAtLt != nil { + args["filter_created_at_lt"] = *f.createdAtLt + } + return args } diff --git a/pkg/coredata/export_job.go b/pkg/coredata/export_job.go index 68940edd8..26d6ea456 100644 --- a/pkg/coredata/export_job.go +++ b/pkg/coredata/export_job.go @@ -63,6 +63,11 @@ type ( FrameworkExportArguments struct { FrameworkID gid.GID `json:"framework_id"` } + + LogExportArguments struct { + FromTime time.Time `json:"from_time"` + ToTime time.Time `json:"to_time"` + } ) var ( @@ -164,7 +169,6 @@ SET status = @status, error = @error, file_id = @file_id, - started_at = @started_at, completed_at = @completed_at WHERE %s @@ -175,14 +179,110 @@ WHERE "status": ej.Status, "error": ej.Error, "file_id": ej.FileID, - "started_at": ej.StartedAt, "completed_at": ej.CompletedAt, "id": ej.ID, } maps.Copy(args, scope.SQLArguments()) - _, err := conn.Exec(ctx, q, args) - return err + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update export job: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +// UpdateIfStatus updates the export job only when its current status matches +// expected. Used to finalize a claim without clobbering a job that was +// reclaimed and reassigned to another worker. +func (ej *ExportJob) UpdateIfStatus( + ctx context.Context, + conn pg.Tx, + scope Scoper, + expected ExportJobStatus, +) error { + q := ` +UPDATE + export_jobs +SET + status = @status, + error = @error, + file_id = @file_id, + completed_at = @completed_at +WHERE + %s + AND id = @id + AND status = @expected_status +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "status": ej.Status, + "error": ej.Error, + "file_id": ej.FileID, + "completed_at": ej.CompletedAt, + "id": ej.ID, + "expected_status": expected, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update export job: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +// MarkProcessing claims the job for processing and starts its lease clock. +func (ej *ExportJob) MarkProcessing( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + now := time.Now() + ej.Status = ExportJobStatusProcessing + ej.StartedAt = &now + + q := ` +UPDATE + export_jobs +SET + status = @status, + started_at = @started_at, + error = NULL, + completed_at = NULL +WHERE + %s + AND id = @id + AND status = @pending_status +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "status": ej.Status, + "started_at": ej.StartedAt, + "id": ej.ID, + "pending_status": ExportJobStatusPending, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot mark export job as processing: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil } func (ej *ExportJob) LoadByID( @@ -323,3 +423,85 @@ func (ej *ExportJob) GetFrameworkID() (gid.GID, error) { return args.FrameworkID, nil } + +func (ej *ExportJob) GetLogExportArguments() (*LogExportArguments, error) { + switch ej.Type { + case ExportJobTypeAuditLog, ExportJobTypeSCIMEvent: + default: + return nil, fmt.Errorf("export job is not a log export") + } + + var args LogExportArguments + if err := json.Unmarshal(ej.Arguments, &args); err != nil { + return nil, fmt.Errorf("cannot unmarshal log export arguments: %w", err) + } + + return &args, nil +} + +func ResetStaleExportJobs( + ctx context.Context, + conn pg.Querier, + staleAfter time.Duration, +) error { + q := ` +UPDATE export_jobs +SET + status = @pending_status, + started_at = NULL +WHERE + status = @processing_status + AND started_at < @stale_threshold +` + + args := pgx.StrictNamedArgs{ + "pending_status": ExportJobStatusPending, + "processing_status": ExportJobStatusProcessing, + "stale_threshold": time.Now().Add(-staleAfter), + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot reset stale export jobs: %w", err) + } + + return nil +} + +// TouchExportJobLease renews the processing lease so long-running exports +// are not requeued by ResetStaleExportJobs while still actively working. +func TouchExportJobLease( + ctx context.Context, + conn pg.Querier, + scope Scoper, + id gid.GID, +) error { + q := ` +UPDATE export_jobs +SET + started_at = @started_at +WHERE + %s + AND id = @id + AND status = @processing_status +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "id": id, + "started_at": time.Now(), + "processing_status": ExportJobStatusProcessing, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot touch export job lease: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} diff --git a/pkg/coredata/export_job_type.go b/pkg/coredata/export_job_type.go index 26b451886..a0746e05e 100644 --- a/pkg/coredata/export_job_type.go +++ b/pkg/coredata/export_job_type.go @@ -32,6 +32,8 @@ type ( const ( ExportJobTypeFramework ExportJobType = "FRAMEWORK" ExportJobTypeDocument ExportJobType = "DOCUMENT" + ExportJobTypeAuditLog ExportJobType = "AUDIT_LOG" + ExportJobTypeSCIMEvent ExportJobType = "SCIM_EVENT" ) var ( @@ -44,6 +46,8 @@ func ExportJobTypes() []ExportJobType { return []ExportJobType{ ExportJobTypeFramework, ExportJobTypeDocument, + ExportJobTypeAuditLog, + ExportJobTypeSCIMEvent, } } @@ -51,7 +55,9 @@ func (v ExportJobType) IsValid() bool { switch v { case ExportJobTypeFramework, - ExportJobTypeDocument: + ExportJobTypeDocument, + ExportJobTypeAuditLog, + ExportJobTypeSCIMEvent: return true } diff --git a/pkg/coredata/migrations/20260729T135958Z.sql b/pkg/coredata/migrations/20260729T135958Z.sql new file mode 100644 index 000000000..b9c650148 --- /dev/null +++ b/pkg/coredata/migrations/20260729T135958Z.sql @@ -0,0 +1,22 @@ +-- 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. + +ALTER TYPE export_jobs_type ADD VALUE IF NOT EXISTS 'AUDIT_LOG'; +ALTER TYPE export_jobs_type ADD VALUE IF NOT EXISTS 'SCIM_EVENT'; diff --git a/pkg/coredata/scim_event.go b/pkg/coredata/scim_event.go index 4a00a40fe..cc1ebd1f6 100644 --- a/pkg/coredata/scim_event.go +++ b/pkg/coredata/scim_event.go @@ -221,6 +221,7 @@ func (s *SCIMEvents) LoadByOrganizationID( scope Scoper, organizationID gid.GID, cursor *page.Cursor[SCIMEventOrderField], + filter *SCIMEventFilter, ) error { q := ` SELECT @@ -242,12 +243,14 @@ WHERE %s AND organization_id = @organization_id AND %s + AND %s ` - q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) args := pgx.StrictNamedArgs{"organization_id": organizationID} maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) maps.Copy(args, cursor.SQLArguments()) rows, err := conn.Query(ctx, q, args) diff --git a/pkg/coredata/scim_event_filter.go b/pkg/coredata/scim_event_filter.go new file mode 100644 index 000000000..33da4d3ab --- /dev/null +++ b/pkg/coredata/scim_event_filter.go @@ -0,0 +1,80 @@ +// 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 coredata + +import ( + "time" + + "github.com/jackc/pgx/v5" +) + +type SCIMEventFilter struct { + createdAtGte *time.Time + createdAtLt *time.Time +} + +func NewSCIMEventFilter() *SCIMEventFilter { + return &SCIMEventFilter{} +} + +func (f *SCIMEventFilter) WithCreatedAtGte(t time.Time) *SCIMEventFilter { + f.createdAtGte = &t + return f +} + +func (f *SCIMEventFilter) WithCreatedAtLt(t time.Time) *SCIMEventFilter { + f.createdAtLt = &t + return f +} + +func (f *SCIMEventFilter) SQLFragment() string { + return ` +( + CASE + WHEN @filter_created_at_gte::timestamptz IS NOT NULL THEN + created_at >= @filter_created_at_gte::timestamptz + ELSE TRUE + END + AND + CASE + WHEN @filter_created_at_lt::timestamptz IS NOT NULL THEN + created_at < @filter_created_at_lt::timestamptz + ELSE TRUE + END +)` +} + +func (f *SCIMEventFilter) SQLArguments() pgx.StrictNamedArgs { + args := pgx.StrictNamedArgs{ + "filter_created_at_gte": nil, + "filter_created_at_lt": nil, + } + + if f.createdAtGte != nil { + args["filter_created_at_gte"] = *f.createdAtGte + } + + if f.createdAtLt != nil { + args["filter_created_at_lt"] = *f.createdAtLt + } + + return args +} diff --git a/pkg/filemanager/s3.go b/pkg/filemanager/s3.go index 84f36927e..79755f171 100644 --- a/pkg/filemanager/s3.go +++ b/pkg/filemanager/s3.go @@ -31,6 +31,7 @@ import ( "strings" "time" + "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" "github.com/aws/aws-sdk-go-v2/service/s3" smithyhttp "github.com/aws/smithy-go/transport/http" "go.probo.inc/probo/pkg/coredata" @@ -203,23 +204,51 @@ func (s *Service) OpenFile( return obj, nil } +type putFileConfig struct { + attachmentDisposition bool +} + +// PutFileOption configures optional PutFile behavior. +type PutFileOption func(*putFileConfig) + +// WithAttachmentContentDisposition stores Content-Disposition: attachment on the +// uploaded object so browsers download it when served from object storage. +func WithAttachmentContentDisposition() PutFileOption { + return func(cfg *putFileConfig) { + cfg.attachmentDisposition = true + } +} + func (s *Service) PutFile( ctx context.Context, file *coredata.File, content io.Reader, metadata map[string]string, + opts ...PutFileOption, ) (int64, error) { - _, err := s.s3Client.PutObject( - ctx, - &s3.PutObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - Body: content, - ContentType: new(file.MimeType), - CacheControl: new("private, max-age=3600"), - Metadata: metadata, - }, - ) + cfg := putFileConfig{} + for _, opt := range opts { + opt(&cfg) + } + + // Transfer manager accepts unseekable readers (e.g. io.Pipe) by buffering + // parts in memory, which works against plain-HTTP S3-compatible endpoints + // where PutObject checksums require a seekable body. + uploader := transfermanager.New(s.s3Client) + + input := &transfermanager.UploadObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + Body: content, + ContentType: new(file.MimeType), + CacheControl: new("private, max-age=3600"), + Metadata: metadata, + } + if cfg.attachmentDisposition && file.FileName != "" { + input.ContentDisposition = new(attachmentContentDisposition(file.FileName)) + } + + _, err := uploader.UploadObject(ctx, input) if err != nil { return 0, fmt.Errorf("cannot upload file to S3: %w", err) } @@ -245,11 +274,7 @@ func (s *Service) GeneratePresignedURL( ) (string, error) { presignClient := s3.NewPresignClient(s.s3Client) - contentDisposition := fmt.Sprintf( - "attachment; filename=%q; filename*=UTF-8''%s", - asciiFilename(file.FileName), - url.PathEscape(file.FileName), - ) + contentDisposition := attachmentContentDisposition(file.FileName) presignedReq, err := presignClient.PresignGetObject( ctx, @@ -300,6 +325,14 @@ func ifRangeMatches(ifRange, etag string, lastModified time.Time) bool { return !lastModified.IsZero() && lastModified.Truncate(time.Second).Equal(t) } +func attachmentContentDisposition(filename string) string { + return fmt.Sprintf( + "attachment; filename=%q; filename*=UTF-8''%s", + asciiFilename(filename), + url.PathEscape(filename), + ) +} + func asciiFilename(filename string) string { var b strings.Builder b.Grow(len(filename)) diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 34f3a0971..2eb54ed11 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -499,3 +499,24 @@ func NewConnectorNotFoundError(connectorID gid.GID) error { func (e ErrConnectorNotFound) Error() string { return fmt.Sprintf("connector %q not found", e.ConnectorID) } + +type ErrInvalidLogExportTimeRange struct{ message string } + +const maxLogExportTimeRangeYears = 1 + +func NewInvalidLogExportTimeRangeError() error { + return &ErrInvalidLogExportTimeRange{message: "from_time must be before to_time"} +} + +func NewLogExportTimeRangeTooLargeError() error { + return &ErrInvalidLogExportTimeRange{ + message: fmt.Sprintf( + "export time range must not exceed %d year", + maxLogExportTimeRangeYears, + ), + } +} + +func (e ErrInvalidLogExportTimeRange) Error() string { + return e.message +} diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index 1e953f6f6..39920e4a3 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -106,4 +106,8 @@ const ( // Audit log entry actions ActionAuditLogEntryGet = "iam:audit-log-entry:get" ActionAuditLogEntryList = "iam:audit-log-entry:list" + + // Log export actions + ActionAuditLogExport = "iam:audit-log:export" + ActionSCIMEventExport = "iam:scim-event:export" ) diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go index c49290a63..00b59ed02 100644 --- a/pkg/iam/iam_policies.go +++ b/pkg/iam/iam_policies.go @@ -242,6 +242,7 @@ var IAMOwnerPolicy = policy.NewPolicy( policy.Allow( ActionAuditLogEntryGet, ActionAuditLogEntryList, + ActionAuditLogExport, ). WithSID("audit-log-entry-access"). When(policy.Equals("principal.organization_id", "resource.organization_id")), @@ -360,15 +361,21 @@ var IAMAdminPolicy = policy.NewPolicy( ). WithSID("deny-scim-management"), - // Can view audit log entries (scoped to own organization) + // Can view and export audit log entries (scoped to own organization) policy.Allow( ActionAuditLogEntryGet, ActionAuditLogEntryList, + ActionAuditLogExport, ). WithSID("audit-log-entry-admin-access"). When( policy.Equals("principal.organization_id", "resource.organization_id"), ), + + // Can export SCIM events (scoped to own organization) + policy.Allow(ActionSCIMEventExport). + WithSID("scim-event-export-admin-access"). + When(policy.Equals("principal.organization_id", "resource.organization_id")), ). WithDescription("IAM admin access - can manage members but cannot delete organization or manage SAML/SCIM") diff --git a/pkg/iam/log_export_service.go b/pkg/iam/log_export_service.go new file mode 100644 index 000000000..d6e226217 --- /dev/null +++ b/pkg/iam/log_export_service.go @@ -0,0 +1,329 @@ +// 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/json" + "fmt" + "io" + "strings" + "time" + + "go.gearno.de/crypto/uuid" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/packages/emails" + "go.probo.inc/probo/pkg/coredata" + "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 { + pg *pg.Client + fm *filemanager.Service + bucket string + baseURL string +} + +func NewLogExportService( + pgClient *pg.Client, + fm *filemanager.Service, + bucket string, + baseURL string, +) *LogExportService { + return &LogExportService{ + pg: pgClient, + fm: fm, + bucket: bucket, + baseURL: baseURL, + } +} + +func (s *LogExportService) BuildAndUploadExport( + ctx context.Context, + scope coredata.Scoper, + exportJobID gid.GID, +) (*coredata.ExportJob, error) { + exportJob := &coredata.ExportJob{} + + if err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return exportJob.LoadByID(ctx, conn, scope, exportJobID) + }, + ); err != nil { + return nil, fmt.Errorf("cannot load export job: %w", err) + } + + args, err := exportJob.GetLogExportArguments() + if err != nil { + return nil, err + } + + typeName := "audit-log" + if exportJob.Type == coredata.ExportJobTypeSCIMEvent { + typeName = "scim-event" + } + + now := time.Now() + fileKey := uuid.MustNewV4().String() + fileName := fmt.Sprintf( + "%s-export-%s-to-%s.jsonl", + typeName, + args.FromTime.Format("2006-01-02"), + args.ToTime.Format("2006-01-02"), + ) + + file := coredata.File{ + ID: gid.New(exportJob.ID.TenantID(), coredata.FileEntityType), + OrganizationID: exportJob.OrganizationID, + BucketName: s.bucket, + MimeType: "application/octet-stream", + FileName: fileName, + FileKey: fileKey, + Visibility: coredata.FileVisibilityPrivate, + CreatedAt: now, + UpdatedAt: now, + } + + pr, pw := io.Pipe() + + var uploadErr error + + var fileSize int64 + + uploadDone := make(chan struct{}) + + go func() { + defer close(uploadDone) + + fileSize, uploadErr = s.fm.PutFile( + ctx, + &file, + pr, + map[string]string{ + "type": typeName + "-export", + "export-job-id": exportJob.ID.String(), + "organization-id": exportJob.OrganizationID.String(), + }, + filemanager.WithAttachmentContentDisposition(), + ) + _ = pr.CloseWithError(uploadErr) + }() + + writeErr := s.streamJSONL(ctx, exportJob, args, scope, pw) + if writeErr != nil { + _ = pw.CloseWithError(writeErr) + } else { + _ = pw.Close() + } + + <-uploadDone + + if writeErr != nil { + return nil, fmt.Errorf("cannot write JSONL: %w", writeErr) + } + + if uploadErr != nil { + return nil, fmt.Errorf("cannot upload file to S3: %w", uploadErr) + } + + file.FileSize = fileSize + + if err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := file.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert file: %w", err) + } + + exportJob.FileID = &file.ID + if err := exportJob.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update export job: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return exportJob, nil +} + +func (s *LogExportService) SendExportEmail( + ctx context.Context, + scope coredata.Scoper, + fileID gid.GID, + recipientName string, + recipientEmail mail.Addr, +) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + file := &coredata.File{} + if err := file.LoadByID(ctx, tx, scope, fileID); err != nil { + return fmt.Errorf("cannot load file: %w", err) + } + + downloadURL := s.fm.GenerateFileURL(file) + + emailPresenter := emails.NewPresenter(s.baseURL, recipientName) + + isSCIMEvent := strings.HasPrefix(file.FileName, "scim-event-export") + + subject, textBody, htmlBody, err := emailPresenter.RenderLogExport(ctx, downloadURL, isSCIMEvent) + if err != nil { + return fmt.Errorf("cannot render log export email: %w", err) + } + + email := coredata.NewEmail( + recipientName, + recipientEmail, + subject, + textBody, + htmlBody, + nil, + ) + if err := email.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert email: %w", err) + } + + return nil + }, + ) +} + +func (s *LogExportService) streamJSONL( + ctx context.Context, + exportJob *coredata.ExportJob, + args *coredata.LogExportArguments, + scope coredata.Scoper, + pw io.Writer, +) error { + return s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + enc := json.NewEncoder(pw) + + switch exportJob.Type { + case coredata.ExportJobTypeAuditLog: + return s.streamAuditLogEntries(ctx, conn, scope, exportJob.OrganizationID, args, enc) + case coredata.ExportJobTypeSCIMEvent: + return s.streamSCIMEvents(ctx, conn, scope, exportJob.OrganizationID, args, enc) + 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/iam/oauth2_scopes.go b/pkg/iam/oauth2_scopes.go index cc107599c..d52006905 100644 --- a/pkg/iam/oauth2_scopes.go +++ b/pkg/iam/oauth2_scopes.go @@ -109,5 +109,7 @@ var IAMOAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{ ActionSCIMBridgeUpdate, ActionSCIMBridgeDelete, ActionOAuth2ConsentApprove, + ActionAuditLogExport, + ActionSCIMEventExport, }, } diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 2950f1fb4..2224c7784 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -22,6 +22,7 @@ package iam import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -1565,7 +1566,14 @@ func (s OrganizationService) ListSCIMEvents( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - err := scimEvents.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor) + err := scimEvents.LoadByOrganizationID( + ctx, + conn, + scope, + organizationID, + cursor, + coredata.NewSCIMEventFilter(), + ) if err != nil { return fmt.Errorf("cannot load scim events: %w", err) } @@ -2353,7 +2361,7 @@ func (s *OrganizationService) ListAuditLogEntries( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := entries.LoadAllByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil { + if err := entries.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil { return fmt.Errorf("cannot load audit log entries: %w", err) } @@ -2393,3 +2401,71 @@ func (s *OrganizationService) CountAuditLogEntries( return count, err } + +type RequestLogExportRequest struct { + OrganizationID gid.GID + Type coredata.ExportJobType + FromTime time.Time + ToTime time.Time + RecipientEmail mail.Addr + RecipientName string +} + +func (s *OrganizationService) RequestLogExport( + ctx context.Context, + scope coredata.Scoper, + req RequestLogExportRequest, +) (*coredata.ExportJob, error) { + if !req.FromTime.Before(req.ToTime) { + return nil, NewInvalidLogExportTimeRangeError() + } + + if req.ToTime.After(req.FromTime.AddDate(maxLogExportTimeRangeYears, 0, 0)) { + return nil, NewLogExportTimeRangeTooLargeError() + } + + switch req.Type { + case coredata.ExportJobTypeAuditLog, coredata.ExportJobTypeSCIMEvent: + default: + return nil, fmt.Errorf("unsupported log export type: %q", req.Type) + } + + arguments, err := json.Marshal(coredata.LogExportArguments{ + FromTime: req.FromTime, + ToTime: req.ToTime, + }) + if err != nil { + return nil, fmt.Errorf("cannot marshal log export arguments: %w", err) + } + + exportJob := &coredata.ExportJob{} + + err = s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + now := time.Now() + + exportJob = &coredata.ExportJob{ + ID: gid.New(scope.GetTenantID(), coredata.ExportJobEntityType), + OrganizationID: req.OrganizationID, + Type: req.Type, + Arguments: arguments, + Status: coredata.ExportJobStatusPending, + RecipientEmail: req.RecipientEmail, + RecipientName: req.RecipientName, + CreatedAt: now, + } + + if err := exportJob.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert export job: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot request log export: %w", err) + } + + return exportJob, nil +} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 11221e856..d0e36bfcb 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -82,6 +82,7 @@ type ( OAuth2ScopeRegistry *oauth2scope.Registry samlDomainVerifier *SAMLDomainVerifier + LogExports *LogExportService } Config struct { @@ -241,6 +242,13 @@ func NewService( cfg.DomainVerificationResolverAddr, ) + svc.LogExports = NewLogExportService( + pgClient, + fm, + cfg.Bucket, + cfg.BaseURL.String(), + ) + return svc, nil } diff --git a/pkg/page/load_all.go b/pkg/page/load_all.go index b523a9d3b..551d65488 100644 --- a/pkg/page/load_all.go +++ b/pkg/page/load_all.go @@ -27,7 +27,9 @@ import ( // MaxLoadAllPages caps how many pages LoadAll walks, bounding a single // call to MaxLoadAllPages*MaxCursorSize rows. Past that, LoadAll errors -// rather than materialising an unbounded set. +// rather than materialising an unbounded set. WalkAll is uncapped: it +// streams page-by-page and is meant for callers that can process rows +// without holding the full set in memory. const MaxLoadAllPages = 20 // Loader runs one paginated query for the given cursor and returns the @@ -36,6 +38,39 @@ const MaxLoadAllPages = 20 // LoadBy* on a fresh receiver). type Loader[T Paginable[U], U OrderField] func(ctx context.Context, cursor *Cursor[U]) ([]T, error) +// WalkAll walks every matching row via keyset pagination, advancing a +// MaxCursorSize forward cursor until no rows remain, and invokes walk with +// every page of rows. Unlike LoadAll, it does not apply MaxLoadAllPages. +func WalkAll[T Paginable[U], U OrderField]( + ctx context.Context, + orderBy OrderBy[U], + fetch Loader[T, U], + walk func(rows []T) error, +) error { + var key *CursorKey + + for { + cursor := NewCursor(MaxCursorSize, key, Head, orderBy) + + rows, err := fetch(ctx, cursor) + if err != nil { + return fmt.Errorf("cannot load all rows: %w", err) + } + + p := NewPage(rows, cursor) + if err := walk(p.Data); err != nil { + return err + } + + if !p.Info.HasNext { + return nil + } + + k := p.Last().CursorKey(orderBy.Field) + key = &k + } +} + // LoadAll walks every matching row via keyset pagination, advancing a // MaxCursorSize forward cursor until no rows remain, and returns them // concatenated. fetch runs the paginated query for the cursor. It errors @@ -46,36 +81,34 @@ func LoadAll[T Paginable[U], U OrderField]( fetch Loader[T, U], ) ([]T, error) { var ( - all []T - key *CursorKey + all []T + pages int ) - for page := 0; ; page++ { - if page >= MaxLoadAllPages { - return nil, fmt.Errorf( - "cannot load all rows: result set exceeds %d rows (%d pages of %d)", - MaxLoadAllPages*MaxCursorSize, - MaxLoadAllPages, - MaxCursorSize, - ) - } + err := WalkAll( + ctx, + orderBy, + func(ctx context.Context, cursor *Cursor[U]) ([]T, error) { + if pages >= MaxLoadAllPages { + return nil, fmt.Errorf( + "cannot load all rows: result set exceeds %d rows (%d pages of %d)", + MaxLoadAllPages*MaxCursorSize, + MaxLoadAllPages, + MaxCursorSize, + ) + } - cursor := NewCursor(MaxCursorSize, key, Head, orderBy) + pages++ - rows, err := fetch(ctx, cursor) - if err != nil { - return nil, fmt.Errorf("cannot load all rows: %w", err) - } - - p := NewPage(rows, cursor) - all = append(all, p.Data...) - - if !p.Info.HasNext { - break - } - - k := p.Last().CursorKey(orderBy.Field) - key = &k + return fetch(ctx, cursor) + }, + func(rows []T) error { + all = append(all, rows...) + return nil + }, + ) + if err != nil { + return nil, err } return all, nil diff --git a/pkg/page/load_all_test.go b/pkg/page/load_all_test.go index 41e1db37c..82c781e83 100644 --- a/pkg/page/load_all_test.go +++ b/pkg/page/load_all_test.go @@ -183,3 +183,32 @@ func TestLoadAllRefusesUnboundedResultSet(t *testing.T) { assert.Contains(t, err.Error(), "result set exceeds") assert.Equal(t, MaxLoadAllPages, fetchs, "stops after walking the max number of pages") } + +func TestWalkAllHasNoPageCap(t *testing.T) { + t.Parallel() + + // More pages than MaxLoadAllPages: WalkAll must keep going. + store := newLoadAllStore(MaxLoadAllPages*MaxCursorSize + 1) + + fetchs := 0 + + var got []*loadAllItem + + err := WalkAll( + context.Background(), + ascOrderBy(), + func(_ context.Context, cursor *Cursor[testOrderField]) ([]*loadAllItem, error) { + fetchs++ + return keysetPage(store, cursor), nil + }, + func(rows []*loadAllItem) error { + got = append(got, rows...) + return nil + }, + ) + + require.NoError(t, err) + require.Len(t, got, len(store)) + assert.Equal(t, loadAllValues(store), loadAllValues(got)) + assert.Greater(t, fetchs, MaxLoadAllPages) +} diff --git a/pkg/probo/export_job_worker.go b/pkg/probo/export_job_worker.go new file mode 100644 index 000000000..f352a81f3 --- /dev/null +++ b/pkg/probo/export_job_worker.go @@ -0,0 +1,164 @@ +// 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 probo + +import ( + "context" + "errors" + "fmt" + "time" + + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/kit/worker" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +const defaultExportJobStaleAfter = 25 * time.Minute + +type ( + exportJobHandler struct { + service *Service + logger *log.Logger + staleAfter time.Duration + } + + ExportJobWorkerConfig struct { + StaleAfter time.Duration + } +) + +var ( + _ worker.Handler[coredata.ExportJob] = (*exportJobHandler)(nil) + _ worker.StaleRecoverer = (*exportJobHandler)(nil) +) + +func NewExportJobWorker( + service *Service, + logger *log.Logger, + cfg ExportJobWorkerConfig, + opts ...worker.Option, +) *worker.Worker[coredata.ExportJob] { + staleAfter := cfg.StaleAfter + if staleAfter <= 0 { + staleAfter = defaultExportJobStaleAfter + } + + h := &exportJobHandler{ + service: service, + logger: logger, + staleAfter: staleAfter, + } + + return worker.New( + "export-job-worker", + h, + logger, + opts..., + ) +} + +func (h *exportJobHandler) Claim(ctx context.Context) (coredata.ExportJob, error) { + exportJob, err := h.service.lockExportJob(ctx) + if err != nil { + if errors.Is(err, coredata.ErrNoExportJobAvailable) { + return coredata.ExportJob{}, worker.ErrNoTask + } + + return coredata.ExportJob{}, err + } + + return *exportJob, nil +} + +func (h *exportJobHandler) Process(ctx context.Context, exportJob coredata.ExportJob) error { + stopHeartbeat := h.startHeartbeat(ctx, exportJob.ID) + defer stopHeartbeat() + + if err := h.service.processExportJob(ctx, &exportJob); err != nil { + h.logger.ErrorCtx( + ctx, + "export job worker failure", + log.Error(err), + log.String("export_job_id", exportJob.ID.String()), + log.String("export_job_type", exportJob.Type.String()), + ) + + return err + } + + return nil +} + +func (h *exportJobHandler) RecoverStale(ctx context.Context) error { + return h.service.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := coredata.ResetStaleExportJobs(ctx, conn, h.staleAfter); err != nil { + return fmt.Errorf("cannot reset stale export jobs: %w", err) + } + + return nil + }, + ) +} + +func (h *exportJobHandler) startHeartbeat(ctx context.Context, exportJobID gid.GID) func() { + done := make(chan struct{}) + + interval := max(h.staleAfter/2, time.Second) + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + if err := h.service.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return coredata.TouchExportJobLease( + ctx, + conn, + coredata.NewScope(exportJobID.TenantID()), + exportJobID, + ) + }, + ); err != nil { + h.logger.ErrorCtx( + ctx, + "cannot renew export job lease", + log.Error(err), + log.String("export_job_id", exportJobID.String()), + ) + } + } + } + }() + + return func() { close(done) } +} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 78d25c965..4603ea867 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -118,6 +118,7 @@ type ( GeneratedDocuments *GeneratedDocumentService Files *FileService SlackMessages *slack.Service + LogExports ExportService } ) @@ -231,16 +232,12 @@ func NewService( svc.GeneratedDocuments = &GeneratedDocumentService{svc: svc} svc.Files = &FileService{svc: svc} svc.SlackMessages = slackService + svc.LogExports = iamService.LogExports return svc, nil } -func (s *Service) ExportJob(ctx context.Context) error { - exportJob, err := s.lockExportJob(ctx) - if err != nil { - return fmt.Errorf("cannot lock export job: %w", err) - } - +func (s *Service) processExportJob(ctx context.Context, exportJob *coredata.ExportJob) error { scope := coredata.NewScope(exportJob.ID.TenantID()) var exportService ExportService @@ -250,6 +247,8 @@ func (s *Service) ExportJob(ctx context.Context) error { exportService = s.Frameworks case coredata.ExportJobTypeDocument: exportService = s.Documents + case coredata.ExportJobTypeAuditLog, coredata.ExportJobTypeSCIMEvent: + exportService = s.LogExports default: unknownTypeErr := fmt.Errorf("unknown export job type: %q", exportJob.Type) if err := s.commitFailedExport(ctx, exportJob, unknownTypeErr); err != nil { @@ -314,10 +313,7 @@ func (s *Service) lockExportJob(ctx context.Context) (*coredata.ExportJob, error scope = coredata.NewScope(exportJob.ID.TenantID()) - exportJob.Status = coredata.ExportJobStatusProcessing - - exportJob.StartedAt = new(time.Now()) - if err := exportJob.Update(ctx, tx, scope); err != nil { + if err := exportJob.MarkProcessing(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } @@ -341,7 +337,7 @@ func (s *Service) commitFailedExport(ctx context.Context, exportJob *coredata.Ex ctx, func(ctx context.Context, tx pg.Tx) error { scope := coredata.NewScope(exportJob.ID.TenantID()) - if err := exportJob.Update(ctx, tx, scope); err != nil { + if err := exportJob.UpdateIfStatus(ctx, tx, scope, coredata.ExportJobStatusProcessing); err != nil { return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } @@ -358,7 +354,7 @@ func (s *Service) commitSuccessfulExport(ctx context.Context, exportJob *coredat ctx, func(ctx context.Context, tx pg.Tx) error { scope := coredata.NewScope(exportJob.ID.TenantID()) - if err := exportJob.Update(ctx, tx, scope); err != nil { + if err := exportJob.UpdateIfStatus(ctx, tx, scope, coredata.ExportJobStatusProcessing); err != nil { return fmt.Errorf("cannot update %s export job: %w", exportJob.Type, err) } diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 7d77c1164..538ff35ca 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -1218,19 +1218,12 @@ func (impl *Implm) runExportJob( proboService *probo.Service, l *log.Logger, ) error { -LOOP: - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(30 * time.Second): - if err := proboService.ExportJob(ctx); err != nil { - if !errors.Is(err, coredata.ErrNoExportJobAvailable) { - l.ErrorCtx(ctx, "cannot process export job", log.Error(err)) - } - } - - goto LOOP - } + return probo.NewExportJobWorker( + proboService, + l, + probo.ExportJobWorkerConfig{}, + worker.WithMaxConcurrency(3), + ).Run(ctx) } func (impl *Implm) runApiServer( diff --git a/pkg/server/api/connect/v1/audit_log_resolvers.go b/pkg/server/api/connect/v1/audit_log_resolvers.go index b4aa5ad08..bcbda08bf 100644 --- a/pkg/server/api/connect/v1/audit_log_resolvers.go +++ b/pkg/server/api/connect/v1/audit_log_resolvers.go @@ -7,9 +7,12 @@ package connect_v1 import ( "context" + "errors" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/api/connect/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" @@ -41,6 +44,42 @@ func (r *auditLogEntryConnectionResolver) TotalCount(ctx context.Context, obj *t return count, nil } +// RequestAuditLogExport is the resolver for the requestAuditLogExport field. +func (r *mutationResolver) RequestAuditLogExport(ctx context.Context, input types.RequestAuditLogExportInput) (*types.RequestAuditLogExportPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, iam.ActionAuditLogExport) + if err != nil { + return nil, err + } + + identity := authn.IdentityFromContext(ctx) + + logExport, err := r.iam.OrganizationService.RequestLogExport( + ctx, + scope, + iam.RequestLogExportRequest{ + OrganizationID: input.OrganizationID, + Type: coredata.ExportJobTypeAuditLog, + FromTime: input.FromTime, + ToTime: input.ToTime, + RecipientEmail: identity.EmailAddress, + RecipientName: identity.FullName, + }, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrInvalidLogExportTimeRange](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot request audit log export", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.RequestAuditLogExportPayload{ + ExportJobID: logExport.ID, + }, nil +} + // AuditLogEntry returns schema.AuditLogEntryResolver implementation. func (r *Resolver) AuditLogEntry() schema.AuditLogEntryResolver { return &auditLogEntryResolver{r} } diff --git a/pkg/server/api/connect/v1/graphql/audit_log.graphql b/pkg/server/api/connect/v1/graphql/audit_log.graphql index 6dd724edc..1ebdedd34 100644 --- a/pkg/server/api/connect/v1/graphql/audit_log.graphql +++ b/pkg/server/api/connect/v1/graphql/audit_log.graphql @@ -70,3 +70,19 @@ type AuditLogEntryEdge { cursor: CursorKey! node: AuditLogEntry! } + +extend type Mutation { + requestAuditLogExport( + input: RequestAuditLogExportInput! + ): RequestAuditLogExportPayload @authentication(required: PRESENT) @sessionOnly +} + +input RequestAuditLogExportInput { + organizationId: ID! + fromTime: Datetime! + toTime: Datetime! +} + +type RequestAuditLogExportPayload { + exportJobId: ID! +} diff --git a/pkg/server/api/connect/v1/graphql/scim.graphql b/pkg/server/api/connect/v1/graphql/scim.graphql index 5afd2ed69..ae2b8585c 100644 --- a/pkg/server/api/connect/v1/graphql/scim.graphql +++ b/pkg/server/api/connect/v1/graphql/scim.graphql @@ -140,6 +140,9 @@ extend type Mutation { updateSCIMBridge( input: UpdateSCIMBridgeInput! ): UpdateSCIMBridgePayload @authentication(required: PRESENT) + requestSCIMEventExport( + input: RequestSCIMEventExportInput! + ): RequestSCIMEventExportPayload @authentication(required: PRESENT) @sessionOnly } input CreateSCIMConfigurationInput { @@ -181,3 +184,13 @@ type RegenerateSCIMTokenPayload { type UpdateSCIMBridgePayload { scimBridge: SCIMBridge! } + +input RequestSCIMEventExportInput { + organizationId: ID! + fromTime: Datetime! + toTime: Datetime! +} + +type RequestSCIMEventExportPayload { + exportJobId: ID! +} diff --git a/pkg/server/api/connect/v1/scim_resolvers.go b/pkg/server/api/connect/v1/scim_resolvers.go index 921a93cb7..119d3ca5c 100644 --- a/pkg/server/api/connect/v1/scim_resolvers.go +++ b/pkg/server/api/connect/v1/scim_resolvers.go @@ -13,6 +13,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/api/connect/v1/types" "go.probo.inc/probo/pkg/server/gqlutils" @@ -107,6 +108,42 @@ func (r *mutationResolver) UpdateSCIMBridge(ctx context.Context, input types.Upd }, nil } +// RequestSCIMEventExport is the resolver for the requestSCIMEventExport field. +func (r *mutationResolver) RequestSCIMEventExport(ctx context.Context, input types.RequestSCIMEventExportInput) (*types.RequestSCIMEventExportPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, iam.ActionSCIMEventExport) + if err != nil { + return nil, err + } + + identity := authn.IdentityFromContext(ctx) + + logExport, err := r.iam.OrganizationService.RequestLogExport( + ctx, + scope, + iam.RequestLogExportRequest{ + OrganizationID: input.OrganizationID, + Type: coredata.ExportJobTypeSCIMEvent, + FromTime: input.FromTime, + ToTime: input.ToTime, + RecipientEmail: identity.EmailAddress, + RecipientName: identity.FullName, + }, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrInvalidLogExportTimeRange](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot request SCIM event export", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.RequestSCIMEventExportPayload{ + ExportJobID: logExport.ID, + }, nil +} + // ScimConfiguration is the resolver for the scimConfiguration field. func (r *sCIMBridgeResolver) ScimConfiguration(ctx context.Context, obj *types.SCIMBridge) (*types.SCIMConfiguration, error) { if _, err := r.authorize(ctx, obj.ScimConfiguration.ID, iam.ActionSCIMConfigurationGet); err != nil { diff --git a/pkg/server/api/console/v1/audit_log_resolvers.go b/pkg/server/api/console/v1/audit_log_resolvers.go index 95cdc0eb6..30b462e0b 100644 --- a/pkg/server/api/console/v1/audit_log_resolvers.go +++ b/pkg/server/api/console/v1/audit_log_resolvers.go @@ -14,6 +14,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" "go.probo.inc/probo/pkg/server/api/console/v1/schema" "go.probo.inc/probo/pkg/server/api/console/v1/types" @@ -67,6 +68,42 @@ func (r *auditLogEntryConnectionResolver) TotalCount(ctx context.Context, obj *t return count, nil } +// RequestAuditLogExport is the resolver for the requestAuditLogExport field. +func (r *mutationResolver) RequestAuditLogExport(ctx context.Context, input types.RequestAuditLogExportInput) (*types.RequestAuditLogExportPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, iam.ActionAuditLogExport) + if err != nil { + return nil, err + } + + identity := authn.IdentityFromContext(ctx) + + logExport, err := r.iam.OrganizationService.RequestLogExport( + ctx, + scope, + iam.RequestLogExportRequest{ + OrganizationID: input.OrganizationID, + Type: coredata.ExportJobTypeAuditLog, + FromTime: input.FromTime, + ToTime: input.ToTime, + RecipientEmail: identity.EmailAddress, + RecipientName: identity.FullName, + }, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrInvalidLogExportTimeRange](err); ok { + return nil, gqlutils.Invalid(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot request audit log export", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.RequestAuditLogExportPayload{ + ExportJobID: logExport.ID, + }, nil +} + // AuditLogEntry returns schema.AuditLogEntryResolver implementation. func (r *Resolver) AuditLogEntry() schema.AuditLogEntryResolver { return &auditLogEntryResolver{r} } diff --git a/pkg/server/api/console/v1/graphql/audit_log.graphql b/pkg/server/api/console/v1/graphql/audit_log.graphql index 9f84d0ce5..462fde8b8 100644 --- a/pkg/server/api/console/v1/graphql/audit_log.graphql +++ b/pkg/server/api/console/v1/graphql/audit_log.graphql @@ -68,3 +68,19 @@ type AuditLogEntryEdge { cursor: CursorKey! node: AuditLogEntry! } + +extend type Mutation { + requestAuditLogExport( + input: RequestAuditLogExportInput! + ): RequestAuditLogExportPayload! +} + +input RequestAuditLogExportInput { + organizationId: ID! + fromTime: Datetime! + toTime: Datetime! +} + +type RequestAuditLogExportPayload { + exportJobId: ID! +} diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 35035cb78..bd1496af1 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -7351,3 +7351,69 @@ func (r *Resolver) DeleteCommitmentTool(ctx context.Context, req *mcp.CallToolRe return nil, types.DeleteCommitmentOutput{DeletedCommitmentID: input.ID}, nil } + +func (r *Resolver) RequestAuditLogExportTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestAuditLogExportInput) (*mcp.CallToolResult, types.RequestAuditLogExportOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, iam.ActionAuditLogExport) + if err != nil { + return nil, types.RequestAuditLogExportOutput{}, err + } + + identity := authn.IdentityFromContext(ctx) + + logExport, err := r.iamSvc.OrganizationService.RequestLogExport( + ctx, + scope, + iam.RequestLogExportRequest{ + OrganizationID: input.OrganizationID, + Type: coredata.ExportJobTypeAuditLog, + FromTime: input.FromTime, + ToTime: input.ToTime, + RecipientEmail: identity.EmailAddress, + RecipientName: identity.FullName, + }, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrInvalidLogExportTimeRange](err); ok { + return nil, types.RequestAuditLogExportOutput{}, err + } + + return nil, types.RequestAuditLogExportOutput{}, fmt.Errorf("cannot request audit log export: %w", err) + } + + return nil, types.RequestAuditLogExportOutput{ + ExportJobID: logExport.ID, + }, nil +} + +func (r *Resolver) RequestSCIMEventExportTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestSCIMEventExportInput) (*mcp.CallToolResult, types.RequestSCIMEventExportOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, iam.ActionSCIMEventExport) + if err != nil { + return nil, types.RequestSCIMEventExportOutput{}, err + } + + identity := authn.IdentityFromContext(ctx) + + logExport, err := r.iamSvc.OrganizationService.RequestLogExport( + ctx, + scope, + iam.RequestLogExportRequest{ + OrganizationID: input.OrganizationID, + Type: coredata.ExportJobTypeSCIMEvent, + FromTime: input.FromTime, + ToTime: input.ToTime, + RecipientEmail: identity.EmailAddress, + RecipientName: identity.FullName, + }, + ) + if err != nil { + if _, ok := errors.AsType[*iam.ErrInvalidLogExportTimeRange](err); ok { + return nil, types.RequestSCIMEventExportOutput{}, err + } + + return nil, types.RequestSCIMEventExportOutput{}, fmt.Errorf("cannot request SCIM event export: %w", err) + } + + return nil, types.RequestSCIMEventExportOutput{ + ExportJobID: logExport.ID, + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 2802ce42f..687fcb0a6 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -8487,6 +8487,66 @@ components: items: $ref: "#/components/schemas/AuditLogEntry" + RequestAuditLogExportInput: + type: object + required: + - organization_id + - from_time + - to_time + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + from_time: + type: string + format: date-time + go.probo.inc/mcpgen/type: time.Time + description: Start of the time range (inclusive). The range must not exceed 1 year. + to_time: + type: string + format: date-time + go.probo.inc/mcpgen/type: time.Time + description: End of the time range (exclusive). The range must not exceed 1 year. + + RequestAuditLogExportOutput: + type: object + required: + - export_job_id + properties: + export_job_id: + $ref: "#/components/schemas/GID" + description: ID of the created log export + + RequestSCIMEventExportInput: + type: object + required: + - organization_id + - from_time + - to_time + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + from_time: + type: string + format: date-time + go.probo.inc/mcpgen/type: time.Time + description: Start of the time range (inclusive). The range must not exceed 1 year. + to_time: + type: string + format: date-time + go.probo.inc/mcpgen/type: time.Time + description: End of the time range (exclusive). The range must not exceed 1 year. + + RequestSCIMEventExportOutput: + type: object + required: + - export_job_id + properties: + export_job_id: + $ref: "#/components/schemas/GID" + description: ID of the created log export + AuditLogEntry: type: object required: @@ -13955,6 +14015,24 @@ tools: $ref: "#/components/schemas/ListAuditLogEntriesInput" outputSchema: $ref: "#/components/schemas/ListAuditLogEntriesOutput" + - name: requestAuditLogExport + 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. + hints: + readonly: false + idempotent: false + inputSchema: + $ref: "#/components/schemas/RequestAuditLogExportInput" + outputSchema: + $ref: "#/components/schemas/RequestAuditLogExportOutput" + - name: requestSCIMEventExport + description: Request an export of SCIM events for the organization within a time range. The export will be emailed as a JSONL download link. + hints: + readonly: false + idempotent: false + inputSchema: + $ref: "#/components/schemas/RequestSCIMEventExportInput" + outputSchema: + $ref: "#/components/schemas/RequestSCIMEventExportOutput" - name: listWebhookSubscriptions description: List all webhook subscriptions for the organization hints: