Add audit log feature for recording all actions

Adds audit logging that records all authorized actions performed by
users and API keys. The audit log is automatically populated whenever
the authorizer approves an action, and is queryable via GraphQL, MCP,
and CLI interfaces. Permission checks are excluded via a dry-run flag
to avoid phantom entries on page loads.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-20 15:42:11 +01:00
parent 000347b1f9
commit 7b320916af
34 changed files with 2546 additions and 13 deletions

View File

@@ -0,0 +1,233 @@
import { formatDate } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
IconChevronDown,
Spinner,
Tbody,
Td,
Th,
Thead,
Table,
Tr,
} from "@probo/ui";
import {
type PreloadedQuery,
graphql,
useFragment,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import type { AuditLogSettingsPageQuery } from "#/__generated__/iam/AuditLogSettingsPageQuery.graphql";
import type { AuditLogSettingsPageFragment$key } from "#/__generated__/iam/AuditLogSettingsPageFragment.graphql";
import type { AuditLogSettingsPageRefetchQuery } from "#/__generated__/iam/AuditLogSettingsPageRefetchQuery.graphql";
import type { AuditLogSettingsPageRowFragment$key } from "#/__generated__/iam/AuditLogSettingsPageRowFragment.graphql";
export const auditLogSettingsPageQuery = graphql`
query AuditLogSettingsPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) @required(action: THROW) {
__typename
... on Organization {
...AuditLogSettingsPageFragment
}
}
}
`;
const auditLogSettingsPageFragment = graphql`
fragment AuditLogSettingsPageFragment on Organization
@refetchable(queryName: "AuditLogSettingsPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
after: { type: "CursorKey" }
) {
auditLogEntries(
first: $first
after: $after
orderBy: { field: CREATED_AT, direction: DESC }
) @connection(key: "AuditLogSettingsPage_auditLogEntries") {
edges {
node {
id
...AuditLogSettingsPageRowFragment
}
}
totalCount
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const auditLogEntryRowFragment = graphql`
fragment AuditLogSettingsPageRowFragment on AuditLogEntry {
id
actorId
actorType
action
resourceType
resourceId
createdAt
}
`;
function ActorTypeBadge({ type }: { type: string }) {
switch (type) {
case "USER":
return <Badge variant="info" size="sm">{type}</Badge>;
case "API_KEY":
return <Badge variant="warning" size="sm">{type}</Badge>;
case "SYSTEM":
return <Badge variant="neutral" size="sm">{type}</Badge>;
default:
return <Badge size="sm">{type}</Badge>;
}
}
function ActionBadge({ action }: { action: string }) {
const parts = action.split(":");
const verb = parts[parts.length - 1];
if (
verb === "create" ||
verb === "upload" ||
verb === "import" ||
verb === "publish"
) {
return <Badge variant="success" size="sm">{action}</Badge>;
}
if (verb === "delete" || verb === "archive") {
return <Badge variant="danger" size="sm">{action}</Badge>;
}
if (
verb === "update" ||
verb === "assign" ||
verb === "unassign" ||
verb === "unarchive"
) {
return <Badge variant="warning" size="sm">{action}</Badge>;
}
if (verb === "get" || verb === "list") {
return <Badge variant="neutral" size="sm">{action}</Badge>;
}
return <Badge size="sm">{action}</Badge>;
}
function AuditLogEntryRow({
entryKey,
}: {
entryKey: AuditLogSettingsPageRowFragment$key;
}) {
const entry = useFragment(auditLogEntryRowFragment, entryKey);
return (
<Tr>
<Td>
<span className="text-sm text-txt-secondary whitespace-nowrap">
{formatDate(entry.createdAt)}
</span>
</Td>
<Td>
<div className="flex items-center gap-2">
<ActorTypeBadge type={entry.actorType} />
<span className="text-sm font-mono text-txt-secondary truncate max-w-48">
{entry.actorId}
</span>
</div>
</Td>
<Td>
<ActionBadge action={entry.action} />
</Td>
<Td>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{entry.resourceType}
</span>
<span className="text-sm font-mono text-txt-tertiary truncate max-w-48">
{entry.resourceId}
</span>
</div>
</Td>
</Tr>
);
}
export function AuditLogSettingsPage(props: {
queryRef: PreloadedQuery<AuditLogSettingsPageQuery>;
}) {
const { __ } = useTranslate();
const { organization } = usePreloadedQuery(
auditLogSettingsPageQuery,
props.queryRef,
);
if (organization.__typename === "%other") {
throw new Error("Relay node is not an organization");
}
const { data, loadNext, hasNext, isLoadingNext } =
usePaginationFragment<
AuditLogSettingsPageRefetchQuery,
AuditLogSettingsPageFragment$key
>(auditLogSettingsPageFragment, organization);
const entries = data?.auditLogEntries?.edges?.map((e) => e.node) ?? [];
const totalCount = data?.auditLogEntries?.totalCount ?? 0;
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-medium">{__("Audit Log")}</h2>
<p className="text-sm text-txt-tertiary">
{__(
"A record of all actions performed in your organization. Entries are immutable and cannot be modified or deleted.",
)}
</p>
</div>
{entries.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-txt-tertiary">
{__("No audit log entries yet.")}
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-txt-tertiary">
{`${__("Showing")} ${entries.length} ${__("of")} ${totalCount} ${__("entries")}`}
</p>
<Table>
<Thead>
<Tr>
<Th>{__("Date")}</Th>
<Th>{__("Actor")}</Th>
<Th>{__("Action")}</Th>
<Th>{__("Resource")}</Th>
</Tr>
</Thead>
<Tbody>
{entries.map((entry) => (
<AuditLogEntryRow key={entry.id} entryKey={entry} />
))}
</Tbody>
</Table>
{hasNext && (
<Button
variant="tertiary"
onClick={() => loadNext(50)}
className="mx-auto"
disabled={isLoadingNext}
icon={isLoadingNext ? Spinner : IconChevronDown}
>
{__("Show more")}
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { AuditLogSettingsPageQuery } from "#/__generated__/iam/AuditLogSettingsPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
AuditLogSettingsPage,
auditLogSettingsPageQuery,
} from "#/pages/iam/organizations/settings/AuditLogSettingsPage";
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
function AuditLogSettingsPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<AuditLogSettingsPageQuery>(
auditLogSettingsPageQuery,
);
useEffect(() => {
loadQuery({
organizationId,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return null;
}
return <AuditLogSettingsPage queryRef={queryRef} />;
}
export default function AuditLogSettingsPageLoader() {
return (
<IAMRelayProvider>
<AuditLogSettingsPageQueryLoader />
</IAMRelayProvider>
);
}

View File

@@ -1,6 +1,7 @@
import { useTranslate } from "@probo/i18n";
import {
IconKey,
IconListStack,
IconLock,
IconSend,
IconSettingsGear2,
@@ -37,6 +38,10 @@ export default function SettingsLayout() {
<IconSend size={20} />
{__("Webhooks")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/settings/audit-log`}>
<IconListStack size={20} />
{__("Audit Log")}
</TabLink>
</Tabs>
<Outlet />

View File

@@ -210,6 +210,13 @@ const routes = [
import("./pages/iam/organizations/settings/WebhooksSettingsPageLoader"),
),
},
{
path: "audit-log",
Component: lazy(
() =>
import("./pages/iam/organizations/settings/AuditLogSettingsPageLoader"),
),
},
],
},
...peopleRoutes,