From 6b15dcdd89ff5aa49a674832c21af4c3e9c3360a Mon Sep 17 00:00:00 2001 From: Wayne <5291640+ringoinca@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:58:41 +0200 Subject: [PATCH] Add option to disable deletions This commit introduces a new feature that allows admins to disable the deletion of emails and ingestion sources for the entire instance. This is a critical feature for compliance and data retention, as it prevents accidental or unauthorized deletions. Changes: - **Configuration**: Added an `ENABLE_DELETION` environment variable. If this variable is not set to `true`, all deletion operations will be disabled. - **Deletion Guard**: A centralized `checkDeletionEnabled` guard has been implemented to enforce this setting at both the controller and service levels, ensuring a robust and secure implementation. - **Documentation**: The installation guide has been updated to include the new `ENABLE_DELETION` environment variable and its behavior. - **Refactor**: The `IngestionService`'s `create` method was refactored to remove unnecessary calls to the `delete` method, simplifying the code and improving its robustness. --- .env.example | 3 +++ docs/user-guides/installation.md | 17 +++++++++-------- .../controllers/archived-email.controller.ts | 2 ++ .../src/api/controllers/ingestion.controller.ts | 4 ++++ packages/backend/src/config/app.ts | 1 + packages/backend/src/helpers/deletionGuard.ts | 9 +++++++++ .../backend/src/locales/de/translation.json | 3 ++- .../backend/src/locales/en/translation.json | 3 ++- .../src/services/ArchivedEmailService.ts | 2 ++ .../backend/src/services/IngestionService.ts | 2 ++ .../routes/dashboard/ingestions/+page.svelte | 1 + 11 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/helpers/deletionGuard.ts diff --git a/.env.example b/.env.example index 16634c3..daa76ef 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,9 @@ STORAGE_ENCRYPTION_KEY= # --- Security & Authentication --- +# Enable or disable deletion of emails and ingestion sources. Defaults to false. +ENABLE_DELETION=false + # Rate Limiting # The window in milliseconds for which API requests are checked. Defaults to 60000 (1 minute). RATE_LIMIT_WINDOW_MS=60000 diff --git a/docs/user-guides/installation.md b/docs/user-guides/installation.md index ba437ae..e95f1ee 100644 --- a/docs/user-guides/installation.md +++ b/docs/user-guides/installation.md @@ -132,14 +132,15 @@ These variables are used by `docker-compose.yml` to configure the services. #### Security & Authentication -| Variable | Description | Default Value | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` | -| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` | -| ~~`SUPER_API_KEY`~~ (Deprecated) | An API key with super admin privileges. (The SUPER_API_KEY is deprecated since v0.3.0 after we roll out the role-based access control system.) | | -| `RATE_LIMIT_WINDOW_MS` | The window in milliseconds for which API requests are checked. | `900000` (15 minutes) | -| `RATE_LIMIT_MAX_REQUESTS` | The maximum number of API requests allowed from an IP within the window. | `100` | -| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | | +| Variable | Description | Default Value | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `ENABLE_DELETION` | Enable or disable deletion of emails and ingestion sources. If this option is not set, or is set to any value other than `true`, deletion will be disabled for the entire instance. | `false` | +| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` | +| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` | +| ~~`SUPER_API_KEY`~~ (Deprecated) | An API key with super admin privileges. (The SUPER_API_KEY is deprecated since v0.3.0 after we roll out the role-based access control system.) | | +| `RATE_LIMIT_WINDOW_MS` | The window in milliseconds for which API requests are checked. | `900000` (15 minutes) | +| `RATE_LIMIT_MAX_REQUESTS` | The maximum number of API requests allowed from an IP within the window. | `100` | +| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | | #### Apache Tika Integration diff --git a/packages/backend/src/api/controllers/archived-email.controller.ts b/packages/backend/src/api/controllers/archived-email.controller.ts index e18866b..e04b551 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { ArchivedEmailService } from '../../services/ArchivedEmailService'; import { config } from '../../config'; import { UserService } from '../../services/UserService'; +import { checkDeletionEnabled } from '../../helpers/deletionGuard'; export class ArchivedEmailController { private userService = new UserService(); @@ -63,6 +64,7 @@ export class ArchivedEmailController { return res.status(403).json({ message: req.t('errors.demoMode') }); } try { + checkDeletionEnabled(); const { id } = req.params; const userId = req.user?.sub; if (!userId) { diff --git a/packages/backend/src/api/controllers/ingestion.controller.ts b/packages/backend/src/api/controllers/ingestion.controller.ts index 3cbfdb6..1ed0501 100644 --- a/packages/backend/src/api/controllers/ingestion.controller.ts +++ b/packages/backend/src/api/controllers/ingestion.controller.ts @@ -9,6 +9,7 @@ import { import { logger } from '../../config/logger'; import { config } from '../../config'; import { UserService } from '../../services/UserService'; +import { checkDeletionEnabled } from '../../helpers/deletionGuard'; export class IngestionController { private userService = new UserService(); @@ -111,6 +112,7 @@ export class IngestionController { return res.status(403).json({ message: req.t('errors.demoMode') }); } try { + checkDeletionEnabled(); const { id } = req.params; const userId = req.user?.sub; if (!userId) { @@ -126,6 +128,8 @@ export class IngestionController { console.error(`Delete ingestion source ${req.params.id} error:`, error); if (error instanceof Error && error.message === 'Ingestion source not found') { return res.status(404).json({ message: req.t('ingestion.notFound') }); + } else if (error instanceof Error) { + return res.status(400).json({ message: error.message }); } return res.status(500).json({ message: req.t('errors.internalServerError') }); } diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index f527986..32bc760 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -6,4 +6,5 @@ export const app = { encryptionKey: process.env.ENCRYPTION_KEY, isDemo: process.env.IS_DEMO === 'true', syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute + enableDeletion: process.env.ENABLE_DELETION === 'true', }; diff --git a/packages/backend/src/helpers/deletionGuard.ts b/packages/backend/src/helpers/deletionGuard.ts new file mode 100644 index 0000000..4acec41 --- /dev/null +++ b/packages/backend/src/helpers/deletionGuard.ts @@ -0,0 +1,9 @@ +import { config } from '../config'; +import i18next from 'i18next'; + +export function checkDeletionEnabled() { + if (!config.app.enableDeletion) { + const errorMessage = i18next.t('Deletion is disabled for this instance.'); + throw new Error(errorMessage); + } +} diff --git a/packages/backend/src/locales/de/translation.json b/packages/backend/src/locales/de/translation.json index 2f679f7..4a861ec 100644 --- a/packages/backend/src/locales/de/translation.json +++ b/packages/backend/src/locales/de/translation.json @@ -14,7 +14,8 @@ "demoMode": "Dieser Vorgang ist im Demo-Modus nicht zulässig.", "unauthorized": "Unbefugt", "unknown": "Ein unbekannter Fehler ist aufgetreten", - "noPermissionToAction": "Sie haben keine Berechtigung, die aktuelle Aktion auszuführen." + "noPermissionToAction": "Sie haben keine Berechtigung, die aktuelle Aktion auszuführen.", + "deletion_disabled": "Das Löschen ist für diese Instanz deaktiviert." }, "user": { "notFound": "Benutzer nicht gefunden", diff --git a/packages/backend/src/locales/en/translation.json b/packages/backend/src/locales/en/translation.json index 5978d9d..9ac9c2b 100644 --- a/packages/backend/src/locales/en/translation.json +++ b/packages/backend/src/locales/en/translation.json @@ -14,7 +14,8 @@ "demoMode": "This operation is not allowed in demo mode.", "unauthorized": "Unauthorized", "unknown": "An unknown error occurred", - "noPermissionToAction": "You don't have the permission to perform the current action." + "noPermissionToAction": "You don't have the permission to perform the current action.", + "deletion_disabled": "Deletion is disabled for this instance." }, "user": { "notFound": "User not found", diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 20fdfcd..9be1228 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -19,6 +19,7 @@ import { SearchService } from './SearchService'; import type { Readable } from 'stream'; import { AuditService } from './AuditService'; import { User } from '@open-archiver/types'; +import { checkDeletionEnabled } from '../helpers/deletionGuard'; interface DbRecipients { to: { name: string; address: string }[]; @@ -198,6 +199,7 @@ export class ArchivedEmailService { actor: User, actorIp: string ): Promise { + checkDeletionEnabled(); const [email] = await db .select() .from(archivedEmails) diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index 938b1a3..4c2ae59 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -27,6 +27,7 @@ import { config } from '../config/index'; import { FilterBuilder } from './FilterBuilder'; import { AuditService } from './AuditService'; import { User } from '@open-archiver/types'; +import { checkDeletionEnabled } from '../helpers/deletionGuard'; export class IngestionService { private static auditService = new AuditService(); @@ -205,6 +206,7 @@ export class IngestionService { } public static async delete(id: string, actor: User, actorIp: string): Promise { + checkDeletionEnabled(); const source = await this.findById(id); if (!source) { throw new Error('Ingestion source not found'); diff --git a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte index 13a5295..5d25099 100644 --- a/packages/frontend/src/routes/dashboard/ingestions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/ingestions/+page.svelte @@ -153,6 +153,7 @@ duration: 5000, show: true, }); + return; } } ingestionSources = ingestionSources.filter((s) => !selectedIds.includes(s.id));