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, + } +}