Merge pull request #3311 from Infisical/daniel/audit-log-secretname

feat(audit-logs): filter audit logs by secret key
This commit is contained in:
Daniel Hougaard
2025-04-01 17:16:34 +04:00
committed by GitHub
17 changed files with 649 additions and 369 deletions
@@ -9,13 +9,14 @@ import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue"; import { QueueName } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { EventType } from "./audit-log-types"; import { EventType, filterableSecretEvents } from "./audit-log-types";
export type TAuditLogDALFactory = ReturnType<typeof auditLogDALFactory>; export type TAuditLogDALFactory = ReturnType<typeof auditLogDALFactory>;
type TFindQuery = { type TFindQuery = {
actor?: string; actor?: string;
projectId?: string; projectId?: string;
environment?: string;
orgId?: string; orgId?: string;
eventType?: string; eventType?: string;
startDate?: string; startDate?: string;
@@ -32,6 +33,7 @@ export const auditLogDALFactory = (db: TDbClient) => {
{ {
orgId, orgId,
projectId, projectId,
environment,
userAgentType, userAgentType,
startDate, startDate,
endDate, endDate,
@@ -40,12 +42,14 @@ export const auditLogDALFactory = (db: TDbClient) => {
actorId, actorId,
actorType, actorType,
secretPath, secretPath,
secretKey,
eventType, eventType,
eventMetadata eventMetadata
}: Omit<TFindQuery, "actor" | "eventType"> & { }: Omit<TFindQuery, "actor" | "eventType"> & {
actorId?: string; actorId?: string;
actorType?: ActorType; actorType?: ActorType;
secretPath?: string; secretPath?: string;
secretKey?: string;
eventType?: EventType[]; eventType?: EventType[];
eventMetadata?: Record<string, string>; eventMetadata?: Record<string, string>;
}, },
@@ -90,8 +94,29 @@ export const auditLogDALFactory = (db: TDbClient) => {
}); });
} }
if (projectId && secretPath) { const eventIsSecretType = !eventType?.length || eventType.some((event) => filterableSecretEvents.includes(event));
void sqlQuery.whereRaw(`"eventMetadata" @> jsonb_build_object('secretPath', ?::text)`, [secretPath]); // We only want to filter for environment/secretPath/secretKey if the user is either checking for all event types
// ? Note(daniel): use the `eventMetadata" @> ?::jsonb` approach to properly use our GIN index
if (projectId && eventIsSecretType) {
if (environment || secretPath) {
// Handle both environment and secret path together to only use the GIN index once
void sqlQuery.whereRaw(`"eventMetadata" @> ?::jsonb`, [
JSON.stringify({
...(environment && { environment }),
...(secretPath && { secretPath })
})
]);
}
// Handle secret key separately to include the OR condition
if (secretKey) {
void sqlQuery.whereRaw(
`("eventMetadata" @> ?::jsonb
OR "eventMetadata"->'secrets' @> ?::jsonb)`,
[JSON.stringify({ secretKey }), JSON.stringify([{ secretKey }])]
);
}
} }
// Filter by actor type // Filter by actor type
@@ -63,6 +63,8 @@ export const auditLogServiceFactory = ({
actorType: filter.actorType, actorType: filter.actorType,
eventMetadata: filter.eventMetadata, eventMetadata: filter.eventMetadata,
secretPath: filter.secretPath, secretPath: filter.secretPath,
secretKey: filter.secretKey,
environment: filter.environment,
...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId }) ...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId })
}); });
@@ -33,9 +33,11 @@ export type TListProjectAuditLogDTO = {
endDate?: string; endDate?: string;
startDate?: string; startDate?: string;
projectId?: string; projectId?: string;
environment?: string;
auditLogActorId?: string; auditLogActorId?: string;
actorType?: ActorType; actorType?: ActorType;
secretPath?: string; secretPath?: string;
secretKey?: string;
eventMetadata?: Record<string, string>; eventMetadata?: Record<string, string>;
}; };
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -286,6 +288,16 @@ export enum EventType {
KMIP_OPERATION_REGISTER = "kmip-operation-register" KMIP_OPERATION_REGISTER = "kmip-operation-register"
} }
export const filterableSecretEvents: EventType[] = [
EventType.GET_SECRET,
EventType.DELETE_SECRETS,
EventType.CREATE_SECRETS,
EventType.UPDATE_SECRETS,
EventType.CREATE_SECRET,
EventType.UPDATE_SECRET,
EventType.DELETE_SECRET
];
interface UserActorMetadata { interface UserActorMetadata {
userId: string; userId: string;
email?: string | null; email?: string | null;
+4
View File
@@ -840,9 +840,13 @@ export const AUDIT_LOGS = {
EXPORT: { EXPORT: {
projectId: projectId:
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.", "Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
environment:
"The environment to filter logs by. If not provided, logs from all environments will be returned. Note that the projectId parameter must also be provided.",
eventType: "The type of the event to export.", eventType: "The type of the event to export.",
secretPath: secretPath:
"The path of the secret to query audit logs for. Note that the projectId parameter must also be provided.", "The path of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
secretKey:
"The key of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
userAgentType: "Choose which consuming application to export audit logs for.", userAgentType: "Choose which consuming application to export audit logs for.",
eventMetadata: eventMetadata:
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.", "Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
@@ -111,12 +111,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
description: "Get all audit logs for an organization", description: "Get all audit logs for an organization",
querystring: z.object({ querystring: z.object({
projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId), projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment),
actorType: z.nativeEnum(ActorType).optional(), actorType: z.nativeEnum(ActorType).optional(),
secretPath: z secretPath: z
.string() .string()
.optional() .optional()
.transform((val) => (!val ? val : removeTrailingSlash(val))) .transform((val) => (!val ? val : removeTrailingSlash(val)))
.describe(AUDIT_LOGS.EXPORT.secretPath), .describe(AUDIT_LOGS.EXPORT.secretPath),
secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey),
// eventType is split with , for multiple values, we need to transform it to array // eventType is split with , for multiple values, we need to transform it to array
eventType: z eventType: z
@@ -1,5 +1,16 @@
import { EventType, UserAgentType } from "./enums"; import { EventType, UserAgentType } from "./enums";
export const secretEvents: EventType[] = [
EventType.GET_SECRETS,
EventType.GET_SECRET,
EventType.DELETE_SECRETS,
EventType.CREATE_SECRETS,
EventType.UPDATE_SECRETS,
EventType.CREATE_SECRET,
EventType.UPDATE_SECRET,
EventType.DELETE_SECRET
];
export const eventToNameMap: { [K in EventType]: string } = { export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_SECRETS]: "List secrets", [EventType.GET_SECRETS]: "List secrets",
[EventType.GET_SECRET]: "Read secret", [EventType.GET_SECRET]: "Read secret",
+2 -2
View File
@@ -12,8 +12,8 @@ export enum UserAgentType {
CLI = "cli", CLI = "cli",
K8_OPERATOR = "k8-operator", K8_OPERATOR = "k8-operator",
TERRAFORM = "terraform", TERRAFORM = "terraform",
NODE_SDK = "node-sdk", NODE_SDK = "InfisicalNodeSDK",
PYTHON_SDK = "python-sdk", PYTHON_SDK = "InfisicalPythonSDK",
OTHER = "other" OTHER = "other"
} }
@@ -9,8 +9,10 @@ export type TGetAuditLogsFilter = {
eventMetadata?: Record<string, string>; eventMetadata?: Record<string, string>;
actorType?: ActorType; actorType?: ActorType;
projectId?: string; projectId?: string;
environment?: string;
actor?: string; // user ID format actor?: string; // user ID format
secretPath?: string; secretPath?: string;
secretKey?: string;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
limit: number; limit: number;
@@ -18,7 +18,7 @@ export const AuditLogsPage = () => {
title="Audit logs" title="Audit logs"
description="Audit logs for security and compliance teams to monitor information access." description="Audit logs for security and compliance teams to monitor information access."
/> />
<LogsSection filterClassName="static py-2" showFilters /> <LogsSection />
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,32 @@
import { twMerge } from "tailwind-merge";
import { Button, Tooltip } from "@app/components/v2";
type Props = {
hoverTooltip?: string;
className?: string;
label: string;
onClear: () => void;
children: React.ReactNode;
};
export const LogFilterItem = ({ label, onClear, hoverTooltip, children, className }: Props) => {
return (
<Tooltip className="relative top-4" content={hoverTooltip} isDisabled={!hoverTooltip}>
<div className={twMerge("flex flex-col justify-between", className)}>
<div className="flex items-center justify-between pr-1">
<p className="text-xs opacity-60">{label}</p>
<Button
onClick={() => onClear()}
variant="link"
className="font-normal text-mineshaft-400 transition-all duration-75 hover:text-mineshaft-300"
size="xs"
>
Clear
</Button>
</div>
{children}
</div>
</Tooltip>
);
};
@@ -1,11 +1,26 @@
/* eslint-disable no-nested-ternary */ /* eslint-disable no-nested-ternary */
import { useState } from "react"; import { useMemo, useState } from "react";
import { Control, Controller, UseFormReset, UseFormSetValue, UseFormWatch } from "react-hook-form"; import {
import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; Control,
Controller,
UseFormGetFieldState,
UseFormReset,
UseFormResetField,
UseFormSetValue,
UseFormWatch
} from "react-hook-form";
import {
faArrowRight,
faCaretDown,
faCheckCircle,
faFilterCircleXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { import {
Badge,
Button, Button,
DatePicker, DatePicker,
DropdownMenu, DropdownMenu,
@@ -19,13 +34,17 @@ import {
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/api"; import { useGetUserWorkspaces } from "@app/hooks/api";
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants"; import {
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums"; eventToNameMap,
import { Actor } from "@app/hooks/api/auditLogs/types"; secretEvents,
import { ProjectType } from "@app/hooks/api/workspace/types"; userAgentTTypeoNameMap
} from "@app/hooks/api/auditLogs/constants";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { UserAgentType } from "@app/hooks/api/auth/types";
import { AuditLogFilterFormData } from "./types"; import { LogFilterItem } from "./LogFilterItem";
import { AuditLogFilterFormData, Presets } from "./types";
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value })); const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
@@ -34,26 +53,70 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label
})); }));
type Props = { type Props = {
presets?: { presets?: Presets;
actorId?: string;
eventType?: EventType[];
};
className?: string;
isOrgAuditLogs?: boolean;
setValue: UseFormSetValue<AuditLogFilterFormData>;
control: Control<AuditLogFilterFormData>; control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>; reset: UseFormReset<AuditLogFilterFormData>;
resetField: UseFormResetField<AuditLogFilterFormData>;
watch: UseFormWatch<AuditLogFilterFormData>; watch: UseFormWatch<AuditLogFilterFormData>;
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>;
setValue: UseFormSetValue<AuditLogFilterFormData>;
};
const getActiveFilterCount = (
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>,
watch: UseFormWatch<AuditLogFilterFormData>
) => {
const fields = [
"actor",
"project",
"eventType",
"startDate",
"endDate",
"environment",
"secretPath",
"userAgentType",
"secretKey"
] as Partial<keyof AuditLogFilterFormData>[];
let filterCount = 0;
// either start or end date should only be counted as one filter
let dateProcessed = false;
fields.forEach((field) => {
const fieldState = getFieldState(field);
if (
field === "userAgentType" ||
field === "environment" ||
field === "secretKey" ||
field === "secretPath"
) {
const value = watch(field);
if (value !== undefined && value !== "") {
filterCount += 1;
}
} else if (fieldState.isDirty && !dateProcessed) {
filterCount += 1;
if (field === "startDate" || field === "endDate") {
dateProcessed = true;
}
}
});
return filterCount;
}; };
export const LogsFilter = ({ export const LogsFilter = ({
presets, presets,
isOrgAuditLogs,
className,
control, control,
reset, reset,
setValue, resetField,
watch watch,
getFieldState,
setValue
}: Props) => { }: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
@@ -63,288 +126,423 @@ export const LogsFilter = ({
const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id); const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id);
const { data, isPending } = useGetAuditLogActorFilterOpts(workspaces?.[0]?.id ?? "");
const renderActorSelectItem = (actor: Actor) => {
switch (actor.type) {
case ActorType.USER:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.userId}`}
key={`user-actor-filter-${actor.metadata.userId}`}
>
{actor.metadata.email}
</SelectItem>
);
case ActorType.SERVICE:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.serviceId}`}
key={`service-actor-filter-${actor.metadata.serviceId}`}
>
{actor.metadata.name}
</SelectItem>
);
case ActorType.IDENTITY:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.identityId}`}
key={`identity-filter-${actor.metadata.identityId}`}
>
{actor.metadata.name}
</SelectItem>
);
case ActorType.KMIP_CLIENT:
return (
<SelectItem
value={`${actor.type}-${actor.metadata.clientId}`}
key={`kmip-client-filter-${actor.metadata.clientId}`}
>
{actor.metadata.name}
</SelectItem>
);
default:
return (
<SelectItem value="actor-none" key="actor-none">
N/A
</SelectItem>
);
}
};
const selectedEventTypes = watch("eventType") as EventType[] | undefined; const selectedEventTypes = watch("eventType") as EventType[] | undefined;
const selectedProject = watch("project"); const selectedProject = watch("project");
const showSecretsSection =
selectedEventTypes?.some(
(eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS
) || selectedEventTypes?.length === 0;
const availableEnvironments = useMemo(() => {
if (!selectedProject) return [];
return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? [];
}, [selectedProject, workspacesInOrg]);
const activeFilterCount = getActiveFilterCount(getFieldState, watch);
return ( return (
<div <DropdownMenu>
className={twMerge( <DropdownMenuTrigger asChild>
"sticky top-20 z-10 flex flex-wrap items-center justify-between bg-bunker-800", <Button variant="outline_bg" colorSchema="primary">
className <FontAwesomeIcon icon={faFilterCircleXmark} className="mr-3 px-[0.1rem]" />
)} Filters
> {activeFilterCount > 0 && (
<div className="flex items-center gap-4"> <Badge className="ml-2 px-1.5 py-0.5" variant="primary">
{isOrgAuditLogs && workspacesInOrg.length > 0 && ( {activeFilterCount}
<Controller </Badge>
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="w-64"
>
<FilterableSelect
value={value}
isClearable
onChange={(e) => {
if (e === null) {
setValue("secretPath", "");
}
onChange(e);
}}
placeholder="Select a project..."
options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
)}
{selectedProject?.type === ProjectType.SecretManager && (
<Controller
control={control}
name="secretPath"
render={({ field: { onChange, value, ...field } }) => (
<FormControl label="Secret path" className="w-40">
<Input {...field} value={value} onChange={(e) => onChange(e.target.value)} />
</FormControl>
)}
/>
)}
</div>
<div className="mt-1 flex items-center space-x-2">
<Controller
control={control}
name="eventType"
render={({ field }) => (
<FormControl label="Events">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label
: selectedEventTypes?.length === 0
? "All events"
: `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[100] max-h-80 overflow-hidden">
<div className="max-h-80 overflow-y-auto">
{eventTypes && eventTypes.length > 0 ? (
eventTypes.map((eventType) => {
const isSelected = selectedEventTypes?.includes(
eventType.value as EventType
);
return (
<DropdownMenuItem
onSelect={(event) => eventTypes.length > 1 && event.preventDefault()}
onClick={() => {
if (selectedEventTypes?.includes(eventType.value as EventType)) {
field.onChange(
selectedEventTypes?.filter((e: string) => e !== eventType.value)
);
} else {
field.onChange([...(selectedEventTypes || []), eventType.value]);
}
}}
key={`event-type-${eventType.value}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{eventType.label}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)} )}
/>
{!isPending && data && data.length > 0 && !presets?.actorId && (
<Controller
control={control}
name="actor"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Actor"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{data.map((actor) => renderActorSelectItem(actor))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="userAgentType"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Source"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
value={value === undefined ? "all" : value}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
>
<SelectItem value="all" key="all">
All sources
</SelectItem>
{userAgentTypes.map(({ label, value: userAgent }) => (
<SelectItem value={userAgent} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<Button
isLoading={false}
colorSchema="primary"
variant="outline_bg"
className="mt-[0.45rem]"
type="submit"
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
onClick={() =>
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
project: null
})
}
>
Clear filters
</Button> </Button>
</div> </DropdownMenuTrigger>
</div> <DropdownMenuContent align="end" className="mt-4 py-4">
<div className="flex min-w-64 flex-col font-inter">
<div className="mb-3 flex items-center border-b border-b-mineshaft-500 px-3 pb-2">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<span>Filters</span>
<Badge className="px-1.5 py-0.5" variant="primary">
{activeFilterCount}
</Badge>
</div>
<Button
onClick={() => {
reset({
eventType: presets?.eventType || [],
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined,
project: null,
secretPath: undefined,
secretKey: undefined
});
}}
variant="link"
className="text-mineshaft-400"
size="xs"
>
Clear filters
</Button>
</div>
</div>
<div className="px-3">
<LogFilterItem
label="Events"
onClear={() => {
resetField("eventType");
}}
>
<Controller
control={control}
name="eventType"
render={({ field }) => (
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="thin-scrollbar inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{selectedEventTypes?.length === 1
? eventTypes.find(
(eventType) => eventType.value === selectedEventTypes[0]
)?.label
: selectedEventTypes?.length === 0
? "All events"
: `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80 overflow-hidden"
>
<div className="max-h-80 overflow-y-auto">
{eventTypes && eventTypes.length > 0 ? (
eventTypes.map((eventType) => {
const isSelected = selectedEventTypes?.includes(
eventType.value as EventType
);
return (
<DropdownMenuItem
onSelect={(event) =>
eventTypes.length > 1 && event.preventDefault()
}
onClick={() => {
if (
selectedEventTypes?.includes(eventType.value as EventType)
) {
field.onChange(
selectedEventTypes?.filter(
(e: string) => e !== eventType.value
)
);
} else {
field.onChange([
...(selectedEventTypes || []),
eventType.value
]);
}
}}
key={`event-type-${eventType.value}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{eventType.label}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</LogFilterItem>
<LogFilterItem
label="Source"
onClear={() => {
resetField("userAgentType");
}}
>
<Controller
control={control}
name="userAgentType"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<Select
value={value === undefined ? "all" : value}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else setValue("userAgentType", e as UserAgentType, { shouldDirty: true });
}}
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
>
<SelectItem value="all" key="all">
All sources
</SelectItem>
{userAgentTypes.map(({ label, value: userAgent }) => (
<SelectItem value={userAgent} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</LogFilterItem>
<LogFilterItem
label="Date"
onClear={() => {
resetField("startDate");
resetField("endDate");
}}
>
<div className="flex h-10 w-full items-center justify-between gap-2">
<Controller
name="startDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
className="relative top-2"
errorText={error?.message}
isError={Boolean(error)}
>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
<div className="flex items-center -space-x-3">
<div className="h-[2px] w-[20px] rounded-full bg-mineshaft-500" />
<FontAwesomeIcon icon={faArrowRight} className="text-mineshaft-500" />
</div>
<Controller
name="endDate"
control={control}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
className="relative top-2"
errorText={error?.message}
isError={Boolean(error)}
>
<DatePicker
value={field.value || undefined}
onChange={onChange}
dateFormat="P"
popUpProps={{
open: isEndDatePickerOpen,
onOpenChange: setIsEndDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/>
</div>
</LogFilterItem>
<AnimatePresence initial={false}>
{showSecretsSection && (
<motion.div
className="mt-2 overflow-hidden"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="mb-3 mt-2">
<p className="text-xs opacity-60">Secrets</p>
<div className="h-[1px] w-full rounded-full bg-mineshaft-500" />
</div>
<LogFilterItem
label="Project"
onClear={() => {
resetField("project");
resetField("environment");
setValue("secretPath", "");
setValue("secretKey", "");
}}
>
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<FilterableSelect
value={value}
isClearable
onChange={(e) => {
if (e === null) {
setValue("secretPath", "");
setValue("secretKey", "");
}
resetField("environment");
onChange(e);
}}
placeholder="All projects"
options={workspacesInOrg.map(({ name, id, type }) => ({
name,
id,
type
}))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
</LogFilterItem>
<LogFilterItem
label="Environment"
hoverTooltip={
!selectedProject
? "Select a project before filtering by environment."
: undefined
}
className={twMerge(!selectedProject && "opacity-50")}
onClear={() => {
resetField("environment");
}}
>
<Controller
control={control}
name="environment"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
className="w-full"
>
<FilterableSelect
value={value}
key={value?.name || "filter-environment"}
isClearable
isDisabled={!selectedProject}
onChange={(e) => onChange(e)}
placeholder="All environments"
options={availableEnvironments.map(({ name, slug }) => ({
name,
slug
}))}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
</LogFilterItem>
<LogFilterItem
label="Secret Path"
hoverTooltip={
!selectedProject
? "Select a project before filtering by secret path."
: undefined
}
className={twMerge(!selectedProject && "opacity-50")}
onClear={() => {
setValue("secretPath", "");
}}
>
<Controller
control={control}
name="secretPath"
render={({ field: { onChange, value, ...field } }) => (
<FormControl
tooltipText="Filter audit logs related to events that occurred on a specific secret path."
className="w-full"
>
<Input
placeholder="Enter secret path"
className="disabled:cursor-not-allowed"
isDisabled={!selectedProject}
{...field}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</LogFilterItem>
<LogFilterItem
hoverTooltip={
!selectedProject
? "Select a project before filtering by secret key."
: undefined
}
className={twMerge(!selectedProject && "opacity-50")}
label="Secret Key"
onClear={() => {
setValue("secretKey", "");
}}
>
<Controller
control={control}
name="secretKey"
render={({ field: { onChange, value, ...field } }) => (
<FormControl
tooltipText="Filter audit logs related to a specific secret."
className="w-full"
>
<Input
isDisabled={!selectedProject}
{...field}
placeholder="Enter secret key"
className="disabled:cursor-not-allowed"
value={value}
onChange={(e) =>
setValue("secretKey", e.target.value, { shouldDirty: true })
}
/>
</FormControl>
)}
/>
</LogFilterItem>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
); );
}; };
@@ -6,46 +6,40 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks"; import { useDebounce } from "@app/hooks";
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { LogsFilter } from "./LogsFilter"; import { LogsFilter } from "./LogsFilter";
import { LogsTable } from "./LogsTable"; import { LogsTable } from "./LogsTable";
import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types"; import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types";
type Props = { type Props = {
presets?: { presets?: Presets;
actorId?: string;
eventType?: EventType[];
actorType?: ActorType;
startDate?: Date;
endDate?: Date;
eventMetadata?: Record<string, string>;
};
showFilters?: boolean;
filterClassName?: string;
refetchInterval?: number; refetchInterval?: number;
showFilters?: boolean;
}; };
export const LogsSection = withPermission( export const LogsSection = withPermission(
({ presets, filterClassName, refetchInterval, showFilters }: Props) => { ({ presets, refetchInterval, showFilters = true }: Props) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const { control, reset, watch, setValue } = useForm<AuditLogFilterFormData>({ const { control, reset, watch, getFieldState, resetField, setValue } =
resolver: zodResolver(auditLogFilterFormSchema), useForm<AuditLogFilterFormData>({
defaultValues: { resolver: zodResolver(auditLogFilterFormSchema),
project: null, defaultValues: {
actor: presets?.actorId, project: null,
eventType: presets?.eventType || [], environment: undefined,
page: 1, secretKey: "",
perPage: 10, secretPath: "",
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today actor: presets?.actorId,
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today eventType: presets?.eventType || [],
} userAgentType: undefined,
}); startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)),
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999))
}
});
useEffect(() => { useEffect(() => {
if (subscription && !subscription.auditLogs) { if (subscription && !subscription.auditLogs) {
@@ -57,30 +51,37 @@ export const LogsSection = withPermission(
const userAgentType = watch("userAgentType") as UserAgentType | undefined; const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor"); const actor = watch("actor");
const projectId = watch("project")?.id; const projectId = watch("project")?.id;
const environment = watch("environment")?.slug;
const secretPath = watch("secretPath"); const secretPath = watch("secretPath");
const secretKey = watch("secretKey");
const startDate = watch("startDate"); const startDate = watch("startDate");
const endDate = watch("endDate"); const endDate = watch("endDate");
const [debouncedSecretPath] = useDebounce<string>(secretPath!, 500); const [debouncedSecretPath] = useDebounce<string>(secretPath!, 500);
const [debouncedSecretKey] = useDebounce<string>(secretKey!, 500);
return ( return (
<div> <div className="space-y-2">
{showFilters && ( <div className="flex w-full justify-end">
<LogsFilter {showFilters && (
isOrgAuditLogs <LogsFilter
className={filterClassName} presets={presets}
presets={presets} control={control}
control={control} watch={watch}
setValue={setValue} reset={reset}
watch={watch} resetField={resetField}
reset={reset} getFieldState={getFieldState}
/> setValue={setValue}
)} />
)}
</div>
<LogsTable <LogsTable
refetchInterval={refetchInterval} refetchInterval={refetchInterval}
filter={{ filter={{
secretPath: debouncedSecretPath || undefined, secretPath: debouncedSecretPath || undefined,
secretKey: debouncedSecretKey || undefined,
eventMetadata: presets?.eventMetadata, eventMetadata: presets?.eventMetadata,
projectId, projectId,
actorType: presets?.actorType, actorType: presets?.actorType,
@@ -89,6 +90,7 @@ export const LogsSection = withPermission(
userAgentType, userAgentType,
startDate, startDate,
endDate, endDate,
environment,
actor actor
}} }}
/> />
@@ -1,10 +1,12 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { faFile, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { faFile, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { import {
Button, Button,
EmptyState, EmptyState,
Spinner,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -52,7 +54,9 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="w-24" /> <Th className="w-24">
<Spinner size="xs" className={twMerge(isPending ? "opacity-100" : "opacity-0")} />
</Th>
<Th className="w-64"> <Th className="w-64">
Timestamp Timestamp
<Tooltip <Tooltip
@@ -94,7 +98,7 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
<Button <Button
className="mb-20 mt-4 px-4 py-3 text-sm" className="mb-20 mt-4 px-4 py-3 text-sm"
isFullWidth isFullWidth
variant="star" variant="outline_bg"
isLoading={isFetchingNextPage} isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage} isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums"; import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
export const auditLogFilterFormSchema = z export const auditLogFilterFormSchema = z
@@ -10,10 +10,12 @@ export const auditLogFilterFormSchema = z
.object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) }) .object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) })
.optional() .optional()
.nullable(), .nullable(),
environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(),
eventType: z.nativeEnum(EventType).array(), eventType: z.nativeEnum(EventType).array(),
actor: z.string().optional(), actor: z.string().optional(),
userAgentType: z.nativeEnum(UserAgentType), userAgentType: z.nativeEnum(UserAgentType),
secretPath: z.string().optional(), secretPath: z.string().optional(),
secretKey: z.string().optional(),
startDate: z.date().optional(), startDate: z.date().optional(),
endDate: z.date().optional(), endDate: z.date().optional(),
page: z.coerce.number().optional(), page: z.coerce.number().optional(),
@@ -39,3 +41,12 @@ export type SetValueType = (
shouldDirty?: boolean; shouldDirty?: boolean;
} }
) => void; ) => void;
export type Presets = {
actorId?: string;
eventType?: EventType[];
actorType?: ActorType;
startDate?: Date;
endDate?: Date;
eventMetadata?: Record<string, string>;
};
@@ -1,8 +1,3 @@
import { useState } from "react";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { OrgUser } from "@app/hooks/api/types"; import { OrgUser } from "@app/hooks/api/types";
@@ -14,7 +9,6 @@ type Props = {
export const UserAuditLogsSection = withPermission( export const UserAuditLogsSection = withPermission(
({ orgMembership }: Props) => { ({ orgMembership }: Props) => {
const [showFilter, setShowFilter] = useState(false);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
@@ -23,25 +17,8 @@ export const UserAuditLogsSection = withPermission(
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4"> <div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Audit Logs</p> <p className="text-lg font-semibold text-gray-200">Audit Logs</p>
<Tooltip content="Show audit log filters">
<IconButton
colorSchema="primary"
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => setShowFilter(!showFilter)}
>
<div className="flex items-center space-x-2">
<p>Filter</p>
<FontAwesomeIcon icon={faFilter} />
</div>
</IconButton>
</Tooltip>
</div> </div>
<LogsSection <LogsSection
showFilters={showFilter}
filterClassName="bg-mineshaft-900 static"
presets={{ presets={{
actorId: orgMembership.user.id actorId: orgMembership.user.id
}} }}
@@ -35,7 +35,6 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
eventType: INTEGRATION_EVENTS eventType: INTEGRATION_EVENTS
}} }}
filterClassName="bg-mineshaft-900 static"
/> />
</div> </div>
) : ( ) : (
@@ -41,7 +41,6 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
eventType: INTEGRATION_EVENTS eventType: INTEGRATION_EVENTS
}} }}
filterClassName="bg-mineshaft-900 static"
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200"> <div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200">