Merge pull request #2448 from Infisical/daniel/org-level-audit-logs

feat(audit-logs): moved audit logs to organization-level
This commit is contained in:
Daniel Hougaard
2024-09-21 02:54:06 +04:00
committed by GitHub
28 changed files with 288 additions and 127 deletions
+11 -3
View File
@@ -87,6 +87,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
} }
}); });
/*
* Daniel: This endpoint is no longer is use.
* We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows.
*
* Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality.
*/
server.route({ server.route({
method: "GET", method: "GET",
url: "/:workspaceId/audit-logs", url: "/:workspaceId/audit-logs",
@@ -101,7 +107,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
} }
], ],
params: z.object({ params: z.object({
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.workspaceId) workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId)
}), }),
querystring: z.object({ querystring: z.object({
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
@@ -122,10 +128,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}) })
.merge( .merge(
z.object({ z.object({
project: z.object({ project: z
.object({
name: z.string(), name: z.string(),
slug: z.string() slug: z.string()
}), })
.optional(),
event: z.object({ event: z.object({
type: z.string(), type: z.string(),
metadata: z.any() metadata: z.any()
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { AuditLogsSchema, TableName } from "@app/db/schemas"; import { AuditLogsSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; 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 { 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";
@@ -48,47 +48,61 @@ export const auditLogDALFactory = (db: TDbClient) => {
}, },
tx?: Knex tx?: Knex
) => { ) => {
if (!orgId && !projectId) {
throw new Error("Either orgId or projectId must be provided");
}
try { try {
// Find statements
const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog) const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog)
.where(
stripUndefinedInWhere({
projectId,
[`${TableName.AuditLog}.orgId`]: orgId,
userAgentType
})
)
.leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`) .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 sqlQuery.where("userAgentType", userAgentType);
}
// Select statements
void sqlQuery
.select(selectAllTableCols(TableName.AuditLog)) .select(selectAllTableCols(TableName.AuditLog))
.select( .select(
db.ref("name").withSchema(TableName.Project).as("projectName"), db.ref("name").withSchema(TableName.Project).as("projectName"),
db.ref("slug").withSchema(TableName.Project).as("projectSlug") db.ref("slug").withSchema(TableName.Project).as("projectSlug")
) )
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.orderBy(`${TableName.AuditLog}.createdAt`, "desc"); .orderBy(`${TableName.AuditLog}.createdAt`, "desc");
// Special case: Filter by actor ID
if (actorId) { if (actorId) {
void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]); void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]);
} }
// Special case: Filter by key/value pairs in eventMetadata field
if (eventMetadata && Object.keys(eventMetadata).length) { if (eventMetadata && Object.keys(eventMetadata).length) {
Object.entries(eventMetadata).forEach(([key, value]) => { Object.entries(eventMetadata).forEach(([key, value]) => {
void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]); void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]);
}); });
} }
// Filter by actor type
if (actorType) { if (actorType) {
void sqlQuery.where("actor", actorType); void sqlQuery.where("actor", actorType);
} }
// Filter by event types
if (eventType?.length) { if (eventType?.length) {
void sqlQuery.whereIn("eventType", eventType); void sqlQuery.whereIn("eventType", eventType);
} }
// Filter by date range
if (startDate) { if (startDate) {
void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate); void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate);
} }
@@ -97,13 +111,21 @@ export const auditLogDALFactory = (db: TDbClient) => {
} }
const docs = await sqlQuery; const docs = await sqlQuery;
return docs.map((doc) => ({ 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), ...AuditLogsSchema.parse(doc),
...(projectDoc?.projectSlug && {
project: { project: {
name: doc.projectName, name: projectDoc.projectName,
slug: doc.projectSlug slug: projectDoc.projectSlug
} }
})); })
};
});
} catch (error) { } catch (error) {
throw new DatabaseError({ error }); throw new DatabaseError({ error });
} }
@@ -24,6 +24,7 @@ export const auditLogServiceFactory = ({
permissionService permissionService
}: TAuditLogServiceFactoryDep) => { }: TAuditLogServiceFactoryDep) => {
const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => { const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => {
// Filter logs for specific project
if (filter.projectId) { if (filter.projectId) {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -34,6 +35,7 @@ export const auditLogServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
} else { } else {
// Organization-wide logs
const { permission } = await permissionService.getOrgPermission( const { permission } = await permissionService.getOrgPermission(
actor, actor,
actorId, actorId,
@@ -44,13 +46,12 @@ export const auditLogServiceFactory = ({
/** /**
* NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved * NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved
* to the organization level * to the organization level ✅
*/ */
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
} }
// If project ID is not provided, then we need to return all the audit logs for the organization itself. // If project ID is not provided, then we need to return all the audit logs for the organization itself.
const auditLogs = await auditLogDAL.find({ const auditLogs = await auditLogDAL.find({
startDate: filter.startDate, startDate: filter.startDate,
endDate: filter.endDate, endDate: filter.endDate,
@@ -25,7 +25,8 @@ export enum OrgPermissionSubjects {
SecretScanning = "secret-scanning", SecretScanning = "secret-scanning",
Identity = "identity", Identity = "identity",
Kms = "kms", Kms = "kms",
AdminConsole = "organization-admin-console" AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs"
} }
export type OrgPermissionSet = export type OrgPermissionSet =
@@ -43,6 +44,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
const buildAdminPermission = () => { const buildAdminPermission = () => {
@@ -111,6 +113,11 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
return rules; return rules;
@@ -140,6 +147,8 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
return rules; return rules;
}; };
@@ -145,6 +145,8 @@ export const fullProjectPermissionSet: [ProjectPermissionActions, ProjectPermiss
[ProjectPermissionActions.Edit, ProjectPermissionSub.Tags], [ProjectPermissionActions.Edit, ProjectPermissionSub.Tags],
[ProjectPermissionActions.Delete, ProjectPermissionSub.Tags], [ProjectPermissionActions.Delete, ProjectPermissionSub.Tags],
// TODO(Daniel): Remove the audit logs permissions from project-level permissions.
// TODO: We haven't done this yet because it might break existing roles, since those roles will become "invalid" since the audit log permission defined on those roles, no longer exist in the project-level defined permissions.
[ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs], [ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs],
[ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs], [ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs],
[ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs], [ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs],
+4 -1
View File
@@ -731,9 +731,12 @@ export const DASHBOARD = {
export const AUDIT_LOGS = { export const AUDIT_LOGS = {
EXPORT: { EXPORT: {
workspaceId: "The ID of the project to export audit logs from.", projectId:
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
eventType: "The type of the event to export.", eventType: "The type of the event to export.",
userAgentType: "Choose which consuming application to export audit logs for.", userAgentType: "Choose which consuming application to export audit logs for.",
eventMetadata:
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
startDate: "The date to start the export from.", startDate: "The date to start the export from.",
endDate: "The date to end the export at.", endDate: "The date to end the export at.",
offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.", offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.",
@@ -74,7 +74,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
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(), projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
actorType: z.nativeEnum(ActorType).optional(), actorType: z.nativeEnum(ActorType).optional(),
// 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
@@ -102,7 +102,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}, },
{} as Record<string, string> {} as Record<string, string>
); );
}), })
.describe(AUDIT_LOGS.EXPORT.eventMetadata),
startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate),
endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate),
offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset),
@@ -120,10 +121,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}) })
.merge( .merge(
z.object({ z.object({
project: z.object({ project: z
.object({
name: z.string(), name: z.string(),
slug: z.string() slug: z.string()
}), })
.optional(),
event: z.object({ event: z.object({
type: z.string(), type: z.string(),
metadata: z.any() metadata: z.any()
@@ -1,4 +1,4 @@
--- ---
title: "Export" title: "Export"
openapi: "GET /api/v1/workspace/{workspaceId}/audit-logs" openapi: "GET /api/v1/organization/audit-logs"
--- ---
@@ -21,7 +21,8 @@ export enum OrgPermissionSubjects {
SecretScanning = "secret-scanning", SecretScanning = "secret-scanning",
Identity = "identity", Identity = "identity",
Kms = "kms", Kms = "kms",
AdminConsole = "organization-admin-console" AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs"
} }
export enum OrgPermissionAdminConsoleAction { export enum OrgPermissionAdminConsoleAction {
@@ -43,6 +44,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs];
export type TOrgPermission = MongoAbility<OrgPermissionSet>; export type TOrgPermission = MongoAbility<OrgPermissionSet>;
+2 -1
View File
@@ -8,6 +8,7 @@ export type TGetAuditLogsFilter = {
userAgentType?: UserAgentType; userAgentType?: UserAgentType;
eventMetadata?: Record<string, string>; eventMetadata?: Record<string, string>;
actorType?: ActorType; actorType?: ActorType;
projectId?: string;
actorId?: string; // user ID format actorId?: string; // user ID format
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
@@ -885,7 +886,7 @@ export type AuditLog = {
userAgentType: UserAgentType; userAgentType: UserAgentType;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
project: { project?: {
name: string; name: string;
slug: string; slug: string;
}; };
+10 -12
View File
@@ -675,18 +675,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
<Link href={`/project/${currentWorkspace?.id}/audit-logs`} passHref>
<a>
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?.id}/audit-logs`
}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?.id}/settings`} passHref> <Link href={`/project/${currentWorkspace?.id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -755,6 +743,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
</a> </a>
</Link> </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> <Link href={`/org/${currentOrg?.id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -1,15 +1,12 @@
import { useTranslation } from "react-i18next";
import Head from "next/head"; import Head from "next/head";
import { AuditLogsPage } from "@app/views/Project/AuditLogsPage"; import { AuditLogsPage } from "@app/views/Org/AuditLogsPage";
const Logs = () => { const Logs = () => {
const { t } = useTranslation();
return ( return (
<div className="h-full bg-bunker-800"> <div className="h-full bg-bunker-800">
<Head> <Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title> <title>Infisical | Audit Logs</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" /> <meta property="og:image" content="/images/message.png" />
</Head> </Head>
@@ -4,7 +4,7 @@ import { EmptyState } from "@app/components/v2";
import { useSubscription } from "@app/context"; import { useSubscription } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums"; import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; import { LogsSection } from "@app/views/Org/AuditLogsPage/components";
// Add more events if needed // Add more events if needed
const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED]; const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED];
@@ -0,0 +1,21 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { LogsSection } from "./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.AuditLogs }
);
@@ -40,16 +40,24 @@ type Props = {
eventType?: EventType[]; eventType?: EventType[];
}; };
className?: string; className?: string;
isOrgAuditLogs?: boolean;
control: Control<AuditLogFilterFormData>; control: Control<AuditLogFilterFormData>;
reset: UseFormReset<AuditLogFilterFormData>; reset: UseFormReset<AuditLogFilterFormData>;
watch: UseFormWatch<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 [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace, workspaces } = useWorkspace();
const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? ""); const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? "");
const renderActorSelectItem = (actor: Actor) => { const renderActorSelectItem = (actor: Actor) => {
@@ -112,7 +120,7 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0]) ? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
?.label ?.label
: selectedEventTypes?.length === 0 : selectedEventTypes?.length === 0
? "Select event types" ? "All events"
: `${selectedEventTypes?.length} events selected`} : `${selectedEventTypes?.length} events selected`}
<FontAwesomeIcon icon={faChevronDown} className="ml-2 text-xs" /> <FontAwesomeIcon icon={faChevronDown} className="ml-2 text-xs" />
</div> </div>
@@ -191,7 +199,7 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
<Controller <Controller
control={control} control={control}
name="userAgentType" name="userAgentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Source" label="Source"
errorText={error?.message} errorText={error?.message}
@@ -199,13 +207,22 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
className="w-40" className="w-40"
> >
<Select <Select
{...(field.value ? { value: field.value } : { placeholder: "Select" })} value={value === undefined ? "all" : value}
{...field} {...field}
onValueChange={(e) => onChange(e)} onValueChange={(e) => {
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100" if (e === "all") onChange(undefined);
else onChange(e);
}}
className={twMerge(
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
value === undefined && "text-mineshaft-400"
)}
> >
{userAgentTypes.map(({ label, value }) => ( <SelectItem value="all" key="all">
<SelectItem value={String(value || "")} key={label}> All sources
</SelectItem>
{userAgentTypes.map(({ label, value: userAgent }) => (
<SelectItem value={userAgent} key={label}>
{label} {label}
</SelectItem> </SelectItem>
))} ))}
@@ -213,6 +230,43 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
</FormControl> </FormControl>
)} )}
/> />
{isOrgAuditLogs && workspaces.length > 0 && (
<Controller
control={control}
name="projectId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
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 text-mineshaft-100",
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 <Controller
name="startDate" name="startDate"
control={control} control={control}
@@ -272,7 +326,8 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
actor: presets?.actorId, actor: presets?.actorId,
userAgentType: undefined, userAgentType: undefined,
startDate: undefined, startDate: undefined,
endDate: undefined endDate: undefined,
projectId: undefined
}) })
} }
> >
@@ -47,6 +47,7 @@ export const LogsSection = ({
const { control, reset, watch } = useForm<AuditLogFilterFormData>({ const { control, reset, watch } = useForm<AuditLogFilterFormData>({
resolver: yupResolver(auditLogFilterFormSchema), resolver: yupResolver(auditLogFilterFormSchema),
defaultValues: { defaultValues: {
projectId: undefined,
actor: presets?.actorId, actor: presets?.actorId,
eventType: presets?.eventType || [], eventType: presets?.eventType || [],
page: 1, page: 1,
@@ -65,6 +66,7 @@ export const LogsSection = ({
const eventType = watch("eventType") as EventType[] | undefined; const eventType = watch("eventType") as EventType[] | undefined;
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("projectId");
const startDate = watch("startDate"); const startDate = watch("startDate");
const endDate = watch("endDate"); const endDate = watch("endDate");
@@ -73,6 +75,7 @@ export const LogsSection = ({
<div> <div>
{showFilters && ( {showFilters && (
<LogsFilter <LogsFilter
isOrgAuditLogs
className={filterClassName} className={filterClassName}
presets={presets} presets={presets}
control={control} control={control}
@@ -87,6 +90,7 @@ export const LogsSection = ({
showActorColumn={!!showActorColumn && !isOrgAuditLogs} showActorColumn={!!showActorColumn && !isOrgAuditLogs}
filter={{ filter={{
eventMetadata: presets?.eventMetadata, eventMetadata: presets?.eventMetadata,
projectId,
actorType: presets?.actorType, actorType: presets?.actorType,
limit: 15, limit: 15,
eventType, eventType,
@@ -41,12 +41,23 @@ export const LogsTable = ({
}: Props) => { }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
// Determine the project ID for filtering
const filterProjectId =
// Use the projectId from the filter if it exists
filter?.projectId ??
// Otherwise, if we're not looking at org-wide audit logs
(!isOrgAuditLogs
? // Use the current workspace ID (or an empty string if that's null)
currentWorkspace?.id ?? ""
: // For org-wide audit logs, use null (no specific project filter)
null);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs( const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs(
{ {
...filter, ...filter,
limit: AUDIT_LOG_LIMIT limit: AUDIT_LOG_LIMIT
}, },
!isOrgAuditLogs ? currentWorkspace?.id ?? "" : null, filterProjectId,
{ {
refetchInterval refetchInterval
} }
@@ -39,6 +39,8 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
}; };
const renderMetadata = (event: Event) => { const renderMetadata = (event: Event) => {
const metadataKeys = Object.keys(event.metadata);
switch (event.type) { switch (event.type) {
case EventType.GET_SECRETS: case EventType.GET_SECRETS:
return ( return (
@@ -476,7 +478,47 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
</Tooltip> </Tooltip>
</Td> </Td>
); );
case EventType.GET_WORKSPACE_KEY:
return (
<Td>
<p>{`Key ID: ${event.metadata.keyId}`}</p>
</Td>
);
case EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH:
case EventType.ADD_IDENTITY_UNIVERSAL_AUTH:
case EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH:
case EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS:
return (
<Td>
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
</Td>
);
case EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
case EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
return (
<Td>
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
<p>{`Client Secret ID: ${event.metadata.clientSecretId}`}</p>
</Td>
);
// ? If for some reason non the above events are matched, we will display the first 3 metadata items in the metadata object.
default: default:
if (metadataKeys.length) {
const maxMetadataLength = metadataKeys.length > 3 ? 3 : metadataKeys.length;
return (
<Td>
{Object.entries(event.metadata)
.slice(0, maxMetadataLength)
.map(([key, value]) => {
return <p key={`audit-log-metadata-${key}`}>{`${key}: ${value}`}</p>;
})}
</Td>
);
}
return <Td />; return <Td />;
} }
}; };
@@ -531,7 +573,7 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
<Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}> <Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}>
<Td>{formatDate(auditLog.createdAt)}</Td> <Td>{formatDate(auditLog.createdAt)}</Td>
<Td>{`${eventToNameMap[auditLog.event.type]}`}</Td> <Td>{`${eventToNameMap[auditLog.event.type]}`}</Td>
{isOrgAuditLogs && <Td>{auditLog.project.name}</Td>} {isOrgAuditLogs && <Td>{auditLog?.project?.name ?? "N/A"}</Td>}
{showActorColumn && renderActor(auditLog.actor)} {showActorColumn && renderActor(auditLog.actor)}
{renderSource()} {renderSource()}
{renderMetadata(auditLog.event)} {renderMetadata(auditLog.event)}
@@ -5,6 +5,7 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
export const auditLogFilterFormSchema = yup export const auditLogFilterFormSchema = yup
.object({ .object({
eventMetadata: yup.object({}).optional(), eventMetadata: yup.object({}).optional(),
projectId: yup.string().optional(),
eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")), eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")),
actor: yup.string(), actor: yup.string(),
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"), userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),
@@ -32,6 +32,8 @@ export const formSchema = z.object({
create: z.boolean().optional() create: z.boolean().optional()
}) })
.optional(), .optional(),
"audit-logs": generalPermissionSchema,
member: generalPermissionSchema, member: generalPermissionSchema,
groups: generalPermissionSchema, groups: generalPermissionSchema,
role: generalPermissionSchema, role: generalPermissionSchema,
@@ -41,6 +41,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "Incident Contacts", title: "Incident Contacts",
formName: "incident-contact" formName: "incident-contact"
}, },
{
title: "Audit Logs",
formName: "audit-logs"
},
{ {
title: "Organization Profile", title: "Organization Profile",
formName: "settings" formName: "settings"
@@ -7,7 +7,7 @@ import { EmptyState, 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";
import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; import { LogsSection } from "@app/views/Org/AuditLogsPage/components";
type Props = { type Props = {
orgMembership: OrgUser; orgMembership: OrgUser;
@@ -1,21 +0,0 @@
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { LogsSection } from "./components";
export const AuditLogsPage = withProjectPermission(
() => {
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="sticky top-0 z-10 bg-bunker-800 py-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div />
</div>
<LogsSection showFilters />
</div>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
);
@@ -47,7 +47,6 @@ export const formSchema = z.object({
settings: generalPermissionSchema, settings: generalPermissionSchema,
environments: generalPermissionSchema, environments: generalPermissionSchema,
tags: generalPermissionSchema, tags: generalPermissionSchema,
"audit-logs": generalPermissionSchema,
"ip-allowlist": generalPermissionSchema, "ip-allowlist": generalPermissionSchema,
"certificate-authorities": generalPermissionSchema, "certificate-authorities": generalPermissionSchema,
certificates: generalPermissionSchema, certificates: generalPermissionSchema,
@@ -65,10 +65,6 @@ const SINGLE_PERMISSION_LIST = [
title: "Tags", title: "Tags",
formName: "tags" formName: "tags"
}, },
{
title: "Audit Logs",
formName: "audit-logs"
},
{ {
title: "IP Allowlist", title: "IP Allowlist",
formName: "ip-allowlist" formName: "ip-allowlist"
@@ -28,7 +28,8 @@ export const BackfillSecretReferenceSecretion = () => {
<p className="text-xl font-semibold">Index Secret References</p> <p className="text-xl font-semibold">Index Secret References</p>
</div> </div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400"> <p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This will index all secret references, enabling integrations to be triggered when their values change going forward. This will index all secret references, enabling integrations to be triggered when their
values change going forward. This happens automatically when secrets are created or updated.
</p> </p>
<Button <Button
variant="outline_bg" variant="outline_bg"