From 11ea5990c975c8e2a1611981d016e6f76650c3e4 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 25 Jun 2024 21:14:43 +0800 Subject: [PATCH 1/4] feat: enabled customization of project audit logs retention period --- ...115447_configurable-audit-log-retention.ts | 19 +++ backend/src/db/schemas/projects.ts | 3 +- .../ee/services/audit-log/audit-log-queue.ts | 19 ++- .../src/server/routes/v1/project-router.ts | 38 ++++++ .../src/services/project/project-service.ts | 43 ++++++- backend/src/services/project/project-types.ts | 5 + frontend/src/hooks/api/workspace/queries.tsx | 16 +++ frontend/src/hooks/api/workspace/types.ts | 2 + .../AuditLogsRetentionSection.tsx | 120 ++++++++++++++++++ .../AuditLogsRetentionSection/index.tsx | 1 + .../ProjectGeneralTab/ProjectGeneralTab.tsx | 2 + 11 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 backend/src/db/migrations/20240625115447_configurable-audit-log-retention.ts create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx diff --git a/backend/src/db/migrations/20240625115447_configurable-audit-log-retention.ts b/backend/src/db/migrations/20240625115447_configurable-audit-log-retention.ts new file mode 100644 index 0000000000..6ac4b6fe1c --- /dev/null +++ b/backend/src/db/migrations/20240625115447_configurable-audit-log-retention.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Project, "auditLogsRetentionDays"))) { + await knex.schema.alterTable(TableName.Project, (tb) => { + tb.integer("auditLogsRetentionDays").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Project, "auditLogsRetentionDays")) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("auditLogsRetentionDays"); + }); + } +} diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 211626b7c5..f776e864c9 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -18,7 +18,8 @@ export const ProjectsSchema = z.object({ version: z.number().default(1), upgradeStatus: z.string().nullable().optional(), pitVersionLimit: z.number().default(10), - kmsCertificateKeyId: z.string().uuid().nullable().optional() + kmsCertificateKeyId: z.string().uuid().nullable().optional(), + auditLogsRetentionDays: z.number().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index f93b391a59..3fde40c8eb 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -45,18 +45,29 @@ export const auditLogQueueServiceFactory = ({ const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data; let { orgId } = job.data; const MS_IN_DAY = 24 * 60 * 60 * 1000; + let project; if (!orgId) { // it will never be undefined for both org and project id // TODO(akhilmhdh): use caching here in dal to avoid db calls - const project = await projectDAL.findById(projectId as string); + project = await projectDAL.findById(projectId as string); orgId = project.orgId; } const plan = await licenseService.getPlan(orgId); - const ttl = plan.auditLogsRetentionDays * MS_IN_DAY; - // skip inserting if audit log retention is 0 meaning its not supported - if (ttl === 0) return; + if (plan.auditLogsRetentionDays === 0) { + // skip inserting if audit log retention is 0 meaning its not supported + return; + } + + // For project actions, set TTL to project-level audit log retention config + // This condition ensures that the plan's audit log retention days cannot be bypassed + const ttlInDays = + project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays + ? project.auditLogsRetentionDays + : plan.auditLogsRetentionDays; + + const ttl = ttlInDays * MS_IN_DAY; const auditLog = await auditLogDAL.create({ actor: actor.type, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 0984b66f61..4cde6a96a5 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -372,6 +372,44 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "PUT", + url: "/:workspaceSlug/audit-logs-retention", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceSlug: z.string().trim() + }), + body: z.object({ + auditLogsRetentionDays: z.number().min(1).max(100) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: ProjectsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.updateAuditLogsRetention({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + workspaceSlug: req.params.workspaceSlug, + auditLogsRetentionDays: req.body.auditLogsRetentionDays + }); + + return { + message: "Successfull changed project audit log retention", + workspace + }; + } + }); + server.route({ method: "GET", url: "/:workspaceId/integrations", diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 1a8e65a410..981f90bb6c 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -11,7 +11,7 @@ import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { createSecretBlindIndex } from "@app/lib/crypto"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; @@ -41,6 +41,7 @@ import { TListProjectCasDTO, TListProjectCertsDTO, TToggleProjectAutoCapitalizationDTO, + TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, TUpdateProjectNameDTO, TUpdateProjectVersionLimitDTO, @@ -446,6 +447,43 @@ export const projectServiceFactory = ({ return projectDAL.updateById(project.id, { pitVersionLimit }); }; + const updateAuditLogsRetention = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + auditLogsRetentionDays, + workspaceSlug + }: TUpdateAuditLogsRetentionDTO) => { + const project = await projectDAL.findProjectBySlug(workspaceSlug, actorOrgId); + if (!project) { + throw new NotFoundError({ + message: "Project not found." + }); + } + + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new BadRequestError({ message: "Only admins are allowed to take this action" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.auditLogs || auditLogsRetentionDays > plan.auditLogsRetentionDays) { + throw new BadRequestError({ + message: "Failed to update audit logs retention due to plan limit reached. Upgrade plan to increase." + }); + } + + return projectDAL.updateById(project.id, { auditLogsRetentionDays }); + }; + const updateName = async ({ projectId, actor, @@ -621,6 +659,7 @@ export const projectServiceFactory = ({ upgradeProject, listProjectCas, listProjectCertificates, - updateVersionLimit + updateVersionLimit, + updateAuditLogsRetention }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index e2d145d3de..eb08fba982 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -49,6 +49,11 @@ export type TUpdateProjectVersionLimitDTO = { workspaceSlug: string; } & Omit; +export type TUpdateAuditLogsRetentionDTO = { + auditLogsRetentionDays: number; + workspaceSlug: string; +} & Omit; + export type TUpdateProjectNameDTO = { name: string; } & TProjectPermission; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7f94bb4982..202480de8f 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -22,6 +22,7 @@ import { ToggleAutoCapitalizationDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, + UpdateAuditLogsRetentionDTO, UpdateEnvironmentDTO, UpdatePitVersionLimitDTO, Workspace @@ -284,6 +285,21 @@ export const useUpdateWorkspaceVersionLimit = () => { }); }; +export const useUpdateWorkspaceAuditLogsRetention = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdateAuditLogsRetentionDTO>({ + mutationFn: ({ projectSlug, auditLogsRetentionDays }) => { + return apiRequest.put(`/api/v1/workspace/${projectSlug}/audit-logs-retention`, { + auditLogsRetentionDays + }); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; + export const useDeleteWorkspace = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 5a90cf2b10..53994e88c4 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -18,6 +18,7 @@ export type Workspace = { autoCapitalization: boolean; environments: WorkspaceEnv[]; pitVersionLimit: number; + auditLogsRetentionDays: number; slug: string; }; @@ -51,6 +52,7 @@ export type CreateWorkspaceDTO = { export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; +export type UpdateAuditLogsRetentionDTO = { projectSlug: string; auditLogsRetentionDays: number }; export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean }; export type DeleteWorkspaceDTO = { workspaceID: string }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx new file mode 100644 index 0000000000..e4f0a99227 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx @@ -0,0 +1,120 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; +import { useProjectPermission, useSubscription, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { useUpdateWorkspaceAuditLogsRetention } from "@app/hooks/api/workspace/queries"; + +const formSchema = z.object({ + auditLogsRetentionDays: z.coerce.number().min(0) +}); + +type TForm = z.infer; + +export const AuditLogsRetentionSection = () => { + const { mutateAsync: updateAuditLogsRetention } = useUpdateWorkspaceAuditLogsRetention(); + + const { currentWorkspace } = useWorkspace(); + const { membership } = useProjectPermission(); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + auditLogsRetentionDays: + currentWorkspace?.auditLogsRetentionDays ?? subscription?.auditLogsRetentionDays ?? 0 + } + }); + + if (!currentWorkspace) return null; + + const handleAuditLogsRetentionSubmit = async ({ auditLogsRetentionDays }: TForm) => { + try { + if (!subscription?.auditLogs) { + handlePopUpOpen("upgradePlan", { + description: "You can only configure audit logs retention if you upgrade your plan." + }); + + return; + } + + if (subscription && auditLogsRetentionDays > subscription?.auditLogsRetentionDays) { + handlePopUpOpen("upgradePlan", { + description: + "To update your audit logs retention period to a higher value, upgrade your plan." + }); + + return; + } + + await updateAuditLogsRetention({ + auditLogsRetentionDays, + projectSlug: currentWorkspace.slug + }); + + createNotification({ + text: "Successfully updated audit logs retention period", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed updating audit logs retention period", + type: "error" + }); + } + }; + + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + return ( + <> +
+
+

Audit Logs Retention

+
+

+ Set the number of days to keep your project audit logs. +

+
+
+ ( + + + + )} + /> +
+ +
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + + ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx new file mode 100644 index 0000000000..38a07f53b1 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/index.tsx @@ -0,0 +1 @@ +export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index bdbd41c1c9..c7da5fd28c 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -1,3 +1,4 @@ +import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AutoCapitalizationSection } from "../AutoCapitalizationSection"; import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection"; import { DeleteProjectSection } from "../DeleteProjectSection"; @@ -17,6 +18,7 @@ export const ProjectGeneralTab = () => { + From d34b2669c5398276c7991f714ec304d6d397f5ed Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 25 Jun 2024 21:32:24 +0800 Subject: [PATCH 2/4] misc: finalized success message --- backend/src/server/routes/v1/project-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 4cde6a96a5..969d113eab 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -404,7 +404,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); return { - message: "Successfull changed project audit log retention", + message: "Successfully updated project's audit logs retention period", workspace }; } From a1d01d5cbd4e2463c58b3ad56bbc4ac15037fef9 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 26 Jun 2024 12:44:56 +0800 Subject: [PATCH 3/4] misc: display retention settings only for self-hosted/dedicated --- .../AuditLogsRetentionSection.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx index e4f0a99227..26cf95a4ef 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx @@ -73,6 +73,14 @@ export const AuditLogsRetentionSection = () => { } }; + // render only for dedicated/self-hosted instances of Infisical + if ( + window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://gamma.infisical.com") + ) { + return null; + } + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); return ( <> From e0a6f09b5e60138f4f308c2f4c5a222066d7202b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 26 Jun 2024 23:17:31 +0800 Subject: [PATCH 4/4] misc: removed max in schema for api layer --- backend/src/server/routes/v1/project-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 969d113eab..99f05cf94e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -383,7 +383,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { workspaceSlug: z.string().trim() }), body: z.object({ - auditLogsRetentionDays: z.number().min(1).max(100) + auditLogsRetentionDays: z.number().min(0) }), response: { 200: z.object({