Add log export for audit logs and SCIM events

Route audit-log and SCIM-event exports through export_jobs with typed
arguments, an iam BuildAndUploadExport/SendExportEmail implementation,
and a concurrent export-job worker with stale recovery. Stream JSONL via
page.WalkAll into S3, and expose the request flow on console, connect,
MCP, and CLI.

Co-authored-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-07-29 16:12:23 +02:00
parent b2e2d15582
commit cd6c46212a
45 changed files with 2340 additions and 153 deletions

View File

@@ -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." },

View File

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

View File

@@ -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<AuditLogSettingsPageExportMutation>(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 (
<>
<Button
variant="secondary"
icon={IconArrowDown}
onClick={() => dialogRef.current?.open()}
>
{t("auditLogSettingsPage.export.actions.export")}
</Button>
<Dialog
className="max-w-md"
ref={dialogRef}
title={t("auditLogSettingsPage.export.title")}
>
<DialogContent className="space-y-4" padded>
<p className="text-sm text-txt-secondary">
{t("auditLogSettingsPage.export.description")}
</p>
<Field label={t("auditLogSettingsPage.export.fields.from")}>
<Input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
required
/>
</Field>
<Field label={t("auditLogSettingsPage.export.fields.to")}>
<Input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
required
/>
</Field>
</DialogContent>
<DialogFooter>
<Button
onClick={handleExport}
disabled={isExporting || !fromDate || !toDate || fromDate > toDate}
>
{isExporting
? (
<>
<Spinner size={16} />
{t("auditLogSettingsPage.export.actions.exporting")}
</>
)
: t("auditLogSettingsPage.export.actions.export")}
</Button>
</DialogFooter>
</Dialog>
</>
);
}
export function AuditLogSettingsPage(props: {
queryRef: PreloadedQuery<AuditLogSettingsPageQuery>;
}) {
@@ -202,11 +332,16 @@ export function AuditLogSettingsPage(props: {
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-medium">{t("auditLogSettingsPage.title")}</h2>
<p className="text-sm text-txt-tertiary">
{t("auditLogSettingsPage.description")}
</p>
<div className="flex items-start justify-between">
<div>
<h2 className="text-base font-medium">{t("auditLogSettingsPage.title")}</h2>
<p className="text-sm text-txt-tertiary">
{t("auditLogSettingsPage.description")}
</p>
</div>
{organization.canExportAuditLog && (
<ExportAuditLogDialog organizationId={organization.id} />
)}
</div>
{entries.length === 0

View File

@@ -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<SCIMSettingsPageExportMutation>(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 (
<>
<Button
variant="secondary"
icon={IconArrowDown}
onClick={() => dialogRef.current?.open()}
>
{t("scimSettingsPage.export.actions.export")}
</Button>
<Dialog
className="max-w-md"
ref={dialogRef}
title={t("scimSettingsPage.export.title")}
>
<DialogContent className="space-y-4" padded>
<p className="text-sm text-txt-secondary">
{t("scimSettingsPage.export.description")}
</p>
<Field label={t("scimSettingsPage.export.fields.from")}>
<Input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
required
/>
</Field>
<Field label={t("scimSettingsPage.export.fields.to")}>
<Input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
required
/>
</Field>
</DialogContent>
<DialogFooter>
<Button
onClick={handleExport}
disabled={isExporting || !fromDate || !toDate || fromDate > toDate}
>
{isExporting
? (
<>
<Spinner size={16} />
{t("scimSettingsPage.export.actions.exporting")}
</>
)
: t("scimSettingsPage.export.actions.export")}
</Button>
</DialogFooter>
</Dialog>
</>
);
}
export function SCIMSettingsPage(props: {
queryRef: PreloadedQuery<SCIMSettingsPageQuery>;
}) {
@@ -180,9 +314,14 @@ export function SCIMSettingsPage(props: {
{showProvisioningEvents && (
<div className="space-y-4">
<h2 className="text-base font-medium">
{t("scimSettingsPage.provisioningEventHistory")}
</h2>
<div className="flex items-start justify-between">
<h2 className="text-base font-medium">
{t("scimSettingsPage.provisioningEventHistory")}
</h2>
{organization.canExportSCIMEvents && (
<ExportSCIMEventsDialog organizationId={organization.id} />
)}
</div>
<SCIMEventList fKey={organization.scimConfiguration} />
</div>
)}