enterprise: Audit log API, UI

This commit is contained in:
Wayne
2025-10-03 01:11:32 +02:00
parent d20fe8badb
commit d99fcfcc27
54 changed files with 2644 additions and 126 deletions

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only index.ts",
"dev": "ts-node-dev -r tsconfig-paths/register --project tsconfig.json --respawn --transpile-only index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@types/dotenv": "^8.2.3",
"ts-node-dev": "^2.0.0"
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0"
}
}

View File

@@ -8,8 +8,10 @@
"build:enterprise": "cross-env VITE_ENTERPRISE_MODE=true pnpm build",
"start:oss": "dotenv -- concurrently \"node apps/open-archiver/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
"start:enterprise": "dotenv -- concurrently \"node apps/open-archiver-enterprise/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
"dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/frontend\" --filter \"open-archiver-enterprise-app\" --parallel dev",
"dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/*\" --filter \"open-archiver-enterprise-app\" --parallel dev",
"dev:oss": "dotenv -- pnpm --filter \"@open-archiver/frontend\" --filter \"open-archiver-app\" --parallel dev",
"build": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" build",
"start": "dotenv -- pnpm --filter \"open-archiver-app\" --parallel start",
"start:workers": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker\" \"pnpm --filter @open-archiver/backend start:indexing-worker\" \"pnpm --filter @open-archiver/backend start:sync-scheduler\"",
"start:workers:dev": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker:dev\" \"pnpm --filter @open-archiver/backend start:indexing-worker:dev\" \"pnpm --filter @open-archiver/backend start:sync-scheduler:dev\"",
"db:generate": "dotenv -- pnpm --filter @open-archiver/backend db:generate",

View File

@@ -7,6 +7,7 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc && pnpm copy-assets",
"dev": "tsc --watch",
"copy-assets": "cp -r src/locales dist/locales",
"start:ingestion-worker": "node dist/workers/ingestion.worker.js",
"start:indexing-worker": "node dist/workers/indexing.worker.js",
@@ -58,7 +59,6 @@
"pst-extractor": "^1.11.0",
"reflect-metadata": "^0.2.2",
"sqlite3": "^5.1.7",
"tsconfig-paths": "^4.2.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yauzl": "^3.2.0",
"zod": "^4.1.5"
@@ -75,6 +75,7 @@
"@types/node": "^24.0.12",
"@types/yauzl": "^2.10.3",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3"
}
}

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { ApiKeyService } from '../../services/ApiKeyService';
import { z } from 'zod';
import { config } from '../../config';
import { UserService } from '../../services/UserService';
const generateApiKeySchema = z.object({
name: z
@@ -14,8 +15,8 @@ const generateApiKeySchema = z.object({
.positive('Only positive number is allowed')
.max(730, 'The API key must expire within 2 years / 730 days.'),
});
export class ApiKeyController {
private userService = new UserService();
public async generateApiKey(req: Request, res: Response) {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
@@ -26,8 +27,18 @@ export class ApiKeyController {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.sub;
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const key = await ApiKeyService.generate(userId, name, expiresInDays);
const key = await ApiKeyService.generate(
userId,
name,
expiresInDays,
actor,
req.ip || 'unknown'
);
res.status(201).json({ key });
} catch (error) {
@@ -59,7 +70,11 @@ export class ApiKeyController {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.sub;
await ApiKeyService.deleteKey(id, userId);
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
}

View File

@@ -1,8 +1,10 @@
import { Request, Response } from 'express';
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
import { config } from '../../config';
import { UserService } from '../../services/UserService';
export class ArchivedEmailController {
private userService = new UserService();
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
try {
const { ingestionSourceId } = req.params;
@@ -35,8 +37,17 @@ export class ArchivedEmailController {
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const email = await ArchivedEmailService.getArchivedEmailById(id, userId);
const email = await ArchivedEmailService.getArchivedEmailById(
id,
userId,
actor,
req.ip || 'unknown'
);
if (!email) {
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
}
@@ -53,7 +64,15 @@ export class ArchivedEmailController {
}
try {
const { id } = req.params;
await ArchivedEmailService.deleteArchivedEmail(id);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
await ArchivedEmailService.deleteArchivedEmail(id, actor, req.ip || 'unknown');
return res.status(204).send();
} catch (error) {
console.error(`Delete archived email ${req.params.id} error:`, error);

View File

@@ -44,7 +44,7 @@ export class AuthController {
{ email, password, first_name, last_name },
true
);
const result = await this.#authService.login(email, password);
const result = await this.#authService.login(email, password, req.ip || 'unknown');
return res.status(201).json(result);
} catch (error) {
console.error('Setup error:', error);
@@ -60,7 +60,7 @@ export class AuthController {
}
try {
const result = await this.#authService.login(email, password);
const result = await this.#authService.login(email, password, req.ip || 'unknown');
if (!result) {
return res.status(401).json({ message: req.t('auth.login.invalidCredentials') });

View File

@@ -8,8 +8,10 @@ import {
} from '@open-archiver/types';
import { logger } from '../../config/logger';
import { config } from '../../config';
import { UserService } from '../../services/UserService';
export class IngestionController {
private userService = new UserService();
/**
* Converts an IngestionSource object to a safe version for client-side consumption
* by removing the credentials.
@@ -31,7 +33,11 @@ export class IngestionController {
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const newSource = await IngestionService.create(dto, userId);
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const newSource = await IngestionService.create(dto, userId, actor, req.ip || 'unknown');
const safeSource = this.toSafeIngestionSource(newSource);
return res.status(201).json(safeSource);
} catch (error: any) {
@@ -80,7 +86,15 @@ export class IngestionController {
try {
const { id } = req.params;
const dto: UpdateIngestionSourceDto = req.body;
const updatedSource = await IngestionService.update(id, dto);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const updatedSource = await IngestionService.update(id, dto, actor, req.ip || 'unknown');
const safeSource = this.toSafeIngestionSource(updatedSource);
return res.status(200).json(safeSource);
} catch (error) {
@@ -98,7 +112,15 @@ export class IngestionController {
}
try {
const { id } = req.params;
await IngestionService.delete(id);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
await IngestionService.delete(id, actor, req.ip || 'unknown');
return res.status(204).send();
} catch (error) {
console.error(`Delete ingestion source ${req.params.id} error:`, error);
@@ -132,7 +154,20 @@ export class IngestionController {
}
try {
const { id } = req.params;
const updatedSource = await IngestionService.update(id, { status: 'paused' });
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const updatedSource = await IngestionService.update(
id,
{ status: 'paused' },
actor,
req.ip || 'unknown'
);
const safeSource = this.toSafeIngestionSource(updatedSource);
return res.status(200).json(safeSource);
} catch (error) {
@@ -150,7 +185,15 @@ export class IngestionController {
}
try {
const { id } = req.params;
await IngestionService.triggerForceSync(id);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
const actor = await this.userService.findById(userId);
if (!actor) {
return res.status(401).json({ message: req.t('errors.unauthorized') });
}
await IngestionService.triggerForceSync(id, actor, req.ip || 'unknown');
return res.status(202).json({ message: req.t('ingestion.forceSyncTriggered') });
} catch (error) {
console.error(`Trigger force sync for ${req.params.id} error:`, error);

View File

@@ -31,7 +31,8 @@ export class SearchController {
limit: limit ? parseInt(limit as string) : 10,
matchingStrategy: matchingStrategy as MatchingStrategies,
},
userId
userId,
req.ip || 'unknown'
);
res.status(200).json(results);

View File

@@ -1,8 +1,10 @@
import type { Request, Response } from 'express';
import { SettingsService } from '../../services/SettingsService';
import { config } from '../../config';
import { UserService } from '../../services/UserService';
const settingsService = new SettingsService();
const userService = new UserService();
export const getSystemSettings = async (req: Request, res: Response) => {
try {
@@ -20,7 +22,18 @@ export const updateSystemSettings = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const updatedSettings = await settingsService.updateSystemSettings(req.body);
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const updatedSettings = await settingsService.updateSystemSettings(
req.body,
actor,
req.ip || 'unknown'
);
res.status(200).json(updatedSettings);
} catch (error) {
// A more specific error could be logged here

View File

@@ -5,6 +5,7 @@ import { sql } from 'drizzle-orm';
import { db } from '../../database';
import { config } from '../../config';
const userService = new UserService();
export const getUsers = async (req: Request, res: Response) => {
@@ -25,10 +26,19 @@ export const createUser = async (req: Request, res: Response) => {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { email, first_name, last_name, password, roleId } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const newUser = await userService.createUser(
{ email, first_name, last_name, password },
roleId
roleId,
actor,
req.ip || 'unknown'
);
res.status(201).json(newUser);
};
@@ -38,10 +48,19 @@ export const updateUser = async (req: Request, res: Response) => {
return res.status(403).json({ message: req.t('errors.demoMode') });
}
const { email, first_name, last_name, roleId } = req.body;
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
const updatedUser = await userService.updateUser(
req.params.id,
{ email, first_name, last_name },
roleId
roleId,
actor,
req.ip || 'unknown'
);
if (!updatedUser) {
return res.status(404).json({ message: req.t('user.notFound') });
@@ -61,6 +80,13 @@ export const deleteUser = async (req: Request, res: Response) => {
message: req.t('user.cannotDeleteOnlyUser'),
});
}
await userService.deleteUser(req.params.id);
if (!req.user || !req.user.sub) {
return res.status(401).json({ message: 'Unauthorized' });
}
const actor = await userService.findById(req.user.sub);
if (!actor) {
return res.status(401).json({ message: 'Unauthorized' });
}
await userService.deleteUser(req.params.id, actor, req.ip || 'unknown');
res.status(204).send();
};

View File

@@ -6,7 +6,6 @@ import { ArchivedEmailController } from './controllers/archived-email.controller
import { StorageController } from './controllers/storage.controller';
import { SearchController } from './controllers/search.controller';
import { IamController } from './controllers/iam.controller';
import { requireAuth } from './middleware/requireAuth';
import { createAuthRouter } from './routes/auth.routes';
import { createIamRouter } from './routes/iam.routes';
import { createIngestionRouter } from './routes/ingestion.routes';
@@ -19,6 +18,7 @@ import { createUserRouter } from './routes/user.routes';
import { createSettingsRouter } from './routes/settings.routes';
import { apiKeyRoutes } from './routes/api-key.routes';
import { AuthService } from '../services/AuthService';
import { AuditService } from '../services/AuditService';
import { UserService } from '../services/UserService';
import { IamService } from '../services/IamService';
import { StorageService } from '../services/StorageService';
@@ -33,10 +33,12 @@ import { rateLimiter } from './middleware/rateLimiter';
import { config } from '../config';
// Define the "plugin" interface
export interface ArchiverModule {
initialize: (app: Express) => Promise<void>;
initialize: (app: Express, authService: AuthService) => Promise<void>;
name: string;
}
export let authService: AuthService;
export async function createServer(modules: ArchiverModule[] = []): Promise<Express> {
// Load environment variables
dotenv.config();
@@ -51,8 +53,9 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
}
// --- Dependency Injection Setup ---
const auditService = new AuditService();
const userService = new UserService();
const authService = new AuthService(userService, JWT_SECRET, JWT_EXPIRES_IN);
authService = new AuthService(userService, auditService, JWT_SECRET, JWT_EXPIRES_IN);
const authController = new AuthController(authService, userService);
const ingestionController = new IngestionController();
const archivedEmailController = new ArchivedEmailController();
@@ -90,6 +93,10 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
const app = express();
// Trust the proxy to get the real IP address of the client.
// This is important for audit logging and security.
app.set('trust proxy', true);
// --- Routes ---
const authRouter = createAuthRouter(authController);
const ingestionRouter = createIngestionRouter(ingestionController, authService);
@@ -132,7 +139,7 @@ export async function createServer(modules: ArchiverModule[] = []): Promise<Expr
app.use(`/${config.api.version}/users`, userRouter);
// Load all provided extension modules
for (const module of modules) {
await module.initialize(app);
await module.initialize(app, authService);
console.log(`🏢 Enterprise module loaded: ${module.name}`);
}

View File

@@ -1,4 +1,4 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import 'dotenv/config';
@@ -12,3 +12,4 @@ if (!process.env.DATABASE_URL) {
const connectionString = encodeDatabaseUrl(process.env.DATABASE_URL);
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export type Database = PostgresJsDatabase<typeof schema>;

View File

@@ -0,0 +1,9 @@
CREATE TYPE "public"."audit_log_action" AS ENUM('CREATE', 'READ', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'SETUP', 'IMPORT', 'PAUSE', 'SYNC', 'UPLOAD', 'SEARCH', 'DOWNLOAD', 'GENERATE');--> statement-breakpoint
CREATE TYPE "public"."audit_log_target_type" AS ENUM('ApiKey', 'ArchivedEmail', 'Dashboard', 'IngestionSource', 'Role', 'SystemSettings', 'User', 'File');--> statement-breakpoint
ALTER TABLE "audit_logs" ALTER COLUMN "target_type" SET DATA TYPE "public"."audit_log_target_type" USING "target_type"::"public"."audit_log_target_type";--> statement-breakpoint
ALTER TABLE "audit_logs" ADD COLUMN "previous_hash" varchar(64);--> statement-breakpoint
ALTER TABLE "audit_logs" ADD COLUMN "actor_ip" text;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD COLUMN "action_type" "audit_log_action" NOT NULL;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD COLUMN "current_hash" varchar(64) NOT NULL;--> statement-breakpoint
ALTER TABLE "audit_logs" DROP COLUMN "action";--> statement-breakpoint
ALTER TABLE "audit_logs" DROP COLUMN "is_tamper_evident";

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,13 @@
"when": 1757860242528,
"tag": "0020_panoramic_wolverine",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1759412986134,
"tag": "0021_nosy_veda",
"breakpoints": true
}
]
}

View File

@@ -7,3 +7,5 @@ export * from './schema/ingestion-sources';
export * from './schema/users';
export * from './schema/system-settings';
export * from './schema/api-keys';
export * from './schema/audit-logs';
export * from './schema/enums';

View File

@@ -1,12 +1,34 @@
import { bigserial, boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { bigserial, jsonb, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { auditLogActionEnum, auditLogTargetTypeEnum } from './enums';
export const auditLogs = pgTable('audit_logs', {
// A unique, sequential, and gapless primary key for ordering.
id: bigserial('id', { mode: 'number' }).primaryKey(),
// The SHA-256 hash of the preceding log entry's `currentHash`.
previousHash: varchar('previous_hash', { length: 64 }),
// A high-precision, UTC timestamp of when the event occurred.
timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
// A stable identifier for the actor who performed the action.
actorIdentifier: text('actor_identifier').notNull(),
action: text('action').notNull(),
targetType: text('target_type'),
// The IP address from which the action was initiated.
actorIp: text('actor_ip'),
// A standardized, machine-readable identifier for the event.
actionType: auditLogActionEnum('action_type').notNull(),
// The type of resource that was affected by the action.
targetType: auditLogTargetTypeEnum('target_type'),
// The unique identifier of the affected resource.
targetId: text('target_id'),
// A JSON object containing specific, contextual details of the event.
details: jsonb('details'),
isTamperEvident: boolean('is_tamper_evident').default(false),
// The SHA-256 hash of this entire log entry's contents.
currentHash: varchar('current_hash', { length: 64 }).notNull(),
});

View File

@@ -0,0 +1,5 @@
import { pgEnum } from 'drizzle-orm/pg-core';
import { AuditLogActions, AuditLogTargetTypes } from '@open-archiver/types';
export const auditLogActionEnum = pgEnum('audit_log_action', AuditLogActions);
export const auditLogTargetTypeEnum = pgEnum('audit_log_target_type', AuditLogTargetTypes);

View File

@@ -1,3 +1,6 @@
export { createServer, ArchiverModule } from './api/server';
export { logger } from './config/logger';
export { config } from './config';
export * from './services/AuthService'
export * from './services/AuditService'
export * from './api/middleware/requireAuth'

View File

@@ -3,13 +3,17 @@ import { db } from '../database';
import { apiKeys } from '../database/schema/api-keys';
import { CryptoService } from './CryptoService';
import { and, eq } from 'drizzle-orm';
import { ApiKey } from '@open-archiver/types';
import { ApiKey, User } from '@open-archiver/types';
import { AuditService } from './AuditService';
export class ApiKeyService {
private static auditService = new AuditService();
public static async generate(
userId: string,
name: string,
expiresInDays: number
expiresInDays: number,
actor: User,
actorIp: string
): Promise<string> {
const key = randomBytes(32).toString('hex');
const expiresAt = new Date();
@@ -24,6 +28,17 @@ export class ApiKeyService {
expiresAt,
});
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'GENERATE',
targetType: 'ApiKey',
targetId: name,
actorIp,
details: {
keyName: name,
},
});
return key;
}
@@ -46,8 +61,19 @@ export class ApiKeyService {
.filter((k): k is NonNullable<typeof k> => k !== null);
}
public static async deleteKey(id: string, userId: string) {
public static async deleteKey(id: string, userId: string, actor: User, actorIp: string) {
const [key] = await db.select().from(apiKeys).where(eq(apiKeys.id, id));
await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'ApiKey',
targetId: id,
actorIp,
details: {
keyName: key?.name,
},
});
}
/**
*

View File

@@ -17,6 +17,8 @@ import type {
import { StorageService } from './StorageService';
import { SearchService } from './SearchService';
import type { Readable } from 'stream';
import { AuditService } from './AuditService';
import { User } from '@open-archiver/types';
interface DbRecipients {
to: { name: string; address: string }[];
@@ -34,6 +36,7 @@ async function streamToBuffer(stream: Readable): Promise<Buffer> {
}
export class ArchivedEmailService {
private static auditService = new AuditService();
private static mapRecipients(dbRecipients: unknown): Recipient[] {
const { to = [], cc = [], bcc = [] } = dbRecipients as DbRecipients;
@@ -98,7 +101,9 @@ export class ArchivedEmailService {
public static async getArchivedEmailById(
emailId: string,
userId: string
userId: string,
actor: User,
actorIp: string
): Promise<ArchivedEmail | null> {
const email = await db.query.archivedEmails.findFirst({
where: eq(archivedEmails.id, emailId),
@@ -118,6 +123,15 @@ export class ArchivedEmailService {
return null;
}
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'READ',
targetType: 'ArchivedEmail',
targetId: emailId,
actorIp,
details: {},
});
let threadEmails: ThreadEmail[] = [];
if (email.threadId) {
@@ -179,7 +193,11 @@ export class ArchivedEmailService {
return mappedEmail;
}
public static async deleteArchivedEmail(emailId: string): Promise<void> {
public static async deleteArchivedEmail(
emailId: string,
actor: User,
actorIp: string
): Promise<void> {
const [email] = await db
.select()
.from(archivedEmails)
@@ -245,5 +263,16 @@ export class ArchivedEmailService {
await searchService.deleteDocuments('emails', [emailId]);
await db.delete(archivedEmails).where(eq(archivedEmails.id, emailId));
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'ArchivedEmail',
targetId: emailId,
actorIp,
details: {
reason: 'ManualDeletion',
},
});
}
}

View File

@@ -0,0 +1,193 @@
import { db, Database } from '../database';
import * as schema from '../database/schema';
import { AuditLogEntry, CreateAuditLogEntry, GetAuditLogsOptions, GetAuditLogsResponse } from '@open-archiver/types';
import { desc, sql, asc, and, gte, lte, eq } from 'drizzle-orm';
import { createHash } from 'crypto';
export class AuditService {
private sanitizeObject(obj: any): any {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => this.sanitizeObject(item));
}
const sanitizedObj: { [key: string]: any } = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
sanitizedObj[key] = value === undefined ? null : this.sanitizeObject(value);
}
}
return sanitizedObj;
}
public async createAuditLog(entry: CreateAuditLogEntry) {
return db.transaction(async (tx) => {
// Lock the table to prevent race conditions
await tx.execute(sql`LOCK TABLE audit_logs IN EXCLUSIVE MODE`);
const sanitizedEntry = this.sanitizeObject(entry);
const previousHash = await this.getLatestHash(tx);
const newEntry = {
...sanitizedEntry,
previousHash,
timestamp: new Date()
};
const currentHash = this.calculateHash(newEntry);
const finalEntry = {
...newEntry,
currentHash
};
await tx.insert(schema.auditLogs).values(finalEntry);
return finalEntry;
});
}
private async getLatestHash(tx: Database): Promise<string | null> {
const [latest] = await tx
.select({
currentHash: schema.auditLogs.currentHash
})
.from(schema.auditLogs)
.orderBy(desc(schema.auditLogs.id))
.limit(1);
return latest?.currentHash ?? null;
}
private calculateHash(entry: any): string {
// Create a canonical object for hashing to ensure consistency in property order and types.
const objectToHash = {
actorIdentifier: entry.actorIdentifier,
actorIp: entry.actorIp ?? null,
actionType: entry.actionType,
targetType: entry.targetType ?? null,
targetId: entry.targetId ?? null,
details: entry.details ?? null,
previousHash: entry.previousHash ?? null,
// Normalize timestamp to milliseconds since epoch to avoid precision issues.
timestamp: new Date(entry.timestamp).getTime()
};
const data = this.canonicalStringify(objectToHash);
return createHash('sha256').update(data).digest('hex');
}
private canonicalStringify(obj: any): string {
if (obj === undefined) {
return 'null';
}
if (obj === null || typeof obj !== 'object') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
return `[${obj.map((item) => this.canonicalStringify(item)).join(',')}]`;
}
const keys = Object.keys(obj).sort();
const pairs = keys.map((key) => {
const value = obj[key];
return `${JSON.stringify(key)}:${this.canonicalStringify(value)}`;
});
return `{${pairs.join(',')}}`;
}
public async getAuditLogs(options: GetAuditLogsOptions = {}): Promise<GetAuditLogsResponse> {
const {
page = 1,
limit = 20,
startDate,
endDate,
actor,
actionType,
sort = 'desc'
} = options;
const whereClauses = [];
if (startDate) whereClauses.push(gte(schema.auditLogs.timestamp, startDate));
if (endDate) whereClauses.push(lte(schema.auditLogs.timestamp, endDate));
if (actor) whereClauses.push(eq(schema.auditLogs.actorIdentifier, actor));
if (actionType) whereClauses.push(eq(schema.auditLogs.actionType, actionType));
const where = and(...whereClauses);
const logs = await db.query.auditLogs.findMany({
where,
orderBy: [sort === 'asc' ? asc(schema.auditLogs.id) : desc(schema.auditLogs.id)],
limit,
offset: (page - 1) * limit
});
const totalResult = await db
.select({
count: sql<number>`count(*)`
})
.from(schema.auditLogs)
.where(where);
const total = totalResult[0].count;
return {
data: logs as AuditLogEntry[],
meta: {
total,
page,
limit
}
};
}
public async verifyAuditLog(): Promise<{ ok: boolean; message: string; logId?: number }> {
const chunkSize = 1000;
let offset = 0;
let previousHash: string | null = null;
/**
* TODO: create job for audit log verification, generate audit report (new DB table)
*/
while (true) {
const logs = await db.query.auditLogs.findMany({
orderBy: [asc(schema.auditLogs.id)],
limit: chunkSize,
offset
});
if (logs.length === 0) {
break;
}
for (const log of logs) {
if (log.previousHash !== previousHash) {
return {
ok: false,
message: 'Audit log chain is broken!',
logId: log.id
};
}
const calculatedHash = this.calculateHash(log);
if (log.currentHash !== calculatedHash) {
return {
ok: false,
message: 'Audit log entry is tampered!',
logId: log.id
};
}
previousHash = log.currentHash;
}
offset += chunkSize;
}
return {
ok: true,
message: 'Audit log integrity verified successfully. The logs are not tempered with and the log chain is complete.'
};
}
}

View File

@@ -2,17 +2,25 @@ import { compare } from 'bcryptjs';
import { SignJWT, jwtVerify } from 'jose';
import type { AuthTokenPayload, LoginResponse } from '@open-archiver/types';
import { UserService } from './UserService';
import { AuditService } from './AuditService';
import { db } from '../database';
import * as schema from '../database/schema';
import { eq } from 'drizzle-orm';
export class AuthService {
#userService: UserService;
#auditService: AuditService;
#jwtSecret: Uint8Array;
#jwtExpiresIn: string;
constructor(userService: UserService, jwtSecret: string, jwtExpiresIn: string) {
constructor(
userService: UserService,
auditService: AuditService,
jwtSecret: string,
jwtExpiresIn: string
) {
this.#userService = userService;
this.#auditService = auditService;
this.#jwtSecret = new TextEncoder().encode(jwtSecret);
this.#jwtExpiresIn = jwtExpiresIn;
}
@@ -33,16 +41,40 @@ export class AuthService {
.sign(this.#jwtSecret);
}
public async login(email: string, password: string): Promise<LoginResponse | null> {
public async login(
email: string,
password: string,
ip: string
): Promise<LoginResponse | null> {
const user = await this.#userService.findByEmail(email);
if (!user || !user.password) {
await this.#auditService.createAuditLog({
actorIdentifier: email,
actionType: 'LOGIN',
targetType: 'User',
targetId: email,
actorIp: ip,
details: {
error: 'UserNotFound',
},
});
return null; // User not found or password not set
}
const isPasswordValid = await this.verifyPassword(password, user.password);
if (!isPasswordValid) {
await this.#auditService.createAuditLog({
actorIdentifier: user.id,
actionType: 'LOGIN',
targetType: 'User',
targetId: user.id,
actorIp: ip,
details: {
error: 'InvalidPassword',
},
});
return null; // Invalid password
}
@@ -63,6 +95,15 @@ export class AuthService {
roles: roles,
});
await this.#auditService.createAuditLog({
actorIdentifier: user.id,
actionType: 'LOGIN',
targetType: 'User',
targetId: user.id,
actorIp: ip,
details: {},
});
return {
accessToken,
user: {

View File

@@ -22,14 +22,14 @@ import {
} from '../database/schema';
import { createHash } from 'crypto';
import { logger } from '../config/logger';
import { IndexingService } from './IndexingService';
import { SearchService } from './SearchService';
import { DatabaseService } from './DatabaseService';
import { config } from '../config/index';
import { FilterBuilder } from './FilterBuilder';
import e from 'express';
import { AuditService } from './AuditService';
import { User } from '@open-archiver/types';
export class IngestionService {
private static auditService = new AuditService();
private static decryptSource(
source: typeof ingestionSources.$inferSelect
): IngestionSource | null {
@@ -54,7 +54,9 @@ export class IngestionService {
public static async create(
dto: CreateIngestionSourceDto,
userId: string
userId: string,
actor: User,
actorIp: string
): Promise<IngestionSource> {
const { providerConfig, ...rest } = dto;
const encryptedCredentials = CryptoService.encryptObject(providerConfig);
@@ -68,9 +70,21 @@ export class IngestionService {
const [newSource] = await db.insert(ingestionSources).values(valuesToInsert).returning();
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'CREATE',
targetType: 'IngestionSource',
targetId: newSource.id,
actorIp,
details: {
sourceName: newSource.name,
sourceType: newSource.provider,
},
});
const decryptedSource = this.decryptSource(newSource);
if (!decryptedSource) {
await this.delete(newSource.id);
await this.delete(newSource.id, actor, actorIp);
throw new Error(
'Failed to process newly created ingestion source due to a decryption error.'
);
@@ -81,13 +95,18 @@ export class IngestionService {
const connectionValid = await connector.testConnection();
// If connection succeeds, update status to auth_success, which triggers the initial import.
if (connectionValid) {
return await this.update(decryptedSource.id, { status: 'auth_success' });
return await this.update(
decryptedSource.id,
{ status: 'auth_success' },
actor,
actorIp
);
} else {
throw Error('Ingestion authentication failed.')
throw Error('Ingestion authentication failed.');
}
} catch (error) {
// If connection fails, delete the newly created source and throw the error.
await this.delete(decryptedSource.id);
await this.delete(decryptedSource.id, actor, actorIp);
throw error;
}
}
@@ -124,7 +143,9 @@ export class IngestionService {
public static async update(
id: string,
dto: UpdateIngestionSourceDto
dto: UpdateIngestionSourceDto,
actor?: User,
actorIp?: string
): Promise<IngestionSource> {
const { providerConfig, ...rest } = dto;
const valuesToUpdate: Partial<typeof ingestionSources.$inferInsert> = { ...rest };
@@ -159,11 +180,31 @@ export class IngestionService {
if (originalSource.status !== 'auth_success' && decryptedSource.status === 'auth_success') {
await this.triggerInitialImport(decryptedSource.id);
}
if (actor && actorIp) {
const changedFields = Object.keys(dto).filter(
(key) =>
key !== 'providerConfig' &&
originalSource[key as keyof IngestionSource] !==
decryptedSource[key as keyof IngestionSource]
);
if (changedFields.length > 0) {
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'IngestionSource',
targetId: id,
actorIp,
details: {
changedFields,
},
});
}
}
return decryptedSource;
}
public static async delete(id: string): Promise<IngestionSource> {
public static async delete(id: string, actor: User, actorIp: string): Promise<IngestionSource> {
const source = await this.findById(id);
if (!source) {
throw new Error('Ingestion source not found');
@@ -196,6 +237,17 @@ export class IngestionService {
.where(eq(ingestionSources.id, id))
.returning();
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'IngestionSource',
targetId: id,
actorIp,
details: {
sourceName: deletedSource.name,
},
});
const decryptedSource = this.decryptSource(deletedSource);
if (!decryptedSource) {
// Even if decryption fails, we should confirm deletion.
@@ -216,7 +268,7 @@ export class IngestionService {
await ingestionQueue.add('initial-import', { ingestionSourceId: source.id });
}
public static async triggerForceSync(id: string): Promise<void> {
public static async triggerForceSync(id: string, actor: User, actorIp: string): Promise<void> {
const source = await this.findById(id);
logger.info({ ingestionSourceId: id }, 'Force syncing started.');
if (!source) {
@@ -241,15 +293,35 @@ export class IngestionService {
}
// Reset status to 'active'
await this.update(id, {
status: 'active',
lastSyncStatusMessage: 'Force sync triggered by user.',
await this.update(
id,
{
status: 'active',
lastSyncStatusMessage: 'Force sync triggered by user.',
},
actor,
actorIp
);
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'SYNC',
targetType: 'IngestionSource',
targetId: id,
actorIp,
details: {
sourceName: source.name,
},
});
await ingestionQueue.add('continuous-sync', { ingestionSourceId: source.id });
}
public async performBulkImport(job: IInitialImportJob): Promise<void> {
public static async performBulkImport(
job: IInitialImportJob,
actor: User,
actorIp: string
): Promise<void> {
const { ingestionSourceId } = job;
const source = await IngestionService.findById(ingestionSourceId);
if (!source) {
@@ -257,10 +329,15 @@ export class IngestionService {
}
logger.info(`Starting bulk import for source: ${source.name} (${source.id})`);
await IngestionService.update(ingestionSourceId, {
status: 'importing',
lastSyncStartedAt: new Date(),
});
await IngestionService.update(
ingestionSourceId,
{
status: 'importing',
lastSyncStartedAt: new Date(),
},
actor,
actorIp
);
const connector = EmailProviderFactory.createConnector(source);
@@ -288,12 +365,17 @@ export class IngestionService {
}
} catch (error) {
logger.error(`Bulk import failed for source: ${source.name} (${source.id})`, error);
await IngestionService.update(ingestionSourceId, {
status: 'error',
lastSyncFinishedAt: new Date(),
lastSyncStatusMessage:
error instanceof Error ? error.message : 'An unknown error occurred.',
});
await IngestionService.update(
ingestionSourceId,
{
status: 'error',
lastSyncFinishedAt: new Date(),
lastSyncStatusMessage:
error instanceof Error ? error.message : 'An unknown error occurred.',
},
actor,
actorIp
);
throw error; // Re-throw to allow BullMQ to handle the job failure
}
}

View File

@@ -1,16 +1,25 @@
import { Index, MeiliSearch, SearchParams } from 'meilisearch';
import { config } from '../config';
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
import type {
SearchQuery,
SearchResult,
EmailDocument,
TopSender,
User,
} from '@open-archiver/types';
import { FilterBuilder } from './FilterBuilder';
import { AuditService } from './AuditService';
export class SearchService {
private client: MeiliSearch;
private auditService: AuditService;
constructor() {
this.client = new MeiliSearch({
host: config.search.host,
apiKey: config.search.apiKey,
});
this.auditService = new AuditService();
}
public async getIndex<T extends Record<string, any>>(name: string): Promise<Index<T>> {
@@ -48,7 +57,11 @@ export class SearchService {
return index.deleteDocuments({ filter });
}
public async searchEmails(dto: SearchQuery, userId: string): Promise<SearchResult> {
public async searchEmails(
dto: SearchQuery,
userId: string,
actorIp: string
): Promise<SearchResult> {
const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto;
const index = await this.getIndex<EmailDocument>('emails');
@@ -84,9 +97,24 @@ export class SearchService {
searchParams.filter = searchFilter;
}
}
console.log('searchParams', searchParams);
// console.log('searchParams', searchParams);
const searchResults = await index.search(query, searchParams);
await this.auditService.createAuditLog({
actorIdentifier: userId,
actionType: 'SEARCH',
targetType: 'ArchivedEmail',
targetId: '',
actorIp,
details: {
query,
filters,
page,
limit,
matchingStrategy,
},
});
return {
hits: searchResults.hits,
total: searchResults.estimatedTotalHits ?? searchResults.hits.length,

View File

@@ -1,7 +1,7 @@
import { db } from '../database';
import { systemSettings } from '../database/schema/system-settings';
import type { SystemSettings } from '@open-archiver/types';
import { eq } from 'drizzle-orm';
import type { SystemSettings, User } from '@open-archiver/types';
import { AuditService } from './AuditService';
const DEFAULT_SETTINGS: SystemSettings = {
language: 'en',
@@ -10,6 +10,7 @@ const DEFAULT_SETTINGS: SystemSettings = {
};
export class SettingsService {
private auditService = new AuditService();
/**
* Retrieves the current system settings.
* If no settings exist, it initializes and returns the default settings.
@@ -30,13 +31,35 @@ export class SettingsService {
* @param newConfig - A partial object of the new settings configuration.
* @returns The updated system settings.
*/
public async updateSystemSettings(newConfig: Partial<SystemSettings>): Promise<SystemSettings> {
public async updateSystemSettings(
newConfig: Partial<SystemSettings>,
actor: User,
actorIp: string
): Promise<SystemSettings> {
const currentConfig = await this.getSystemSettings();
const mergedConfig = { ...currentConfig, ...newConfig };
// Since getSettings ensures a record always exists, we can directly update.
const [result] = await db.update(systemSettings).set({ config: mergedConfig }).returning();
const changedFields = Object.keys(newConfig).filter(
(key) =>
currentConfig[key as keyof SystemSettings] !== newConfig[key as keyof SystemSettings]
);
if (changedFields.length > 0) {
await this.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'SystemSettings',
targetId: 'system',
actorIp,
details: {
changedFields,
},
});
}
return result.config;
}

View File

@@ -3,8 +3,10 @@ import * as schema from '../database/schema';
import { eq, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import type { CaslPolicy, User } from '@open-archiver/types';
import { AuditService } from './AuditService';
export class UserService {
private static auditService = new AuditService();
/**
* Finds a user by their email address.
* @param email The email address of the user to find.
@@ -60,7 +62,9 @@ export class UserService {
public async createUser(
userDetails: Pick<User, 'email' | 'first_name' | 'last_name'> & { password?: string },
roleId: string
roleId: string,
actor: User,
actorIp: string
): Promise<typeof schema.users.$inferSelect> {
const { email, first_name, last_name, password } = userDetails;
const hashedPassword = password ? await hash(password, 10) : undefined;
@@ -80,33 +84,72 @@ export class UserService {
roleId: roleId,
});
await UserService.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'CREATE',
targetType: 'User',
targetId: newUser[0].id,
actorIp,
details: {
createdUserEmail: newUser[0].email,
},
});
return newUser[0];
}
public async updateUser(
id: string,
userDetails: Partial<Pick<User, 'email' | 'first_name' | 'last_name'>>,
roleId?: string
roleId: string | undefined,
actor: User,
actorIp: string
): Promise<typeof schema.users.$inferSelect | null> {
const originalUser = await this.findById(id);
const updatedUser = await db
.update(schema.users)
.set(userDetails)
.where(eq(schema.users.id, id))
.returning();
if (roleId) {
if (roleId && originalUser?.role?.id !== roleId) {
await db.delete(schema.userRoles).where(eq(schema.userRoles.userId, id));
await db.insert(schema.userRoles).values({
userId: id,
roleId: roleId,
});
await UserService.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'UPDATE',
targetType: 'User',
targetId: id,
actorIp,
details: {
field: 'role',
oldValue: originalUser?.role?.name,
newValue: roleId, // TODO: get role name
},
});
}
// TODO: log other user detail changes
return updatedUser[0] || null;
}
public async deleteUser(id: string): Promise<void> {
public async deleteUser(id: string, actor: User, actorIp: string): Promise<void> {
const userToDelete = await this.findById(id);
await db.delete(schema.users).where(eq(schema.users.id, id));
await UserService.auditService.createAuditLog({
actorIdentifier: actor.id,
actionType: 'DELETE',
targetType: 'User',
targetId: id,
actorIp,
details: {
deletedUserEmail: userToDelete?.email,
},
});
}
/**
@@ -152,6 +195,17 @@ export class UserService {
roleId: superAdminRole.id,
});
await UserService.auditService.createAuditLog({
actorIdentifier: 'SYSTEM',
actionType: 'SETUP',
targetType: 'User',
targetId: newUser[0].id,
actorIp: '::1', // System action
details: {
setupAdminEmail: newUser[0].email,
},
});
return newUser[0];
}

View File

@@ -8,5 +8,10 @@
"composite": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "../types"
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -7,15 +7,19 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc && pnpm copy-assets",
"dev": "tsc --watch",
"copy-assets": "mkdir -p dist/modules/license && cp src/modules/license/public-key.pem dist/modules/license/public-key.pem"
},
"dependencies": {
"@open-archiver/backend": "workspace:*",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10"
"@types/jsonwebtoken": "^9.0.10",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0"
}
}

View File

@@ -1,8 +1,10 @@
import { ArchiverModule } from '@open-archiver/backend';
import { statusModule } from './modules/status/status.module';
import { retentionPolicyModule } from './modules/retention-policy/retention-policy.module';
import { auditLogModule } from './modules/audit-log/audit-log.module';
export const enterpriseModules: ArchiverModule[] = [
statusModule,
retentionPolicyModule,
auditLogModule
];

View File

@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { AuditService } from '@open-archiver/backend';
import { AuditLogActions, AuditLogTargetTypes } from '@open-archiver/types';
import { z } from 'zod';
const getAuditLogsSchema = z.object({
page: z.coerce.number().min(1).optional(),
limit: z.coerce.number().min(1).max(100).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
actor: z.string().optional(),
action: z.enum(AuditLogActions).optional(),
targetType: z.enum(AuditLogTargetTypes).optional(),
sort: z.enum(['asc', 'desc']).optional()
});
export class AuditLogController {
private auditService = new AuditService();
public getAuditLogs = async (req: Request, res: Response) => {
try {
const query = getAuditLogsSchema.parse(req.query);
const result = await this.auditService.getAuditLogs(query);
res.status(200).json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ message: 'Invalid query parameters', errors: error.issues });
}
res.status(500).json({ message: 'Internal server error.' });
}
};
public verifyAuditLog = async (req: Request, res: Response) => {
const result = await this.auditService.verifyAuditLog();
if (result.ok) {
res.status(200).json(result);
} else {
res.status(500).json(result);
}
};
}

View File

@@ -0,0 +1,15 @@
import { Express } from 'express';
import { ArchiverModule } from '@open-archiver/backend';
import { auditLogRoutes } from './audit-log.routes';
import { AuthService } from '@open-archiver/backend';
import { config } from '@open-archiver/backend';
class AuditLogModule implements ArchiverModule {
name = 'audit-log';
async initialize(app: Express, authService: AuthService): Promise<void> {
app.use(`/${config.api.version}/enterprise/audit-logs`, auditLogRoutes(authService));
}
}
export const auditLogModule = new AuditLogModule();

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { AuditLogController } from './audit-log.controller';
import { requireAuth } from '@open-archiver/backend';
import { AuthService } from '@open-archiver/backend';
export const auditLogRoutes = (authService: AuthService): Router => {
const router = Router();
const controller = new AuditLogController();
router.get('/', requireAuth(authService), controller.getAuditLogs);
router.post('/verify', requireAuth(authService), controller.verifyAuditLog);
return router;
};

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ declare global {
interface Locals {
user: Omit<User, 'passwordHash'> | null;
accessToken: string | null;
enterpriseMode: boolean | null;
}
// interface PageData {}
// interface PageState {}
@@ -15,4 +16,4 @@ declare global {
}
}
export {};
export { };

View File

@@ -22,6 +22,11 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.accessToken = null;
}
if (import.meta.env.VITE_ENTERPRISE_MODE === true) {
event.locals.enterpriseMode = true
} else {
event.locals.enterpriseMode = false
}
return resolve(event);
};

View File

@@ -255,6 +255,28 @@
"no_emails_found": "Keine archivierten E-Mails gefunden.",
"prev": "Zurück",
"next": "Weiter"
},
"audit_log": {
"title": "Audit-Protokoll",
"header": "Audit-Protokoll",
"verify_integrity": "Integrität überprüfen",
"log_entries": "Protokolleinträge",
"timestamp": "Zeitstempel",
"actor": "Akteur",
"action": "Aktion",
"target": "Ziel",
"details": "Details",
"ip_address": "IP Adresse",
"target_type": "Zieltyp",
"target_id": "Ziel-ID",
"no_logs_found": "Keine Audit-Protokolle gefunden.",
"prev": "Zurück",
"next": "Weiter",
"verification_successful_title": "Überprüfung erfolgreich",
"verification_successful_message": "Die Integrität des Audit-Protokolls wurde erfolgreich überprüft.",
"verification_failed_title": "Überprüfung fehlgeschlagen",
"verification_failed_message": "Die Integritätsprüfung des Audit-Protokolls ist fehlgeschlagen. Bitte überprüfen Sie die Systemprotokolle für weitere Details.",
"verification_error_message": "Während der Überprüfung ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut."
}
}
}

View File

@@ -287,6 +287,34 @@
"indexed_insights": "Indexed insights",
"top_10_senders": "Top 10 Senders",
"no_indexed_insights": "No indexed insights available."
},
"audit_log": {
"title": "Audit Log",
"header": "Audit Log",
"verify_integrity": "Verify Log Integrity",
"log_entries": "Log Entries",
"timestamp": "Timestamp",
"actor": "Actor",
"action": "Action",
"target": "Target",
"details": "Details",
"ip_address": "IP Address",
"target_type": "Target Type",
"target_id": "Target ID",
"no_logs_found": "No audit logs found.",
"prev": "Prev",
"next": "Next",
"log_entry_details": "Log Entry Details",
"viewing_details_for": "Viewing the complete details for log entry #",
"actor_id": "Actor ID",
"previous_hash": "Previous Hash",
"current_hash": "Current Hash",
"close": "Close",
"verification_successful_title": "Verification Successful",
"verification_successful_message": "Audit log integrity verified successfully.",
"verification_failed_title": "Verification Failed",
"verification_failed_message": "The audit log integrity check failed. Please review the system logs for more details.",
"verification_error_message": "An unexpected error occurred during verification. Please try again."
}
}
}

View File

@@ -63,6 +63,7 @@ export const load: LayoutServerLoad = async (event) => {
return {
user: locals.user,
accessToken: locals.accessToken,
enterpriseMode: locals.enterpriseMode,
isDemo: process.env.IS_DEMO === 'true',
systemSettings,
currentVersion: version,

View File

@@ -6,6 +6,8 @@
import { page } from '$app/state';
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
import { t } from '$lib/translations';
import type { LayoutData } from '../$types';
let { data, children } = $props();
interface NavItem {
href?: string;
@@ -45,14 +47,19 @@
];
const enterpriseNavItems: NavItem[] = [
{ href: '/dashboard/compliance-center', label: 'Compliance Center' },
{
label: 'Compliance',
subMenu: [
{ href: '/dashboard/compliance-center', label: 'Compliance Center' },
{ href: '/dashboard/compliance/audit-log', label: 'Audit Log' },
],
},
];
let navItems: NavItem[] = $state(baseNavItems);
if (import.meta.env.VITE_ENTERPRISE_MODE) {
if (data.enterpriseMode) {
navItems = [...baseNavItems, ...enterpriseNavItems];
}
let { children } = $props();
function handleLogout() {
authStore.logout();
goto('/signin');

View File

@@ -1,18 +0,0 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (import.meta.env.VITE_ENTERPRISE_MODE) {
try {
const response = await api('/enterprise/status', event);
const data = await response.json();
return {
status: data
};
} catch (err) {
console.log(err)
throw error(500, 'Failed to fetch enterprise status.');
}
}
};

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
{#if import.meta.env.VITE_ENTERPRISE_MODE}
<h1 class="text-2xl font-bold">Compliance Center (Enterprise)</h1>
<p class="mt-4">This is a placeholder page for the Compliance Center.</p>
{#if data && data.status}
<pre class="mt-4">{JSON.stringify(data.status, null, 2)}</pre>
{/if}
{:else}
<div class="text-center">
<h1 class="text-2xl font-bold">Access Denied</h1>
<p class="mt-4">This feature is only available in the Enterprise Edition.</p>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
import { api } from '$lib/server/api';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { GetAuditLogsResponse } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.enterpriseMode) {
throw redirect(307, '/dashboard')
}
try {
// Forward search params from the page URL to the API request
const response = await api(`/enterprise/audit-logs?${event.url.searchParams.toString()}`, event);
if (!response.ok) {
const error = await response.json();
return { error: error.message, logs: [], meta: { total: 0, page: 1, limit: 20 } };
}
const result: GetAuditLogsResponse = await response.json();
return {
logs: result.data,
meta: result.meta
};
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
logs: [],
meta: { total: 0, page: 1, limit: 20 }
};
}
};

View File

@@ -0,0 +1,335 @@
<script lang="ts">
import type { PageData } from './$types';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { t } from '$lib/translations';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { ArrowUpDown } from 'lucide-svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api.client';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import * as HoverCard from '$lib/components/ui/hover-card/index.js';
import type { AuditLogAction, AuditLogEntry } from '@open-archiver/types';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label';
let { data }: { data: PageData } = $props();
let logs = $derived(data.logs);
let meta = $derived(data.meta);
const getPaginationItems = (currentPage: number, totalPages: number, siblingCount = 1) => {
const totalPageNumbers = siblingCount + 5;
if (totalPages <= totalPageNumbers) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);
const shouldShowLeftDots = leftSiblingIndex > 2;
const shouldShowRightDots = rightSiblingIndex < totalPages - 2;
const firstPageIndex = 1;
const lastPageIndex = totalPages;
if (!shouldShowLeftDots && shouldShowRightDots) {
let leftItemCount = 3 + 2 * siblingCount;
let leftRange = Array.from({ length: leftItemCount }, (_, i) => i + 1);
return [...leftRange, '...', totalPages];
}
if (shouldShowLeftDots && !shouldShowRightDots) {
let rightItemCount = 3 + 2 * siblingCount;
let rightRange = Array.from(
{ length: rightItemCount },
(_, i) => totalPages - rightItemCount + i + 1
);
return [firstPageIndex, '...', ...rightRange];
}
if (shouldShowLeftDots && shouldShowRightDots) {
let middleRange = Array.from(
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
(_, i) => leftSiblingIndex + i
);
return [firstPageIndex, '...', ...middleRange, '...', lastPageIndex];
}
return [];
};
let paginationItems = $derived(
getPaginationItems(meta.page, Math.ceil(meta.total / meta.limit))
);
let isDetailViewOpen = $state(false);
let selectedLog = $state<AuditLogEntry | null>(null);
function viewLogDetails(log: AuditLogEntry) {
selectedLog = log;
isDetailViewOpen = true;
}
let sort = $state($page.url.searchParams.get('sort') ?? 'desc');
function getActionBadgeClasses(action: AuditLogAction): string {
switch (action) {
case 'LOGIN':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 'CREATE':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'UPDATE':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 'DELETE':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
case 'SEARCH':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
case 'DOWNLOAD':
return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
}
function handleSort() {
sort = sort === 'desc' ? 'asc' : 'desc';
const newUrl = new URL($page.url);
newUrl.searchParams.set('sort', sort);
goto(newUrl);
}
async function handleVerify() {
try {
const res = await api('/enterprise/audit-logs/verify', { method: 'POST' });
const body = await res.json();
if (res.ok) {
setAlert({
type: 'success',
title: $t('app.audit_log.verification_successful_title'),
message: body.message || $t('app.audit_log.verification_successful_message'),
duration: 5000,
show: true,
});
} else {
setAlert({
type: 'error',
title: $t('app.audit_log.verification_failed_title'),
message: body.message || $t('app.audit_log.verification_failed_message'),
duration: 5000,
show: true,
});
}
} catch (e) {
setAlert({
type: 'error',
title: $t('app.audit_log.verification_failed_title'),
message: $t('app.audit_log.verification_error_message'),
duration: 5000,
show: true,
});
}
}
</script>
<svelte:head>
<title>{$t('app.audit_log.title')} - OpenArchiver</title>
</svelte:head>
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">{$t('app.audit_log.header')}</h1>
<Button onclick={handleVerify}>{$t('app.audit_log.verify_integrity')}</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>
<span class="flex flex-row items-center space-x-1">
<span>
{$t('app.audit_log.timestamp')}
</span>
<Button variant="ghost" onclick={handleSort} class="h-4 w-4 p-3">
<ArrowUpDown class=" h-3 w-3" />
</Button>
</span>
</Table.Head>
<Table.Head>{$t('app.audit_log.actor')}</Table.Head>
<Table.Head>{$t('app.audit_log.ip_address')}</Table.Head>
<Table.Head>{$t('app.audit_log.action')}</Table.Head>
<Table.Head>{$t('app.audit_log.target_type')}</Table.Head>
<Table.Head>{$t('app.audit_log.target_id')}</Table.Head>
<Table.Head>{$t('app.audit_log.details')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if logs && logs.length > 0}
{#each logs as log (log.id)}
<Table.Row class="cursor-pointer" onclick={() => viewLogDetails(log)}>
<Table.Cell>{new Date(log.timestamp).toLocaleString()}</Table.Cell>
<Table.Cell>
<span class="font-mono text-xs">{log.actorIdentifier}</span>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-xs">{log.actorIp}</span>
</Table.Cell>
<Table.Cell>
<Badge class={getActionBadgeClasses(log.actionType)}
>{log.actionType}</Badge
>
</Table.Cell>
<Table.Cell>
{#if log.targetType}
<Badge variant="secondary">{log.targetType}</Badge>
{/if}
</Table.Cell>
<Table.Cell>
{#if log.targetId}
<span class="font-mono text-xs">{log.targetId}</span>
{/if}
</Table.Cell>
<Table.Cell>
{#if log.details}
<HoverCard.Root>
<HoverCard.Trigger>
<span class="cursor-pointer font-mono text-xs">
{JSON.stringify(log.details).length > 10
? `${JSON.stringify(log.details).substring(0, 10)}...`
: JSON.stringify(log.details)}
</span>
</HoverCard.Trigger>
<HoverCard.Content>
<pre
class="max-h-64 overflow-y-auto text-xs">{JSON.stringify(
log.details,
null,
2
)}</pre>
</HoverCard.Content>
</HoverCard.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center">
{$t('app.audit_log.no_logs_found')}
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
{#if meta.total > meta.limit}
<div class="mt-8 flex flex-row items-center justify-center space-x-2">
<a
href={`/dashboard/compliance/audit-log?page=${meta.page - 1}&limit=${meta.limit}`}
class={meta.page === 1 ? 'pointer-events-none' : ''}
>
<Button variant="outline" disabled={meta.page === 1}>{$t('app.audit_log.prev')}</Button>
</a>
{#each paginationItems as item}
{#if typeof item === 'number'}
<a href={`/dashboard/compliance/audit-log?page=${item}&limit=${meta.limit}`}>
<Button variant={item === meta.page ? 'default' : 'outline'}>{item}</Button>
</a>
{:else}
<span class="px-4 py-2">...</span>
{/if}
{/each}
<a
href={`/dashboard/compliance/audit-log?page=${meta.page + 1}&limit=${meta.limit}`}
class={meta.page === Math.ceil(meta.total / meta.limit) ? 'pointer-events-none' : ''}
>
<Button variant="outline" disabled={meta.page === Math.ceil(meta.total / meta.limit)}
>{$t('app.audit_log.next')}</Button
>
</a>
</div>
{/if}
<Dialog.Root bind:open={isDetailViewOpen}>
<Dialog.Content class="sm:max-w-[625px]">
<Dialog.Header>
<Dialog.Title>{$t('app.audit_log.log_entry_details')}</Dialog.Title>
<Dialog.Description>
{$t('app.audit_log.viewing_details_for')}{selectedLog?.id}.
</Dialog.Description>
</Dialog.Header>
{#if selectedLog}
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.timestamp')}</Label>
<span class="col-span-3 font-mono text-sm"
>{new Date(selectedLog.timestamp).toLocaleString()}</span
>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.actor_id')}</Label>
<span class="col-span-3 font-mono text-sm">{selectedLog.actorIdentifier}</span>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.ip_address')}</Label>
<span class="col-span-3 font-mono text-sm">{selectedLog.actorIp}</span>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.action')}</Label>
<div class="col-span-3">
<Badge class={getActionBadgeClasses(selectedLog.actionType)}
>{selectedLog.actionType}</Badge
>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.target_type')}</Label>
<div class="col-span-3">
{#if selectedLog.targetType}
<Badge variant="secondary">{selectedLog.targetType}</Badge>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{$t('app.audit_log.target_id')}</Label>
<span class="col-span-3 font-mono text-sm">{selectedLog.targetId}</span>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right">{$t('app.audit_log.details')}</Label>
<div class="col-span-3">
<pre
class="max-h-64 overflow-y-auto rounded-md bg-slate-100 p-2 text-xs dark:bg-slate-800">{JSON.stringify(
selectedLog.details,
null,
2
)}</pre>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right">{$t('app.audit_log.previous_hash')}</Label>
<span class="col-span-3 break-all font-mono text-sm"
>{selectedLog.previousHash}</span
>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right">{$t('app.audit_log.current_hash')}</Label>
<span class="col-span-3 break-all font-mono text-sm"
>{selectedLog.currentHash}</span
>
</div>
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (isDetailViewOpen = false)}
>{$t('app.audit_log.close')}</Button
>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -62,7 +62,8 @@
};
}
function handleSearch() {
function handleSearch(e: SubmitEvent) {
e.preventDefault();
const params = new URLSearchParams();
params.set('keywords', keywords);
params.set('page', '1');
@@ -179,7 +180,7 @@
<div class="container mx-auto p-4 md:p-8">
<h1 class="mb-4 text-2xl font-bold">{$t('app.search.email_search')}</h1>
<form onsubmit={handleSearch} class="mb-8 flex flex-col space-y-2">
<form onsubmit={(e) => handleSearch(e)} class="mb-8 flex flex-col space-y-2">
<div class="flex items-center gap-2">
<Input
type="search"

View File

@@ -20,6 +20,7 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
allowedHosts: ['2b449016927c.ngrok-free.app']
},
ssr: {
noExternal: ['layerchart'],

View File

@@ -0,0 +1,37 @@
export const AuditLogActions = [
// General CRUD
'CREATE',
'READ',
'UPDATE',
'DELETE',
// User & Session Management
'LOGIN',
'LOGOUT',
'SETUP', // Initial user setup
// Ingestion Actions
'IMPORT',
'PAUSE',
'SYNC',
'UPLOAD',
// Other Actions
'SEARCH',
'DOWNLOAD',
'GENERATE' // For API keys
] as const;
export const AuditLogTargetTypes = [
'ApiKey',
'ArchivedEmail',
'Dashboard',
'IngestionSource',
'Role',
'SystemSettings',
'User',
'File' // For uploads and downloads
] as const;
export type AuditLogAction = (typeof AuditLogActions)[number];
export type AuditLogTargetType = (typeof AuditLogTargetTypes)[number];

View File

@@ -0,0 +1,39 @@
import type { AuditLogAction, AuditLogTargetType } from './audit-log.enums';
export interface AuditLogEntry {
id: number;
previousHash: string | null;
timestamp: Date;
actorIdentifier: string;
actorIp: string | null;
actionType: AuditLogAction;
targetType: AuditLogTargetType | null;
targetId: string | null;
details: Record<string, any> | null;
currentHash: string;
}
export type CreateAuditLogEntry = Omit<
AuditLogEntry,
'id' | 'previousHash' | 'timestamp' | 'currentHash'
>;
export interface GetAuditLogsOptions {
page?: number;
limit?: number;
startDate?: Date;
endDate?: Date;
actor?: string;
actionType?: AuditLogAction;
targetType?: AuditLogTargetType | null;
sort?: 'asc' | 'desc';
}
export interface GetAuditLogsResponse {
data: AuditLogEntry[];
meta: {
total: number;
page: number;
limit: number;
};
}

View File

@@ -8,3 +8,5 @@ export * from './search.types';
export * from './dashboard.types';
export * from './iam.types';
export * from './system.types';
export * from './audit-log.types';
export * from './audit-log.enums';

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
"declaration": true,
"composite": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]

File diff suppressed because one or more lines are too long

19
pnpm-lock.yaml generated
View File

@@ -68,6 +68,9 @@ importers:
ts-node-dev:
specifier: ^2.0.0
version: 2.0.0(@types/node@24.0.13)(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
packages/backend:
dependencies:
@@ -185,9 +188,6 @@ importers:
sqlite3:
specifier: ^5.1.7
version: 5.1.7
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
xlsx:
specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
@@ -231,6 +231,9 @@ importers:
ts-node-dev:
specifier: ^2.0.0
version: 2.0.0(@types/node@24.0.13)(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -246,6 +249,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
zod:
specifier: ^4.1.5
version: 4.1.5
devDependencies:
'@types/express':
specifier: ^5.0.3
@@ -253,6 +259,12 @@ importers:
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
ts-node-dev:
specifier: ^2.0.0
version: 2.0.0(@types/node@24.0.13)(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
packages/frontend:
dependencies:
@@ -3724,7 +3736,6 @@ packages:
resolution: {integrity: sha512-Nkwo9qeCvqVH0ZgYRUfPyj6o4o7StvNIxMFECeiz4y0uMOVyqc5Y9hjsdFVxdYCeiUjjXLQXA8KIz0iJL3HM0w==}
engines: {node: '>=20.18.0'}
hasBin: true
bundledDependencies: []
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}

View File

@@ -7,6 +7,12 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@open-archiver/backend/*": ["packages/backend/src/*"],
"@open-archiver/types": ["packages/types/src/index.ts"],
"@open-archiver/enterprise/*": ["packages/enterprise/src/*"]
}
}
}