Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enabled customization of project audit logs retention period #2020

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
}),
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: "Successfully updated project's audit logs retention period",
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."
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
});
}

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)
sheensantoscapadngan marked this conversation as resolved.
Show resolved Hide resolved
});

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
Loading