From b5660c87a033bfe89e130ab98cf269d8f8c572d3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 18 Sep 2024 14:36:28 +0400 Subject: [PATCH] feat(dashboard): organization-level audit logs --- .../ee/services/audit-log/audit-log-dal.ts | 52 +++++++++----- frontend/src/hooks/api/auditLogs/types.tsx | 1 + frontend/src/layouts/AppLayout/AppLayout.tsx | 10 +++ .../src/pages/org/[id]/audit-logs/index.tsx | 20 ++++++ .../views/Org/AuditLogsPage/AuditLogsPage.tsx | 20 ++++++ .../src/views/Org/AuditLogsPage/index.tsx | 1 + .../AuditLogsPage/components/LogsFilter.tsx | 69 +++++++++++++++++-- .../AuditLogsPage/components/LogsSection.tsx | 3 + .../AuditLogsPage/components/LogsTable.tsx | 5 +- .../AuditLogsPage/components/types.tsx | 1 + 10 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 frontend/src/pages/org/[id]/audit-logs/index.tsx create mode 100644 frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx create mode 100644 frontend/src/views/Org/AuditLogsPage/index.tsx diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index ce345766b..4b967bd38 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -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 }); } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index fc616321a..80a39acae 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -8,6 +8,7 @@ export type TGetAuditLogsFilter = { userAgentType?: UserAgentType; eventMetadata?: Record; actorType?: ActorType; + projectId?: string; actorId?: string; // user ID format startDate?: Date; endDate?: Date; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 4e3602686..3f7f1a7e0 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -755,6 +755,16 @@ export const AppLayout = ({ children }: LayoutProps) => { )} + + + + Audit Logs + + + { + return ( +
+ + Infisical | Audit Logs + + + + +
+ ); +}; + +export default Logs; + +Logs.requireAuth = true; diff --git a/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx new file mode 100644 index 000000000..239ec7ff7 --- /dev/null +++ b/frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx @@ -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 ( +
+
+
+

Audit Logs

+
+
+ +
+
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } // TODO(Daniel): Create a permission for org audit logs +); diff --git a/frontend/src/views/Org/AuditLogsPage/index.tsx b/frontend/src/views/Org/AuditLogsPage/index.tsx new file mode 100644 index 000000000..3864b1435 --- /dev/null +++ b/frontend/src/views/Org/AuditLogsPage/index.tsx @@ -0,0 +1 @@ +export { AuditLogsPage } from "./AuditLogsPage"; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 619b0503a..ba1b91a85 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -40,16 +40,24 @@ type Props = { eventType?: EventType[]; }; className?: string; + isOrgAuditLogs?: boolean; control: Control; reset: UseFormReset; watch: UseFormWatch; }; -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`}
@@ -199,11 +207,20 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props) className="w-40" > { + 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" + )} + > + + All projects + + {workspaces.map((project) => ( + + {project.name} + + ))} + + + )} + /> + )} diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx index 68fd354e3..83ae4d1df 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx @@ -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 = ({
{showFilters && ( { 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 } diff --git a/frontend/src/views/Project/AuditLogsPage/components/types.tsx b/frontend/src/views/Project/AuditLogsPage/components/types.tsx index f1165c80f..12afd0779 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/types.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/types.tsx @@ -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"),