diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 40eb173cad869..87f07cf243125 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,8 +13,8 @@ We track the following resources: | APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| AuditableOrganizationMember
|
FieldTracked
created_attrue
organization_idtrue
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| CustomRole
|
FieldTracked
created_atfalse
display_nametrue
idfalse
nametrue
org_permissionstrue
organization_idtrue
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| AuditableOrganizationMember
|
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| CustomRole
|
FieldTracked
created_atfalse
display_nametrue
idfalse
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 3a0a1eb209ed9..0a3608dae7169 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -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, diff --git a/site/src/components/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx index 7521affc7efb6..e679c3fbd7572 100644 --- a/site/src/components/Filter/SelectFilter.tsx +++ b/site/src/components/Filter/SelectFilter.tsx @@ -32,6 +32,7 @@ export type SelectFilterProps = { onSelect: (option: SelectFilterOption | undefined) => void; // SelectFilterSearch element selectFilterSearch?: ReactNode; + width?: number; }; export const SelectFilter: FC = ({ @@ -42,6 +43,7 @@ export const SelectFilter: FC = ({ placeholder, emptyText, selectFilterSearch, + width = BASE_WIDTH, }) => { const [open, setOpen] = useState(false); @@ -50,7 +52,7 @@ export const SelectFilter: FC = ({ {selectedOption?.label ?? placeholder} @@ -64,7 +66,7 @@ export const SelectFilter: FC = ({ // wide as possible. width: selectFilterSearch ? "100%" : undefined, maxWidth: POPOVER_WIDTH, - minWidth: BASE_WIDTH, + minWidth: width, }, }} > diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 2a69717cb8eaa..29267eb855214 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -97,9 +97,10 @@ export type UserFilterMenu = ReturnType; interface UserMenuProps { menu: UserFilterMenu; + width?: number; } -export const UserMenu: FC = ({ menu }) => { +export const UserMenu: FC = ({ menu, width }) => { return ( = ({ menu }) => { onChange={menu.setQuery} /> } + width={width} /> ); }; diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index 0127637a4b69d..01a38a1c5077f 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -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 = ({ filter, error, menus }) => { + // Use a smaller width if including the organization filter. + const width = menus.organization && 175; return ( = ({ filter, error, menus }) => { error={error} options={ <> - - - + + + + {menus.organization && ( + + )} } skeleton={ @@ -92,7 +102,12 @@ export const useActionFilterMenu = ({ export type ActionFilterMenu = ReturnType; -const ActionMenu = (menu: ActionFilterMenu) => { +interface ActionMenuProps { + menu: ActionFilterMenu; + width?: number; +} + +const ActionMenu: FC = ({ menu, width }) => { return ( { 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 = ({ menu, width }) => { return ( { options={menu.searchOptions} onSelect={menu.selectOption} selectedOption={menu.selectedOption ?? undefined} + width={width} + /> + ); +}; + +export const useOrganizationsFilterMenu = ({ + value, + onChange, +}: Pick, "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: ( + + ), + }; + } + } + return null; + }, + getOptions: async () => { + const organizationsRes = await API.getOrganizations(); + return organizationsRes.map((organization) => ({ + label: organization.display_name || organization.name, + value: organization.name, + startIcon: ( + + ), + })); + }, + }); +}; + +export type OrganizationsFilterMenu = ReturnType< + typeof useOrganizationsFilterMenu +>; + +interface OrganizationsMenuProps { + menu: OrganizationsFilterMenu; + width?: number; +} + +export const OrganizationsMenu: FC = ({ + menu, + width, +}) => { + return ( + + } + width={width} /> ); }; diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx index 15e1a8e8254b4..5cd7a26120a25 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx @@ -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 = ({ auditLog, defaultIsDiffOpen = false, + showOrgDetails, }) => { const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen); const diffs = Object.entries(auditLog.diff); @@ -132,6 +136,20 @@ export const AuditLogRow: FC = ({ )} + {showOrgDetails && auditLog.organization && ( + + <>Org: + + + {auditLog.organization.display_name || + auditLog.organization.name} + + + + )} { 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 ( <> @@ -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, }, }} /> diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index fa6ac9f17f066..1a2c65763d4ea 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -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["filterProps"]; @@ -21,6 +26,7 @@ const defaultFilterProps = getDefaultFilterProps({ username: MockUser.username, action: undefined, resource_type: undefined, + organization: undefined, }, menus: { user: MockMenu, @@ -33,9 +39,10 @@ const meta: Meta = { 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, + }, + }, + }, +}; diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index 70c12fa1ca294..c93193c823869 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -38,6 +38,7 @@ export interface AuditPageViewProps { error?: unknown; filterProps: ComponentProps; auditsQuery: PaginationResult; + showOrgDetails: boolean; } export const AuditPageView: FC = ({ @@ -47,6 +48,7 @@ export const AuditPageView: FC = ({ error, filterProps, auditsQuery: paginationResult, + showOrgDetails, }) => { const isLoading = (auditLogs === undefined || paginationResult.totalRecords === undefined) && @@ -117,7 +119,11 @@ export const AuditPageView: FC = ({ items={auditLogs} getDate={(log) => new Date(log.time)} row={(log) => ( - + )} /> )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 453f1455615ec..f69c400838052 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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: {