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
+13 -5
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
name: z.string(), .object({
slug: z.string() name: 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) => {
...AuditLogsSchema.parse(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.
project: { // This is a quick and dirty way to get around the types.
name: doc.projectName, const projectDoc = doc as unknown as { projectName: string; projectSlug: string };
slug: doc.projectSlug
} return {
})); ...AuditLogsSchema.parse(doc),
...(projectDoc?.projectSlug && {
project: {
name: projectDoc.projectName,
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
name: z.string(), .object({
slug: z.string() name: 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"
@@ -5,39 +5,40 @@ import { useBackfillSecretReference } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
export const BackfillSecretReferenceSecretion = () => { export const BackfillSecretReferenceSecretion = () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission(); const { membership } = useProjectPermission();
const backfillSecretReferences = useBackfillSecretReference(); const backfillSecretReferences = useBackfillSecretReference();
if (!currentWorkspace) return null; if (!currentWorkspace) return null;
const handleBackfill = async () => { const handleBackfill = async () => {
if (backfillSecretReferences.isLoading) return; if (backfillSecretReferences.isLoading) return;
try { try {
await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" }); await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" });
createNotification({ text: "Successfully re-indexed secret references", type: "success" }); createNotification({ text: "Successfully re-indexed secret references", type: "success" });
} catch { } catch {
createNotification({ text: "Failed to re-index secret references", type: "error" }); createNotification({ text: "Failed to re-index secret references", type: "error" });
} }
}; };
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<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
</p> values change going forward. This happens automatically when secrets are created or updated.
<Button </p>
variant="outline_bg" <Button
isLoading={backfillSecretReferences.isLoading} variant="outline_bg"
onClick={handleBackfill} isLoading={backfillSecretReferences.isLoading}
isDisabled={!isAdmin} onClick={handleBackfill}
> isDisabled={!isAdmin}
Index Secret References >
</Button> Index Secret References
</div> </Button>
); </div>
);
}; };