From 7b320916af675cf981ac1c4a31681d712862f4da Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 20 Mar 2026 15:42:11 +0100 Subject: [PATCH] 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 --- .../settings/AuditLogSettingsPage.tsx | 233 +++++++++++++ .../settings/AuditLogSettingsPageLoader.tsx | 37 +++ .../organizations/settings/SettingsLayout.tsx | 5 + apps/console/src/routes.tsx | 7 + e2e/console/audit_log_test.go | 306 ++++++++++++++++++ pkg/cmd/auditlog/audit_log.go | 34 ++ pkg/cmd/auditlog/list/list.go | 227 +++++++++++++ pkg/cmd/auditlog/view/view.go | 137 ++++++++ pkg/cmd/root/root.go | 2 + pkg/coredata/audit_log_actor_type.go | 70 ++++ pkg/coredata/audit_log_entry.go | 243 ++++++++++++++ pkg/coredata/audit_log_entry_filter.go | 107 ++++++ pkg/coredata/audit_log_entry_order_field.go | 57 ++++ pkg/coredata/audit_log_resource_type.go | 121 +++++++ pkg/coredata/entity_type_reg.go | 3 + pkg/coredata/migrations/20260320T120000Z.sql | 31 ++ pkg/iam/authorizer.go | 106 +++++- pkg/iam/iam_actions.go | 4 + pkg/iam/iam_policies.go | 24 ++ pkg/iam/organization_service.go | 76 +++++ pkg/iam/service.go | 2 +- pkg/probo/service.go | 1 - pkg/server/api/authz/authorization.go | 8 +- pkg/server/api/connect/v1/resolver.go | 2 +- pkg/server/api/connect/v1/schema.graphql | 82 +++++ .../api/connect/v1/types/audit_log_entry.go | 85 +++++ pkg/server/api/connect/v1/v1_resolver.go | 80 +++++ pkg/server/api/console/v1/resolver.go | 2 +- pkg/server/api/console/v1/schema.graphql | 82 +++++ .../api/console/v1/types/audit_log_entry.go | 85 +++++ pkg/server/api/console/v1/v1_resolver.go | 80 +++++ pkg/server/api/mcp/v1/schema.resolvers.go | 47 +++ pkg/server/api/mcp/v1/specification.yaml | 122 +++++++ .../api/mcp/v1/types/audit_log_entry.go | 51 +++ 34 files changed, 2546 insertions(+), 13 deletions(-) create mode 100644 apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPage.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/AuditLogSettingsPageLoader.tsx create mode 100644 e2e/console/audit_log_test.go create mode 100644 pkg/cmd/auditlog/audit_log.go create mode 100644 pkg/cmd/auditlog/list/list.go create mode 100644 pkg/cmd/auditlog/view/view.go create mode 100644 pkg/coredata/audit_log_actor_type.go create mode 100644 pkg/coredata/audit_log_entry.go create mode 100644 pkg/coredata/audit_log_entry_filter.go create mode 100644 pkg/coredata/audit_log_entry_order_field.go create mode 100644 pkg/coredata/audit_log_resource_type.go create mode 100644 pkg/coredata/migrations/20260320T120000Z.sql create mode 100644 pkg/server/api/connect/v1/types/audit_log_entry.go create mode 100644 pkg/server/api/console/v1/types/audit_log_entry.go create mode 100644 pkg/server/api/mcp/v1/types/audit_log_entry.go 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")}`} +

+ + + + + + + + + + + {entries.map((entry) => ( + + ))} + +
{__("Date")}{__("Actor")}{__("Action")}{__("Resource")}
+ {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, + } +}