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,

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 <command>",
Short: "Manage audit log entries",
}
cmd.AddCommand(list.NewCmdList(f))
cmd.AddCommand(view.NewCmdView(f))
return cmd
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 <id>",
Short: "View an audit log entry",
Example: ` prb audit-log view <audit-log-entry-id>`,
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
}

View File

@@ -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))

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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"
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,31 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- 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);

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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()),
)
}
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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!

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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,
}
}

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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!
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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,
}
}

View File

@@ -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 }

View File

@@ -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
}

View File

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

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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,
}
}