From d348b1744938e6110235da270bd4f4d60c4c427d Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Wed, 20 May 2026 08:53:00 -0700 Subject: [PATCH] Workspace deletion protection (#5662) * add WORKSPACE_DELETION_PROTECTION env flag to disable workspace deletion from UI and APIs * minor nits * patch test for phrase --------- Co-authored-by: Timothy Carambat --- docker/.env.example | 3 ++ .../Admin/Workspaces/WorkspaceRow/index.jsx | 24 +++++++---- frontend/src/pages/Admin/Workspaces/index.jsx | 11 ++++- .../DeleteWorkspace/index.jsx | 4 +- .../GeneralAppearance/index.jsx | 4 +- .../src/pages/WorkspaceSettings/index.jsx | 8 +++- server/.env.example | 3 ++ .../workspaceDeletionProtection.test.js | 43 +++++++++++++++++++ server/endpoints/admin.js | 9 +++- server/endpoints/api/workspace/index.js | 5 ++- server/endpoints/workspaces.js | 9 +++- server/models/systemSettings.js | 2 + .../middleware/workspaceDeletionProtection.js | 18 ++++++++ 13 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 server/__tests__/utils/middleware/workspaceDeletionProtection.test.js create mode 100644 server/utils/middleware/workspaceDeletionProtection.js diff --git a/docker/.env.example b/docker/.env.example index c98782d41..4e449de98 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -408,6 +408,9 @@ GID='1000' # See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. # DISABLE_VIEW_CHAT_HISTORY=1 +# Disable workspace deletion from the UI and APIs when this ENV is present with any value. +# WORKSPACE_DELETION_PROTECTION=1 + # Enable simple SSO passthrough to pre-authenticate users from a third party service. # See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. # SIMPLE_SSO_ENABLED=1 diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx index 31ce51a1b..bbda8100b 100644 --- a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx +++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx @@ -3,7 +3,11 @@ import Admin from "@/models/admin"; import paths from "@/utils/paths"; import { LinkSimple, Trash } from "@phosphor-icons/react"; -export default function WorkspaceRow({ workspace, users: _users }) { +export default function WorkspaceRow({ + workspace, + users: _users, + deletionProtected = false, +}) { const rowRef = useRef(null); const handleDelete = async () => { if ( @@ -25,7 +29,7 @@ export default function WorkspaceRow({ workspace, users: _users }) { {workspace.name} - + {workspace.createdAt} - - + + {!deletionProtected && ( + + )} diff --git a/frontend/src/pages/Admin/Workspaces/index.jsx b/frontend/src/pages/Admin/Workspaces/index.jsx index 8cdb57659..0055999a9 100644 --- a/frontend/src/pages/Admin/Workspaces/index.jsx +++ b/frontend/src/pages/Admin/Workspaces/index.jsx @@ -5,6 +5,7 @@ import * as Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { BookOpen } from "@phosphor-icons/react"; import Admin from "@/models/admin"; +import System from "@/models/system"; import WorkspaceRow from "./WorkspaceRow"; import NewWorkspaceModal from "./NewWorkspaceModal"; import { useModal } from "@/hooks/useModal"; @@ -57,13 +58,18 @@ function WorkspacesContainer() { const [loading, setLoading] = useState(true); const [users, setUsers] = useState([]); const [workspaces, setWorkspaces] = useState([]); + const [deletionProtected, setDeletionProtected] = useState(false); useEffect(() => { async function fetchData() { - const _users = await Admin.users(); - const _workspaces = await Admin.workspaces(); + const [_users, _workspaces, _settings] = await Promise.all([ + Admin.users(), + Admin.workspaces(), + System.keys(), + ]); setUsers(_users); setWorkspaces(_workspaces); + setDeletionProtected(_settings?.WorkspaceDeletionProtection === true); setLoading(false); } fetchData(); @@ -110,6 +116,7 @@ function WorkspacesContainer() { key={workspace.id} workspace={workspace} users={users} + deletionProtected={deletionProtected} /> ))} diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx index 0023e3301..f24ee5a90 100644 --- a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx @@ -5,7 +5,7 @@ import paths from "@/utils/paths"; import { useTranslation } from "react-i18next"; import showToast from "@/utils/toast"; -export default function DeleteWorkspace({ workspace }) { +export default function DeleteWorkspace({ workspace, visible = true }) { const { slug } = useParams(); const [deleting, setDeleting] = useState(false); const { t } = useTranslation(); @@ -32,6 +32,8 @@ export default function DeleteWorkspace({ workspace }) { ? (window.location = paths.home()) : window.location.reload(); }; + + if (!visible) return null; return (
diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx index d5c981594..9639bc58f 100644 --- a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/index.jsx @@ -7,7 +7,7 @@ import SuggestedChatMessages from "./SuggestedChatMessages"; import DeleteWorkspace from "./DeleteWorkspace"; import CTAButton from "@/components/lib/CTAButton"; -export default function GeneralInfo({ slug }) { +export default function GeneralInfo({ slug, deletionProtected = false }) { const [workspace, setWorkspace] = useState(null); const [hasChanges, setHasChanges] = useState(false); const [saving, setSaving] = useState(false); @@ -64,7 +64,7 @@ export default function GeneralInfo({ slug }) { /> - +
); } diff --git a/frontend/src/pages/WorkspaceSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/index.jsx index a41a64f2d..f44aad314 100644 --- a/frontend/src/pages/WorkspaceSettings/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/index.jsx @@ -49,6 +49,7 @@ function ShowWorkspaceChat() { const { slug, tab } = useParams(); const { user } = useUser(); const [workspace, setWorkspace] = useState(null); + const [deletionProtected, setDeletionProtected] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { @@ -67,6 +68,7 @@ function ShowWorkspaceChat() { vectorDB: _settings?.VectorDB, suggestedMessages, }); + setDeletionProtected(_settings?.WorkspaceDeletionProtection === true); setLoading(false); } getWorkspace(); @@ -117,7 +119,11 @@ function ShowWorkspaceChat() { />
- +
diff --git a/server/.env.example b/server/.env.example index 35ab47f22..2d36caa6c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -412,6 +412,9 @@ TTS_PROVIDER="native" # See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. # DISABLE_VIEW_CHAT_HISTORY=1 +# Disable workspace deletion from the UI and APIs when this ENV is present with any value. +# WORKSPACE_DELETION_PROTECTION=1 + # Enable simple SSO passthrough to pre-authenticate users from a third party service. # See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. # SIMPLE_SSO_ENABLED=1 diff --git a/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js b/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js new file mode 100644 index 000000000..b8afa8e4c --- /dev/null +++ b/server/__tests__/utils/middleware/workspaceDeletionProtection.test.js @@ -0,0 +1,43 @@ +const { + workspaceDeletionProtection, +} = require("../../../utils/middleware/workspaceDeletionProtection"); + +describe("workspaceDeletionProtection middleware", () => { + const originalValue = process.env.WORKSPACE_DELETION_PROTECTION; + const hadValue = "WORKSPACE_DELETION_PROTECTION" in process.env; + + afterEach(() => { + if (hadValue) { + process.env.WORKSPACE_DELETION_PROTECTION = originalValue; + } else { + delete process.env.WORKSPACE_DELETION_PROTECTION; + } + }); + + it("calls next when WORKSPACE_DELETION_PROTECTION is not present", () => { + delete process.env.WORKSPACE_DELETION_PROTECTION; + const next = jest.fn(); + + workspaceDeletionProtection({}, {}, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it("returns 403 when WORKSPACE_DELETION_PROTECTION is present with any value", () => { + process.env.WORKSPACE_DELETION_PROTECTION = "1"; + const next = jest.fn(); + const response = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + workspaceDeletionProtection({}, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith({ + success: false, + error: "Workspace deletion is blocked by the system administrator.", + }); + }); +}); diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 6e23071c1..c817c67f0 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -28,6 +28,9 @@ const ImportedPlugin = require("../utils/agents/imported"); const { simpleSSOLoginDisabledMiddleware, } = require("../utils/middleware/simpleSSOEnabled"); +const { + workspaceDeletionProtection, +} = require("../utils/middleware/workspaceDeletionProtection"); function adminEndpoints(app) { if (!app) return; @@ -291,7 +294,11 @@ function adminEndpoints(app) { app.delete( "/admin/workspaces/:id", - [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + [ + validatedRequest, + strictMultiUserRoleValid([ROLES.admin, ROLES.manager]), + workspaceDeletionProtection, + ], async (request, response) => { try { const { id } = request.params; diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js index 4405b5f97..61a738613 100644 --- a/server/endpoints/api/workspace/index.js +++ b/server/endpoints/api/workspace/index.js @@ -15,6 +15,9 @@ const { } = require("../../../utils/helpers/chat/responses"); const { ApiChatHandler } = require("../../../utils/chats/apiChatHandler"); const { getModelTag } = require("../../utils"); +const { + workspaceDeletionProtection, +} = require("../../../utils/middleware/workspaceDeletionProtection"); function apiWorkspaceEndpoints(app) { if (!app) return; @@ -221,7 +224,7 @@ function apiWorkspaceEndpoints(app) { app.delete( "/v1/workspace/:slug", - [validApiKey], + [validApiKey, workspaceDeletionProtection], async (request, response) => { /* #swagger.tags = ['Workspaces'] diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 1fa60b50e..9529cca56 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -38,6 +38,9 @@ const { purgeDocument } = require("../utils/files/purgeDocument"); const { getModelTag } = require("./utils"); const { searchWorkspaceAndThreads } = require("../utils/helpers/search"); const { workspaceParsedFilesEndpoints } = require("./workspacesParsedFiles"); +const { + workspaceDeletionProtection, +} = require("../utils/middleware/workspaceDeletionProtection"); function workspaceEndpoints(app) { if (!app) return; @@ -270,7 +273,11 @@ function workspaceEndpoints(app) { app.delete( "/workspace/:slug", - [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + workspaceDeletionProtection, + ], async (request, response) => { try { const { slug = "" } = request.params; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index 9b774a6a4..f45f5c5c3 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -544,6 +544,8 @@ const SystemSettings = { // Disable View Chat History for the whole instance. DisableViewChatHistory: "DISABLE_VIEW_CHAT_HISTORY" in process.env || false, + WorkspaceDeletionProtection: + "WORKSPACE_DELETION_PROTECTION" in process.env || false, // -------------------------------------------------------- // Simple SSO Settings diff --git a/server/utils/middleware/workspaceDeletionProtection.js b/server/utils/middleware/workspaceDeletionProtection.js new file mode 100644 index 000000000..74a63fe1d --- /dev/null +++ b/server/utils/middleware/workspaceDeletionProtection.js @@ -0,0 +1,18 @@ +/** + * A simple middleware that blocks workspace deletion routes when the + * `WORKSPACE_DELETION_PROTECTION` environment variable is set AT ALL. + * @param {Request} request - The request object. + * @param {Response} response - The response object. + * @param {NextFunction} next - The next function. + */ +function workspaceDeletionProtection(_request, response, next) { + if (!("WORKSPACE_DELETION_PROTECTION" in process.env)) return next(); + return response.status(403).json({ + success: false, + error: "Workspace deletion is blocked by the system administrator.", + }); +} + +module.exports = { + workspaceDeletionProtection, +};