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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 125564ab3..740fd05b5 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -478,7 +478,8 @@ export const PROJECTS = { name: "The new name of the project.", projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project.", - slug: "An optional slug for the project. (must be unique within the organization)" + slug: "An optional slug for the project. (must be unique within the organization)", + hasDeleteProtection: "Enable or disable delete protection for the project." }, GET_KEY: { workspaceId: "The ID of the project to get the key from." diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 2a87cf7cf..da300981c 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -260,7 +260,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..4182ce389 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -312,6 +312,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .optional() .describe(PROJECTS.UPDATE.projectDescription), autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection), slug: z .string() .trim() @@ -340,6 +341,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { name: req.body.name, description: req.body.description, autoCapitalization: req.body.autoCapitalization, + hasDeleteProtection: req.body.hasDeleteProtection, slug: req.body.slug }, actorAuthMethod: req.permission.authMethod, @@ -390,6 +392,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/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 6e4a8170e..c8f68a926 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -303,7 +303,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), description: z.string().trim().optional().describe(PROJECTS.UPDATE.projectDescription), - autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + hasDeleteProtection: z.boolean().optional().describe(PROJECTS.UPDATE.hasDeleteProtection) }), response: { 200: SanitizedProjectSchema @@ -321,7 +322,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { update: { name: req.body.name, description: req.body.description, - autoCapitalization: req.body.autoCapitalization + autoCapitalization: req.body.autoCapitalization, + hasDeleteProtection: req.body.hasDeleteProtection }, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 1f45734b3..9b7c29c1b 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 @@ -616,6 +623,7 @@ export const projectServiceFactory = ({ description: update.description, autoCapitalization: update.autoCapitalization, enforceCapitalization: update.autoCapitalization, + hasDeleteProtection: update.hasDeleteProtection, slug: update.slug }); @@ -648,6 +656,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 +1530,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..444f6309c 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; @@ -86,6 +90,7 @@ export type TUpdateProjectDTO = { name?: string; description?: string; autoCapitalization?: boolean; + hasDeleteProtection?: boolean; slug?: string; }; } & Omit; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7d94972ea..ca8feb6b6 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(); @@ -316,8 +336,8 @@ export const useUpdateWorkspaceVersionLimit = () => { }); return data.workspace; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace() }); + onSuccess: (dto) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.getAllUserWorkspace(dto.type) }); } }); }; 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) => (