Add audit log feature for recording all actions
Adds audit logging that records all authorized actions performed by users and API keys. The audit log is automatically populated whenever the authorizer approves an action, and is queryable via GraphQL, MCP, and CLI interfaces. Permission checks are excluded via a dry-run flag to avoid phantom entries on page loads. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { formatDate } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
IconChevronDown,
|
||||
Spinner,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Table,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
graphql,
|
||||
useFragment,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
|
||||
import type { AuditLogSettingsPageQuery } from "#/__generated__/iam/AuditLogSettingsPageQuery.graphql";
|
||||
import type { AuditLogSettingsPageFragment$key } from "#/__generated__/iam/AuditLogSettingsPageFragment.graphql";
|
||||
import type { AuditLogSettingsPageRefetchQuery } from "#/__generated__/iam/AuditLogSettingsPageRefetchQuery.graphql";
|
||||
import type { AuditLogSettingsPageRowFragment$key } from "#/__generated__/iam/AuditLogSettingsPageRowFragment.graphql";
|
||||
|
||||
export const auditLogSettingsPageQuery = graphql`
|
||||
query AuditLogSettingsPageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) @required(action: THROW) {
|
||||
__typename
|
||||
... on Organization {
|
||||
...AuditLogSettingsPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const auditLogSettingsPageFragment = graphql`
|
||||
fragment AuditLogSettingsPageFragment on Organization
|
||||
@refetchable(queryName: "AuditLogSettingsPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
after: { type: "CursorKey" }
|
||||
) {
|
||||
auditLogEntries(
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
) @connection(key: "AuditLogSettingsPage_auditLogEntries") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...AuditLogSettingsPageRowFragment
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const auditLogEntryRowFragment = graphql`
|
||||
fragment AuditLogSettingsPageRowFragment on AuditLogEntry {
|
||||
id
|
||||
actorId
|
||||
actorType
|
||||
action
|
||||
resourceType
|
||||
resourceId
|
||||
createdAt
|
||||
}
|
||||
`;
|
||||
|
||||
function ActorTypeBadge({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case "USER":
|
||||
return <Badge variant="info" size="sm">{type}</Badge>;
|
||||
case "API_KEY":
|
||||
return <Badge variant="warning" size="sm">{type}</Badge>;
|
||||
case "SYSTEM":
|
||||
return <Badge variant="neutral" size="sm">{type}</Badge>;
|
||||
default:
|
||||
return <Badge size="sm">{type}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function ActionBadge({ action }: { action: string }) {
|
||||
const parts = action.split(":");
|
||||
const verb = parts[parts.length - 1];
|
||||
|
||||
if (
|
||||
verb === "create" ||
|
||||
verb === "upload" ||
|
||||
verb === "import" ||
|
||||
verb === "publish"
|
||||
) {
|
||||
return <Badge variant="success" size="sm">{action}</Badge>;
|
||||
}
|
||||
if (verb === "delete" || verb === "archive") {
|
||||
return <Badge variant="danger" size="sm">{action}</Badge>;
|
||||
}
|
||||
if (
|
||||
verb === "update" ||
|
||||
verb === "assign" ||
|
||||
verb === "unassign" ||
|
||||
verb === "unarchive"
|
||||
) {
|
||||
return <Badge variant="warning" size="sm">{action}</Badge>;
|
||||
}
|
||||
if (verb === "get" || verb === "list") {
|
||||
return <Badge variant="neutral" size="sm">{action}</Badge>;
|
||||
}
|
||||
return <Badge size="sm">{action}</Badge>;
|
||||
}
|
||||
|
||||
function AuditLogEntryRow({
|
||||
entryKey,
|
||||
}: {
|
||||
entryKey: AuditLogSettingsPageRowFragment$key;
|
||||
}) {
|
||||
const entry = useFragment(auditLogEntryRowFragment, entryKey);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<span className="text-sm text-txt-secondary whitespace-nowrap">
|
||||
{formatDate(entry.createdAt)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActorTypeBadge type={entry.actorType} />
|
||||
<span className="text-sm font-mono text-txt-secondary truncate max-w-48">
|
||||
{entry.actorId}
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionBadge action={entry.action} />
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{entry.resourceType}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-txt-tertiary truncate max-w-48">
|
||||
{entry.resourceId}
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditLogSettingsPage(props: {
|
||||
queryRef: PreloadedQuery<AuditLogSettingsPageQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const { organization } = usePreloadedQuery(
|
||||
auditLogSettingsPageQuery,
|
||||
props.queryRef,
|
||||
);
|
||||
if (organization.__typename === "%other") {
|
||||
throw new Error("Relay node is not an organization");
|
||||
}
|
||||
|
||||
const { data, loadNext, hasNext, isLoadingNext } =
|
||||
usePaginationFragment<
|
||||
AuditLogSettingsPageRefetchQuery,
|
||||
AuditLogSettingsPageFragment$key
|
||||
>(auditLogSettingsPageFragment, organization);
|
||||
|
||||
const entries = data?.auditLogEntries?.edges?.map((e) => e.node) ?? [];
|
||||
const totalCount = data?.auditLogEntries?.totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("Audit Log")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"A record of all actions performed in your organization. Entries are immutable and cannot be modified or deleted.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("No audit log entries yet.")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{`${__("Showing")} ${entries.length} ${__("of")} ${totalCount} ${__("entries")}`}
|
||||
</p>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Date")}</Th>
|
||||
<Th>{__("Actor")}</Th>
|
||||
<Th>{__("Action")}</Th>
|
||||
<Th>{__("Resource")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{entries.map((entry) => (
|
||||
<AuditLogEntryRow key={entry.id} entryKey={entry} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{hasNext && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => loadNext(50)}
|
||||
className="mx-auto"
|
||||
disabled={isLoadingNext}
|
||||
icon={isLoadingNext ? Spinner : IconChevronDown}
|
||||
>
|
||||
{__("Show more")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { AuditLogSettingsPageQuery } from "#/__generated__/iam/AuditLogSettingsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import {
|
||||
AuditLogSettingsPage,
|
||||
auditLogSettingsPageQuery,
|
||||
} from "#/pages/iam/organizations/settings/AuditLogSettingsPage";
|
||||
import { IAMRelayProvider } from "#/providers/IAMRelayProvider";
|
||||
|
||||
function AuditLogSettingsPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<AuditLogSettingsPageQuery>(
|
||||
auditLogSettingsPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({
|
||||
organizationId,
|
||||
});
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AuditLogSettingsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function AuditLogSettingsPageLoader() {
|
||||
return (
|
||||
<IAMRelayProvider>
|
||||
<AuditLogSettingsPageQueryLoader />
|
||||
</IAMRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
IconKey,
|
||||
IconListStack,
|
||||
IconLock,
|
||||
IconSend,
|
||||
IconSettingsGear2,
|
||||
@@ -37,6 +38,10 @@ export default function SettingsLayout() {
|
||||
<IconSend size={20} />
|
||||
{__("Webhooks")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/settings/audit-log`}>
|
||||
<IconListStack size={20} />
|
||||
{__("Audit Log")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
|
||||
@@ -210,6 +210,13 @@ const routes = [
|
||||
import("./pages/iam/organizations/settings/WebhooksSettingsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "audit-log",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/iam/organizations/settings/AuditLogSettingsPageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
...peopleRoutes,
|
||||
|
||||
306
e2e/console/audit_log_test.go
Normal file
306
e2e/console/audit_log_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestAuditLog_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor to generate an audit log entry.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("AuditVendor")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
auditLogEntries(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
actorId
|
||||
actorType
|
||||
action
|
||||
resourceType
|
||||
resourceId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorType string `json:"actorType"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
|
||||
// Find the vendor create entry.
|
||||
found := false
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
if edge.Node.Action == "core:vendor:create" {
|
||||
found = true
|
||||
assert.Equal(t, "USER", edge.Node.ActorType)
|
||||
assert.Equal(t, "Vendor", edge.Node.ResourceType)
|
||||
assert.NotEmpty(t, edge.Node.ActorID)
|
||||
assert.NotEmpty(t, edge.Node.ResourceID)
|
||||
assert.NotEmpty(t, edge.Node.CreatedAt)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected to find core:vendor:create audit log entry")
|
||||
}
|
||||
|
||||
func TestAuditLog_Filter(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create different resources to generate different audit log entries.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("FilterVendor")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!, $filter: AuditLogEntryFilter) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
auditLogEntries(first: 50, filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
action
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
t.Run("filter by action", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
"filter": map[string]any{"action": "core:vendor:create"},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
assert.Equal(t, "core:vendor:create", edge.Node.Action)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter by resource type", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
"filter": map[string]any{"resourceType": "Vendor"},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
assert.Equal(t, "Vendor", edge.Node.ResourceType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuditLog_RBAC(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Generate an audit log entry.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("RBACVendor")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
auditLogEntries(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
action
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
t.Run("viewer can list audit log entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := viewer.Execute(query, map[string]any{
|
||||
"orgId": viewer.GetOrganizationID().String(),
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
})
|
||||
|
||||
t.Run("admin can list audit log entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := admin.Execute(query, map[string]any{
|
||||
"orgId": admin.GetOrganizationID().String(),
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuditLog_TenantIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor in org1 to generate audit log entries.
|
||||
factory.NewVendor(org1Owner).WithName(factory.SafeName("IsoVendor")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
auditLogEntries(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
action
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// org2 should not see org1's audit log entries about vendors.
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := org2Owner.Execute(query, map[string]any{
|
||||
"orgId": org2Owner.GetOrganizationID().String(),
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
// org2 may have its own audit log entries (from user/org creation),
|
||||
// but should never see org1's vendor entries.
|
||||
if edge.Node.ResourceType == "Vendor" {
|
||||
t.Fatalf("org2 should not see org1's vendor audit log entries, but found: %s", edge.Node.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
pkg/cmd/auditlog/audit_log.go
Normal file
34
pkg/cmd/auditlog/audit_log.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cmd/auditlog/list"
|
||||
"go.probo.inc/probo/pkg/cmd/auditlog/view"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
func NewCmdAuditLog(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit-log <command>",
|
||||
Short: "Manage audit log entries",
|
||||
}
|
||||
|
||||
cmd.AddCommand(list.NewCmdList(f))
|
||||
cmd.AddCommand(view.NewCmdView(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
227
pkg/cmd/auditlog/list/list.go
Normal file
227
pkg/cmd/auditlog/list/list.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package list
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
const listQuery = `
|
||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AuditLogEntryOrder, $filter: AuditLogEntryFilter) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on Organization {
|
||||
auditLogEntries(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
actorId
|
||||
actorType
|
||||
action
|
||||
resourceType
|
||||
resourceId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type auditLogEntry struct {
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorType string `json:"actorType"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagAction string
|
||||
flagActorID string
|
||||
flagResourceType string
|
||||
flagResourceID string
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List audit log entries",
|
||||
Aliases: []string{"ls"},
|
||||
Example: ` prb audit-log list
|
||||
prb audit-log list --action core:vendor:create
|
||||
prb audit-log list --resource-type Vendor --limit 50`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, hc, err := cfg.DefaultHost()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewClient(
|
||||
host,
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
)
|
||||
|
||||
if flagOrg == "" {
|
||||
flagOrg = hc.Organization
|
||||
}
|
||||
|
||||
if flagOrg == "" {
|
||||
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
|
||||
}
|
||||
|
||||
variables := map[string]any{
|
||||
"id": flagOrg,
|
||||
}
|
||||
|
||||
if flagOrderBy != "" {
|
||||
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil {
|
||||
return err
|
||||
}
|
||||
variables["orderBy"] = map[string]any{
|
||||
"field": flagOrderBy,
|
||||
"direction": flagOrderDir,
|
||||
}
|
||||
}
|
||||
|
||||
filter := map[string]any{}
|
||||
if flagAction != "" {
|
||||
filter["action"] = flagAction
|
||||
}
|
||||
if flagActorID != "" {
|
||||
filter["actorId"] = flagActorID
|
||||
}
|
||||
if flagResourceType != "" {
|
||||
filter["resourceType"] = flagResourceType
|
||||
}
|
||||
if flagResourceID != "" {
|
||||
filter["resourceId"] = flagResourceID
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
variables["filter"] = filter
|
||||
}
|
||||
|
||||
entries, totalCount, err := api.Paginate(
|
||||
client,
|
||||
listQuery,
|
||||
variables,
|
||||
flagLimit,
|
||||
func(data json.RawMessage) (*api.Connection[auditLogEntry], error) {
|
||||
var resp struct {
|
||||
Node *struct {
|
||||
Typename string `json:"__typename"`
|
||||
AuditLogEntries api.Connection[auditLogEntry] `json:"auditLogEntries"`
|
||||
} `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Node == nil {
|
||||
return nil, fmt.Errorf("organization %s not found", flagOrg)
|
||||
}
|
||||
if resp.Node.Typename != "Organization" {
|
||||
return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
|
||||
}
|
||||
return &resp.Node.AuditLogEntries, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
if entries == nil {
|
||||
entries = []auditLogEntry{}
|
||||
}
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, entries)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
_, _ = fmt.Fprintln(f.IOStreams.Out, "No audit log entries found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
rows = append(rows, []string{
|
||||
e.ID,
|
||||
e.ActorType,
|
||||
e.ActorID,
|
||||
e.Action,
|
||||
e.ResourceType,
|
||||
e.ResourceID,
|
||||
cmdutil.FormatTime(e.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
t := cmdutil.NewTable("ID", "ACTOR TYPE", "ACTOR", "ACTION", "RESOURCE TYPE", "RESOURCE", "CREATED").Rows(rows...)
|
||||
|
||||
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
|
||||
|
||||
if totalCount > len(entries) {
|
||||
_, _ = fmt.Fprintf(
|
||||
f.IOStreams.ErrOut,
|
||||
"\nShowing %d of %d audit log entries\n",
|
||||
len(entries),
|
||||
totalCount,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
|
||||
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list")
|
||||
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
|
||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||
cmd.Flags().StringVar(&flagAction, "action", "", "Filter by action (e.g. core:vendor:create)")
|
||||
cmd.Flags().StringVar(&flagActorID, "actor-id", "", "Filter by actor ID")
|
||||
cmd.Flags().StringVar(&flagResourceType, "resource-type", "", "Filter by resource type (e.g. Vendor)")
|
||||
cmd.Flags().StringVar(&flagResourceID, "resource-id", "", "Filter by resource ID")
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
137
pkg/cmd/auditlog/view/view.go
Normal file
137
pkg/cmd/auditlog/view/view.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package view
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
"go.probo.inc/probo/pkg/cli/api"
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
)
|
||||
|
||||
const viewQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on AuditLogEntry {
|
||||
id
|
||||
actorId
|
||||
actorType
|
||||
action
|
||||
resourceType
|
||||
resourceId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type viewResponse struct {
|
||||
Node *struct {
|
||||
Typename string `json:"__typename"`
|
||||
ID string `json:"id"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorType string `json:"actorType"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
var flagOutput *string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <id>",
|
||||
Short: "View an audit log entry",
|
||||
Example: ` prb audit-log view <audit-log-entry-id>`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, hc, err := cfg.DefaultHost()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := api.NewClient(
|
||||
host,
|
||||
hc.Token,
|
||||
"/api/console/v1/graphql",
|
||||
cfg.HTTPTimeoutDuration(),
|
||||
)
|
||||
|
||||
data, err := client.Do(
|
||||
viewQuery,
|
||||
map[string]any{"id": args[0]},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp viewResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Node == nil {
|
||||
return fmt.Errorf("audit log entry %s not found", args[0])
|
||||
}
|
||||
|
||||
if resp.Node.Typename != "AuditLogEntry" {
|
||||
return fmt.Errorf("expected AuditLogEntry node, got %s", resp.Node.Typename)
|
||||
}
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node)
|
||||
}
|
||||
|
||||
e := resp.Node
|
||||
out := f.IOStreams.Out
|
||||
|
||||
bold := lipgloss.NewStyle().Bold(true)
|
||||
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
|
||||
|
||||
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render("Audit Log Entry"))
|
||||
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), e.ID)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Action:"), e.Action)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Actor Type:"), e.ActorType)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Actor ID:"), e.ActorID)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Resource Type:"), e.ResourceType)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Resource ID:"), e.ResourceID)
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(e.CreatedAt))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
70
pkg/coredata/audit_log_actor_type.go
Normal file
70
pkg/coredata/audit_log_actor_type.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AuditLogActorType string
|
||||
|
||||
const (
|
||||
AuditLogActorTypeUser AuditLogActorType = "USER"
|
||||
AuditLogActorTypeAPIKey AuditLogActorType = "API_KEY"
|
||||
AuditLogActorTypeSystem AuditLogActorType = "SYSTEM"
|
||||
)
|
||||
|
||||
func (a AuditLogActorType) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
func (a AuditLogActorType) IsValid() bool {
|
||||
switch a {
|
||||
case AuditLogActorTypeUser, AuditLogActorTypeAPIKey, AuditLogActorTypeSystem:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a AuditLogActorType) MarshalText() ([]byte, error) {
|
||||
return []byte(a.String()), nil
|
||||
}
|
||||
|
||||
func (a *AuditLogActorType) UnmarshalText(text []byte) error {
|
||||
*a = AuditLogActorType(text)
|
||||
if !a.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid AuditLogActorType", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AuditLogActorType) Scan(value any) error {
|
||||
var s string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for AuditLogActorType: %T", value)
|
||||
}
|
||||
|
||||
return a.UnmarshalText([]byte(s))
|
||||
}
|
||||
|
||||
func (a AuditLogActorType) Value() (driver.Value, error) {
|
||||
return a.String(), nil
|
||||
}
|
||||
243
pkg/coredata/audit_log_entry.go
Normal file
243
pkg/coredata/audit_log_entry.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
AuditLogEntry struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
ActorID gid.GID `db:"actor_id"`
|
||||
ActorType AuditLogActorType `db:"actor_type"`
|
||||
Action string `db:"action"`
|
||||
ResourceType string `db:"resource_type"`
|
||||
ResourceID gid.GID `db:"resource_id"`
|
||||
Metadata json.RawMessage `db:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
AuditLogEntries []*AuditLogEntry
|
||||
)
|
||||
|
||||
func (e AuditLogEntry) CursorKey(orderBy AuditLogEntryOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case AuditLogEntryOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(e.ID, e.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (e *AuditLogEntry) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM audit_log_entries WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, e.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query audit log entry authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (e *AuditLogEntry) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO audit_log_entries (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
action,
|
||||
resource_type,
|
||||
resource_id,
|
||||
metadata,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@actor_id,
|
||||
@actor_type,
|
||||
@action,
|
||||
@resource_type,
|
||||
@resource_id,
|
||||
@metadata,
|
||||
@created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": e.OrganizationID,
|
||||
"actor_id": e.ActorID,
|
||||
"actor_type": e.ActorType,
|
||||
"action": e.Action,
|
||||
"resource_type": e.ResourceType,
|
||||
"resource_id": e.ResourceID,
|
||||
"metadata": e.Metadata,
|
||||
"created_at": e.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert audit log entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *AuditLogEntry) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
action,
|
||||
resource_type,
|
||||
resource_id,
|
||||
metadata,
|
||||
created_at
|
||||
FROM
|
||||
audit_log_entries
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1;
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query audit log entry: %w", err)
|
||||
}
|
||||
|
||||
entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AuditLogEntry])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect audit log entry: %w", err)
|
||||
}
|
||||
|
||||
*e = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *AuditLogEntries) LoadAllByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[AuditLogEntryOrderField],
|
||||
filter *AuditLogEntryFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
action,
|
||||
resource_type,
|
||||
resource_id,
|
||||
metadata,
|
||||
created_at
|
||||
FROM
|
||||
audit_log_entries
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query audit log entries: %w", err)
|
||||
}
|
||||
|
||||
entries, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AuditLogEntry])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect audit log entries: %w", err)
|
||||
}
|
||||
|
||||
*es = entries
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *AuditLogEntries) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
filter *AuditLogEntryFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM audit_log_entries
|
||||
WHERE %s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count audit log entries: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
107
pkg/coredata/audit_log_entry_filter.go
Normal file
107
pkg/coredata/audit_log_entry_filter.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type AuditLogEntryFilter struct {
|
||||
action *string
|
||||
actorID *gid.GID
|
||||
resourceType *string
|
||||
resourceID *gid.GID
|
||||
}
|
||||
|
||||
func NewAuditLogEntryFilter() *AuditLogEntryFilter {
|
||||
return &AuditLogEntryFilter{}
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) WithAction(action string) *AuditLogEntryFilter {
|
||||
f.action = &action
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) WithActorID(actorID gid.GID) *AuditLogEntryFilter {
|
||||
f.actorID = &actorID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) WithResourceType(resourceType string) *AuditLogEntryFilter {
|
||||
f.resourceType = &resourceType
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) WithResourceID(resourceID gid.GID) *AuditLogEntryFilter {
|
||||
f.resourceID = &resourceID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
CASE
|
||||
WHEN @filter_action::text IS NOT NULL THEN
|
||||
action = @filter_action::text
|
||||
ELSE TRUE
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @filter_actor_id::text IS NOT NULL THEN
|
||||
actor_id = @filter_actor_id::text
|
||||
ELSE TRUE
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @filter_resource_type::text IS NOT NULL THEN
|
||||
resource_type = @filter_resource_type::text
|
||||
ELSE TRUE
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @filter_resource_id::text IS NOT NULL THEN
|
||||
resource_id = @filter_resource_id::text
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
|
||||
func (f *AuditLogEntryFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args := pgx.StrictNamedArgs{
|
||||
"filter_action": nil,
|
||||
"filter_actor_id": nil,
|
||||
"filter_resource_type": nil,
|
||||
"filter_resource_id": nil,
|
||||
}
|
||||
|
||||
if f.action != nil {
|
||||
args["filter_action"] = *f.action
|
||||
}
|
||||
|
||||
if f.actorID != nil {
|
||||
args["filter_actor_id"] = *f.actorID
|
||||
}
|
||||
|
||||
if f.resourceType != nil {
|
||||
args["filter_resource_type"] = *f.resourceType
|
||||
}
|
||||
|
||||
if f.resourceID != nil {
|
||||
args["filter_resource_id"] = *f.resourceID
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
57
pkg/coredata/audit_log_entry_order_field.go
Normal file
57
pkg/coredata/audit_log_entry_order_field.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AuditLogEntryOrderField string
|
||||
|
||||
const (
|
||||
AuditLogEntryOrderFieldCreatedAt AuditLogEntryOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p AuditLogEntryOrderField) Column() string {
|
||||
switch p {
|
||||
case AuditLogEntryOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", p))
|
||||
}
|
||||
|
||||
func (p AuditLogEntryOrderField) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p AuditLogEntryOrderField) IsValid() bool {
|
||||
switch p {
|
||||
case AuditLogEntryOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p AuditLogEntryOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
func (p *AuditLogEntryOrderField) UnmarshalText(text []byte) error {
|
||||
*p = AuditLogEntryOrderField(text)
|
||||
if !p.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid AuditLogEntryOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
121
pkg/coredata/audit_log_resource_type.go
Normal file
121
pkg/coredata/audit_log_resource_type.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
// ResourceTypeName returns a human-readable name for an entity type.
|
||||
func ResourceTypeName(entityType uint16) string {
|
||||
switch entityType {
|
||||
case OrganizationEntityType:
|
||||
return "Organization"
|
||||
case FrameworkEntityType:
|
||||
return "Framework"
|
||||
case MeasureEntityType:
|
||||
return "Measure"
|
||||
case TaskEntityType:
|
||||
return "Task"
|
||||
case EvidenceEntityType:
|
||||
return "Evidence"
|
||||
case ConnectorEntityType:
|
||||
return "Connector"
|
||||
case VendorRiskAssessmentEntityType:
|
||||
return "VendorRiskAssessment"
|
||||
case VendorEntityType:
|
||||
return "Vendor"
|
||||
case VendorComplianceReportEntityType:
|
||||
return "VendorComplianceReport"
|
||||
case DocumentEntityType:
|
||||
return "Document"
|
||||
case IdentityEntityType:
|
||||
return "Identity"
|
||||
case ControlEntityType:
|
||||
return "Control"
|
||||
case RiskEntityType:
|
||||
return "Risk"
|
||||
case DocumentVersionEntityType:
|
||||
return "DocumentVersion"
|
||||
case DocumentVersionSignatureEntityType:
|
||||
return "DocumentVersionSignature"
|
||||
case AssetEntityType:
|
||||
return "Asset"
|
||||
case DatumEntityType:
|
||||
return "Datum"
|
||||
case AuditEntityType:
|
||||
return "Audit"
|
||||
case ReportEntityType:
|
||||
return "Report"
|
||||
case TrustCenterEntityType:
|
||||
return "TrustCenter"
|
||||
case TrustCenterAccessEntityType:
|
||||
return "TrustCenterAccess"
|
||||
case VendorBusinessAssociateAgreementEntityType:
|
||||
return "VendorBusinessAssociateAgreement"
|
||||
case FileEntityType:
|
||||
return "File"
|
||||
case VendorContactEntityType:
|
||||
return "VendorContact"
|
||||
case VendorDataPrivacyAgreementEntityType:
|
||||
return "VendorDataPrivacyAgreement"
|
||||
case FindingEntityType:
|
||||
return "Finding"
|
||||
case ObligationEntityType:
|
||||
return "Obligation"
|
||||
case VendorServiceEntityType:
|
||||
return "VendorService"
|
||||
case SnapshotEntityType:
|
||||
return "Snapshot"
|
||||
case ProcessingActivityEntityType:
|
||||
return "ProcessingActivity"
|
||||
case TrustCenterReferenceEntityType:
|
||||
return "TrustCenterReference"
|
||||
case TrustCenterDocumentAccessEntityType:
|
||||
return "TrustCenterDocumentAccess"
|
||||
case CustomDomainEntityType:
|
||||
return "CustomDomain"
|
||||
case InvitationEntityType:
|
||||
return "Invitation"
|
||||
case MembershipEntityType:
|
||||
return "Membership"
|
||||
case TrustCenterFileEntityType:
|
||||
return "TrustCenterFile"
|
||||
case MeetingEntityType:
|
||||
return "Meeting"
|
||||
case DataProtectionImpactAssessmentEntityType:
|
||||
return "DataProtectionImpactAssessment"
|
||||
case TransferImpactAssessmentEntityType:
|
||||
return "TransferImpactAssessment"
|
||||
case RightsRequestEntityType:
|
||||
return "RightsRequest"
|
||||
case StateOfApplicabilityEntityType:
|
||||
return "StateOfApplicability"
|
||||
case ApplicabilityStatementEntityType:
|
||||
return "ApplicabilityStatement"
|
||||
case WebhookSubscriptionEntityType:
|
||||
return "WebhookSubscription"
|
||||
case ComplianceFrameworkEntityType:
|
||||
return "ComplianceFramework"
|
||||
case ComplianceExternalURLEntityType:
|
||||
return "ComplianceExternalURL"
|
||||
case MailingListEntityType:
|
||||
return "MailingList"
|
||||
case MailingListSubscriberEntityType:
|
||||
return "MailingListSubscriber"
|
||||
case MailingListUpdateEntityType:
|
||||
return "MailingListUpdate"
|
||||
case AuditLogEntryEntityType:
|
||||
return "AuditLogEntry"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
31
pkg/coredata/migrations/20260320T120000Z.sql
Normal file
31
pkg/coredata/migrations/20260320T120000Z.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
CREATE TABLE audit_log_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_entries_organization_id ON audit_log_entries (organization_id);
|
||||
CREATE INDEX idx_audit_log_entries_actor_id ON audit_log_entries (actor_id);
|
||||
CREATE INDEX idx_audit_log_entries_action ON audit_log_entries (action);
|
||||
CREATE INDEX idx_audit_log_entries_created_at ON audit_log_entries (created_at);
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
@@ -16,11 +16,14 @@ package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
@@ -35,11 +38,13 @@ type AuthorizationAttributer interface {
|
||||
|
||||
// AuthorizeParams contains the parameters for an authorization request.
|
||||
type AuthorizeParams struct {
|
||||
Principal gid.GID
|
||||
Resource gid.GID
|
||||
Session *gid.GID
|
||||
Action string
|
||||
ResourceAttributes map[string]string
|
||||
Principal gid.GID
|
||||
Resource gid.GID
|
||||
Session *gid.GID
|
||||
Action string
|
||||
ResourceAttributes map[string]string
|
||||
DryRun bool
|
||||
SkipAssumptionCheck bool
|
||||
}
|
||||
|
||||
// Authorizer evaluates authorization requests against registered policies.
|
||||
@@ -47,14 +52,16 @@ type Authorizer struct {
|
||||
pg *pg.Client
|
||||
evaluator *policy.Evaluator
|
||||
policySet *PolicySet
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new Authorizer instance.
|
||||
func NewAuthorizer(pgClient *pg.Client) *Authorizer {
|
||||
func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer {
|
||||
return &Authorizer{
|
||||
pg: pgClient,
|
||||
evaluator: policy.NewEvaluator(),
|
||||
policySet: NewPolicySet(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +94,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
|
||||
}
|
||||
|
||||
// Check whether the viewer is currently assuming the org of the accessed resource
|
||||
if membership != nil && params.Session != nil {
|
||||
if membership != nil && params.Session != nil && !params.SkipAssumptionCheck {
|
||||
if _, err := a.getActiveChildSessionForMembership(
|
||||
ctx,
|
||||
conn,
|
||||
@@ -137,6 +144,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
|
||||
}
|
||||
|
||||
if a.evaluator.Evaluate(req, policies).IsAllowed() {
|
||||
a.recordAuditLog(ctx, conn, params, resourceAttrs)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -258,3 +266,85 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy {
|
||||
|
||||
return policies
|
||||
}
|
||||
|
||||
// resourceTypeFromAction extracts the resource type name from an action
|
||||
// string. For example, "core:vendor:create" returns "Vendor" and
|
||||
// "core:webhook-subscription:delete" returns "WebhookSubscription".
|
||||
func resourceTypeFromAction(action string) string {
|
||||
parts := strings.Split(action, ":")
|
||||
if len(parts) < 3 {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
segments := strings.Split(parts[1], "-")
|
||||
for i, s := range segments {
|
||||
if len(s) > 0 {
|
||||
segments[i] = strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(segments, "")
|
||||
}
|
||||
|
||||
func (a *Authorizer) recordAuditLog(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
params AuthorizeParams,
|
||||
resourceAttrs map[string]string,
|
||||
) {
|
||||
if params.DryRun {
|
||||
return
|
||||
}
|
||||
|
||||
orgIDStr := resourceAttrs["organization_id"]
|
||||
if orgIDStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := gid.ParseGID(orgIDStr)
|
||||
if err != nil {
|
||||
a.logger.ErrorCtx(ctx, "cannot parse organization id for audit log",
|
||||
log.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var actorType coredata.AuditLogActorType
|
||||
if params.Session != nil {
|
||||
actorType = coredata.AuditLogActorTypeUser
|
||||
} else {
|
||||
actorType = coredata.AuditLogActorTypeAPIKey
|
||||
}
|
||||
|
||||
resourceType := resourceTypeFromAction(params.Action)
|
||||
|
||||
metadata, err := json.Marshal(map[string]any{})
|
||||
if err != nil {
|
||||
a.logger.ErrorCtx(ctx, "cannot marshal audit log metadata",
|
||||
log.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
entry := &coredata.AuditLogEntry{
|
||||
ID: gid.New(orgID.TenantID(), coredata.AuditLogEntryEntityType),
|
||||
OrganizationID: orgID,
|
||||
ActorID: params.Principal,
|
||||
ActorType: actorType,
|
||||
Action: params.Action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: params.Resource,
|
||||
Metadata: metadata,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
scope := coredata.NewScope(orgID.TenantID())
|
||||
|
||||
if err := entry.Insert(ctx, conn, scope); err != nil {
|
||||
a.logger.ErrorCtx(ctx, "cannot insert audit log entry",
|
||||
log.Error(err),
|
||||
log.String("action", params.Action),
|
||||
log.String("resource_id", params.Resource.String()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!
|
||||
|
||||
85
pkg/server/api/connect/v1/types/audit_log_entry.go
Normal file
85
pkg/server/api/connect/v1/types/audit_log_entry.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
AuditLogEntryOrderBy OrderBy[coredata.AuditLogEntryOrderField]
|
||||
|
||||
AuditLogEntryConnection struct {
|
||||
TotalCount int
|
||||
Edges []*AuditLogEntryEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filter *coredata.AuditLogEntryFilter
|
||||
}
|
||||
)
|
||||
|
||||
func NewAuditLogEntryConnection(
|
||||
p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField],
|
||||
resolver any,
|
||||
parentID gid.GID,
|
||||
filter *coredata.AuditLogEntryFilter,
|
||||
) *AuditLogEntryConnection {
|
||||
edges := make([]*AuditLogEntryEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewAuditLogEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &AuditLogEntryConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
Resolver: resolver,
|
||||
ParentID: parentID,
|
||||
Filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuditLogEntryEdge(e *coredata.AuditLogEntry, orderBy coredata.AuditLogEntryOrderField) *AuditLogEntryEdge {
|
||||
return &AuditLogEntryEdge{
|
||||
Cursor: e.CursorKey(orderBy),
|
||||
Node: NewAuditLogEntry(e),
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
|
||||
var metadata *string
|
||||
if len(e.Metadata) > 0 {
|
||||
metadata = new(string(e.Metadata))
|
||||
}
|
||||
|
||||
return &AuditLogEntry{
|
||||
ID: e.ID,
|
||||
Organization: &Organization{
|
||||
ID: e.OrganizationID,
|
||||
},
|
||||
ActorID: e.ActorID,
|
||||
ActorType: e.ActorType,
|
||||
Action: e.Action,
|
||||
ResourceType: e.ResourceType,
|
||||
ResourceID: e.ResourceID,
|
||||
Metadata: metadata,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
85
pkg/server/api/console/v1/types/audit_log_entry.go
Normal file
85
pkg/server/api/console/v1/types/audit_log_entry.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
AuditLogEntryOrderBy OrderBy[coredata.AuditLogEntryOrderField]
|
||||
|
||||
AuditLogEntryConnection struct {
|
||||
TotalCount int
|
||||
Edges []*AuditLogEntryEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filter *coredata.AuditLogEntryFilter
|
||||
}
|
||||
)
|
||||
|
||||
func NewAuditLogEntryConnection(
|
||||
p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
filter *coredata.AuditLogEntryFilter,
|
||||
) *AuditLogEntryConnection {
|
||||
edges := make([]*AuditLogEntryEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewAuditLogEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &AuditLogEntryConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
Filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuditLogEntryEdge(e *coredata.AuditLogEntry, orderBy coredata.AuditLogEntryOrderField) *AuditLogEntryEdge {
|
||||
return &AuditLogEntryEdge{
|
||||
Cursor: e.CursorKey(orderBy),
|
||||
Node: NewAuditLogEntry(e),
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
|
||||
var metadata *string
|
||||
if len(e.Metadata) > 0 {
|
||||
metadata = new(string(e.Metadata))
|
||||
}
|
||||
|
||||
return &AuditLogEntry{
|
||||
ID: e.ID,
|
||||
Organization: &Organization{
|
||||
ID: e.OrganizationID,
|
||||
},
|
||||
ActorID: e.ActorID,
|
||||
ActorType: e.ActorType,
|
||||
Action: e.Action,
|
||||
ResourceType: e.ResourceType,
|
||||
ResourceID: e.ResourceID,
|
||||
Metadata: metadata,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
51
pkg/server/api/mcp/v1/types/audit_log_entry.go
Normal file
51
pkg/server/api/mcp/v1/types/audit_log_entry.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
|
||||
return &AuditLogEntry{
|
||||
ID: e.ID,
|
||||
OrganizationID: e.OrganizationID,
|
||||
ActorID: e.ActorID,
|
||||
ActorType: AuditLogEntryActorType(e.ActorType),
|
||||
Action: e.Action,
|
||||
ResourceType: e.ResourceType,
|
||||
ResourceID: e.ResourceID,
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewListAuditLogEntriesOutput(p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField]) ListAuditLogEntriesOutput {
|
||||
entries := make([]*AuditLogEntry, 0, len(p.Data))
|
||||
for _, e := range p.Data {
|
||||
entries = append(entries, NewAuditLogEntry(e))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
if len(p.Data) > 0 {
|
||||
cursorKey := p.Data[len(p.Data)-1].CursorKey(p.Cursor.OrderBy.Field)
|
||||
nextCursor = &cursorKey
|
||||
}
|
||||
|
||||
return ListAuditLogEntriesOutput{
|
||||
NextCursor: nextCursor,
|
||||
AuditLogEntries: entries,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user