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.
This commit is contained in:
Wayne
2025-10-06 00:58:41 +02:00
parent 659d130f3b
commit 6b15dcdd89
11 changed files with 37 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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') });
}

View File

@@ -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',
};

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<void> {
checkDeletionEnabled();
const [email] = await db
.select()
.from(archivedEmails)

View File

@@ -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<IngestionSource> {
checkDeletionEnabled();
const source = await this.findById(id);
if (!source) {
throw new Error('Ingestion source not found');

View File

@@ -153,6 +153,7 @@
duration: 5000,
show: true,
});
return;
}
}
ingestionSources = ingestionSources.filter((s) => !selectedIds.includes(s.id));