Skip to content

Commit

Permalink
feat: enabled customization of project audit logs retention period
Browse files Browse the repository at this point in the history
  • Loading branch information
sheensantoscapadngan committed Jun 25, 2024
1 parent 9a66514 commit 11ea599
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Knex } from "knex";

import { TableName } from "../schemas";

export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.Project, "auditLogsRetentionDays")) {
await knex.schema.alterTable(TableName.Project, (t) => {
t.dropColumn("auditLogsRetentionDays");
});
}
}
3 changes: 2 additions & 1 deletion backend/src/db/schemas/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ProjectsSchema>;
Expand Down
19 changes: 15 additions & 4 deletions backend/src/ee/services/audit-log/audit-log-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions backend/src/server/routes/v1/project-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 41 additions & 2 deletions backend/src/services/project/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -41,6 +41,7 @@ import {
TListProjectCasDTO,
TListProjectCertsDTO,
TToggleProjectAutoCapitalizationDTO,
TUpdateAuditLogsRetentionDTO,
TUpdateProjectDTO,
TUpdateProjectNameDTO,
TUpdateProjectVersionLimitDTO,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -621,6 +659,7 @@ export const projectServiceFactory = ({
upgradeProject,
listProjectCas,
listProjectCertificates,
updateVersionLimit
updateVersionLimit,
updateAuditLogsRetention
};
};
5 changes: 5 additions & 0 deletions backend/src/services/project/project-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export type TUpdateProjectVersionLimitDTO = {
workspaceSlug: string;
} & Omit<TProjectPermission, "projectId">;

export type TUpdateAuditLogsRetentionDTO = {
auditLogsRetentionDays: number;
workspaceSlug: string;
} & Omit<TProjectPermission, "projectId">;

export type TUpdateProjectNameDTO = {
name: string;
} & TProjectPermission;
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/hooks/api/workspace/queries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
UpdateAuditLogsRetentionDTO,
UpdateEnvironmentDTO,
UpdatePitVersionLimitDTO,
Workspace
Expand Down Expand Up @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/hooks/api/workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type Workspace = {
autoCapitalization: boolean;
environments: WorkspaceEnv[];
pitVersionLimit: number;
auditLogsRetentionDays: number;
slug: string;
};

Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof formSchema>;

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<TForm>({
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 (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Audit Logs Retention</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
Set the number of days to keep your project audit logs.
</p>
<form onSubmit={handleSubmit(handleAuditLogsRetentionSubmit)} autoComplete="off">
<div className="max-w-xs">
<Controller
control={control}
defaultValue={0}
name="auditLogsRetentionDays"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Number of days"
>
<Input {...field} type="number" min={1} step={1} isDisabled={!isAdmin} />
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isAdmin || !isDirty}
>
Save
</Button>
</form>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection";
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection";
import { DeleteProjectSection } from "../DeleteProjectSection";
Expand All @@ -17,6 +18,7 @@ export const ProjectGeneralTab = () => {
<AutoCapitalizationSection />
<E2EESection />
<PointInTimeVersionLimitSection />
<AuditLogsRetentionSection />
<BackfillSecretReferenceSecretion />
<RebuildSecretIndicesSection />
<DeleteProjectSection />
Expand Down

0 comments on commit 11ea599

Please sign in to comment.