feat: add organizations filter to audit table (#13978)

* Ignore organization ID in member and role audit logs

Since the organization will never change in any resources, 
and the org is already on the top-level of the response. 

* Add organization details and filter to audit table

These only display if the multi-org experiment is enabled.

This also includes a modification to customize the width 
of the filters since with four things get a bit squishy.

* Add more audit mocks

To test different org names and no org.
This commit is contained in:
Asher
2024-07-24 14:28:23 -08:00
committed by GitHub
parent 4f01372179
commit e8b3db8c7a
10 changed files with 235 additions and 19 deletions

View File

@ -13,8 +13,8 @@ We track the following resources:
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>true</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>true</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |

View File

@ -53,7 +53,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
&database.AuditableOrganizationMember{}: {
"username": ActionTrack,
"user_id": ActionTrack,
"organization_id": ActionTrack,
"organization_id": ActionIgnore, // Never changes.
"created_at": ActionTrack,
"updated_at": ActionTrack,
"roles": ActionTrack,
@ -64,7 +64,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"site_permissions": ActionTrack,
"org_permissions": ActionTrack,
"user_permissions": ActionTrack,
"organization_id": ActionTrack,
"organization_id": ActionIgnore, // Never changes.
"id": ActionIgnore,
"created_at": ActionIgnore,

View File

@ -32,6 +32,7 @@ export type SelectFilterProps = {
onSelect: (option: SelectFilterOption | undefined) => void;
// SelectFilterSearch element
selectFilterSearch?: ReactNode;
width?: number;
};
export const SelectFilter: FC<SelectFilterProps> = ({
@ -42,6 +43,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
placeholder,
emptyText,
selectFilterSearch,
width = BASE_WIDTH,
}) => {
const [open, setOpen] = useState(false);
@ -50,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
<SelectMenuTrigger>
<SelectMenuButton
startIcon={selectedOption?.startIcon}
css={{ width: BASE_WIDTH }}
css={{ width }}
aria-label={label}
>
{selectedOption?.label ?? placeholder}
@ -64,7 +66,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
// wide as possible.
width: selectFilterSearch ? "100%" : undefined,
maxWidth: POPOVER_WIDTH,
minWidth: BASE_WIDTH,
minWidth: width,
},
}}
>

View File

@ -97,9 +97,10 @@ export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>;
interface UserMenuProps {
menu: UserFilterMenu;
width?: number;
}
export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
export const UserMenu: FC<UserMenuProps> = ({ menu, width }) => {
return (
<SelectFilter
label="Select user"
@ -116,6 +117,7 @@ export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
onChange={menu.setQuery}
/>
}
width={width}
/>
);
};

View File

@ -1,5 +1,6 @@
import capitalize from "lodash/capitalize";
import type { FC } from "react";
import { API } from "api/api";
import { AuditActions, ResourceTypes } from "api/typesGenerated";
import {
Filter,
@ -13,9 +14,11 @@ import {
} from "components/Filter/menu";
import {
SelectFilter,
SelectFilterSearch,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { docs } from "utils/docs";
const PRESET_FILTERS = [
@ -42,10 +45,14 @@ interface AuditFilterProps {
user: UserFilterMenu;
action: ActionFilterMenu;
resourceType: ResourceTypeFilterMenu;
// The organization menu is only provided in a multi-org setup.
organization?: OrganizationsFilterMenu;
};
}
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
// Use a smaller width if including the organization filter.
const width = menus.organization && 175;
return (
<Filter
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
@ -55,9 +62,12 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
error={error}
options={
<>
<ResourceTypeMenu {...menus.resourceType} />
<ActionMenu {...menus.action} />
<UserMenu menu={menus.user} />
<ResourceTypeMenu width={width} menu={menus.resourceType} />
<ActionMenu width={width} menu={menus.action} />
<UserMenu width={width} menu={menus.user} />
{menus.organization && (
<OrganizationsMenu width={width} menu={menus.organization} />
)}
</>
}
skeleton={
@ -92,7 +102,12 @@ export const useActionFilterMenu = ({
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>;
const ActionMenu = (menu: ActionFilterMenu) => {
interface ActionMenuProps {
menu: ActionFilterMenu;
width?: number;
}
const ActionMenu: FC<ActionMenuProps> = ({ menu, width }) => {
return (
<SelectFilter
label="Select an action"
@ -100,6 +115,7 @@ const ActionMenu = (menu: ActionFilterMenu) => {
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
width={width}
/>
);
};
@ -146,7 +162,12 @@ export type ResourceTypeFilterMenu = ReturnType<
typeof useResourceTypeFilterMenu
>;
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
interface ResourceTypeMenuProps {
menu: ResourceTypeFilterMenu;
width?: number;
}
const ResourceTypeMenu: FC<ResourceTypeMenuProps> = ({ menu, width }) => {
return (
<SelectFilter
label="Select a resource type"
@ -154,6 +175,88 @@ const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
width={width}
/>
);
};
export const useOrganizationsFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
return useFilterMenu({
onChange,
value,
id: "organizations",
getSelectedOption: async () => {
if (value) {
const organizations = await API.getOrganizations();
const organization = organizations.find((o) => o.name === value);
if (organization) {
return {
label: organization.display_name || organization.name,
value: organization.name,
startIcon: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
};
}
}
return null;
},
getOptions: async () => {
const organizationsRes = await API.getOrganizations();
return organizationsRes.map<SelectFilterOption>((organization) => ({
label: organization.display_name || organization.name,
value: organization.name,
startIcon: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
}));
},
});
};
export type OrganizationsFilterMenu = ReturnType<
typeof useOrganizationsFilterMenu
>;
interface OrganizationsMenuProps {
menu: OrganizationsFilterMenu;
width?: number;
}
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
menu,
width,
}) => {
return (
<SelectFilter
label="Select an organization"
placeholder="All organizations"
emptyText="No organizations found"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
inputProps={{ "aria-label": "Search organization" }}
placeholder="Search organization..."
value={menu.query}
onChange={menu.setQuery}
/>
}
width={width}
/>
);
};

View File

@ -1,7 +1,9 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Collapse from "@mui/material/Collapse";
import Link from "@mui/material/Link";
import TableCell from "@mui/material/TableCell";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import userAgentParser from "ua-parser-js";
import type { AuditLog } from "api/typesGenerated";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
@ -33,11 +35,13 @@ export interface AuditLogRowProps {
auditLog: AuditLog;
// Useful for Storybook
defaultIsDiffOpen?: boolean;
showOrgDetails: boolean;
}
export const AuditLogRow: FC<AuditLogRowProps> = ({
auditLog,
defaultIsDiffOpen = false,
showOrgDetails,
}) => {
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen);
const diffs = Object.entries(auditLog.diff);
@ -132,6 +136,20 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
</strong>
</span>
)}
{showOrgDetails && auditLog.organization && (
<span css={styles.auditLogInfo}>
<>Org: </>
<Link
component={RouterLink}
to={`/organizations/${auditLog.organization.name}`}
>
<strong>
{auditLog.organization.display_name ||
auditLog.organization.name}
</strong>
</Link>
</span>
)}
</Stack>
<Pill

View File

@ -6,13 +6,19 @@ import { useFilter } from "components/Filter/filter";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { isNonInitialPage } from "components/PaginationWidget/utils";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { useDashboard } from "modules/dashboard/useDashboard";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { pageTitle } from "utils/page";
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
import {
useActionFilterMenu,
useOrganizationsFilterMenu,
useResourceTypeFilterMenu,
} from "./AuditFilter";
import { AuditPageView } from "./AuditPageView";
const AuditPage: FC = () => {
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
const { experiments } = useDashboard();
/**
* There is an implicit link between auditsQuery and filter via the
@ -55,6 +61,15 @@ const AuditPage: FC = () => {
}),
});
const organizationsMenu = useOrganizationsFilterMenu({
value: filter.values.organization,
onChange: (option) =>
filter.update({
...filter.values,
organization: option?.value,
}),
});
return (
<>
<Helmet>
@ -67,6 +82,7 @@ const AuditPage: FC = () => {
isAuditLogVisible={isAuditLogVisible}
auditsQuery={auditsQuery}
error={auditsQuery.error}
showOrgDetails={experiments.includes("multi-organization")}
filterProps={{
filter,
error: auditsQuery.error,
@ -74,6 +90,9 @@ const AuditPage: FC = () => {
user: userMenu,
action: actionMenu,
resourceType: resourceTypeMenu,
organization: experiments.includes("multi-organization")
? organizationsMenu
: undefined,
},
}}
/>

View File

@ -10,7 +10,12 @@ import {
} from "components/PaginationWidget/PaginationContainer.mocks";
import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
import { chromaticWithTablet } from "testHelpers/chromatic";
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities";
import {
MockAuditLog,
MockAuditLog2,
MockAuditLog3,
MockUser,
} from "testHelpers/entities";
import { AuditPageView } from "./AuditPageView";
type FilterProps = ComponentProps<typeof AuditPageView>["filterProps"];
@ -21,6 +26,7 @@ const defaultFilterProps = getDefaultFilterProps<FilterProps>({
username: MockUser.username,
action: undefined,
resource_type: undefined,
organization: undefined,
},
menus: {
user: MockMenu,
@ -33,9 +39,10 @@ const meta: Meta<typeof AuditPageView> = {
title: "pages/AuditPage",
component: AuditPageView,
args: {
auditLogs: [MockAuditLog, MockAuditLog2],
auditLogs: [MockAuditLog, MockAuditLog2, MockAuditLog3],
isAuditLogVisible: true,
filterProps: defaultFilterProps,
showOrgDetails: false,
},
};
@ -85,3 +92,18 @@ export const NotVisible: Story = {
auditsQuery: mockInitialRenderResult,
},
};
export const MultiOrg: Story = {
parameters: { chromatic: chromaticWithTablet },
args: {
showOrgDetails: true,
auditsQuery: mockSuccessResult,
filterProps: {
...defaultFilterProps,
menus: {
...defaultFilterProps.menus,
organization: MockMenu,
},
},
},
};

View File

@ -38,6 +38,7 @@ export interface AuditPageViewProps {
error?: unknown;
filterProps: ComponentProps<typeof AuditFilter>;
auditsQuery: PaginationResult;
showOrgDetails: boolean;
}
export const AuditPageView: FC<AuditPageViewProps> = ({
@ -47,6 +48,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
error,
filterProps,
auditsQuery: paginationResult,
showOrgDetails,
}) => {
const isLoading =
(auditLogs === undefined || paginationResult.totalRecords === undefined) &&
@ -117,7 +119,11 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow key={log.id} auditLog={log} />
<AuditLogRow
key={log.id}
auditLog={log}
showOrgDetails={showOrgDetails}
/>
)}
/>
)}

View File

@ -2211,6 +2211,9 @@ export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = {
export const MockExperiments: TypesGen.Experiment[] = [];
/**
* An audit log for MockOrganization.
*/
export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
request_id: "53bded77-7b9d-4e82-8771-991a34d759f9",
@ -2218,9 +2221,9 @@ export const MockAuditLog: TypesGen.AuditLog = {
organization_id: MockOrganization.id,
organization: {
id: MockOrganization.id,
name: "mock name",
display_name: "mock display name",
icon: "/emojis/1f48f-1f3ff.png",
name: MockOrganization.name,
display_name: MockOrganization.display_name,
icon: MockOrganization.icon,
},
ip: "127.0.0.1",
user_agent:
@ -2245,12 +2248,22 @@ export const MockAuditLog: TypesGen.AuditLog = {
is_deleted: false,
};
/**
* An audit log for MockOrganization2.
*/
export const MockAuditLog2: TypesGen.AuditLog = {
...MockAuditLog,
id: "53bded77-7b9d-4e82-8771-991a34d759f9",
action: "write",
time: "2022-05-20T16:45:57.122Z",
description: "{user} updated workspace {target}",
organization_id: MockOrganization2.id,
organization: {
id: MockOrganization2.id,
name: MockOrganization2.name,
display_name: MockOrganization2.display_name,
icon: MockOrganization2.icon,
},
diff: {
workspace_name: {
old: "old-workspace-name",
@ -2275,6 +2288,37 @@ export const MockAuditLog2: TypesGen.AuditLog = {
},
};
/**
* An audit log without an organization.
*/
export const MockAuditLog3: TypesGen.AuditLog = {
id: "8efa9208-656a-422d-842d-b9dec0cf1bf3",
request_id: "57ee9510-8330-480d-9ffa-4024e5805465",
time: "2024-06-11T01:32:11.123Z",
organization_id: "00000000-0000-0000-000000000000",
ip: "127.0.0.1",
user_agent:
'"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"',
resource_type: "template",
resource_id: "a624458c-1562-4689-a671-42c0b7d2d0c5",
resource_target: "docker",
resource_icon: "",
action: "write",
diff: {
display_name: {
old: "old display",
new: "new display",
secret: false,
},
},
status_code: 200,
additional_fields: {},
description: "{user} updated template {target}",
user: MockUser,
resource_link: "/templates/docker",
is_deleted: false,
};
export const MockWorkspaceCreateAuditLogForDifferentOwner = {
...MockAuditLog,
additional_fields: {