From 842f8092d6aac1517db9df19d3341e7ffde7faa2 Mon Sep 17 00:00:00 2001 From: Wayne <5291640+ringoinca@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:01:15 +0300 Subject: [PATCH] Migrating user service to database, sunsetting admin user --- docs/developer-guides/iam-policies.md | 111 ++ .../src/api/controllers/auth.controller.ts | 49 +- .../src/api/controllers/iam.controller.ts | 71 ++ .../backend/src/api/routes/auth.routes.ts | 14 + packages/backend/src/api/routes/iam.routes.ts | 37 + .../migrations/0010_perpetual_lightspeed.sql | 36 + .../migrations/0011_tan_blackheart.sql | 2 + .../migrations/meta/0010_snapshot.json | 1087 ++++++++++++++++ .../migrations/meta/0011_snapshot.json | 1093 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + packages/backend/src/database/schema.ts | 1 + .../src/database/schema/archived-emails.ts | 6 +- packages/backend/src/database/schema/users.ts | 89 ++ .../backend/src/iam-policy/iam-definitions.ts | 155 +++ .../src/iam-policy/policy-validator.ts | 100 ++ packages/backend/src/index.ts | 13 +- packages/backend/src/services/AuthService.ts | 58 +- packages/backend/src/services/IamService.ts | 24 + packages/backend/src/services/UserService.ts | 98 +- .../frontend/src/routes/+layout.server.ts | 20 +- .../frontend/src/routes/setup/+page.server.ts | 5 + .../frontend/src/routes/setup/+page.svelte | 111 ++ .../src/routes/signin/+page.server.ts | 11 + packages/types/src/auth.types.ts | 6 +- packages/types/src/iam.types.ts | 9 + packages/types/src/index.ts | 1 + packages/types/src/user.types.ts | 44 +- 27 files changed, 3174 insertions(+), 91 deletions(-) create mode 100644 docs/developer-guides/iam-policies.md create mode 100644 packages/backend/src/api/controllers/iam.controller.ts create mode 100644 packages/backend/src/api/routes/iam.routes.ts create mode 100644 packages/backend/src/database/migrations/0010_perpetual_lightspeed.sql create mode 100644 packages/backend/src/database/migrations/0011_tan_blackheart.sql create mode 100644 packages/backend/src/database/migrations/meta/0010_snapshot.json create mode 100644 packages/backend/src/database/migrations/meta/0011_snapshot.json create mode 100644 packages/backend/src/database/schema/users.ts create mode 100644 packages/backend/src/iam-policy/iam-definitions.ts create mode 100644 packages/backend/src/iam-policy/policy-validator.ts create mode 100644 packages/backend/src/services/IamService.ts create mode 100644 packages/frontend/src/routes/setup/+page.server.ts create mode 100644 packages/frontend/src/routes/setup/+page.svelte create mode 100644 packages/frontend/src/routes/signin/+page.server.ts create mode 100644 packages/types/src/iam.types.ts diff --git a/docs/developer-guides/iam-policies.md b/docs/developer-guides/iam-policies.md new file mode 100644 index 0000000..edbcea8 --- /dev/null +++ b/docs/developer-guides/iam-policies.md @@ -0,0 +1,111 @@ +# IAM Policies Guide + +This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions. + +## 1. Policy Structure + +A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`. + +```json +{ + "Effect": "Allow", + "Action": ["archive:read", "archive:search"], + "Resource": ["archive/all"] +} +``` + +- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`. +- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`. +- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used. + +## 2. Actions and Resources by Service + +The following sections define the available actions and resources, categorized by their respective services. + +### Service: `archive` + +The `archive` service pertains to all actions related to accessing and managing archived emails. + +**Actions:** + +| Action | Description | +| :--------------- | :--------------------------------------------------------------------- | +| `archive:read` | Grants permission to read the content and metadata of archived emails. | +| `archive:search` | Grants permission to perform search queries against the email archive. | +| `archive:export` | Grants permission to export search results or individual emails. | + +**Resources:** + +| Resource | Description | +| :------------------------------------ | :------------------------------------------------------------- | +| `archive/all` | Represents the entire email archive. | +| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. | +| `archive/email/{emailId}` | Scopes the action to a single, specific email. | +| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. | + +--- + +### Service: `ingestion` + +The `ingestion` service covers the management of email ingestion sources. + +**Actions:** + +| Action | Description | +| :----------------------- | :--------------------------------------------------------------------------- | +| `ingestion:createSource` | Grants permission to create a new ingestion source. | +| `ingestion:readSource` | Grants permission to view the details of ingestion sources. | +| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. | +| `ingestion:deleteSource` | Grants permission to delete an ingestion source. | +| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. | + +**Resources:** + +| Resource | Description | +| :---------------------------- | :-------------------------------------------------------- | +| `ingestion-source/*` | Represents all ingestion sources. | +| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. | + +--- + +### Service: `system` + +The `system` service is for managing system-level settings, users, and roles. + +**Actions:** + +| Action | Description | +| :---------------------- | :-------------------------------------------------- | +| `system:readSettings` | Grants permission to view system settings. | +| `system:updateSettings` | Grants permission to modify system settings. | +| `system:readUsers` | Grants permission to list and view user accounts. | +| `system:createUser` | Grants permission to create new user accounts. | +| `system:updateUser` | Grants permission to modify existing user accounts. | +| `system:deleteUser` | Grants permission to delete user accounts. | +| `system:assignRole` | Grants permission to assign roles to users. | + +**Resources:** + +| Resource | Description | +| :--------------------- | :---------------------------------------------------- | +| `system/settings` | Represents the system configuration. | +| `system/users` | Represents all user accounts within the system. | +| `system/user/{userId}` | Scopes the action to a single, specific user account. | + +--- + +### Service: `dashboard` + +The `dashboard` service relates to viewing analytics and overview information. + +**Actions:** + +| Action | Description | +| :--------------- | :-------------------------------------------------------------- | +| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. | + +**Resources:** + +| Resource | Description | +| :------------ | :------------------------------------------ | +| `dashboard/*` | Represents all components of the dashboard. | diff --git a/packages/backend/src/api/controllers/auth.controller.ts b/packages/backend/src/api/controllers/auth.controller.ts index 557ccca..3ddd3ca 100644 --- a/packages/backend/src/api/controllers/auth.controller.ts +++ b/packages/backend/src/api/controllers/auth.controller.ts @@ -1,13 +1,44 @@ import type { Request, Response } from 'express'; -import type { IAuthService } from '../../services/AuthService'; +import { AuthService } from '../../services/AuthService'; +import { UserService } from '../../services/UserService'; +import { db } from '../../database'; +import * as schema from '../../database/schema'; +import { sql } from 'drizzle-orm'; export class AuthController { - #authService: IAuthService; + #authService: AuthService; + #userService: UserService; - constructor(authService: IAuthService) { + constructor(authService: AuthService, userService: UserService) { this.#authService = authService; + this.#userService = userService; } + public setup = async (req: Request, res: Response): Promise => { + const { email, password, first_name, last_name } = req.body; + + if (!email || !password || !first_name || !last_name) { + return res.status(400).json({ message: 'Email, password, and name are required' }); + } + + try { + const userCountResult = await db.select({ count: sql`count(*)` }).from(schema.users); + const userCount = Number(userCountResult[0].count); + + if (userCount > 0) { + return res.status(403).json({ message: 'Setup has already been completed.' }); + } + + const newUser = await this.#userService.createUser({ email, password, first_name, last_name }); + const result = await this.#authService.login(email, password); + + return res.status(201).json(result); + } catch (error) { + console.error('Setup error:', error); + return res.status(500).json({ message: 'An internal server error occurred' }); + } + }; + public login = async (req: Request, res: Response): Promise => { const { email, password } = req.body; @@ -28,4 +59,16 @@ export class AuthController { return res.status(500).json({ message: 'An internal server error occurred' }); } }; + + public status = async (req: Request, res: Response): Promise => { + try { + const userCountResult = await db.select({ count: sql`count(*)` }).from(schema.users); + const userCount = Number(userCountResult[0].count); + const needsSetup = userCount === 0; + return res.status(200).json({ needsSetup }); + } catch (error) { + console.error('Status check error:', error); + return res.status(500).json({ message: 'An internal server error occurred' }); + } + }; } diff --git a/packages/backend/src/api/controllers/iam.controller.ts b/packages/backend/src/api/controllers/iam.controller.ts new file mode 100644 index 0000000..f24d1f8 --- /dev/null +++ b/packages/backend/src/api/controllers/iam.controller.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express'; +import { IamService } from '../../services/IamService'; +import { PolicyValidator } from '../../iam-policy/policy-validator'; +import type { PolicyStatement } from '@open-archiver/types'; + +export class IamController { + #iamService: IamService; + + constructor(iamService: IamService) { + this.#iamService = iamService; + } + + public getRoles = async (req: Request, res: Response): Promise => { + try { + const roles = await this.#iamService.getRoles(); + res.status(200).json(roles); + } catch (error) { + res.status(500).json({ error: 'Failed to get roles.' }); + } + }; + + public getRoleById = async (req: Request, res: Response): Promise => { + const { id } = req.params; + + try { + const role = await this.#iamService.getRoleById(id); + if (role) { + res.status(200).json(role); + } else { + res.status(404).json({ error: 'Role not found.' }); + } + } catch (error) { + res.status(500).json({ error: 'Failed to get role.' }); + } + }; + + public createRole = async (req: Request, res: Response): Promise => { + const { name, policy } = req.body; + + if (!name || !policy) { + res.status(400).json({ error: 'Missing required fields: name and policy.' }); + return; + } + + for (const statement of policy) { + const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement); + if (!valid) { + res.status(400).json({ error: `Invalid policy statement: ${reason}` }); + return; + } + } + + try { + const role = await this.#iamService.createRole(name, policy); + res.status(201).json(role); + } catch (error) { + res.status(500).json({ error: 'Failed to create role.' }); + } + }; + + public deleteRole = async (req: Request, res: Response): Promise => { + const { id } = req.params; + + try { + await this.#iamService.deleteRole(id); + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: 'Failed to delete role.' }); + } + }; +} diff --git a/packages/backend/src/api/routes/auth.routes.ts b/packages/backend/src/api/routes/auth.routes.ts index 288fdb8..3d42e2e 100644 --- a/packages/backend/src/api/routes/auth.routes.ts +++ b/packages/backend/src/api/routes/auth.routes.ts @@ -5,6 +5,13 @@ import type { AuthController } from '../controllers/auth.controller'; export const createAuthRouter = (authController: AuthController): Router => { const router = Router(); + /** + * @route POST /api/v1/auth/setup + * @description Creates the initial administrator user. + * @access Public + */ + router.post('/setup', loginRateLimiter, authController.setup); + /** * @route POST /api/v1/auth/login * @description Authenticates a user and returns a JWT. @@ -12,5 +19,12 @@ export const createAuthRouter = (authController: AuthController): Router => { */ router.post('/login', loginRateLimiter, authController.login); + /** + * @route GET /api/v1/auth/status + * @description Checks if the application has been set up. + * @access Public + */ + router.get('/status', authController.status); + return router; }; diff --git a/packages/backend/src/api/routes/iam.routes.ts b/packages/backend/src/api/routes/iam.routes.ts new file mode 100644 index 0000000..0c5cbd3 --- /dev/null +++ b/packages/backend/src/api/routes/iam.routes.ts @@ -0,0 +1,37 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/requireAuth'; +import type { IamController } from '../controllers/iam.controller'; + +export const createIamRouter = (iamController: IamController): Router => { + const router = Router(); + + /** + * @route GET /api/v1/iam/roles + * @description Gets all roles. + * @access Private + */ + router.get('/roles', requireAuth, iamController.getRoles); + + /** + * @route GET /api/v1/iam/roles/:id + * @description Gets a role by ID. + * @access Private + */ + router.get('/roles/:id', requireAuth, iamController.getRoleById); + + /** + * @route POST /api/v1/iam/roles + * @description Creates a new role. + * @access Private + */ + router.post('/roles', requireAuth, iamController.createRole); + + /** + * @route DELETE /api/v1/iam/roles/:id + * @description Deletes a role. + * @access Private + */ + router.delete('/roles/:id', requireAuth, iamController.deleteRole); + + return router; +}; diff --git a/packages/backend/src/database/migrations/0010_perpetual_lightspeed.sql b/packages/backend/src/database/migrations/0010_perpetual_lightspeed.sql new file mode 100644 index 0000000..4a406d9 --- /dev/null +++ b/packages/backend/src/database/migrations/0010_perpetual_lightspeed.sql @@ -0,0 +1,36 @@ +CREATE TABLE "roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "policies" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_roles" ( + "user_id" uuid NOT NULL, + "role_id" uuid NOT NULL, + CONSTRAINT "user_roles_user_id_role_id_pk" PRIMARY KEY("user_id","role_id") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text, + "password" text, + "provider" text DEFAULT 'local', + "provider_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0011_tan_blackheart.sql b/packages/backend/src/database/migrations/0011_tan_blackheart.sql new file mode 100644 index 0000000..18e42f8 --- /dev/null +++ b/packages/backend/src/database/migrations/0011_tan_blackheart.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" RENAME COLUMN "name" TO "first_name";--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "last_name" text; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0010_snapshot.json b/packages/backend/src/database/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..087f867 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0010_snapshot.json @@ -0,0 +1,1087 @@ +{ + "id": "ab45f75d-f50c-457f-ad40-e62ca27a4e2b", + "prevId": "701eda75-451a-4a6d-87e3-b6658fca65da", + "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()" + } + }, + "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()" + }, + "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": {}, + "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" + }, + "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" + ] + } + }, + "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 + }, + "name": { + "name": "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 + } + }, + "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" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0011_snapshot.json b/packages/backend/src/database/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..bc8a473 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0011_snapshot.json @@ -0,0 +1,1093 @@ +{ + "id": "6252768a-7c7f-4dae-9dbd-d3ea9f647cea", + "prevId": "ab45f75d-f50c-457f-ad40-e62ca27a4e2b", + "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()" + } + }, + "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()" + }, + "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": {}, + "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" + }, + "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" + ] + } + }, + "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 + } + }, + "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" + ] + }, + "public.ingestion_status": { + "name": "ingestion_status", + "schema": "public", + "values": [ + "active", + "paused", + "error", + "pending_auth", + "syncing", + "importing", + "auth_success" + ] + } + }, + "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 0ca8395..8d05fd2 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -71,6 +71,20 @@ "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 } ] } \ No newline at end of file diff --git a/packages/backend/src/database/schema.ts b/packages/backend/src/database/schema.ts index 571a02b..557f1e8 100644 --- a/packages/backend/src/database/schema.ts +++ b/packages/backend/src/database/schema.ts @@ -4,3 +4,4 @@ export * from './schema/audit-logs'; export * from './schema/compliance'; export * from './schema/custodians'; export * from './schema/ingestion-sources'; +export * from './schema/users'; diff --git a/packages/backend/src/database/schema/archived-emails.ts b/packages/backend/src/database/schema/archived-emails.ts index 0ec8072..01043fc 100644 --- a/packages/backend/src/database/schema/archived-emails.ts +++ b/packages/backend/src/database/schema/archived-emails.ts @@ -25,11 +25,7 @@ export const archivedEmails = pgTable( isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false), archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => { - return { - threadIdIdx: index('thread_id_idx').on(table.threadId) - }; - } + (table) => [index('thread_id_idx').on(table.threadId)] ); export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({ diff --git a/packages/backend/src/database/schema/users.ts b/packages/backend/src/database/schema/users.ts new file mode 100644 index 0000000..9e67661 --- /dev/null +++ b/packages/backend/src/database/schema/users.ts @@ -0,0 +1,89 @@ +import { relations, sql } from 'drizzle-orm'; +import { + pgTable, + text, + timestamp, + uuid, + primaryKey, + jsonb +} from 'drizzle-orm/pg-core'; +import type { PolicyStatement } from '@open-archiver/types'; + +/** + * The `users` table stores the core user information for authentication and identification. + */ +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: text('email').notNull().unique(), + first_name: text('first_name'), + last_name: text('last_name'), + password: text('password'), + provider: text('provider').default('local'), + providerId: text('provider_id'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull() +}); + +/** + * The `sessions` table stores user session information for managing login state. + * It links a session to a user and records its expiration time. + */ +export const sessions = pgTable('sessions', { + id: text('id').primaryKey(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at', { + withTimezone: true, + mode: 'date' + }).notNull() +}); + +/** + * The `roles` table defines the roles that can be assigned to users. + * Each role has a name and a set of policies that define its permissions. + */ +export const roles = pgTable('roles', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull().unique(), + policies: jsonb('policies').$type().notNull().default(sql`'[]'::jsonb`), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull() +}); + +/** + * The `user_roles` table is a join table that maps users to their assigned roles. + * This many-to-many relationship allows a user to have multiple roles. + */ +export const userRoles = pgTable( + 'user_roles', + { + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + roleId: uuid('role_id') + .notNull() + .references(() => roles.id, { onDelete: 'cascade' }) + }, + (t) => [primaryKey({ columns: [t.userId, t.roleId] })] +); + +// Define relationships for Drizzle ORM +export const usersRelations = relations(users, ({ many }) => ({ + userRoles: many(userRoles) +})); + +export const rolesRelations = relations(roles, ({ many }) => ({ + userRoles: many(userRoles) +})); + +export const userRolesRelations = relations(userRoles, ({ one }) => ({ + role: one(roles, { + fields: [userRoles.roleId], + references: [roles.id] + }), + user: one(users, { + fields: [userRoles.userId], + references: [users.id] + }) +})); diff --git a/packages/backend/src/iam-policy/iam-definitions.ts b/packages/backend/src/iam-policy/iam-definitions.ts new file mode 100644 index 0000000..2ba8bd7 --- /dev/null +++ b/packages/backend/src/iam-policy/iam-definitions.ts @@ -0,0 +1,155 @@ +/** + * @file This file serves as the single source of truth for all Identity and Access Management (IAM) + * definitions within Open Archiver. Centralizing these definitions is an industry-standard practice + * that offers several key benefits: + * + * 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources + * throughout the codebase, reducing the risk of typos and inconsistencies. + * 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all + * possible permissions in the system. + * 3. **Enables Validation**: Allows for the creation of a robust validation function that can + * programmatically check if a policy statement is valid before it is saved. + * 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions + * that can be granted. + * + * The structure is inspired by AWS IAM, using a `service:operation` format for actions and a + * hierarchical, slash-separated path for resources. + */ + +// =================================================================================== +// SERVICE: archive +// =================================================================================== + +const ARCHIVE_ACTIONS = { + READ: 'archive:read', + SEARCH: 'archive:search', + EXPORT: 'archive:export', +} as const; + +const ARCHIVE_RESOURCES = { + ALL: 'archive/all', + INGESTION_SOURCE: 'archive/ingestion-source/*', + EMAIL: 'archive/email/*', + CUSTODIAN: 'archive/custodian/*', +} as const; + + +// =================================================================================== +// SERVICE: ingestion +// =================================================================================== + +const INGESTION_ACTIONS = { + CREATE_SOURCE: 'ingestion:createSource', + READ_SOURCE: 'ingestion:readSource', + UPDATE_SOURCE: 'ingestion:updateSource', + DELETE_SOURCE: 'ingestion:deleteSource', + MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs +} as const; + +const INGESTION_RESOURCES = { + ALL: 'ingestion-source/*', + SOURCE: 'ingestion-source/{sourceId}', +} as const; + + +// =================================================================================== +// SERVICE: system +// =================================================================================== + +const SYSTEM_ACTIONS = { + READ_SETTINGS: 'system:readSettings', + UPDATE_SETTINGS: 'system:updateSettings', + READ_USERS: 'system:readUsers', + CREATE_USER: 'system:createUser', + UPDATE_USER: 'system:updateUser', + DELETE_USER: 'system:deleteUser', + ASSIGN_ROLE: 'system:assignRole', +} as const; + +const SYSTEM_RESOURCES = { + SETTINGS: 'system/settings', + USERS: 'system/users', + USER: 'system/user/{userId}', +} as const; + + +// =================================================================================== +// SERVICE: dashboard +// =================================================================================== + +const DASHBOARD_ACTIONS = { + READ: 'dashboard:read', +} as const; + +const DASHBOARD_RESOURCES = { + ALL: 'dashboard/*', +} as const; + + +// =================================================================================== +// EXPORTED DEFINITIONS +// =================================================================================== + +/** + * A comprehensive set of all valid IAM actions in the system. + * This is used by the policy validator to ensure that any action in a policy is recognized. + */ +export const ValidActions: Set = new Set([ + ...Object.values(ARCHIVE_ACTIONS), + ...Object.values(INGESTION_ACTIONS), + ...Object.values(SYSTEM_ACTIONS), + ...Object.values(DASHBOARD_ACTIONS), +]); + +/** + * An object containing regular expressions for validating resource formats. + * The validator uses these patterns to ensure that resource strings in a policy + * conform to the expected structure. + * + * Logic: + * - The key represents the service (e.g., 'archive'). + * - The value is a RegExp that matches all valid resource formats for that service. + * - This allows for flexible validation. For example, `archive/*` is a valid pattern, + * as is `archive/email/123-abc`. + */ +export const ValidResourcePatterns = { + archive: /^archive\/(all|ingestion-source\/[^\/]+|email\/[^\/]+|custodian\/[^\/]+)$/, + ingestion: /^ingestion-source\/(\*|[^\/]+)$/, + system: /^system\/(settings|users|user\/[^\/]+)$/, + dashboard: /^dashboard\/\*$/, +}; + + +/** + * --- How to Use These Definitions for Validation (Conceptual) --- + * + * A validator function would be created, likely in an `AuthorizationService`, + * that accepts a `PolicyStatement` object. + * + * export function isPolicyStatementValid(statement: PolicyStatement): boolean { + * // 1. Validate Actions + * for (const action of statement.Action) { + * if (action.endsWith('*')) { + * // For wildcards, check if the service prefix is valid + * const service = action.split(':')[0]; + * if (!Object.keys(ValidResourcePatterns).includes(service)) { + * return false; // Invalid service + * } + * } else if (!ValidActions.has(action)) { + * return false; // Action is not in the set of known actions + * } + * } + * + * // 2. Validate Resources + * for (const resource of statement.Resource) { + * const service = resource.split('/')[0]; + * const pattern = ValidResourcePatterns[service]; + * + * if (!pattern || !pattern.test(resource)) { + * return false; // Resource format is invalid for the specified service + * } + * } + * + * return true; + * } + */ diff --git a/packages/backend/src/iam-policy/policy-validator.ts b/packages/backend/src/iam-policy/policy-validator.ts new file mode 100644 index 0000000..da910b1 --- /dev/null +++ b/packages/backend/src/iam-policy/policy-validator.ts @@ -0,0 +1,100 @@ +import type { PolicyStatement } from '@open-archiver/types'; +import { ValidActions, ValidResourcePatterns } from './iam-definitions'; + +/** + * @class PolicyValidator + * + * This class provides a static method to validate an IAM policy statement. + * It is designed to be used before a policy is saved to the database, ensuring that + * only valid and well-formed policies are stored. + * + * The verification logic is based on the centralized definitions in `iam-definitions.ts`. + */ +export class PolicyValidator { + /** + * Validates a single policy statement to ensure its actions and resources are valid. + * + * @param {PolicyStatement} statement - The policy statement to validate. + * @returns {{valid: boolean; reason?: string}} - An object containing a boolean `valid` property + * and an optional `reason` string if validation fails. + */ + public static isValid(statement: PolicyStatement): { valid: boolean; reason: string; } { + if (!statement || !statement.Action || !statement.Resource || !statement.Effect) { + return { valid: false, reason: 'Policy statement is missing required fields.' }; + } + + // 1. Validate Actions + for (const action of statement.Action) { + const { valid, reason } = this.isActionValid(action); + if (!valid) { + return { valid: false, reason }; + } + } + + // 2. Validate Resources + for (const resource of statement.Resource) { + const { valid, reason } = this.isResourceValid(resource); + if (!valid) { + return { valid: false, reason }; + } + } + + return { valid: true, reason: 'valid' }; + } + + /** + * Checks if a single action string is valid. + * + * Logic: + * - If the action contains a wildcard (e.g., 'archive:*'), it checks if the service part + * (e.g., 'archive') is a recognized service. + * - If there is no wildcard, it checks if the full action string (e.g., 'archive:read') + * exists in the `ValidActions` set. + * + * @param {string} action - The action string to validate. + * @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure. + */ + private static isActionValid(action: string): { valid: boolean; reason: string; } { + if (action === '*') { + return { valid: true, reason: 'valid' }; + } + if (action.endsWith(':*')) { + const service = action.split(':')[0]; + if (service in ValidResourcePatterns) { + return { valid: true, reason: 'valid' }; + } + return { valid: false, reason: `Invalid service '${service}' in action wildcard '${action}'.` }; + } + if (ValidActions.has(action)) { + return { valid: true, reason: 'valid' }; + } + return { valid: false, reason: `Action '${action}' is not a valid action.` }; + } + + /** + * Checks if a single resource string has a valid format. + * + * Logic: + * - It extracts the service name from the resource string (e.g., 'archive' from 'archive/all'). + * - It looks up the corresponding regular expression for that service in `ValidResourcePatterns`. + * - It tests the resource string against the pattern. If the service does not exist or the + * pattern does not match, the resource is considered invalid. + * + * @param {string} resource - The resource string to validate. + * @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure. + */ + private static isResourceValid(resource: string): { valid: boolean; reason: string; } { + const service = resource.split('/')[0]; + if (service === '*') { + return { valid: true, reason: 'valid' }; + } + if (service in ValidResourcePatterns) { + const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns]; + if (pattern.test(resource)) { + return { valid: true, reason: 'valid' }; + } + return { valid: false, reason: `Resource '${resource}' does not match the expected format for the '${service}' service.` }; + } + return { valid: false, reason: `Invalid service '${service}' in resource '${resource}'.` }; + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 2b3f3fb..f23b382 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,8 +5,10 @@ import { IngestionController } from './api/controllers/ingestion.controller'; import { ArchivedEmailController } from './api/controllers/archived-email.controller'; import { StorageController } from './api/controllers/storage.controller'; import { SearchController } from './api/controllers/search.controller'; +import { IamController } from './api/controllers/iam.controller'; import { requireAuth } from './api/middleware/requireAuth'; import { createAuthRouter } from './api/routes/auth.routes'; +import { createIamRouter } from './api/routes/iam.routes'; import { createIngestionRouter } from './api/routes/ingestion.routes'; import { createArchivedEmailRouter } from './api/routes/archived-email.routes'; import { createStorageRouter } from './api/routes/storage.routes'; @@ -14,7 +16,8 @@ import { createSearchRouter } from './api/routes/search.routes'; import { createDashboardRouter } from './api/routes/dashboard.routes'; import testRouter from './api/routes/test.routes'; import { AuthService } from './services/AuthService'; -import { AdminUserService } from './services/UserService'; +import { UserService } from './services/UserService'; +import { IamService } from './services/IamService'; import { StorageService } from './services/StorageService'; import { SearchService } from './services/SearchService'; @@ -37,15 +40,17 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) { // --- Dependency Injection Setup --- -const userService = new AdminUserService(); +const userService = new UserService(); const authService = new AuthService(userService, JWT_SECRET, JWT_EXPIRES_IN); -const authController = new AuthController(authService); +const authController = new AuthController(authService, userService); const ingestionController = new IngestionController(); const archivedEmailController = new ArchivedEmailController(); const storageService = new StorageService(); const storageController = new StorageController(storageService); const searchService = new SearchService(); const searchController = new SearchController(); +const iamService = new IamService(); +const iamController = new IamController(iamService); // --- Express App Initialization --- const app = express(); @@ -60,7 +65,9 @@ const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, a const storageRouter = createStorageRouter(storageController, authService); const searchRouter = createSearchRouter(searchController, authService); const dashboardRouter = createDashboardRouter(authService); +const iamRouter = createIamRouter(iamController); app.use('/v1/auth', authRouter); +app.use('/v1/iam', iamRouter); app.use('/v1/ingestion-sources', ingestionRouter); app.use('/v1/archived-emails', archivedEmailRouter); app.use('/v1/storage', storageRouter); diff --git a/packages/backend/src/services/AuthService.ts b/packages/backend/src/services/AuthService.ts index d05b42d..ed331f9 100644 --- a/packages/backend/src/services/AuthService.ts +++ b/packages/backend/src/services/AuthService.ts @@ -1,38 +1,23 @@ -import { compare, hash } from 'bcryptjs'; -import type { SignJWT, jwtVerify } from 'jose'; -import type { AuthTokenPayload, User, LoginResponse } from '@open-archiver/types'; +import { compare } from 'bcryptjs'; +import { SignJWT, jwtVerify } from 'jose'; +import type { AuthTokenPayload, LoginResponse } from '@open-archiver/types'; +import { UserService } from './UserService'; +import { db } from '../database'; +import * as schema from '../database/schema'; +import { eq } from 'drizzle-orm'; -// This interface defines the contract for a service that manages users. -// The AuthService will depend on this abstraction, not a concrete implementation. -export interface IUserService { - findByEmail(email: string): Promise; -} - -// This interface defines the contract for our AuthService. -export interface IAuthService { - verifyPassword(password: string, hash: string): Promise; - login(email: string, password: string): Promise; - verifyToken(token: string): Promise; -} - -export class AuthService implements IAuthService { - #userService: IUserService; +export class AuthService { + #userService: UserService; #jwtSecret: Uint8Array; #jwtExpiresIn: string; - #jose: Promise<{ SignJWT: typeof SignJWT; jwtVerify: typeof jwtVerify; }>; - constructor(userService: IUserService, jwtSecret: string, jwtExpiresIn: string) { + constructor(userService: UserService, jwtSecret: string, jwtExpiresIn: string) { this.#userService = userService; this.#jwtSecret = new TextEncoder().encode(jwtSecret); this.#jwtExpiresIn = jwtExpiresIn; - this.#jose = import('jose'); } - #hashPassword(password: string): Promise { - return hash(password, 10); - } - - public verifyPassword(password: string, hash: string): Promise { + public async verifyPassword(password: string, hash: string): Promise { return compare(password, hash); } @@ -40,7 +25,6 @@ export class AuthService implements IAuthService { if (!payload.sub) { throw new Error('JWT payload must have a subject (sub) claim.'); } - const { SignJWT } = await this.#jose; return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() @@ -52,22 +36,31 @@ export class AuthService implements IAuthService { public async login(email: string, password: string): Promise { const user = await this.#userService.findByEmail(email); - if (!user) { - return null; // User not found + if (!user || !user.password) { + return null; // User not found or password not set } - const isPasswordValid = await this.verifyPassword(password, user.passwordHash); + const isPasswordValid = await this.verifyPassword(password, user.password); if (!isPasswordValid) { return null; // Invalid password } - const { passwordHash, ...userWithoutPassword } = user; + const userRoles = await db.query.userRoles.findMany({ + where: eq(schema.userRoles.userId, user.id), + with: { + role: true + } + }); + + const roles = userRoles.map(ur => ur.role.name); + + const { password: _, ...userWithoutPassword } = user; const accessToken = await this.#generateAccessToken({ sub: user.id, email: user.email, - role: user.role, + roles: roles, }); return { accessToken, user: userWithoutPassword }; @@ -75,7 +68,6 @@ export class AuthService implements IAuthService { public async verifyToken(token: string): Promise { try { - const { jwtVerify } = await this.#jose; const { payload } = await jwtVerify(token, this.#jwtSecret); return payload; } catch (error) { diff --git a/packages/backend/src/services/IamService.ts b/packages/backend/src/services/IamService.ts new file mode 100644 index 0000000..eec25d5 --- /dev/null +++ b/packages/backend/src/services/IamService.ts @@ -0,0 +1,24 @@ +import { db } from '../database'; +import { roles } from '../database/schema/users'; +import type { Role, PolicyStatement } from '@open-archiver/types'; +import { eq } from 'drizzle-orm'; + +export class IamService { + public async getRoles(): Promise { + return db.select().from(roles); + } + + public async getRoleById(id: string): Promise { + const [role] = await db.select().from(roles).where(eq(roles.id, id)); + return role; + } + + public async createRole(name: string, policy: PolicyStatement[]): Promise { + const [role] = await db.insert(roles).values({ name, policies: policy }).returning(); + return role; + } + + public async deleteRole(id: string): Promise { + await db.delete(roles).where(eq(roles.id, id)); + } +} diff --git a/packages/backend/src/services/UserService.ts b/packages/backend/src/services/UserService.ts index a3b9e69..6f4ddc0 100644 --- a/packages/backend/src/services/UserService.ts +++ b/packages/backend/src/services/UserService.ts @@ -1,31 +1,79 @@ +import { db } from '../database'; +import * as schema from '../database/schema'; +import { and, eq, asc, sql } from 'drizzle-orm'; import { hash } from 'bcryptjs'; -import type { User } from '@open-archiver/types'; -import type { IUserService } from './AuthService'; +import type { PolicyStatement, User } from '@open-archiver/types'; +import { PolicyValidator } from '../iam-policy/policy-validator'; -// This is a mock implementation of the IUserService. -// Later on, this service would interact with a database. -export class AdminUserService implements IUserService { - #users: User[] = []; - - constructor() { - // Immediately seed the user when the service is instantiated. - this.seed(); - } - - // use .env admin user - private async seed() { - const passwordHash = await hash(process.env.ADMIN_PASSWORD as string, 10); - this.#users.push({ - id: '1', - email: process.env.ADMIN_EMAIL as string, - role: 'Super Administrator', - passwordHash: passwordHash, +export class UserService { + /** + * Finds a user by their email address. + * @param email The email address of the user to find. + * @returns The user object if found, otherwise null. + */ + public async findByEmail(email: string): Promise<(typeof schema.users.$inferSelect) | null> { + const user = await db.query.users.findFirst({ + where: eq(schema.users.email, email) }); - } - - public async findByEmail(email: string): Promise { - // once user service is ready, this would be a database query. - const user = this.#users.find(u => u.email === email); return user || null; } + + /** + * Finds a user by their ID. + * @param id The ID of the user to find. + * @returns The user object if found, otherwise null. + */ + public async findById(id: string): Promise<(typeof schema.users.$inferSelect) | null> { + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, id) + }); + return user || null; + } + + /** + * Creates a new user in the database. + * The first user created will be assigned the 'Super Admin' role. + * @param userDetails The details of the user to create. + * @returns The newly created user object. + */ + public async createUser(userDetails: Pick & { password?: string; }): Promise<(typeof schema.users.$inferSelect)> { + const { email, first_name, last_name, password } = userDetails; + + const userCountResult = await db.select({ count: sql`count(*)` }).from(schema.users); + const isFirstUser = Number(userCountResult[0].count) === 0; + + const hashedPassword = password ? await hash(password, 10) : undefined; + + const newUser = await db.insert(schema.users).values({ + email, + first_name, + last_name, + password: hashedPassword, + }).returning(); + + if (isFirstUser) { + let superAdminRole = await db.query.roles.findFirst({ + where: eq(schema.roles.name, 'Super Admin') + }); + + if (!superAdminRole) { + const suerAdminPolicies: PolicyStatement[] = [{ + Effect: 'Allow', + Action: ['*'], + Resource: ['*'] + }]; + superAdminRole = (await db.insert(schema.roles).values({ + name: 'Super Admin', + policies: suerAdminPolicies + }).returning())[0]; + } + + await db.insert(schema.userRoles).values({ + userId: newUser[0].id, + roleId: superAdminRole.id + }); + } + + return newUser[0]; + } } diff --git a/packages/frontend/src/routes/+layout.server.ts b/packages/frontend/src/routes/+layout.server.ts index 82f5bf6..1e4bb8a 100644 --- a/packages/frontend/src/routes/+layout.server.ts +++ b/packages/frontend/src/routes/+layout.server.ts @@ -1,9 +1,27 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; import 'dotenv/config'; +import { api } from '$lib/server/api'; + +export const load: LayoutServerLoad = async (event) => { + const { locals, url } = event; + try { + const response = await api('/auth/status', event); + const { needsSetup } = await response.json(); + + if (needsSetup && url.pathname !== '/setup') { + throw redirect(307, '/setup'); + } + + if (!needsSetup && url.pathname === '/setup') { + throw redirect(307, '/signin'); + } + } catch (error) { + throw error; + + } -export const load: LayoutServerLoad = async ({ locals }) => { return { user: locals.user, accessToken: locals.accessToken, diff --git a/packages/frontend/src/routes/setup/+page.server.ts b/packages/frontend/src/routes/setup/+page.server.ts new file mode 100644 index 0000000..8baf2b9 --- /dev/null +++ b/packages/frontend/src/routes/setup/+page.server.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from "./$types"; + + +export const load = (async (event) => { }) satisfies PageServerLoad; \ No newline at end of file diff --git a/packages/frontend/src/routes/setup/+page.svelte b/packages/frontend/src/routes/setup/+page.svelte new file mode 100644 index 0000000..ab4fa15 --- /dev/null +++ b/packages/frontend/src/routes/setup/+page.svelte @@ -0,0 +1,111 @@ + + + + Setup - Open Archiver + + + +
+ + + + Welcome + Create the first administrator account to get started. + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+
diff --git a/packages/frontend/src/routes/signin/+page.server.ts b/packages/frontend/src/routes/signin/+page.server.ts new file mode 100644 index 0000000..f715ce1 --- /dev/null +++ b/packages/frontend/src/routes/signin/+page.server.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from "./$types"; + + +export const load = (async (event) => { + const { locals } = event; + if (locals.user) { + throw redirect(307, '/dashboard'); + } + +}) satisfies PageServerLoad; \ No newline at end of file diff --git a/packages/types/src/auth.types.ts b/packages/types/src/auth.types.ts index 85521d0..8322fbf 100644 --- a/packages/types/src/auth.types.ts +++ b/packages/types/src/auth.types.ts @@ -11,9 +11,9 @@ export interface AuthTokenPayload extends JWTPayload { */ email: string; /** - * The user's role, used for authorization. + * The user's assigned roles, which determines their permissions. */ - role: User['role']; + roles: string[]; } /** @@ -27,5 +27,5 @@ export interface LoginResponse { /** * The authenticated user's information. */ - user: Omit; + user: Omit; } diff --git a/packages/types/src/iam.types.ts b/packages/types/src/iam.types.ts new file mode 100644 index 0000000..e6e26d0 --- /dev/null +++ b/packages/types/src/iam.types.ts @@ -0,0 +1,9 @@ +export type Action = string; + +export type Resource = string; + +export interface PolicyStatement { + Effect: 'Allow' | 'Deny'; + Action: Action[]; + Resource: Resource[]; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e16b6cc..d1a16af 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,3 +6,4 @@ export * from './email.types'; export * from './archived-emails.types'; export * from './search.types'; export * from './dashboard.types'; +export * from './iam.types'; diff --git a/packages/types/src/user.types.ts b/packages/types/src/user.types.ts index 1647a45..7bc54ac 100644 --- a/packages/types/src/user.types.ts +++ b/packages/types/src/user.types.ts @@ -1,26 +1,34 @@ -/** - * Defines the possible roles a user can have within the system. - */ -export type UserRole = 'Super Administrator' | 'Auditor/Compliance Officer' | 'End User'; +import { PolicyStatement } from './iam.types'; /** * Represents a user account in the system. + * This is the core user object that will be stored in the database. */ export interface User { - /** - * The unique identifier for the user. - */ id: string; - /** - * The user's email address, used for login. - */ + first_name: string | null; + last_name: string | null; email: string; - /** - * The user's assigned role, which determines their permissions. - */ - role: UserRole; - /** - * The hashed password for the user. This should never be exposed to the client. - */ - passwordHash: string; +} + +/** + * Represents a user's session. + * This is used to track a user's login status. + */ +export interface Session { + id: string; + userId: string; + expiresAt: Date; +} + +/** + * Defines a role that can be assigned to users. + * Roles are used to group a set of permissions together. + */ +export interface Role { + id: string; + name: string; + policies: PolicyStatement[]; + createdAt: Date; + updatedAt: Date; }