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:
@@ -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." },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user