diff --git a/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx
new file mode 100644
index 000000000..0867c854b
--- /dev/null
+++ b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx
@@ -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 {type};
+ case "API_KEY":
+ return {type};
+ case "SYSTEM":
+ return {type};
+ default:
+ return {type};
+ }
+}
+
+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 {action};
+ }
+ if (verb === "delete" || verb === "archive") {
+ return {action};
+ }
+ if (
+ verb === "update" ||
+ verb === "assign" ||
+ verb === "unassign" ||
+ verb === "unarchive"
+ ) {
+ return {action};
+ }
+ if (verb === "get" || verb === "list") {
+ return {action};
+ }
+ return {action};
+}
+
+function AuditLogEntryRow({
+ entryKey,
+}: {
+ entryKey: AuditLogSettingsPageRowFragment$key;
+}) {
+ const entry = useFragment(auditLogEntryRowFragment, entryKey);
+
+ return (
+
+ |
+
+ {formatDate(entry.createdAt)}
+
+ |
+
+
+
+
+ {entry.actorId}
+
+
+ |
+
+
+ |
+
+
+
+ {entry.resourceType}
+
+
+ {entry.resourceId}
+
+
+ |
+
+ );
+}
+
+export function AuditLogSettingsPage(props: {
+ queryRef: PreloadedQuery;
+}) {
+ 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 (
+
+
+
{__("Audit Log")}
+
+ {__(
+ "A record of all actions performed in your organization. Entries are immutable and cannot be modified or deleted.",
+ )}
+
+
+
+ {entries.length === 0 ? (
+
+
+ {__("No audit log entries yet.")}
+
+
+ ) : (
+
+
+ {`${__("Showing")} ${entries.length} ${__("of")} ${totalCount} ${__("entries")}`}
+
+
+
+
+ | {__("Date")} |
+ {__("Actor")} |
+ {__("Action")} |
+ {__("Resource")} |
+
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ {hasNext && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPageLoader.tsx b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPageLoader.tsx
new file mode 100644
index 000000000..c42a2c7bb
--- /dev/null
+++ b/apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPageLoader.tsx
@@ -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,
+ );
+
+ useEffect(() => {
+ loadQuery({
+ organizationId,
+ });
+ }, [loadQuery, organizationId]);
+
+ if (!queryRef) {
+ return null;
+ }
+
+ return ;
+}
+
+export default function AuditLogSettingsPageLoader() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx
index ead22d4fd..da554adc9 100644
--- a/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx
+++ b/apps/console/src/pages/iam/organizations/settings/SettingsLayout.tsx
@@ -1,6 +1,7 @@
import { useTranslate } from "@probo/i18n";
import {
IconKey,
+ IconListStack,
IconLock,
IconSend,
IconSettingsGear2,
@@ -37,6 +38,10 @@ export default function SettingsLayout() {
{__("Webhooks")}
+
+
+ {__("Audit Log")}
+
diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx
index cb581122c..aa8ce2420 100644
--- a/apps/console/src/routes.tsx
+++ b/apps/console/src/routes.tsx
@@ -210,6 +210,13 @@ const routes = [
import("./pages/iam/organizations/settings/WebhooksSettingsPageLoader"),
),
},
+ {
+ path: "audit-log",
+ Component: lazy(
+ () =>
+ import("./pages/iam/organizations/settings/AuditLogSettingsPageLoader"),
+ ),
+ },
],
},
...peopleRoutes,
diff --git a/e2e/console/audit_log_test.go b/e2e/console/audit_log_test.go
new file mode 100644
index 000000000..71c5d71fc
--- /dev/null
+++ b/e2e/console/audit_log_test.go
@@ -0,0 +1,306 @@
+// 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/factory"
+ "go.probo.inc/probo/e2e/internal/testutil"
+)
+
+func TestAuditLog_List(t *testing.T) {
+ t.Parallel()
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ // Create a vendor to generate an audit log entry.
+ factory.NewVendor(owner).WithName(factory.SafeName("AuditVendor")).Create()
+
+ const query = `
+ query($orgId: ID!) {
+ node(id: $orgId) {
+ ... on Organization {
+ auditLogEntries(first: 10) {
+ edges {
+ node {
+ id
+ actorId
+ actorType
+ action
+ resourceType
+ resourceId
+ createdAt
+ }
+ }
+ totalCount
+ }
+ }
+ }
+ }
+ `
+
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ Edges []struct {
+ Node struct {
+ ID string `json:"id"`
+ ActorID string `json:"actorId"`
+ ActorType string `json:"actorType"`
+ Action string `json:"action"`
+ ResourceType string `json:"resourceType"`
+ ResourceID string `json:"resourceId"`
+ CreatedAt string `json:"createdAt"`
+ } `json:"node"`
+ } `json:"edges"`
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := owner.Execute(query, map[string]any{
+ "orgId": owner.GetOrganizationID().String(),
+ }, &result)
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
+
+ // Find the vendor create entry.
+ found := false
+ for _, edge := range result.Node.AuditLogEntries.Edges {
+ if edge.Node.Action == "core:vendor:create" {
+ found = true
+ assert.Equal(t, "USER", edge.Node.ActorType)
+ assert.Equal(t, "Vendor", edge.Node.ResourceType)
+ assert.NotEmpty(t, edge.Node.ActorID)
+ assert.NotEmpty(t, edge.Node.ResourceID)
+ assert.NotEmpty(t, edge.Node.CreatedAt)
+ break
+ }
+ }
+ assert.True(t, found, "expected to find core:vendor:create audit log entry")
+}
+
+func TestAuditLog_Filter(t *testing.T) {
+ t.Parallel()
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ // Create different resources to generate different audit log entries.
+ factory.NewVendor(owner).WithName(factory.SafeName("FilterVendor")).Create()
+
+ const query = `
+ query($orgId: ID!, $filter: AuditLogEntryFilter) {
+ node(id: $orgId) {
+ ... on Organization {
+ auditLogEntries(first: 50, filter: $filter) {
+ edges {
+ node {
+ id
+ action
+ resourceType
+ }
+ }
+ totalCount
+ }
+ }
+ }
+ }
+ `
+
+ t.Run("filter by action", func(t *testing.T) {
+ t.Parallel()
+
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ Edges []struct {
+ Node struct {
+ ID string `json:"id"`
+ Action string `json:"action"`
+ } `json:"node"`
+ } `json:"edges"`
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := owner.Execute(query, map[string]any{
+ "orgId": owner.GetOrganizationID().String(),
+ "filter": map[string]any{"action": "core:vendor:create"},
+ }, &result)
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
+ for _, edge := range result.Node.AuditLogEntries.Edges {
+ assert.Equal(t, "core:vendor:create", edge.Node.Action)
+ }
+ })
+
+ t.Run("filter by resource type", func(t *testing.T) {
+ t.Parallel()
+
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ Edges []struct {
+ Node struct {
+ ID string `json:"id"`
+ ResourceType string `json:"resourceType"`
+ } `json:"node"`
+ } `json:"edges"`
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := owner.Execute(query, map[string]any{
+ "orgId": owner.GetOrganizationID().String(),
+ "filter": map[string]any{"resourceType": "Vendor"},
+ }, &result)
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
+ for _, edge := range result.Node.AuditLogEntries.Edges {
+ assert.Equal(t, "Vendor", edge.Node.ResourceType)
+ }
+ })
+}
+
+func TestAuditLog_RBAC(t *testing.T) {
+ t.Parallel()
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ // Generate an audit log entry.
+ factory.NewVendor(owner).WithName(factory.SafeName("RBACVendor")).Create()
+
+ const query = `
+ query($orgId: ID!) {
+ node(id: $orgId) {
+ ... on Organization {
+ auditLogEntries(first: 10) {
+ edges {
+ node {
+ id
+ action
+ }
+ }
+ totalCount
+ }
+ }
+ }
+ }
+ `
+
+ t.Run("viewer can list audit log entries", func(t *testing.T) {
+ t.Parallel()
+ viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
+
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ Edges []struct {
+ Node struct {
+ ID string `json:"id"`
+ Action string `json:"action"`
+ } `json:"node"`
+ } `json:"edges"`
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := viewer.Execute(query, map[string]any{
+ "orgId": viewer.GetOrganizationID().String(),
+ }, &result)
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
+ })
+
+ t.Run("admin can list audit log entries", func(t *testing.T) {
+ t.Parallel()
+ admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
+
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := admin.Execute(query, map[string]any{
+ "orgId": admin.GetOrganizationID().String(),
+ }, &result)
+ require.NoError(t, err)
+ assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
+ })
+}
+
+func TestAuditLog_TenantIsolation(t *testing.T) {
+ t.Parallel()
+
+ org1Owner := testutil.NewClient(t, testutil.RoleOwner)
+ org2Owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ // Create a vendor in org1 to generate audit log entries.
+ factory.NewVendor(org1Owner).WithName(factory.SafeName("IsoVendor")).Create()
+
+ const query = `
+ query($orgId: ID!) {
+ node(id: $orgId) {
+ ... on Organization {
+ auditLogEntries(first: 50) {
+ edges {
+ node {
+ id
+ action
+ resourceType
+ }
+ }
+ totalCount
+ }
+ }
+ }
+ }
+ `
+
+ // org2 should not see org1's audit log entries about vendors.
+ var result struct {
+ Node struct {
+ AuditLogEntries struct {
+ Edges []struct {
+ Node struct {
+ ID string `json:"id"`
+ Action string `json:"action"`
+ ResourceType string `json:"resourceType"`
+ } `json:"node"`
+ } `json:"edges"`
+ TotalCount int `json:"totalCount"`
+ } `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+
+ err := org2Owner.Execute(query, map[string]any{
+ "orgId": org2Owner.GetOrganizationID().String(),
+ }, &result)
+ require.NoError(t, err)
+
+ for _, edge := range result.Node.AuditLogEntries.Edges {
+ // org2 may have its own audit log entries (from user/org creation),
+ // but should never see org1's vendor entries.
+ if edge.Node.ResourceType == "Vendor" {
+ t.Fatalf("org2 should not see org1's vendor audit log entries, but found: %s", edge.Node.Action)
+ }
+ }
+}
diff --git a/pkg/cmd/auditlog/audit_log.go b/pkg/cmd/auditlog/audit_log.go
new file mode 100644
index 000000000..888b231ab
--- /dev/null
+++ b/pkg/cmd/auditlog/audit_log.go
@@ -0,0 +1,34 @@
+// 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 auditlog
+
+import (
+ "github.com/spf13/cobra"
+ "go.probo.inc/probo/pkg/cmd/auditlog/list"
+ "go.probo.inc/probo/pkg/cmd/auditlog/view"
+ "go.probo.inc/probo/pkg/cmd/cmdutil"
+)
+
+func NewCmdAuditLog(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "audit-log ",
+ Short: "Manage audit log entries",
+ }
+
+ cmd.AddCommand(list.NewCmdList(f))
+ cmd.AddCommand(view.NewCmdView(f))
+
+ return cmd
+}
diff --git a/pkg/cmd/auditlog/list/list.go b/pkg/cmd/auditlog/list/list.go
new file mode 100644
index 000000000..f33c77df6
--- /dev/null
+++ b/pkg/cmd/auditlog/list/list.go
@@ -0,0 +1,227 @@
+// 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 list
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "go.probo.inc/probo/pkg/cli/api"
+ "go.probo.inc/probo/pkg/cmd/cmdutil"
+)
+
+const listQuery = `
+query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AuditLogEntryOrder, $filter: AuditLogEntryFilter) {
+ node(id: $id) {
+ __typename
+ ... on Organization {
+ auditLogEntries(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
+ totalCount
+ edges {
+ node {
+ id
+ actorId
+ actorType
+ action
+ resourceType
+ resourceId
+ createdAt
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ }
+}
+`
+
+type auditLogEntry struct {
+ ID string `json:"id"`
+ ActorID string `json:"actorId"`
+ ActorType string `json:"actorType"`
+ Action string `json:"action"`
+ ResourceType string `json:"resourceType"`
+ ResourceID string `json:"resourceId"`
+ CreatedAt string `json:"createdAt"`
+}
+
+func NewCmdList(f *cmdutil.Factory) *cobra.Command {
+ var (
+ flagOrg string
+ flagLimit int
+ flagOrderBy string
+ flagOrderDir string
+ flagAction string
+ flagActorID string
+ flagResourceType string
+ flagResourceID string
+ flagOutput *string
+ )
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List audit log entries",
+ Aliases: []string{"ls"},
+ Example: ` prb audit-log list
+ prb audit-log list --action core:vendor:create
+ prb audit-log list --resource-type Vendor --limit 50`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
+ return err
+ }
+
+ 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(),
+ )
+
+ if flagOrg == "" {
+ flagOrg = hc.Organization
+ }
+
+ if flagOrg == "" {
+ return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
+ }
+
+ variables := map[string]any{
+ "id": flagOrg,
+ }
+
+ if flagOrderBy != "" {
+ if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil {
+ return err
+ }
+ variables["orderBy"] = map[string]any{
+ "field": flagOrderBy,
+ "direction": flagOrderDir,
+ }
+ }
+
+ filter := map[string]any{}
+ if flagAction != "" {
+ filter["action"] = flagAction
+ }
+ if flagActorID != "" {
+ filter["actorId"] = flagActorID
+ }
+ if flagResourceType != "" {
+ filter["resourceType"] = flagResourceType
+ }
+ if flagResourceID != "" {
+ filter["resourceId"] = flagResourceID
+ }
+ if len(filter) > 0 {
+ variables["filter"] = filter
+ }
+
+ entries, totalCount, err := api.Paginate(
+ client,
+ listQuery,
+ variables,
+ flagLimit,
+ func(data json.RawMessage) (*api.Connection[auditLogEntry], error) {
+ var resp struct {
+ Node *struct {
+ Typename string `json:"__typename"`
+ AuditLogEntries api.Connection[auditLogEntry] `json:"auditLogEntries"`
+ } `json:"node"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, err
+ }
+ if resp.Node == nil {
+ return nil, fmt.Errorf("organization %s not found", flagOrg)
+ }
+ if resp.Node.Typename != "Organization" {
+ return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
+ }
+ return &resp.Node.AuditLogEntries, nil
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ if *flagOutput == cmdutil.OutputJSON {
+ if entries == nil {
+ entries = []auditLogEntry{}
+ }
+ return cmdutil.PrintJSON(f.IOStreams.Out, entries)
+ }
+
+ if len(entries) == 0 {
+ _, _ = fmt.Fprintln(f.IOStreams.Out, "No audit log entries found.")
+ return nil
+ }
+
+ rows := make([][]string, 0, len(entries))
+ for _, e := range entries {
+ rows = append(rows, []string{
+ e.ID,
+ e.ActorType,
+ e.ActorID,
+ e.Action,
+ e.ResourceType,
+ e.ResourceID,
+ cmdutil.FormatTime(e.CreatedAt),
+ })
+ }
+
+ t := cmdutil.NewTable("ID", "ACTOR TYPE", "ACTOR", "ACTION", "RESOURCE TYPE", "RESOURCE", "CREATED").Rows(rows...)
+
+ _, _ = fmt.Fprintln(f.IOStreams.Out, t)
+
+ if totalCount > len(entries) {
+ _, _ = fmt.Fprintf(
+ f.IOStreams.ErrOut,
+ "\nShowing %d of %d audit log entries\n",
+ len(entries),
+ totalCount,
+ )
+ }
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
+ cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list")
+ cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
+ cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
+ cmd.Flags().StringVar(&flagAction, "action", "", "Filter by action (e.g. core:vendor:create)")
+ cmd.Flags().StringVar(&flagActorID, "actor-id", "", "Filter by actor ID")
+ cmd.Flags().StringVar(&flagResourceType, "resource-type", "", "Filter by resource type (e.g. Vendor)")
+ cmd.Flags().StringVar(&flagResourceID, "resource-id", "", "Filter by resource ID")
+ flagOutput = cmdutil.AddOutputFlag(cmd)
+
+ return cmd
+}
diff --git a/pkg/cmd/auditlog/view/view.go b/pkg/cmd/auditlog/view/view.go
new file mode 100644
index 000000000..b2a480a67
--- /dev/null
+++ b/pkg/cmd/auditlog/view/view.go
@@ -0,0 +1,137 @@
+// 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 view
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/spf13/cobra"
+ "go.probo.inc/probo/pkg/cli/api"
+ "go.probo.inc/probo/pkg/cmd/cmdutil"
+)
+
+const viewQuery = `
+query($id: ID!) {
+ node(id: $id) {
+ __typename
+ ... on AuditLogEntry {
+ id
+ actorId
+ actorType
+ action
+ resourceType
+ resourceId
+ createdAt
+ }
+ }
+}
+`
+
+type viewResponse struct {
+ Node *struct {
+ Typename string `json:"__typename"`
+ ID string `json:"id"`
+ ActorID string `json:"actorId"`
+ ActorType string `json:"actorType"`
+ Action string `json:"action"`
+ ResourceType string `json:"resourceType"`
+ ResourceID string `json:"resourceId"`
+ CreatedAt string `json:"createdAt"`
+ } `json:"node"`
+}
+
+func NewCmdView(f *cmdutil.Factory) *cobra.Command {
+ var flagOutput *string
+
+ cmd := &cobra.Command{
+ Use: "view ",
+ Short: "View an audit log entry",
+ Example: ` prb audit-log view `,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
+ return err
+ }
+
+ 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(),
+ )
+
+ data, err := client.Do(
+ viewQuery,
+ map[string]any{"id": args[0]},
+ )
+ if err != nil {
+ return err
+ }
+
+ var resp viewResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return fmt.Errorf("cannot parse response: %w", err)
+ }
+
+ if resp.Node == nil {
+ return fmt.Errorf("audit log entry %s not found", args[0])
+ }
+
+ if resp.Node.Typename != "AuditLogEntry" {
+ return fmt.Errorf("expected AuditLogEntry node, got %s", resp.Node.Typename)
+ }
+
+ if *flagOutput == cmdutil.OutputJSON {
+ return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node)
+ }
+
+ e := resp.Node
+ out := f.IOStreams.Out
+
+ bold := lipgloss.NewStyle().Bold(true)
+ label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
+
+ _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render("Audit Log Entry"))
+
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), e.ID)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Action:"), e.Action)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Actor Type:"), e.ActorType)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Actor ID:"), e.ActorID)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Resource Type:"), e.ResourceType)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Resource ID:"), e.ResourceID)
+
+ _, _ = fmt.Fprintln(out)
+ _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(e.CreatedAt))
+
+ return nil
+ },
+ }
+
+ flagOutput = cmdutil.AddOutputFlag(cmd)
+
+ return cmd
+}
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index 16422c698..557385d11 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -17,6 +17,7 @@ package root
import (
"github.com/spf13/cobra"
cmdapi "go.probo.inc/probo/pkg/cmd/api"
+ "go.probo.inc/probo/pkg/cmd/auditlog"
"go.probo.inc/probo/pkg/cmd/auth"
"go.probo.inc/probo/pkg/cmd/browse"
"go.probo.inc/probo/pkg/cmd/cmdutil"
@@ -65,6 +66,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
)
cmd.AddCommand(cmdapi.NewCmdAPI(f))
+ cmd.AddCommand(auditlog.NewCmdAuditLog(f))
cmd.AddCommand(auth.NewCmdAuth(f))
cmd.AddCommand(browse.NewCmdBrowse(f))
cmd.AddCommand(completion.NewCmdCompletion(f))
diff --git a/pkg/coredata/audit_log_actor_type.go b/pkg/coredata/audit_log_actor_type.go
new file mode 100644
index 000000000..13011b6e9
--- /dev/null
+++ b/pkg/coredata/audit_log_actor_type.go
@@ -0,0 +1,70 @@
+// 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 coredata
+
+import (
+ "database/sql/driver"
+ "fmt"
+)
+
+type AuditLogActorType string
+
+const (
+ AuditLogActorTypeUser AuditLogActorType = "USER"
+ AuditLogActorTypeAPIKey AuditLogActorType = "API_KEY"
+ AuditLogActorTypeSystem AuditLogActorType = "SYSTEM"
+)
+
+func (a AuditLogActorType) String() string {
+ return string(a)
+}
+
+func (a AuditLogActorType) IsValid() bool {
+ switch a {
+ case AuditLogActorTypeUser, AuditLogActorTypeAPIKey, AuditLogActorTypeSystem:
+ return true
+ }
+ return false
+}
+
+func (a AuditLogActorType) MarshalText() ([]byte, error) {
+ return []byte(a.String()), nil
+}
+
+func (a *AuditLogActorType) UnmarshalText(text []byte) error {
+ *a = AuditLogActorType(text)
+ if !a.IsValid() {
+ return fmt.Errorf("%s is not a valid AuditLogActorType", string(text))
+ }
+ return nil
+}
+
+func (a *AuditLogActorType) Scan(value any) error {
+ var s string
+ switch v := value.(type) {
+ case string:
+ s = v
+ case []byte:
+ s = string(v)
+ default:
+ return fmt.Errorf("unsupported type for AuditLogActorType: %T", value)
+ }
+
+ return a.UnmarshalText([]byte(s))
+}
+
+func (a AuditLogActorType) Value() (driver.Value, error) {
+ return a.String(), nil
+}
diff --git a/pkg/coredata/audit_log_entry.go b/pkg/coredata/audit_log_entry.go
new file mode 100644
index 000000000..3277151cd
--- /dev/null
+++ b/pkg/coredata/audit_log_entry.go
@@ -0,0 +1,243 @@
+// 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 coredata
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "go.gearno.de/kit/pg"
+ "go.probo.inc/probo/pkg/gid"
+ "go.probo.inc/probo/pkg/page"
+)
+
+type (
+ AuditLogEntry struct {
+ ID gid.GID `db:"id"`
+ OrganizationID gid.GID `db:"organization_id"`
+ ActorID gid.GID `db:"actor_id"`
+ ActorType AuditLogActorType `db:"actor_type"`
+ Action string `db:"action"`
+ ResourceType string `db:"resource_type"`
+ ResourceID gid.GID `db:"resource_id"`
+ Metadata json.RawMessage `db:"metadata"`
+ CreatedAt time.Time `db:"created_at"`
+ }
+
+ AuditLogEntries []*AuditLogEntry
+)
+
+func (e AuditLogEntry) CursorKey(orderBy AuditLogEntryOrderField) page.CursorKey {
+ switch orderBy {
+ case AuditLogEntryOrderFieldCreatedAt:
+ return page.NewCursorKey(e.ID, e.CreatedAt)
+ }
+
+ panic(fmt.Sprintf("unsupported order by: %s", orderBy))
+}
+
+func (e *AuditLogEntry) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
+ q := `SELECT organization_id FROM audit_log_entries WHERE id = $1 LIMIT 1;`
+
+ var organizationID gid.GID
+ if err := conn.QueryRow(ctx, q, e.ID).Scan(&organizationID); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrResourceNotFound
+ }
+ return nil, fmt.Errorf("cannot query audit log entry authorization attributes: %w", err)
+ }
+
+ return map[string]string{"organization_id": organizationID.String()}, nil
+}
+
+func (e *AuditLogEntry) Insert(
+ ctx context.Context,
+ conn pg.Conn,
+ scope Scoper,
+) error {
+ q := `
+INSERT INTO audit_log_entries (
+ id,
+ tenant_id,
+ organization_id,
+ actor_id,
+ actor_type,
+ action,
+ resource_type,
+ resource_id,
+ metadata,
+ created_at
+)
+VALUES (
+ @id,
+ @tenant_id,
+ @organization_id,
+ @actor_id,
+ @actor_type,
+ @action,
+ @resource_type,
+ @resource_id,
+ @metadata,
+ @created_at
+)
+`
+
+ args := pgx.StrictNamedArgs{
+ "id": e.ID,
+ "tenant_id": scope.GetTenantID(),
+ "organization_id": e.OrganizationID,
+ "actor_id": e.ActorID,
+ "actor_type": e.ActorType,
+ "action": e.Action,
+ "resource_type": e.ResourceType,
+ "resource_id": e.ResourceID,
+ "metadata": e.Metadata,
+ "created_at": e.CreatedAt,
+ }
+
+ _, err := conn.Exec(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot insert audit log entry: %w", err)
+ }
+
+ return nil
+}
+
+func (e *AuditLogEntry) LoadByID(
+ ctx context.Context,
+ conn pg.Conn,
+ scope Scoper,
+ id gid.GID,
+) error {
+ q := `
+SELECT
+ id,
+ organization_id,
+ actor_id,
+ actor_type,
+ action,
+ resource_type,
+ resource_id,
+ metadata,
+ created_at
+FROM
+ audit_log_entries
+WHERE
+ %s
+ AND id = @id
+LIMIT 1;
+`
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"id": id}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query audit log entry: %w", err)
+ }
+
+ entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AuditLogEntry])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrResourceNotFound
+ }
+ return fmt.Errorf("cannot collect audit log entry: %w", err)
+ }
+
+ *e = entry
+ return nil
+}
+
+func (es *AuditLogEntries) LoadAllByOrganizationID(
+ ctx context.Context,
+ conn pg.Conn,
+ scope Scoper,
+ organizationID gid.GID,
+ cursor *page.Cursor[AuditLogEntryOrderField],
+ filter *AuditLogEntryFilter,
+) error {
+ q := `
+SELECT
+ id,
+ organization_id,
+ actor_id,
+ actor_type,
+ action,
+ resource_type,
+ resource_id,
+ metadata,
+ created_at
+FROM
+ audit_log_entries
+WHERE
+ %s
+ AND organization_id = @organization_id
+ AND %s
+ AND %s
+`
+ 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)
+ if err != nil {
+ return fmt.Errorf("cannot query audit log entries: %w", err)
+ }
+
+ entries, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AuditLogEntry])
+ if err != nil {
+ return fmt.Errorf("cannot collect audit log entries: %w", err)
+ }
+
+ *es = entries
+ return nil
+}
+
+func (es *AuditLogEntries) CountByOrganizationID(
+ ctx context.Context,
+ conn pg.Conn,
+ scope Scoper,
+ organizationID gid.GID,
+ filter *AuditLogEntryFilter,
+) (int, error) {
+ q := `
+SELECT COUNT(id)
+FROM audit_log_entries
+WHERE %s
+ AND organization_id = @organization_id
+ AND %s
+`
+ q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"organization_id": organizationID}
+ maps.Copy(args, scope.SQLArguments())
+ maps.Copy(args, filter.SQLArguments())
+
+ var count int
+ if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
+ return 0, fmt.Errorf("cannot count audit log entries: %w", err)
+ }
+
+ return count, nil
+}
diff --git a/pkg/coredata/audit_log_entry_filter.go b/pkg/coredata/audit_log_entry_filter.go
new file mode 100644
index 000000000..3f961811e
--- /dev/null
+++ b/pkg/coredata/audit_log_entry_filter.go
@@ -0,0 +1,107 @@
+// 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 coredata
+
+import (
+ "github.com/jackc/pgx/v5"
+ "go.probo.inc/probo/pkg/gid"
+)
+
+type AuditLogEntryFilter struct {
+ action *string
+ actorID *gid.GID
+ resourceType *string
+ resourceID *gid.GID
+}
+
+func NewAuditLogEntryFilter() *AuditLogEntryFilter {
+ return &AuditLogEntryFilter{}
+}
+
+func (f *AuditLogEntryFilter) WithAction(action string) *AuditLogEntryFilter {
+ f.action = &action
+ return f
+}
+
+func (f *AuditLogEntryFilter) WithActorID(actorID gid.GID) *AuditLogEntryFilter {
+ f.actorID = &actorID
+ return f
+}
+
+func (f *AuditLogEntryFilter) WithResourceType(resourceType string) *AuditLogEntryFilter {
+ f.resourceType = &resourceType
+ return f
+}
+
+func (f *AuditLogEntryFilter) WithResourceID(resourceID gid.GID) *AuditLogEntryFilter {
+ f.resourceID = &resourceID
+ return f
+}
+
+func (f *AuditLogEntryFilter) SQLFragment() string {
+ return `
+(
+ CASE
+ WHEN @filter_action::text IS NOT NULL THEN
+ action = @filter_action::text
+ ELSE TRUE
+ END
+ AND
+ CASE
+ WHEN @filter_actor_id::text IS NOT NULL THEN
+ actor_id = @filter_actor_id::text
+ ELSE TRUE
+ END
+ AND
+ CASE
+ WHEN @filter_resource_type::text IS NOT NULL THEN
+ resource_type = @filter_resource_type::text
+ ELSE TRUE
+ END
+ AND
+ CASE
+ WHEN @filter_resource_id::text IS NOT NULL THEN
+ resource_id = @filter_resource_id::text
+ 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,
+ }
+
+ if f.action != nil {
+ args["filter_action"] = *f.action
+ }
+
+ if f.actorID != nil {
+ args["filter_actor_id"] = *f.actorID
+ }
+
+ if f.resourceType != nil {
+ args["filter_resource_type"] = *f.resourceType
+ }
+
+ if f.resourceID != nil {
+ args["filter_resource_id"] = *f.resourceID
+ }
+
+ return args
+}
diff --git a/pkg/coredata/audit_log_entry_order_field.go b/pkg/coredata/audit_log_entry_order_field.go
new file mode 100644
index 000000000..c4645ee09
--- /dev/null
+++ b/pkg/coredata/audit_log_entry_order_field.go
@@ -0,0 +1,57 @@
+// 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 coredata
+
+import (
+ "fmt"
+)
+
+type AuditLogEntryOrderField string
+
+const (
+ AuditLogEntryOrderFieldCreatedAt AuditLogEntryOrderField = "CREATED_AT"
+)
+
+func (p AuditLogEntryOrderField) Column() string {
+ switch p {
+ case AuditLogEntryOrderFieldCreatedAt:
+ return "created_at"
+ }
+ panic(fmt.Sprintf("unsupported order by: %s", p))
+}
+
+func (p AuditLogEntryOrderField) String() string {
+ return string(p)
+}
+
+func (p AuditLogEntryOrderField) IsValid() bool {
+ switch p {
+ case AuditLogEntryOrderFieldCreatedAt:
+ return true
+ }
+ return false
+}
+
+func (p AuditLogEntryOrderField) MarshalText() ([]byte, error) {
+ return []byte(p.String()), nil
+}
+
+func (p *AuditLogEntryOrderField) UnmarshalText(text []byte) error {
+ *p = AuditLogEntryOrderField(text)
+ if !p.IsValid() {
+ return fmt.Errorf("%s is not a valid AuditLogEntryOrderField", string(text))
+ }
+ return nil
+}
diff --git a/pkg/coredata/audit_log_resource_type.go b/pkg/coredata/audit_log_resource_type.go
new file mode 100644
index 000000000..6e1ac4c49
--- /dev/null
+++ b/pkg/coredata/audit_log_resource_type.go
@@ -0,0 +1,121 @@
+// 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 coredata
+
+// ResourceTypeName returns a human-readable name for an entity type.
+func ResourceTypeName(entityType uint16) string {
+ switch entityType {
+ case OrganizationEntityType:
+ return "Organization"
+ case FrameworkEntityType:
+ return "Framework"
+ case MeasureEntityType:
+ return "Measure"
+ case TaskEntityType:
+ return "Task"
+ case EvidenceEntityType:
+ return "Evidence"
+ case ConnectorEntityType:
+ return "Connector"
+ case VendorRiskAssessmentEntityType:
+ return "VendorRiskAssessment"
+ case VendorEntityType:
+ return "Vendor"
+ case VendorComplianceReportEntityType:
+ return "VendorComplianceReport"
+ case DocumentEntityType:
+ return "Document"
+ case IdentityEntityType:
+ return "Identity"
+ case ControlEntityType:
+ return "Control"
+ case RiskEntityType:
+ return "Risk"
+ case DocumentVersionEntityType:
+ return "DocumentVersion"
+ case DocumentVersionSignatureEntityType:
+ return "DocumentVersionSignature"
+ case AssetEntityType:
+ return "Asset"
+ case DatumEntityType:
+ return "Datum"
+ case AuditEntityType:
+ return "Audit"
+ case ReportEntityType:
+ return "Report"
+ case TrustCenterEntityType:
+ return "TrustCenter"
+ case TrustCenterAccessEntityType:
+ return "TrustCenterAccess"
+ case VendorBusinessAssociateAgreementEntityType:
+ return "VendorBusinessAssociateAgreement"
+ case FileEntityType:
+ return "File"
+ case VendorContactEntityType:
+ return "VendorContact"
+ case VendorDataPrivacyAgreementEntityType:
+ return "VendorDataPrivacyAgreement"
+ case FindingEntityType:
+ return "Finding"
+ case ObligationEntityType:
+ return "Obligation"
+ case VendorServiceEntityType:
+ return "VendorService"
+ case SnapshotEntityType:
+ return "Snapshot"
+ case ProcessingActivityEntityType:
+ return "ProcessingActivity"
+ case TrustCenterReferenceEntityType:
+ return "TrustCenterReference"
+ case TrustCenterDocumentAccessEntityType:
+ return "TrustCenterDocumentAccess"
+ case CustomDomainEntityType:
+ return "CustomDomain"
+ case InvitationEntityType:
+ return "Invitation"
+ case MembershipEntityType:
+ return "Membership"
+ case TrustCenterFileEntityType:
+ return "TrustCenterFile"
+ case MeetingEntityType:
+ return "Meeting"
+ case DataProtectionImpactAssessmentEntityType:
+ return "DataProtectionImpactAssessment"
+ case TransferImpactAssessmentEntityType:
+ return "TransferImpactAssessment"
+ case RightsRequestEntityType:
+ return "RightsRequest"
+ case StateOfApplicabilityEntityType:
+ return "StateOfApplicability"
+ case ApplicabilityStatementEntityType:
+ return "ApplicabilityStatement"
+ case WebhookSubscriptionEntityType:
+ return "WebhookSubscription"
+ case ComplianceFrameworkEntityType:
+ return "ComplianceFramework"
+ case ComplianceExternalURLEntityType:
+ return "ComplianceExternalURL"
+ case MailingListEntityType:
+ return "MailingList"
+ case MailingListSubscriberEntityType:
+ return "MailingListSubscriber"
+ case MailingListUpdateEntityType:
+ return "MailingListUpdate"
+ case AuditLogEntryEntityType:
+ return "AuditLogEntry"
+ default:
+ return "Unknown"
+ }
+}
diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go
index 6e0e462f3..1fcefb412 100644
--- a/pkg/coredata/entity_type_reg.go
+++ b/pkg/coredata/entity_type_reg.go
@@ -91,6 +91,7 @@ const (
MailingListSubscriberEntityType uint16 = 65
MailingListUpdateEntityType uint16 = 66
FindingEntityType uint16 = 67
+ AuditLogEntryEntityType uint16 = 68
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -223,6 +224,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &MailingListSubscriber{ID: id}, true
case MailingListUpdateEntityType:
return &MailingListUpdate{ID: id}, true
+ case AuditLogEntryEntityType:
+ return &AuditLogEntry{ID: id}, true
default:
return nil, false
}
diff --git a/pkg/coredata/migrations/20260320T120000Z.sql b/pkg/coredata/migrations/20260320T120000Z.sql
new file mode 100644
index 000000000..afcd39efd
--- /dev/null
+++ b/pkg/coredata/migrations/20260320T120000Z.sql
@@ -0,0 +1,31 @@
+-- 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.
+
+CREATE TABLE audit_log_entries (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ organization_id TEXT NOT NULL REFERENCES organizations(id),
+ actor_id TEXT NOT NULL,
+ actor_type TEXT NOT NULL,
+ action TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT NOT NULL,
+ metadata JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE INDEX idx_audit_log_entries_organization_id ON audit_log_entries (organization_id);
+CREATE INDEX idx_audit_log_entries_actor_id ON audit_log_entries (actor_id);
+CREATE INDEX idx_audit_log_entries_action ON audit_log_entries (action);
+CREATE INDEX idx_audit_log_entries_created_at ON audit_log_entries (created_at);
diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go
index 370281967..8fb9a9115 100644
--- a/pkg/iam/authorizer.go
+++ b/pkg/iam/authorizer.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2025 Probo Inc .
+// Copyright (c) 2025-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
@@ -16,11 +16,14 @@ package iam
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"maps"
+ "strings"
"time"
+ "go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
@@ -35,11 +38,13 @@ type AuthorizationAttributer interface {
// AuthorizeParams contains the parameters for an authorization request.
type AuthorizeParams struct {
- Principal gid.GID
- Resource gid.GID
- Session *gid.GID
- Action string
- ResourceAttributes map[string]string
+ Principal gid.GID
+ Resource gid.GID
+ Session *gid.GID
+ Action string
+ ResourceAttributes map[string]string
+ DryRun bool
+ SkipAssumptionCheck bool
}
// Authorizer evaluates authorization requests against registered policies.
@@ -47,14 +52,16 @@ type Authorizer struct {
pg *pg.Client
evaluator *policy.Evaluator
policySet *PolicySet
+ logger *log.Logger
}
// NewAuthorizer creates a new Authorizer instance.
-func NewAuthorizer(pgClient *pg.Client) *Authorizer {
+func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer {
return &Authorizer{
pg: pgClient,
evaluator: policy.NewEvaluator(),
policySet: NewPolicySet(),
+ logger: logger,
}
}
@@ -87,7 +94,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
}
// Check whether the viewer is currently assuming the org of the accessed resource
- if membership != nil && params.Session != nil {
+ if membership != nil && params.Session != nil && !params.SkipAssumptionCheck {
if _, err := a.getActiveChildSessionForMembership(
ctx,
conn,
@@ -137,6 +144,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
}
if a.evaluator.Evaluate(req, policies).IsAllowed() {
+ a.recordAuditLog(ctx, conn, params, resourceAttrs)
return nil
}
@@ -258,3 +266,85 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy {
return policies
}
+
+// resourceTypeFromAction extracts the resource type name from an action
+// string. For example, "core:vendor:create" returns "Vendor" and
+// "core:webhook-subscription:delete" returns "WebhookSubscription".
+func resourceTypeFromAction(action string) string {
+ parts := strings.Split(action, ":")
+ if len(parts) < 3 {
+ return "Unknown"
+ }
+
+ segments := strings.Split(parts[1], "-")
+ for i, s := range segments {
+ if len(s) > 0 {
+ segments[i] = strings.ToUpper(s[:1]) + s[1:]
+ }
+ }
+
+ return strings.Join(segments, "")
+}
+
+func (a *Authorizer) recordAuditLog(
+ ctx context.Context,
+ conn pg.Conn,
+ params AuthorizeParams,
+ resourceAttrs map[string]string,
+) {
+ if params.DryRun {
+ return
+ }
+
+ orgIDStr := resourceAttrs["organization_id"]
+ if orgIDStr == "" {
+ return
+ }
+
+ orgID, err := gid.ParseGID(orgIDStr)
+ if err != nil {
+ a.logger.ErrorCtx(ctx, "cannot parse organization id for audit log",
+ log.Error(err),
+ )
+ return
+ }
+
+ var actorType coredata.AuditLogActorType
+ if params.Session != nil {
+ actorType = coredata.AuditLogActorTypeUser
+ } else {
+ actorType = coredata.AuditLogActorTypeAPIKey
+ }
+
+ resourceType := resourceTypeFromAction(params.Action)
+
+ metadata, err := json.Marshal(map[string]any{})
+ if err != nil {
+ a.logger.ErrorCtx(ctx, "cannot marshal audit log metadata",
+ log.Error(err),
+ )
+ return
+ }
+
+ entry := &coredata.AuditLogEntry{
+ ID: gid.New(orgID.TenantID(), coredata.AuditLogEntryEntityType),
+ OrganizationID: orgID,
+ ActorID: params.Principal,
+ ActorType: actorType,
+ Action: params.Action,
+ ResourceType: resourceType,
+ ResourceID: params.Resource,
+ Metadata: metadata,
+ CreatedAt: time.Now(),
+ }
+
+ scope := coredata.NewScope(orgID.TenantID())
+
+ if err := entry.Insert(ctx, conn, scope); err != nil {
+ a.logger.ErrorCtx(ctx, "cannot insert audit log entry",
+ log.Error(err),
+ log.String("action", params.Action),
+ log.String("resource_id", params.Resource.String()),
+ )
+ }
+}
diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go
index 9d883360b..4a44acc4b 100644
--- a/pkg/iam/iam_actions.go
+++ b/pkg/iam/iam_actions.go
@@ -92,4 +92,8 @@ const (
// Connector actions
ActionConnectorGet = "iam:connector:get"
+
+ // Audit log entry actions
+ ActionAuditLogEntryGet = "iam:audit-log-entry:get"
+ ActionAuditLogEntryList = "iam:audit-log-entry:list"
)
diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go
index 39bfc81ac..7d198a4c5 100644
--- a/pkg/iam/iam_policies.go
+++ b/pkg/iam/iam_policies.go
@@ -203,6 +203,14 @@ var IAMOwnerPolicy = policy.NewPolicy(
policy.Allow(ActionSCIMBridgeUpdate).
WithSID("scim-bridge-update-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
+
+ // Full access to audit log entries (scoped to own organization)
+ policy.Allow(
+ ActionAuditLogEntryGet,
+ ActionAuditLogEntryList,
+ ).
+ WithSID("audit-log-entry-access").
+ When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("Full IAM access for organization owners")
@@ -301,6 +309,14 @@ var IAMAdminPolicy = policy.NewPolicy(
ActionSCIMConfigurationDelete,
).
WithSID("deny-scim-management"),
+
+ // Can view audit log entries (scoped to own organization)
+ policy.Allow(
+ ActionAuditLogEntryGet,
+ ActionAuditLogEntryList,
+ ).
+ WithSID("audit-log-entry-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")
@@ -335,5 +351,13 @@ var IAMViewerPolicy = policy.NewPolicy(
policy.Allow(ActionIdentityGet).
WithSID("view-member-identity").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
+
+ // Can view audit log entries (scoped to own organization)
+ policy.Allow(
+ ActionAuditLogEntryGet,
+ ActionAuditLogEntryList,
+ ).
+ WithSID("audit-log-entry-viewer-access").
+ When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("Read-only IAM access for organization viewers")
diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go
index df6f2d3a9..7fd49c791 100644
--- a/pkg/iam/organization_service.go
+++ b/pkg/iam/organization_service.go
@@ -2110,3 +2110,79 @@ func (s OrganizationService) DeleteSCIMBridge(ctx context.Context, organizationI
return nil
}
+
+func (s *OrganizationService) GetAuditLogEntry(
+ ctx context.Context,
+ id gid.GID,
+) (*coredata.AuditLogEntry, error) {
+ var (
+ scope = coredata.NewScopeFromObjectID(id)
+ entry = &coredata.AuditLogEntry{}
+ )
+
+ err := s.pg.WithConn(
+ ctx,
+ func(conn pg.Conn) error {
+ return entry.LoadByID(ctx, conn, scope, id)
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot load audit log entry: %w", err)
+ }
+
+ return entry, nil
+}
+
+func (s *OrganizationService) ListAuditLogEntries(
+ ctx context.Context,
+ organizationID gid.GID,
+ cursor *page.Cursor[coredata.AuditLogEntryOrderField],
+ filter *coredata.AuditLogEntryFilter,
+) (*page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField], error) {
+ var (
+ scope = coredata.NewScopeFromObjectID(organizationID)
+ entries = coredata.AuditLogEntries{}
+ )
+
+ err := s.pg.WithConn(
+ ctx,
+ func(conn pg.Conn) error {
+ if err := entries.LoadAllByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
+ return fmt.Errorf("cannot load audit log entries: %w", err)
+ }
+
+ return nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return page.NewPage(entries, cursor), nil
+}
+
+func (s *OrganizationService) CountAuditLogEntries(
+ ctx context.Context,
+ organizationID gid.GID,
+ filter *coredata.AuditLogEntryFilter,
+) (int, error) {
+ var (
+ scope = coredata.NewScopeFromObjectID(organizationID)
+ count int
+ )
+
+ err := s.pg.WithConn(
+ ctx,
+ func(conn pg.Conn) (err error) {
+ entries := coredata.AuditLogEntries{}
+ count, err = entries.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
+ if err != nil {
+ return fmt.Errorf("cannot count audit log entries: %w", err)
+ }
+
+ return nil
+ },
+ )
+
+ return count, err
+}
diff --git a/pkg/iam/service.go b/pkg/iam/service.go
index a2910e27b..0b78366bd 100644
--- a/pkg/iam/service.go
+++ b/pkg/iam/service.go
@@ -123,7 +123,7 @@ func NewService(
svc.AuthService = NewAuthService(svc)
svc.APIKeyService = NewAPIKeyService(svc)
- svc.Authorizer = NewAuthorizer(pgClient)
+ svc.Authorizer = NewAuthorizer(pgClient, cfg.Logger.Named("authorizer"))
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
diff --git a/pkg/probo/service.go b/pkg/probo/service.go
index da5821328..9c12fb33b 100644
--- a/pkg/probo/service.go
+++ b/pkg/probo/service.go
@@ -284,7 +284,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
logger: s.logger.Named("custom_domains"),
}
tenantService.SlackMessages = s.slack.WithTenant(tenantID).SlackMessages
-
return tenantService
}
diff --git a/pkg/server/api/authz/authorization.go b/pkg/server/api/authz/authorization.go
index c41ced426..80f3bc9b5 100644
--- a/pkg/server/api/authz/authorization.go
+++ b/pkg/server/api/authz/authorization.go
@@ -41,7 +41,13 @@ func WithAttr(key, value string) AuthorizeFuncOption {
// Example: on the viewer memberships page, we're accessing several organization names, but the viewer isn't assuming one yet.
func WithSkipAssumptionCheck() AuthorizeFuncOption {
return func(params *iam.AuthorizeParams) {
- params.Session = nil
+ params.SkipAssumptionCheck = true
+ }
+}
+
+func WithDryRun() AuthorizeFuncOption {
+ return func(params *iam.AuthorizeParams) {
+ params.DryRun = true
}
}
diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go
index 782347c99..ddc367a26 100644
--- a/pkg/server/api/connect/v1/resolver.go
+++ b/pkg/server/api/connect/v1/resolver.go
@@ -65,7 +65,7 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf
}
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
- return r.authorize(ctx, obj.GetID(), action) == nil, nil
+ return r.authorize(ctx, obj.GetID(), action, authz.WithDryRun()) == nil, nil
}
func (r *Resolver) SSOLoginURL(samlConfigID gid.GID) string {
diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql
index 0f18545c4..fb4f68371 100644
--- a/pkg/server/api/connect/v1/schema.graphql
+++ b/pkg/server/api/connect/v1/schema.graphql
@@ -243,6 +243,15 @@ type Organization implements Node {
scimConfiguration: SCIMConfiguration @goField(forceResolver: true)
+ auditLogEntries(
+ first: Int
+ after: CursorKey
+ last: Int
+ before: CursorKey
+ orderBy: AuditLogEntryOrder
+ filter: AuditLogEntryFilter
+ ): AuditLogEntryConnection! @goField(forceResolver: true)
+
viewer: Profile @goField(forceResolver: true)
permission(action: String!): Boolean!
@@ -602,6 +611,79 @@ type SCIMEventEdge {
cursor: CursorKey!
}
+enum AuditLogActorType
+ @goModel(
+ model: "go.probo.inc/probo/pkg/coredata.AuditLogActorType"
+ ) {
+ USER
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeUser"
+ )
+ API_KEY
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeAPIKey"
+ )
+ SYSTEM
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeSystem"
+ )
+}
+
+enum AuditLogEntryOrderField
+ @goModel(
+ model: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderField"
+ ) {
+ CREATED_AT
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderFieldCreatedAt"
+ )
+}
+
+input AuditLogEntryOrder
+ @goModel(
+ model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.AuditLogEntryOrderBy"
+ ) {
+ field: AuditLogEntryOrderField!
+ direction: OrderDirection!
+}
+
+input AuditLogEntryFilter {
+ action: String
+ actorId: ID
+ resourceType: String
+ resourceId: ID
+}
+
+type AuditLogEntry implements Node {
+ id: ID!
+ organization: Organization @goField(forceResolver: true)
+ actorId: ID!
+ actorType: AuditLogActorType!
+ action: String!
+ resourceType: String!
+ resourceId: ID!
+ metadata: String
+ createdAt: Datetime!
+
+ permission(action: String!): Boolean!
+ @goField(forceResolver: true)
+ @session(required: PRESENT)
+}
+
+type AuditLogEntryConnection
+ @goModel(
+ model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.AuditLogEntryConnection"
+ ) {
+ edges: [AuditLogEntryEdge!]!
+ pageInfo: PageInfo!
+ totalCount: Int! @goField(forceResolver: true)
+}
+
+type AuditLogEntryEdge {
+ cursor: CursorKey!
+ node: AuditLogEntry!
+}
+
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
diff --git a/pkg/server/api/connect/v1/types/audit_log_entry.go b/pkg/server/api/connect/v1/types/audit_log_entry.go
new file mode 100644
index 000000000..d0421deec
--- /dev/null
+++ b/pkg/server/api/connect/v1/types/audit_log_entry.go
@@ -0,0 +1,85 @@
+// 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 types
+
+import (
+ "go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/gid"
+ "go.probo.inc/probo/pkg/page"
+)
+
+type (
+ AuditLogEntryOrderBy OrderBy[coredata.AuditLogEntryOrderField]
+
+ AuditLogEntryConnection struct {
+ TotalCount int
+ Edges []*AuditLogEntryEdge
+ PageInfo PageInfo
+
+ Resolver any
+ ParentID gid.GID
+ Filter *coredata.AuditLogEntryFilter
+ }
+)
+
+func NewAuditLogEntryConnection(
+ p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField],
+ resolver any,
+ parentID gid.GID,
+ filter *coredata.AuditLogEntryFilter,
+) *AuditLogEntryConnection {
+ edges := make([]*AuditLogEntryEdge, len(p.Data))
+
+ for i := range edges {
+ edges[i] = NewAuditLogEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
+ }
+
+ return &AuditLogEntryConnection{
+ Edges: edges,
+ PageInfo: *NewPageInfo(p),
+
+ Resolver: resolver,
+ ParentID: parentID,
+ Filter: filter,
+ }
+}
+
+func NewAuditLogEntryEdge(e *coredata.AuditLogEntry, orderBy coredata.AuditLogEntryOrderField) *AuditLogEntryEdge {
+ return &AuditLogEntryEdge{
+ Cursor: e.CursorKey(orderBy),
+ Node: NewAuditLogEntry(e),
+ }
+}
+
+func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
+ var metadata *string
+ if len(e.Metadata) > 0 {
+ metadata = new(string(e.Metadata))
+ }
+
+ return &AuditLogEntry{
+ ID: e.ID,
+ Organization: &Organization{
+ ID: e.OrganizationID,
+ },
+ ActorID: e.ActorID,
+ ActorType: e.ActorType,
+ Action: e.Action,
+ ResourceType: e.ResourceType,
+ ResourceID: e.ResourceID,
+ Metadata: metadata,
+ CreatedAt: e.CreatedAt,
+ }
+}
diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go
index 3099edafe..1b6564f97 100644
--- a/pkg/server/api/connect/v1/v1_resolver.go
+++ b/pkg/server/api/connect/v1/v1_resolver.go
@@ -27,6 +27,32 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils/types/cursor"
)
+// Organization is the resolver for the organization field.
+func (r *auditLogEntryResolver) Organization(ctx context.Context, obj *types.AuditLogEntry) (*types.Organization, error) {
+ return obj.Organization, nil
+}
+
+// Permission is the resolver for the permission field.
+func (r *auditLogEntryResolver) Permission(ctx context.Context, obj *types.AuditLogEntry, action string) (bool, error) {
+ return r.Resolver.Permission(ctx, obj, action)
+}
+
+// TotalCount is the resolver for the totalCount field.
+func (r *auditLogEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AuditLogEntryConnection) (int, error) {
+ filter := coredata.NewAuditLogEntryFilter()
+ if obj.Filter != nil {
+ filter = obj.Filter
+ }
+
+ count, err := r.iam.OrganizationService.CountAuditLogEntries(ctx, obj.ParentID, filter)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot count audit log entries", log.Error(err))
+ return 0, gqlutils.Internal(ctx)
+ }
+
+ return count, nil
+}
+
// Permission is the resolver for the permission field.
func (r *connectorResolver) Permission(ctx context.Context, obj *types.Connector, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -1302,6 +1328,50 @@ func (r *organizationResolver) ScimConfiguration(ctx context.Context, obj *types
return types.NewSCIMConfiguration(config), nil
}
+// AuditLogEntries is the resolver for the auditLogEntries field.
+func (r *organizationResolver) AuditLogEntries(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AuditLogEntryOrderBy, filter *types.AuditLogEntryFilter) (*types.AuditLogEntryConnection, error) {
+ if err := r.authorize(ctx, obj.ID, iam.ActionAuditLogEntryList); err != nil {
+ return nil, err
+ }
+
+ pageOrderBy := page.OrderBy[coredata.AuditLogEntryOrderField]{
+ Field: coredata.AuditLogEntryOrderFieldCreatedAt,
+ Direction: page.OrderDirectionDesc,
+ }
+ if orderBy != nil {
+ pageOrderBy = page.OrderBy[coredata.AuditLogEntryOrderField]{
+ Field: orderBy.Field,
+ Direction: orderBy.Direction,
+ }
+ }
+
+ c := cursor.NewCursor(first, after, last, before, pageOrderBy)
+
+ coredataFilter := coredata.NewAuditLogEntryFilter()
+ if filter != nil {
+ if filter.Action != nil {
+ coredataFilter.WithAction(*filter.Action)
+ }
+ if filter.ActorID != nil {
+ coredataFilter.WithActorID(*filter.ActorID)
+ }
+ if filter.ResourceType != nil {
+ coredataFilter.WithResourceType(*filter.ResourceType)
+ }
+ if filter.ResourceID != nil {
+ coredataFilter.WithResourceID(*filter.ResourceID)
+ }
+ }
+
+ p, err := r.iam.OrganizationService.ListAuditLogEntries(ctx, obj.ID, c, coredataFilter)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot list audit log entries", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ return types.NewAuditLogEntryConnection(p, r, obj.ID, coredataFilter), nil
+}
+
// Viewer is the resolver for the viewer field.
func (r *organizationResolver) Viewer(ctx context.Context, obj *types.Organization) (*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {
@@ -1909,6 +1979,14 @@ func (r *sessionConnectionResolver) TotalCount(ctx context.Context, obj *types.S
return nil, gqlutils.Internal(ctx)
}
+// AuditLogEntry returns schema.AuditLogEntryResolver implementation.
+func (r *Resolver) AuditLogEntry() schema.AuditLogEntryResolver { return &auditLogEntryResolver{r} }
+
+// AuditLogEntryConnection returns schema.AuditLogEntryConnectionResolver implementation.
+func (r *Resolver) AuditLogEntryConnection() schema.AuditLogEntryConnectionResolver {
+ return &auditLogEntryConnectionResolver{r}
+}
+
// Connector returns schema.ConnectorResolver implementation.
func (r *Resolver) Connector() schema.ConnectorResolver { return &connectorResolver{r} }
@@ -1980,6 +2058,8 @@ func (r *Resolver) SessionConnection() schema.SessionConnectionResolver {
return &sessionConnectionResolver{r}
}
+type auditLogEntryResolver struct{ *Resolver }
+type auditLogEntryConnectionResolver struct{ *Resolver }
type connectorResolver struct{ *Resolver }
type identityResolver struct{ *Resolver }
type invitationResolver struct{ *Resolver }
diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go
index ef8310571..bf27f783c 100644
--- a/pkg/server/api/console/v1/resolver.go
+++ b/pkg/server/api/console/v1/resolver.go
@@ -203,5 +203,5 @@ func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *pro
}
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
- return r.authorize(ctx, obj.GetID(), action) == nil, nil
+ return r.authorize(ctx, obj.GetID(), action, authz.WithDryRun()) == nil, nil
}
diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql
index d5f713771..46ed4a174 100644
--- a/pkg/server/api/console/v1/schema.graphql
+++ b/pkg/server/api/console/v1/schema.graphql
@@ -524,6 +524,34 @@ enum WebhookSubscriptionOrderField
)
}
+enum AuditLogActorType
+ @goModel(
+ model: "go.probo.inc/probo/pkg/coredata.AuditLogActorType"
+ ) {
+ USER
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeUser"
+ )
+ API_KEY
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeAPIKey"
+ )
+ SYSTEM
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeSystem"
+ )
+}
+
+enum AuditLogEntryOrderField
+ @goModel(
+ model: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderField"
+ ) {
+ CREATED_AT
+ @goEnum(
+ value: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderFieldCreatedAt"
+ )
+}
+
enum RiskOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.RiskOrderField") {
CREATED_AT
@@ -2018,6 +2046,15 @@ type Organization implements Node {
orderBy: WebhookSubscriptionOrder
): WebhookSubscriptionConnection! @goField(forceResolver: true)
+ auditLogEntries(
+ first: Int
+ after: CursorKey
+ last: Int
+ before: CursorKey
+ orderBy: AuditLogEntryOrder
+ filter: AuditLogEntryFilter
+ ): AuditLogEntryConnection! @goField(forceResolver: true)
+
createdAt: Datetime!
updatedAt: Datetime!
@@ -5956,3 +5993,48 @@ type ElectronicSignatureEvent {
occurredAt: Datetime!
createdAt: Datetime!
}
+
+# Audit Log
+
+input AuditLogEntryOrder
+ @goModel(
+ model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AuditLogEntryOrderBy"
+ ) {
+ field: AuditLogEntryOrderField!
+ direction: OrderDirection!
+}
+
+input AuditLogEntryFilter {
+ action: String
+ actorId: ID
+ resourceType: String
+ resourceId: ID
+}
+
+type AuditLogEntry implements Node {
+ id: ID!
+ organization: Organization @goField(forceResolver: true)
+ actorId: ID!
+ actorType: AuditLogActorType!
+ action: String!
+ resourceType: String!
+ resourceId: ID!
+ metadata: String
+ createdAt: Datetime!
+
+ permission(action: String!): Boolean! @goField(forceResolver: true)
+}
+
+type AuditLogEntryConnection
+ @goModel(
+ model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AuditLogEntryConnection"
+ ) {
+ edges: [AuditLogEntryEdge!]!
+ pageInfo: PageInfo!
+ totalCount: Int! @goField(forceResolver: true)
+}
+
+type AuditLogEntryEdge {
+ cursor: CursorKey!
+ node: AuditLogEntry!
+}
diff --git a/pkg/server/api/console/v1/types/audit_log_entry.go b/pkg/server/api/console/v1/types/audit_log_entry.go
new file mode 100644
index 000000000..d28ba7f6e
--- /dev/null
+++ b/pkg/server/api/console/v1/types/audit_log_entry.go
@@ -0,0 +1,85 @@
+// 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 types
+
+import (
+ "go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/gid"
+ "go.probo.inc/probo/pkg/page"
+)
+
+type (
+ AuditLogEntryOrderBy OrderBy[coredata.AuditLogEntryOrderField]
+
+ AuditLogEntryConnection struct {
+ TotalCount int
+ Edges []*AuditLogEntryEdge
+ PageInfo PageInfo
+
+ Resolver any
+ ParentID gid.GID
+ Filter *coredata.AuditLogEntryFilter
+ }
+)
+
+func NewAuditLogEntryConnection(
+ p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField],
+ parentType any,
+ parentID gid.GID,
+ filter *coredata.AuditLogEntryFilter,
+) *AuditLogEntryConnection {
+ edges := make([]*AuditLogEntryEdge, len(p.Data))
+
+ for i := range edges {
+ edges[i] = NewAuditLogEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
+ }
+
+ return &AuditLogEntryConnection{
+ Edges: edges,
+ PageInfo: *NewPageInfo(p),
+
+ Resolver: parentType,
+ ParentID: parentID,
+ Filter: filter,
+ }
+}
+
+func NewAuditLogEntryEdge(e *coredata.AuditLogEntry, orderBy coredata.AuditLogEntryOrderField) *AuditLogEntryEdge {
+ return &AuditLogEntryEdge{
+ Cursor: e.CursorKey(orderBy),
+ Node: NewAuditLogEntry(e),
+ }
+}
+
+func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
+ var metadata *string
+ if len(e.Metadata) > 0 {
+ metadata = new(string(e.Metadata))
+ }
+
+ return &AuditLogEntry{
+ ID: e.ID,
+ Organization: &Organization{
+ ID: e.OrganizationID,
+ },
+ ActorID: e.ActorID,
+ ActorType: e.ActorType,
+ Action: e.Action,
+ ResourceType: e.ResourceType,
+ ResourceID: e.ResourceID,
+ Metadata: metadata,
+ CreatedAt: e.CreatedAt,
+ }
+}
diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go
index c093d1282..0eeeadb3f 100644
--- a/pkg/server/api/console/v1/v1_resolver.go
+++ b/pkg/server/api/console/v1/v1_resolver.go
@@ -407,6 +407,32 @@ func (r *auditConnectionResolver) TotalCount(ctx context.Context, obj *types.Aud
}
}
+// Organization is the resolver for the organization field.
+func (r *auditLogEntryResolver) Organization(ctx context.Context, obj *types.AuditLogEntry) (*types.Organization, error) {
+ return obj.Organization, nil
+}
+
+// Permission is the resolver for the permission field.
+func (r *auditLogEntryResolver) Permission(ctx context.Context, obj *types.AuditLogEntry, action string) (bool, error) {
+ return r.Resolver.Permission(ctx, obj, action)
+}
+
+// TotalCount is the resolver for the totalCount field.
+func (r *auditLogEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AuditLogEntryConnection) (int, error) {
+ filter := coredata.NewAuditLogEntryFilter()
+ if obj.Filter != nil {
+ filter = obj.Filter
+ }
+
+ count, err := r.iam.OrganizationService.CountAuditLogEntries(ctx, obj.ParentID, filter)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot count audit log entries", log.Error(err))
+ return 0, gqlutils.Internal(ctx)
+ }
+
+ return count, nil
+}
+
// Permission is the resolver for the permission field.
func (r *complianceExternalURLResolver) Permission(ctx context.Context, obj *types.ComplianceExternalURL, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -7246,6 +7272,50 @@ func (r *organizationResolver) WebhookSubscriptions(ctx context.Context, obj *ty
return types.NewWebhookSubscriptionConnection(page, r, obj.ID), nil
}
+// AuditLogEntries is the resolver for the auditLogEntries field.
+func (r *organizationResolver) AuditLogEntries(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AuditLogEntryOrderBy, filter *types.AuditLogEntryFilter) (*types.AuditLogEntryConnection, error) {
+ if err := r.authorize(ctx, obj.ID, iam.ActionAuditLogEntryList); err != nil {
+ return nil, err
+ }
+
+ pageOrderBy := page.OrderBy[coredata.AuditLogEntryOrderField]{
+ Field: coredata.AuditLogEntryOrderFieldCreatedAt,
+ Direction: page.OrderDirectionDesc,
+ }
+ if orderBy != nil {
+ pageOrderBy = page.OrderBy[coredata.AuditLogEntryOrderField]{
+ Field: orderBy.Field,
+ Direction: orderBy.Direction,
+ }
+ }
+
+ cursor := types.NewCursor(first, after, last, before, pageOrderBy)
+
+ coredataFilter := coredata.NewAuditLogEntryFilter()
+ if filter != nil {
+ if filter.Action != nil {
+ coredataFilter.WithAction(*filter.Action)
+ }
+ if filter.ActorID != nil {
+ coredataFilter.WithActorID(*filter.ActorID)
+ }
+ if filter.ResourceType != nil {
+ coredataFilter.WithResourceType(*filter.ResourceType)
+ }
+ if filter.ResourceID != nil {
+ coredataFilter.WithResourceID(*filter.ResourceID)
+ }
+ }
+
+ p, err := r.iam.OrganizationService.ListAuditLogEntries(ctx, obj.ID, cursor, coredataFilter)
+ if err != nil {
+ r.logger.ErrorCtx(ctx, "cannot list audit log entries", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ return types.NewAuditLogEntryConnection(p, r, obj.ID, coredataFilter), nil
+}
+
// Permission is the resolver for the permission field.
func (r *organizationResolver) Permission(ctx context.Context, obj *types.Organization, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -9752,6 +9822,14 @@ func (r *Resolver) AuditConnection() schema.AuditConnectionResolver {
return &auditConnectionResolver{r}
}
+// AuditLogEntry returns schema.AuditLogEntryResolver implementation.
+func (r *Resolver) AuditLogEntry() schema.AuditLogEntryResolver { return &auditLogEntryResolver{r} }
+
+// AuditLogEntryConnection returns schema.AuditLogEntryConnectionResolver implementation.
+func (r *Resolver) AuditLogEntryConnection() schema.AuditLogEntryConnectionResolver {
+ return &auditLogEntryConnectionResolver{r}
+}
+
// ComplianceExternalURL returns schema.ComplianceExternalURLResolver implementation.
func (r *Resolver) ComplianceExternalURL() schema.ComplianceExternalURLResolver {
return &complianceExternalURLResolver{r}
@@ -10067,6 +10145,8 @@ type assetResolver struct{ *Resolver }
type assetConnectionResolver struct{ *Resolver }
type auditResolver struct{ *Resolver }
type auditConnectionResolver struct{ *Resolver }
+type auditLogEntryResolver struct{ *Resolver }
+type auditLogEntryConnectionResolver struct{ *Resolver }
type complianceExternalURLResolver struct{ *Resolver }
type complianceFrameworkResolver struct{ *Resolver }
type controlResolver struct{ *Resolver }
diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go
index c9a1f9f32..41cb1cb5c 100644
--- a/pkg/server/api/mcp/v1/schema.resolvers.go
+++ b/pkg/server/api/mcp/v1/schema.resolvers.go
@@ -3312,3 +3312,50 @@ func (r *Resolver) GetAuditReportUrlTool(ctx context.Context, req *mcp.CallToolR
URL: *url,
}, nil
}
+
+func (r *Resolver) ListAuditLogEntriesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAuditLogEntriesInput) (*mcp.CallToolResult, types.ListAuditLogEntriesOutput, error) {
+ r.MustAuthorize(ctx, input.OrganizationID, iam.ActionAuditLogEntryList)
+
+ pageOrderBy := page.OrderBy[coredata.AuditLogEntryOrderField]{
+ Field: coredata.AuditLogEntryOrderFieldCreatedAt,
+ Direction: page.OrderDirectionDesc,
+ }
+
+ cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
+
+ filter := coredata.NewAuditLogEntryFilter()
+ if input.Filter != nil {
+ if input.Filter.Action != nil {
+ filter.WithAction(*input.Filter.Action)
+ }
+ if input.Filter.ActorID != nil {
+ filter.WithActorID(*input.Filter.ActorID)
+ }
+ if input.Filter.ResourceType != nil {
+ filter.WithResourceType(*input.Filter.ResourceType)
+ }
+ if input.Filter.ResourceID != nil {
+ filter.WithResourceID(*input.Filter.ResourceID)
+ }
+ }
+
+ p, err := r.iamSvc.OrganizationService.ListAuditLogEntries(ctx, input.OrganizationID, cursor, filter)
+ if err != nil {
+ panic(fmt.Errorf("cannot list audit log entries: %w", err))
+ }
+
+ return nil, types.NewListAuditLogEntriesOutput(p), nil
+}
+
+func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetAuditLogEntryInput) (*mcp.CallToolResult, types.GetAuditLogEntryOutput, error) {
+ r.MustAuthorize(ctx, input.ID, iam.ActionAuditLogEntryGet)
+
+ entry, err := r.iamSvc.OrganizationService.GetAuditLogEntry(ctx, input.ID)
+ if err != nil {
+ panic(fmt.Errorf("cannot get audit log entry: %w", err))
+ }
+
+ return nil, types.GetAuditLogEntryOutput{
+ AuditLogEntry: types.NewAuditLogEntry(entry),
+ }, nil
+}
diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml
index 8d38fef4b..8ce6f07b6 100644
--- a/pkg/server/api/mcp/v1/specification.yaml
+++ b/pkg/server/api/mcp/v1/specification.yaml
@@ -6428,6 +6428,110 @@ components:
organization_context:
$ref: "#/components/schemas/OrganizationContext"
+ GetAuditLogEntryInput:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ $ref: "#/components/schemas/GID"
+ description: Audit log entry ID
+
+ GetAuditLogEntryOutput:
+ type: object
+ required:
+ - audit_log_entry
+ properties:
+ audit_log_entry:
+ $ref: "#/components/schemas/AuditLogEntry"
+
+ ListAuditLogEntriesInput:
+ type: object
+ required:
+ - organization_id
+ properties:
+ organization_id:
+ $ref: "#/components/schemas/GID"
+ description: Organization ID
+ size:
+ type: integer
+ description: Page size
+ cursor:
+ $ref: "#/components/schemas/CursorKey"
+ description: Page cursor
+ filter:
+ type: object
+ properties:
+ action:
+ type: string
+ description: Filter by action (e.g. "core:vendor:create")
+ actor_id:
+ $ref: "#/components/schemas/GID"
+ description: Filter by actor ID
+ resource_type:
+ type: string
+ description: Filter by resource type (e.g. "Vendor")
+ resource_id:
+ $ref: "#/components/schemas/GID"
+ description: Filter by resource ID
+
+ ListAuditLogEntriesOutput:
+ type: object
+ required:
+ - audit_log_entries
+ properties:
+ next_cursor:
+ $ref: "#/components/schemas/CursorKey"
+ description: Next cursor
+ audit_log_entries:
+ type: array
+ items:
+ $ref: "#/components/schemas/AuditLogEntry"
+
+ AuditLogEntry:
+ type: object
+ required:
+ - id
+ - organization_id
+ - actor_id
+ - actor_type
+ - action
+ - resource_type
+ - resource_id
+ - created_at
+ properties:
+ id:
+ $ref: "#/components/schemas/GID"
+ description: Audit log entry ID
+ organization_id:
+ $ref: "#/components/schemas/GID"
+ description: Organization ID
+ actor_id:
+ $ref: "#/components/schemas/GID"
+ description: ID of the actor who performed the action
+ actor_type:
+ type: string
+ enum: [USER, API_KEY, SYSTEM]
+ go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AuditLogActorType
+ description: Type of actor
+ action:
+ type: string
+ description: Action performed (e.g. "core:vendor:create")
+ resource_type:
+ type: string
+ description: Type of resource affected (e.g. "Vendor")
+ resource_id:
+ $ref: "#/components/schemas/GID"
+ description: ID of the affected resource
+ metadata:
+ type: object
+ description: Additional metadata about the action
+ created_at:
+ type: string
+ format: date-time
+ go.probo.inc/mcpgen/type: time.Time
+ description: When the action was performed
+
tools:
- name: listOrganizations
description: List all organizations the user has access to
@@ -7572,3 +7676,21 @@ tools:
$ref: "#/components/schemas/UpdateOrganizationContextInput"
outputSchema:
$ref: "#/components/schemas/UpdateOrganizationContextOutput"
+ - name: getAuditLogEntry
+ description: Get an audit log entry by ID
+ hints:
+ readonly: true
+ idempotent: true
+ inputSchema:
+ $ref: "#/components/schemas/GetAuditLogEntryInput"
+ outputSchema:
+ $ref: "#/components/schemas/GetAuditLogEntryOutput"
+ - name: listAuditLogEntries
+ description: List audit log entries for the organization. Audit log entries record write actions (create, update, delete) performed by users and API keys.
+ hints:
+ readonly: true
+ idempotent: true
+ inputSchema:
+ $ref: "#/components/schemas/ListAuditLogEntriesInput"
+ outputSchema:
+ $ref: "#/components/schemas/ListAuditLogEntriesOutput"
diff --git a/pkg/server/api/mcp/v1/types/audit_log_entry.go b/pkg/server/api/mcp/v1/types/audit_log_entry.go
new file mode 100644
index 000000000..56aed32be
--- /dev/null
+++ b/pkg/server/api/mcp/v1/types/audit_log_entry.go
@@ -0,0 +1,51 @@
+// 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 types
+
+import (
+ "go.probo.inc/probo/pkg/coredata"
+ "go.probo.inc/probo/pkg/page"
+)
+
+func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
+ return &AuditLogEntry{
+ ID: e.ID,
+ OrganizationID: e.OrganizationID,
+ ActorID: e.ActorID,
+ ActorType: AuditLogEntryActorType(e.ActorType),
+ Action: e.Action,
+ ResourceType: e.ResourceType,
+ ResourceID: e.ResourceID,
+ CreatedAt: e.CreatedAt,
+ }
+}
+
+func NewListAuditLogEntriesOutput(p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField]) ListAuditLogEntriesOutput {
+ entries := make([]*AuditLogEntry, 0, len(p.Data))
+ for _, e := range p.Data {
+ entries = append(entries, NewAuditLogEntry(e))
+ }
+
+ var nextCursor *page.CursorKey
+ if len(p.Data) > 0 {
+ cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field)
+ nextCursor = &cursorKey
+ }
+
+ return ListAuditLogEntriesOutput{
+ NextCursor: nextCursor,
+ AuditLogEntries: entries,
+ }
+}