feat(dashboard): organization-level audit logs

This commit is contained in:
Daniel Hougaard
2024-09-18 14:36:28 +04:00
parent 5b9903a226
commit b5660c87a0
10 changed files with 156 additions and 26 deletions

View File

@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { AuditLogsSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type";
@@ -49,46 +49,56 @@ export const auditLogDALFactory = (db: TDbClient) => {
tx?: Knex
) => {
try {
// Find statements
const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog)
.where(
stripUndefinedInWhere({
projectId,
[`${TableName.AuditLog}.orgId`]: orgId,
userAgentType
})
)
.leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`)
// eslint-disable-next-line func-names
.where(function () {
if (orgId) {
void this.where(`${TableName.Project}.orgId`, orgId);
// .orWhere(`${TableName.AuditLog}.orgId`, orgId);
} else if (projectId) {
void this.where(`${TableName.AuditLog}.projectId`, projectId);
}
if (userAgentType) {
void this.where(`${TableName.AuditLog}.userAgentType`, userAgentType);
}
});
// Select statements
void sqlQuery
.select(selectAllTableCols(TableName.AuditLog))
.select(
db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug")
)
.limit(limit)
.offset(offset)
.orderBy(`${TableName.AuditLog}.createdAt`, "desc");
// Special case: Filter by actor ID
if (actorId) {
void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]);
}
// Special case: Filter by key/value pairs in eventMetadata field
if (eventMetadata && Object.keys(eventMetadata).length) {
Object.entries(eventMetadata).forEach(([key, value]) => {
void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]);
});
}
// Filter by actor type
if (actorType) {
void sqlQuery.where("actor", actorType);
}
// Filter by event types
if (eventType?.length) {
void sqlQuery.whereIn("eventType", eventType);
}
// Filter by date range
if (startDate) {
void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate);
}
@@ -97,13 +107,19 @@ export const auditLogDALFactory = (db: TDbClient) => {
}
const docs = await sqlQuery;
return docs.map((doc) => ({
...AuditLogsSchema.parse(doc),
project: {
name: doc.projectName,
slug: doc.projectSlug
}
}));
return docs.map((doc) => {
// Our type system refuses to acknowledge that the project name and slug are present in the doc, due to the disjointed query structure above.
// This is a quick and dirty way to get around the types.
const projectDoc = doc as unknown as { projectName: string; projectSlug: string };
return {
...AuditLogsSchema.parse(doc),
project: {
name: projectDoc.projectName,
slug: projectDoc.projectSlug
}
};
});
} catch (error) {
throw new DatabaseError({ error });
}

View File

@@ -8,6 +8,7 @@ export type TGetAuditLogsFilter = {
userAgentType?: UserAgentType;
eventMetadata?: Record<string, string>;
actorType?: ActorType;
projectId?: string;
actorId?: string; // user ID format
startDate?: Date;
endDate?: Date;

View File

@@ -755,6 +755,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
</a>
</Link>
)}
<Link href={`/org/${currentOrg?.id}/audit-logs`} passHref>
<a>
<MenuItem
isSelected={router.asPath === `/org/${currentOrg?.id}/audit-logs`}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
<Link href={`/org/${currentOrg?.id}/settings`} passHref>
<a>
<MenuItem

View File

@@ -0,0 +1,20 @@
import Head from "next/head";
import { AuditLogsPage } from "@app/views/Org/AuditLogsPage";
const Logs = () => {
return (
<div className="h-full bg-bunker-800">
<Head>
<title>Infisical | Audit Logs</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<AuditLogsPage />
</div>
);
};
export default Logs;
Logs.requireAuth = true;

View File

@@ -0,0 +1,20 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { LogsSection } from "@app/views/Project/AuditLogsPage/components";
export const AuditLogsPage = withPermission(
() => {
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="bg-bunker-800 py-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div />
</div>
<LogsSection filterClassName="static p-2" showFilters isOrgAuditLogs />
</div>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } // TODO(Daniel): Create a permission for org audit logs
);

View File

@@ -0,0 +1 @@
export { AuditLogsPage } from "./AuditLogsPage";

View File

@@ -40,16 +40,24 @@ type Props = {
eventType?: EventType[];
};
className?: string;
isOrgAuditLogs?: boolean;
control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>;
watch: UseFormWatch<AuditLogFilterFormData>;
};
export const LogsFilter = ({ presets, className, control, reset, watch }: Props) => {
export const LogsFilter = ({
presets,
isOrgAuditLogs,
className,
control,
reset,
watch
}: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const { currentWorkspace } = useWorkspace();
const { currentWorkspace, workspaces } = useWorkspace();
const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? "");
const renderActorSelectItem = (actor: Actor) => {
@@ -112,7 +120,7 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label
: selectedEventTypes?.length === 0
? "Select event types"
? "All events"
: `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faChevronDown} className="ml-2 text-xs" />
</div>
@@ -199,11 +207,20 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
{...(field.value ? { value: field.value } : { placeholder: "All sources" })}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
(field.value === "all" || field.value === undefined) && "text-mineshaft-400"
)}
>
<SelectItem value="all" key="all">
All sources
</SelectItem>
{userAgentTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
@@ -213,6 +230,43 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
</FormControl>
)}
/>
{isOrgAuditLogs && workspaces.length && (
<Controller
control={control}
name="projectId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="w-40"
>
<Select
{...(field.value ? { value: field.value } : { placeholder: "All projects" })}
{...field}
onValueChange={(e) => {
if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
(field.value === "all" || field.value === undefined) && "text-mineshaft-400"
)}
>
<SelectItem value="all" key="all">
All projects
</SelectItem>
{workspaces.map((project) => (
<SelectItem value={String(project.id || "")} key={project.id}>
{project.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
name="startDate"
control={control}
@@ -272,7 +326,8 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
actor: presets?.actorId,
userAgentType: undefined,
startDate: undefined,
endDate: undefined
endDate: undefined,
projectId: undefined
})
}
>

View File

@@ -65,6 +65,7 @@ export const LogsSection = ({
const eventType = watch("eventType") as EventType[] | undefined;
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor");
const projectId = watch("projectId");
const startDate = watch("startDate");
const endDate = watch("endDate");
@@ -73,6 +74,7 @@ export const LogsSection = ({
<div>
{showFilters && (
<LogsFilter
isOrgAuditLogs
className={filterClassName}
presets={presets}
control={control}
@@ -87,6 +89,7 @@ export const LogsSection = ({
showActorColumn={!!showActorColumn && !isOrgAuditLogs}
filter={{
eventMetadata: presets?.eventMetadata,
projectId,
actorType: presets?.actorType,
limit: 15,
eventType,

View File

@@ -41,12 +41,15 @@ export const LogsTable = ({
}: Props) => {
const { currentWorkspace } = useWorkspace();
const filterProjectId =
filter?.projectId ?? (!isOrgAuditLogs ? currentWorkspace?.id ?? "" : null);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs(
{
...filter,
limit: AUDIT_LOG_LIMIT
},
!isOrgAuditLogs ? currentWorkspace?.id ?? "" : null,
filterProjectId,
{
refetchInterval
}

View File

@@ -5,6 +5,7 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
export const auditLogFilterFormSchema = yup
.object({
eventMetadata: yup.object({}).optional(),
projectId: yup.string().optional(),
eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")),
actor: yup.string(),
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),