diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 84c241e..0c5207e 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -1,45 +1,25 @@ # API Authentication -To access protected API endpoints, you need to include a JSON Web Token (JWT) in the `Authorization` header of your requests. +To access protected API endpoints, you need to include a user-generated API key in the `X-API-KEY` header of your requests. -## Obtaining a JWT +## 1. Creating an API Key -First, you need to authenticate with the `/api/v1/auth/login` endpoint by providing your email and password. If the credentials are correct, the API will return an `accessToken`. +You can create, manage, and view your API keys through the application's user interface. -**Request:** +1. Navigate to **Settings > API Keys** in the dashboard. +2. Click the **"Generate API Key"** button. +3. Provide a descriptive name for your key and select an expiration period. +4. The new API key will be displayed. **Copy this key immediately and store it in a secure location. You will not be able to see it again.** -```http -POST /api/v1/auth/login -Content-Type: application/json +## 2. Making Authenticated Requests -{ - "email": "user@example.com", - "password": "your-password" -} -``` - -**Successful Response:** - -```json -{ - "accessToken": "your.jwt.token", - "user": { - "id": "user-id", - "email": "user@example.com", - "role": "user" - } -} -``` - -## Making Authenticated Requests - -Once you have the `accessToken`, you must include it in the `Authorization` header of all subsequent requests to protected endpoints, using the `Bearer` scheme. +Once you have your API key, you must include it in the `X-API-KEY` header of all subsequent requests to protected API endpoints. **Example:** ```http GET /api/v1/dashboard/stats -Authorization: Bearer your.jwt.token +X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 ``` -If the token is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code. +If the API key is missing, expired, or invalid, the API will respond with a `401 Unauthorized` status code. diff --git a/packages/backend/package.json b/packages/backend/package.json index 288f3b7..ca605a9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -60,7 +60,8 @@ "sqlite3": "^5.1.7", "tsconfig-paths": "^4.2.0", "xlsx": "^0.18.5", - "yauzl": "^3.2.0" + "yauzl": "^3.2.0", + "zod": "^4.1.5" }, "devDependencies": { "@bull-board/api": "^6.11.0", diff --git a/packages/backend/src/api/controllers/api-key.controller.ts b/packages/backend/src/api/controllers/api-key.controller.ts new file mode 100644 index 0000000..a62a49a --- /dev/null +++ b/packages/backend/src/api/controllers/api-key.controller.ts @@ -0,0 +1,50 @@ +import { Request, Response } from 'express'; +import { ApiKeyService } from '../../services/ApiKeyService'; +import { z } from 'zod'; + +const generateApiKeySchema = z.object({ + name: z.string().min(1, 'API kay name must be more than 1 characters').max(255, 'API kay name must not be more than 255 characters'), + expiresInDays: z.number().int().positive('Only positive number is allowed').max(730, "The API key must expire within 2 years / 730 days."), +}); + +export class ApiKeyController { + public async generateApiKey(req: Request, res: Response) { + try { + const { name, expiresInDays } = generateApiKeySchema.parse(req.body); + if (!req.user || !req.user.sub) { + return res.status(401).json({ message: 'Unauthorized' }); + } + const userId = req.user.sub; + + const key = await ApiKeyService.generate(userId, name, expiresInDays); + + res.status(201).json({ key }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ message: req.t('api.requestBodyInvalid'), errors: error.message }); + } + res.status(500).json({ message: req.t('errors.internalServerError') }); + } + } + + public async getApiKeys(req: Request, res: Response) { + if (!req.user || !req.user.sub) { + return res.status(401).json({ message: 'Unauthorized' }); + } + const userId = req.user.sub; + const keys = await ApiKeyService.getKeys(userId); + + res.status(200).json(keys); + } + + public async deleteApiKey(req: Request, res: Response) { + const { id } = req.params; + if (!req.user || !req.user.sub) { + return res.status(401).json({ message: 'Unauthorized' }); + } + const userId = req.user.sub; + await ApiKeyService.deleteKey(id, userId); + + res.status(204).send({ message: req.t('apiKeys.deleteSuccess') }); + } +} diff --git a/packages/backend/src/api/controllers/settings.controller.ts b/packages/backend/src/api/controllers/settings.controller.ts index 318d638..6ebc730 100644 --- a/packages/backend/src/api/controllers/settings.controller.ts +++ b/packages/backend/src/api/controllers/settings.controller.ts @@ -4,9 +4,9 @@ import { config } from '../../config'; const settingsService = new SettingsService(); -export const getSettings = async (req: Request, res: Response) => { +export const getSystemSettings = async (req: Request, res: Response) => { try { - const settings = await settingsService.getSettings(); + const settings = await settingsService.getSystemSettings(); res.status(200).json(settings); } catch (error) { // A more specific error could be logged here @@ -14,13 +14,13 @@ export const getSettings = async (req: Request, res: Response) => { } }; -export const updateSettings = async (req: Request, res: Response) => { +export const updateSystemSettings = async (req: Request, res: Response) => { try { // Basic validation can be performed here if necessary if (config.app.isDemo) { return res.status(403).json({ message: req.t('errors.demoMode') }); } - const updatedSettings = await settingsService.updateSettings(req.body); + const updatedSettings = await settingsService.updateSystemSettings(req.body); res.status(200).json(updatedSettings); } catch (error) { // A more specific error could be logged here diff --git a/packages/backend/src/api/middleware/rateLimiter.ts b/packages/backend/src/api/middleware/rateLimiter.ts index 5e444b6..4042325 100644 --- a/packages/backend/src/api/middleware/rateLimiter.ts +++ b/packages/backend/src/api/middleware/rateLimiter.ts @@ -1,10 +1,12 @@ import rateLimit from 'express-rate-limit'; +import { config } from '../../config'; -// Rate limiter to prevent brute-force attacks on the login endpoint -export const loginRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10, // Limit each IP to 10 login requests per windowMs - message: 'Too many login attempts from this IP, please try again after 15 minutes', - standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers - legacyHeaders: false, // Disable the `X-RateLimit-*` headers +const windowInMinutes = Math.ceil(config.api.rateLimit.windowMs / 60000); + +export const rateLimiter = rateLimit({ + windowMs: config.api.rateLimit.windowMs, + max: config.api.rateLimit.max, + message: `Too many requests from this IP, please try again after ${windowInMinutes} minutes`, + standardHeaders: true, + legacyHeaders: false, }); diff --git a/packages/backend/src/api/middleware/requireAuth.ts b/packages/backend/src/api/middleware/requireAuth.ts index 41bb3f7..6381a39 100644 --- a/packages/backend/src/api/middleware/requireAuth.ts +++ b/packages/backend/src/api/middleware/requireAuth.ts @@ -2,6 +2,9 @@ import type { Request, Response, NextFunction } from 'express'; import type { AuthService } from '../../services/AuthService'; import type { AuthTokenPayload } from '@open-archiver/types'; import 'dotenv/config'; +import { ApiKeyService } from '../../services/ApiKeyService'; +import { UserService } from '../../services/UserService'; + // By using module augmentation, we can add our custom 'user' property // to the Express Request interface in a type-safe way. declare global { @@ -15,6 +18,25 @@ declare global { export const requireAuth = (authService: AuthService) => { return async (req: Request, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; + const apiKeyHeader = req.headers['x-api-key']; + + if (apiKeyHeader) { + const userId = await ApiKeyService.validateKey(apiKeyHeader as string); + if (!userId) { + return res.status(401).json({ message: 'Unauthorized: Invalid API key' }); + } + const user = await new UserService().findById(userId); + if (!user) { + return res.status(401).json({ message: 'Unauthorized: Invalid user' }); + } + req.user = { + sub: user.id, + email: user.email, + roles: user.role ? [user.role.name] : [] + }; + return next(); + } + if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: 'Unauthorized: No token provided' }); } diff --git a/packages/backend/src/api/routes/api-key.routes.ts b/packages/backend/src/api/routes/api-key.routes.ts new file mode 100644 index 0000000..0df0a8c --- /dev/null +++ b/packages/backend/src/api/routes/api-key.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { ApiKeyController } from '../controllers/api-key.controller'; +import { requireAuth } from '../middleware/requireAuth'; +import { AuthService } from '../../services/AuthService'; + +export const apiKeyRoutes = (authService: AuthService) => { + const router = Router(); + const controller = new ApiKeyController(); + + router.post('/', requireAuth(authService), controller.generateApiKey); + router.get('/', requireAuth(authService), controller.getApiKeys); + router.delete('/:id', requireAuth(authService), controller.deleteApiKey); + + return router; +}; diff --git a/packages/backend/src/api/routes/auth.routes.ts b/packages/backend/src/api/routes/auth.routes.ts index ea2f295..e326165 100644 --- a/packages/backend/src/api/routes/auth.routes.ts +++ b/packages/backend/src/api/routes/auth.routes.ts @@ -1,5 +1,4 @@ import { Router } from 'express'; -import { loginRateLimiter } from '../middleware/rateLimiter'; import type { AuthController } from '../controllers/auth.controller'; export const createAuthRouter = (authController: AuthController): Router => { @@ -10,14 +9,14 @@ export const createAuthRouter = (authController: AuthController): Router => { * @description Creates the initial administrator user. * @access Public */ - router.post('/setup', loginRateLimiter, authController.setup); + router.post('/setup', authController.setup); /** * @route POST /api/v1/auth/login * @description Authenticates a user and returns a JWT. * @access Public */ - router.post('/login', loginRateLimiter, authController.login); + router.post('/login', authController.login); /** * @route GET /api/v1/auth/status diff --git a/packages/backend/src/api/routes/settings.routes.ts b/packages/backend/src/api/routes/settings.routes.ts index 8bf9693..6d2d430 100644 --- a/packages/backend/src/api/routes/settings.routes.ts +++ b/packages/backend/src/api/routes/settings.routes.ts @@ -11,14 +11,14 @@ export const createSettingsRouter = (authService: AuthService): Router => { /** * @returns SystemSettings */ - router.get('/', settingsController.getSettings); + router.get('/system', settingsController.getSystemSettings); // Protected route to update settings router.put( - '/', + '/system', requireAuth(authService), requirePermission('manage', 'settings', 'settings.noPermissionToUpdate'), - settingsController.updateSettings + settingsController.updateSystemSettings ); return router; diff --git a/packages/backend/src/config/api.ts b/packages/backend/src/config/api.ts new file mode 100644 index 0000000..5941eeb --- /dev/null +++ b/packages/backend/src/config/api.ts @@ -0,0 +1,8 @@ +import 'dotenv/config'; + +export const apiConfig = { + rateLimit: { + windowMs: process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) : 15 * 60 * 1000, // 15 minutes + max: process.env.RATE_LIMIT_MAX_REQUESTS ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) : 100, // limit each IP to 100 requests per windowMs + } +}; diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 133f880..506d065 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -2,10 +2,12 @@ import { storage } from './storage'; import { app } from './app'; import { searchConfig } from './search'; import { connection as redisConfig } from './redis'; +import { apiConfig } from './api'; export const config = { storage, app, search: searchConfig, redis: redisConfig, + api: apiConfig, }; diff --git a/packages/backend/src/database/migrations/0018_flawless_owl.sql b/packages/backend/src/database/migrations/0018_flawless_owl.sql new file mode 100644 index 0000000..faef0ef --- /dev/null +++ b/packages/backend/src/database/migrations/0018_flawless_owl.sql @@ -0,0 +1,11 @@ +CREATE TABLE "api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "user_id" uuid NOT NULL, + "key" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0019_confused_scream.sql b/packages/backend/src/database/migrations/0019_confused_scream.sql new file mode 100644 index 0000000..75a97cb --- /dev/null +++ b/packages/backend/src/database/migrations/0019_confused_scream.sql @@ -0,0 +1 @@ +ALTER TABLE "api_keys" ADD COLUMN "key_hash" text NOT NULL; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0018_snapshot.json b/packages/backend/src/database/migrations/meta/0018_snapshot.json new file mode 100644 index 0000000..d0880ca --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0018_snapshot.json @@ -0,0 +1,1238 @@ +{ + "id": "03762d62-41bc-45e7-bc50-a99f8e8b3567", + "prevId": "74921769-c190-4fbd-b239-dea052f9bc99", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_content_hash_sha256_unique": { + "name": "attachments_content_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": [ + "content_hash_sha256" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": [ + "email_id", + "attachment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_tamper_evident": { + "name": "is_tamper_evident", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custodian_id": { + "name": "custodian_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hold_criteria": { + "name": "hold_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_by_identifier": { + "name": "applied_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legal_holds_custodian_id_custodians_id_fk": { + "name": "legal_holds_custodian_id_custodians_id_fk", + "tableFrom": "legal_holds", + "tableTo": "custodians", + "columnsFrom": [ + "custodian_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": [ + "delete_permanently", + "notify_admin" + ] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0019_snapshot.json b/packages/backend/src/database/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..8f32abc --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0019_snapshot.json @@ -0,0 +1,1244 @@ +{ + "id": "a83bff5b-47ef-43e9-8e7d-667af35a206f", + "prevId": "03762d62-41bc-45e7-bc50-a99f8e8b3567", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": [ + "ingestion_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_content_hash_sha256_unique": { + "name": "attachments_content_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": [ + "content_hash_sha256" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": [ + "email_id", + "attachment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_tamper_evident": { + "name": "is_tamper_evident", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custodian_id": { + "name": "custodian_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hold_criteria": { + "name": "hold_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_by_identifier": { + "name": "applied_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legal_holds_custodian_id_custodians_id_fk": { + "name": "legal_holds_custodian_id_custodians_id_fk", + "tableFrom": "legal_holds", + "tableTo": "custodians", + "columnsFrom": [ + "custodian_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_action": { + "name": "retention_action", + "schema": "public", + "values": [ + "delete_permanently", + "notify_admin" + ] + }, + "public.ingestion_provider": { + "name": "ingestion_provider", + "schema": "public", + "values": [ + "google_workspace", + "microsoft_365", + "generic_imap", + "pst_import", + "eml_import" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success", + "imported" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/_journal.json b/packages/backend/src/database/migrations/meta/_journal.json index a93709e..5b7996c 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -1,132 +1,146 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1752225352591, - "tag": "0000_amusing_namora", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1752326803882, - "tag": "0001_odd_night_thrasher", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1752332648392, - "tag": "0002_lethal_quentin_quire", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1752332967084, - "tag": "0003_petite_wrecker", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1752606108876, - "tag": "0004_sleepy_paper_doll", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1752606327253, - "tag": "0005_chunky_sue_storm", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1753112018514, - "tag": "0006_majestic_caretaker", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1753190159356, - "tag": "0007_handy_archangel", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1753370737317, - "tag": "0008_eminent_the_spike", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1754337938241, - "tag": "0009_late_lenny_balinger", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1754420780849, - "tag": "0010_perpetual_lightspeed", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1754422064158, - "tag": "0011_tan_blackheart", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1754476962901, - "tag": "0012_warm_the_stranger", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1754659373517, - "tag": "0013_classy_talkback", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1754831765718, - "tag": "0014_foamy_vapor", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1755443936046, - "tag": "0015_wakeful_norman_osborn", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1755780572342, - "tag": "0016_lonely_mariko_yashida", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1755961566627, - "tag": "0017_tranquil_shooting_star", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1752225352591, + "tag": "0000_amusing_namora", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1752326803882, + "tag": "0001_odd_night_thrasher", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1752332648392, + "tag": "0002_lethal_quentin_quire", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1752332967084, + "tag": "0003_petite_wrecker", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1752606108876, + "tag": "0004_sleepy_paper_doll", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1752606327253, + "tag": "0005_chunky_sue_storm", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1753112018514, + "tag": "0006_majestic_caretaker", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1753190159356, + "tag": "0007_handy_archangel", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1753370737317, + "tag": "0008_eminent_the_spike", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1754337938241, + "tag": "0009_late_lenny_balinger", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1754420780849, + "tag": "0010_perpetual_lightspeed", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1754422064158, + "tag": "0011_tan_blackheart", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1754476962901, + "tag": "0012_warm_the_stranger", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1754659373517, + "tag": "0013_classy_talkback", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1754831765718, + "tag": "0014_foamy_vapor", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1755443936046, + "tag": "0015_wakeful_norman_osborn", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1755780572342, + "tag": "0016_lonely_mariko_yashida", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1755961566627, + "tag": "0017_tranquil_shooting_star", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1756911118035, + "tag": "0018_flawless_owl", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1756937533843, + "tag": "0019_confused_scream", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index a8f5897..8441331 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -6,3 +6,4 @@ export * from './schema/custodians'; export * from './schema/ingestion-sources'; export * from './schema/users'; export * from './schema/system-settings'; +export * from './schema/api-keys'; diff --git a/packages/backend/src/database/schema/api-keys.ts b/packages/backend/src/database/schema/api-keys.ts new file mode 100644 index 0000000..6fd4874 --- /dev/null +++ b/packages/backend/src/database/schema/api-keys.ts @@ -0,0 +1,15 @@ +import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { users } from './users'; + +export const apiKeys = pgTable('api_keys', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + key: text('key').notNull(), // Encrypted API key + keyHash: text('key_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ecacabc..fcab0cb 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -17,6 +17,7 @@ import { createDashboardRouter } from './api/routes/dashboard.routes'; import { createUploadRouter } from './api/routes/upload.routes'; import { createUserRouter } from './api/routes/user.routes'; import { createSettingsRouter } from './api/routes/settings.routes'; +import { apiKeyRoutes } from './api/routes/api-key.routes'; import { AuthService } from './services/AuthService'; import { UserService } from './services/UserService'; import { IamService } from './services/IamService'; @@ -28,6 +29,7 @@ import FsBackend from 'i18next-fs-backend'; import i18nextMiddleware from 'i18next-http-middleware'; import path from 'path'; import { logger } from './config/logger'; +import { rateLimiter } from './api/middleware/rateLimiter'; // Load environment variables dotenv.config(); @@ -43,7 +45,7 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) { // --- i18next Initialization --- const initializeI18next = async () => { - const systemSettings = await settingsService.getSettings(); + const systemSettings = await settingsService.getSystemSettings(); const defaultLanguage = systemSettings?.language || 'en'; logger.info({ language: defaultLanguage }, 'Default language'); await i18next.use(FsBackend).init({ @@ -86,10 +88,12 @@ const iamRouter = createIamRouter(iamController, authService); const uploadRouter = createUploadRouter(authService); const userRouter = createUserRouter(authService); const settingsRouter = createSettingsRouter(authService); +const apiKeyRouter = apiKeyRoutes(authService); // upload route is added before middleware because it doesn't use the json middleware. app.use('/v1/upload', uploadRouter); // Middleware for all other routes +app.use(rateLimiter); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -105,6 +109,7 @@ app.use('/v1/search', searchRouter); app.use('/v1/dashboard', dashboardRouter); app.use('/v1/users', userRouter); app.use('/v1/settings', settingsRouter); +app.use('/v1/api-keys', apiKeyRouter); // Example of a protected route app.get('/v1/protected', requireAuth(authService), (req, res) => { diff --git a/packages/backend/src/locales/en/translation.json b/packages/backend/src/locales/en/translation.json index 7e62865..5978d9d 100644 --- a/packages/backend/src/locales/en/translation.json +++ b/packages/backend/src/locales/en/translation.json @@ -58,5 +58,12 @@ "invalidFilePath": "Invalid file path", "fileNotFound": "File not found", "downloadError": "Error downloading file" + }, + "apiKeys": { + "generateSuccess": "API key generated successfully.", + "deleteSuccess": "API key deleted successfully." + }, + "api": { + "requestBodyInvalid": "Invalid request body." } } diff --git a/packages/backend/src/services/ApiKeyService.ts b/packages/backend/src/services/ApiKeyService.ts new file mode 100644 index 0000000..4c3da5a --- /dev/null +++ b/packages/backend/src/services/ApiKeyService.ts @@ -0,0 +1,72 @@ +import { randomBytes, createHash } from 'crypto'; +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'; + +export class ApiKeyService { + public static async generate( + userId: string, + name: string, + expiresInDays: number + ): Promise { + const key = randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + const keyHash = createHash('sha256').update(key).digest('hex'); + + await db.insert(apiKeys).values({ + userId, + name, + key: CryptoService.encrypt(key), + keyHash, + expiresAt + }); + + return key; + } + + public static async getKeys(userId: string): Promise { + const keys = await db.select().from(apiKeys).where(eq(apiKeys.userId, userId)); + + return keys + .map((apiKey) => { + const decryptedKey = CryptoService.decrypt(apiKey.key); + if (!decryptedKey) { + return null; + } + return { + ...apiKey, + key: decryptedKey.slice(0, 5) + "*****", + expiresAt: apiKey.expiresAt.toISOString(), + createdAt: apiKey.createdAt.toISOString() + }; + }) + .filter((k): k is NonNullable => k !== null); + } + + public static async deleteKey(id: string, userId: string) { + await db.delete(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))); + } + /** + * + * @param key API key + * @returns The owner user ID or null. null means the API key is not found. + */ + public static async validateKey(key: string): Promise { + const keyHash = createHash('sha256').update(key).digest('hex'); + const [apiKey] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)); + if (!apiKey || apiKey.expiresAt < new Date()) { + return null; + } + + const decryptedKey = CryptoService.decrypt(apiKey.key); + if (decryptedKey !== key) { + // This should not happen if the hash matches, but as a security measure, we double-check. + return null; + } + + return apiKey.userId; + } +} diff --git a/packages/backend/src/services/SettingsService.ts b/packages/backend/src/services/SettingsService.ts index a739c10..eea4225 100644 --- a/packages/backend/src/services/SettingsService.ts +++ b/packages/backend/src/services/SettingsService.ts @@ -15,11 +15,11 @@ export class SettingsService { * If no settings exist, it initializes and returns the default settings. * @returns The system settings. */ - public async getSettings(): Promise { + public async getSystemSettings(): Promise { const settings = await db.select().from(systemSettings).limit(1); if (settings.length === 0) { - return this.createDefaultSettings(); + return this.createDefaultSystemSettings(); } return settings[0].config; @@ -30,8 +30,8 @@ export class SettingsService { * @param newConfig - A partial object of the new settings configuration. * @returns The updated system settings. */ - public async updateSettings(newConfig: Partial): Promise { - const currentConfig = await this.getSettings(); + public async updateSystemSettings(newConfig: Partial): Promise { + const currentConfig = await this.getSystemSettings(); const mergedConfig = { ...currentConfig, ...newConfig }; // Since getSettings ensures a record always exists, we can directly update. @@ -45,7 +45,7 @@ export class SettingsService { * This is called internally when no settings are found. * @returns The newly created default settings. */ - private async createDefaultSettings(): Promise { + private async createDefaultSystemSettings(): Promise { const [result] = await db .insert(systemSettings) .values({ config: DEFAULT_SETTINGS }) diff --git a/packages/frontend/src/lib/translations/en.json b/packages/frontend/src/lib/translations/en.json index 0d5190b..f8d98b2 100644 --- a/packages/frontend/src/lib/translations/en.json +++ b/packages/frontend/src/lib/translations/en.json @@ -222,8 +222,36 @@ "system": "System", "users": "Users", "roles": "Roles", + "api_keys": "API Keys", "logout": "Logout" }, + "api_keys_page": { + "title": "API Keys", + "header": "API Keys", + "generate_new_key": "Generate New Key", + "name": "Name", + "key": "Key", + "expires_at": "Expires At", + "created_at": "Created At", + "actions": "Actions", + "delete": "Delete", + "no_keys_found": "No API keys found.", + "generate_modal_title": "Generate New API Key", + "generate_modal_description": "Please provide a name and expiration for your new API key.", + "expires_in": "Expires In", + "select_expiration": "Select an expiration", + "30_days": "30 Days", + "60_days": "60 Days", + "6_months": "6 Months", + "12_months": "12 Months", + "24_months": "24 Months", + "generate": "Generate", + "new_api_key": "New API Key", + "failed_to_delete": "Failed to delete API key", + "api_key_deleted": "API key deleted", + "generated_title": "API Key Generated", + "generated_message": "Your API key is generated, please copy and save it in a secure place. This key will only be shown once." + }, "archived_emails_page": { "title": "Archived emails", "header": "Archived Emails", diff --git a/packages/frontend/src/routes/dashboard/+layout.svelte b/packages/frontend/src/routes/dashboard/+layout.svelte index 848c392..70f55a0 100644 --- a/packages/frontend/src/routes/dashboard/+layout.svelte +++ b/packages/frontend/src/routes/dashboard/+layout.svelte @@ -33,6 +33,10 @@ href: '/dashboard/settings/roles', label: $t('app.layout.roles'), }, + { + href: '/dashboard/settings/api-keys', + label: $t('app.layout.api_keys'), + }, ], }, ]; diff --git a/packages/frontend/src/routes/dashboard/settings/api-keys/+page.server.ts b/packages/frontend/src/routes/dashboard/settings/api-keys/+page.server.ts new file mode 100644 index 0000000..57a4bce --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/api-keys/+page.server.ts @@ -0,0 +1,50 @@ +import { api } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + const response = await api('/api-keys', event); + const apiKeys = await response.json(); + + return { + apiKeys, + }; +}; + +export const actions: Actions = { + generate: async (event) => { + const data = await event.request.formData(); + const name = data.get('name') as string; + const expiresInDays = Number(data.get('expiresInDays')); + + const response = await api('/api-keys', event, { + method: 'POST', + body: JSON.stringify({ name, expiresInDays }), + }); + + const responseBody = await response.json(); + + if (!response.ok) { + return { + message: responseBody.message || '', + errors: responseBody.errors + } + } + + + return { + newApiKey: responseBody.key, + }; + }, + delete: async (event) => { + const data = await event.request.formData(); + const id = data.get('id') as string; + + await api(`/api-keys/${id}`, event, { + method: 'DELETE', + }); + + return { + success: true, + }; + }, +}; diff --git a/packages/frontend/src/routes/dashboard/settings/api-keys/+page.svelte b/packages/frontend/src/routes/dashboard/settings/api-keys/+page.svelte new file mode 100644 index 0000000..5ac3e92 --- /dev/null +++ b/packages/frontend/src/routes/dashboard/settings/api-keys/+page.svelte @@ -0,0 +1,266 @@ + + + + {$t('app.api_keys_page.title')} - Open Archiver + + +
+
+

{$t('app.api_keys_page.title')}

+ + + + + + + {$t('app.api_keys_page.generate_modal_title')} + + {$t('app.api_keys_page.generate_modal_description')} + + +
{ + newAPIKeyDialogOpen = false; + }} + > +
+
+ + +
+
+ + + + {triggerContent} + + + {#each expirationOptions as option} + {option.label} + {/each} + + +
+
+ + + +
+
+
+
+ {#if form?.newApiKey} + + + {$t('app.api_keys_page.generated_title')} + {$t('app.api_keys_page.generated_message')} + + +

{form?.newApiKey}

+
+
+ {/if} + +
+ + + + {$t('app.api_keys_page.name')} + {$t('app.api_keys_page.key')} + {$t('app.api_keys_page.expires_at')} + {$t('app.api_keys_page.created_at')} + {$t('app.users.actions')} + + + + {#if apiKeys.length > 0} + {#each apiKeys as apiKey (apiKey.id)} + + {apiKey.name} + {apiKey.key.substring(0, 8)} + {new Date(apiKey.expiresAt).toLocaleDateString()} + {new Date(apiKey.createdAt).toLocaleDateString()} + + + + + + + {$t('app.users.actions')} + + openDeleteDialog(apiKey)} + > + + {$t('app.users.delete')} + + + + + + {/each} + {:else} + + {$t('app.api_keys_page.no_keys_found')} + + {/if} + + +
+
+ + + + + {$t('app.users.delete_confirmation_title')} + + {$t('app.users.delete_confirmation_description')} + + + + + + + + + + diff --git a/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts b/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts index a9eb0f8..27cacac 100644 --- a/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts +++ b/packages/frontend/src/routes/dashboard/settings/system/+page.server.ts @@ -4,7 +4,7 @@ import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { - const response = await api('/settings', event); + const response = await api('/settings/system', event); if (!response.ok) { const { message } = await response.json(); @@ -30,7 +30,7 @@ export const actions: Actions = { supportEmail: supportEmail ? String(supportEmail) : null, }; - const response = await api('/settings', event, { + const response = await api('/settings/system', event, { method: 'PUT', body: JSON.stringify(body), }); diff --git a/packages/types/src/user.types.ts b/packages/types/src/user.types.ts index e7fc150..df4013f 100644 --- a/packages/types/src/user.types.ts +++ b/packages/types/src/user.types.ts @@ -35,3 +35,11 @@ export interface Role { createdAt: Date; updatedAt: Date; } + +export interface ApiKey { + id: string; + name: string; + key: string; + expiresAt: string; + createdAt: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2376c..3dbb1fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: yauzl: specifier: ^3.2.0 version: 3.2.0 + zod: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@bull-board/api': specifier: ^6.11.0 @@ -4801,6 +4804,9 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod@4.1.5: + resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9765,4 +9771,6 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod@4.1.5: {} + zwitch@2.0.4: {}