mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
enterprise: Audit log API, UI
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
1292
packages/backend/src/database/migrations/meta/0021_snapshot.json
Normal file
1292
packages/backend/src/database/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,13 @@
|
||||
"when": 1757860242528,
|
||||
"tag": "0020_panoramic_wolverine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1759412986134,
|
||||
"tag": "0021_nosy_veda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
5
packages/backend/src/database/schema/enums.ts
Normal file
5
packages/backend/src/database/schema/enums.ts
Normal 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);
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
193
packages/backend/src/services/AuditService.ts
Normal file
193
packages/backend/src/services/AuditService.ts
Normal 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.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
3
packages/frontend/src/app.d.ts
vendored
3
packages/frontend/src/app.d.ts
vendored
@@ -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 { };
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
allowedHosts: ['2b449016927c.ngrok-free.app']
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['layerchart'],
|
||||
|
||||
37
packages/types/src/audit-log.enums.ts
Normal file
37
packages/types/src/audit-log.enums.ts
Normal 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];
|
||||
39
packages/types/src/audit-log.types.ts
Normal file
39
packages/types/src/audit-log.types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
19
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user