diff --git a/backend/src/db/migrations/20250414234624_add-project-delete-protection.ts b/backend/src/db/migrations/20250414234624_add-project-delete-protection.ts new file mode 100644 index 000000000..bf5e438e1 --- /dev/null +++ b/backend/src/db/migrations/20250414234624_add-project-delete-protection.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Project, "hasDeleteProtection"); + if (!hasCol) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.boolean("hasDeleteProtection").defaultTo(true); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Project, "hasDeleteProtection"); + if (hasCol) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("hasDeleteProtection"); + }); + } +} diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 8c1a0386c..2403d6cf4 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -26,7 +26,8 @@ export const ProjectsSchema = z.object({ kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(), description: z.string().nullable().optional(), type: z.string(), - enforceCapitalization: z.boolean().default(false) + enforceCapitalization: z.boolean().default(false), + hasDeleteProtection: z.boolean().default(true).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 8bf6f7390..4dbd60377 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -255,7 +255,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ upgradeStatus: true, pitVersionLimit: true, kmsCertificateKeyId: true, - auditLogsRetentionDays: true + auditLogsRetentionDays: true, + hasDeleteProtection: true }); export const SanitizedTagSchema = SecretTagsSchema.pick({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 2496d8f62..78f53947a 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -390,6 +390,43 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:workspaceId/delete-protection", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + hasDeleteProtection: z.boolean() + }), + response: { + 200: z.object({ + message: z.string(), + workspace: SanitizedProjectSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.toggleDeleteProtection({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + hasDeleteProtection: req.body.hasDeleteProtection + }); + return { + message: "Successfully changed workspace settings", + workspace + }; + } + }); + server.route({ method: "PUT", url: "/:workspaceSlug/version-limit", diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 1f45734b3..5426e9fad 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -86,6 +86,7 @@ import { TProjectAccessRequestDTO, TSearchProjectsDTO, TToggleProjectAutoCapitalizationDTO, + TToggleProjectDeleteProtectionDTO, TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, TUpdateProjectKmsDTO, @@ -482,6 +483,12 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + if (project.hasDeleteProtection) { + throw new ForbiddenRequestError({ + message: "Project delete protection is enabled" + }); + } + const deletedProject = await projectDAL.transaction(async (tx) => { // delete these so that project custom roles can be deleted in cascade effect // direct deletion of project without these will cause fk error @@ -648,6 +655,29 @@ export const projectServiceFactory = ({ return updatedProject; }; + const toggleDeleteProtection = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + hasDeleteProtection + }: TToggleProjectDeleteProtectionDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const updatedProject = await projectDAL.updateById(projectId, { hasDeleteProtection }); + + return updatedProject; + }; + const updateVersionLimit = async ({ actor, actorId, @@ -1499,6 +1529,7 @@ export const projectServiceFactory = ({ getProjectUpgradeStatus, getAProject, toggleAutoCapitalization, + toggleDeleteProtection, updateName, upgradeProject, listProjectCas, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 4346ae2c4..a11f7d6a8 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -66,6 +66,10 @@ export type TToggleProjectAutoCapitalizationDTO = { autoCapitalization: boolean; } & TProjectPermission; +export type TToggleProjectDeleteProtectionDTO = { + hasDeleteProtection: boolean; +} & TProjectPermission; + export type TUpdateProjectVersionLimitDTO = { pitVersionLimit: number; workspaceSlug: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7d94972ea..93f6b51e1 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -33,6 +33,7 @@ import { TGetUpgradeProjectStatusDTO, TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, + ToggleDeleteProjectProtectionDTO, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -306,6 +307,25 @@ export const useToggleAutoCapitalization = () => { }); }; +export const useToggleDeleteProjectProtection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ workspaceID, state }) => { + const { data } = await apiRequest.post<{ workspace: Workspace }>( + `/api/v1/workspace/${workspaceID}/delete-protection`, + { + hasDeleteProtection: state + } + ); + return data.workspace; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace() }); + } + }); +}; + export const useUpdateWorkspaceVersionLimit = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index afeb9a6c4..814a920c2 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -36,6 +36,7 @@ export type Workspace = { slug: string; createdAt: string; roles?: TProjectRole[]; + hasDeleteProtection: boolean; }; export type WorkspaceEnv = { @@ -80,6 +81,7 @@ export type UpdateProjectDTO = { export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; export type UpdateAuditLogsRetentionDTO = { projectSlug: string; auditLogsRetentionDays: number }; export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean }; +export type ToggleDeleteProjectProtectionDTO = { workspaceID: string; state: boolean }; export type DeleteWorkspaceDTO = { workspaceID: string }; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx new file mode 100644 index 000000000..f8ef239d9 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/DeleteProjectProtection.tsx @@ -0,0 +1,57 @@ +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Checkbox } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useToggleDeleteProjectProtection } from "@app/hooks/api/workspace/queries"; + +export const DeleteProjectProtection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync } = useToggleDeleteProjectProtection(); + + const handleToggleDeleteProjectProtection = async (state: boolean) => { + try { + if (!currentWorkspace?.id) return; + + await mutateAsync({ + workspaceID: currentWorkspace.id, + state + }); + + const text = `Successfully ${state ? "enabled" : "disabled"} delete protection`; + createNotification({ + text, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update delete protection", + type: "error" + }); + } + }; + + return ( +
+

Delete Protection

+ + {(isAllowed) => ( +
+ { + handleToggleDeleteProjectProtection(state as boolean); + }} + > + Protects the project from being deleted accidentally. While this option is enabled, + you can't delete the project. + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx new file mode 100644 index 000000000..38761cf0a --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectProtection/index.tsx @@ -0,0 +1 @@ +export { DeleteProjectProtection } from "./DeleteProjectProtection"; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index 042adff1f..2f11523c4 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -144,7 +144,7 @@ export const DeleteProjectSection = () => { {(isAllowed) => (