mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(dashboard): organization-level audit logs
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
20
frontend/src/pages/org/[id]/audit-logs/index.tsx
Normal file
20
frontend/src/pages/org/[id]/audit-logs/index.tsx
Normal 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;
|
||||
20
frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx
Normal file
20
frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx
Normal 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
|
||||
);
|
||||
1
frontend/src/views/Org/AuditLogsPage/index.tsx
Normal file
1
frontend/src/views/Org/AuditLogsPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { AuditLogsPage } from "./AuditLogsPage";
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user