diff --git a/docs/services/IAM-service/iam-policies.md b/docs/services/IAM-service/iam-policies.md index c938319..012661b 100644 --- a/docs/services/IAM-service/iam-policies.md +++ b/docs/services/IAM-service/iam-policies.md @@ -1,141 +1,119 @@ -# IAM Policies Guide +# IAM Policies -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. +This document provides a comprehensive guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles. -## 1. Policy Structure +## Policy Structure -A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`. +IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows: ```json { - "Effect": "Allow", - "Action": ["archive:read", "archive:search"], - "Resource": ["archive/*"] + "action": "action_name", + "subject": "subject_name", + "conditions": { + "field_name": "value" + } } ``` -- **`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. +- `action`: The action to be performed on the subject. +- `subject`: The resource or entity on which the action is to be performed. +- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted. -## 2. Wildcard Support +## Actions -Our IAM system supports wildcards (`*`) in both `Action` and `Resource` fields to provide flexible permission management, as defined in the `PolicyValidator`. +The following actions are available for use in IAM policies: -### Action Wildcards +- `manage`: A wildcard action that grants all permissions on a subject. +- `create`: Allows the user to create a new resource. +- `read`: Allows the user to view a resource. +- `update`: Allows the user to modify an existing resource. +- `delete`: Allows the user to delete a resource. +- `search`: Allows the user to search for resources. +- `export`: Allows the user to export resources. +- `assign`: Allows the user to assign a resource to another user. +- `sync`: Allows the user to synchronize a resource. -You can use wildcards to grant broad permissions for actions: +## Subjects -- **Global Wildcard (`*`)**: A standalone `*` in the `Action` field grants permission for all possible actions across all services. - ```json - "Action": ["*"] - ``` -- **Service-Level Wildcard (`service:*`)**: A wildcard at the end of an action string grants permission for all actions within that specific service. - ```json - "Action": ["archive:*"] - ``` +The following subjects are available for use in IAM policies: -### Resource Wildcards +- `all`: A wildcard subject that represents all resources. +- `archive`: Represents archived emails. +- `ingestion`: Represents ingestion sources. +- `settings`: Represents system settings. +- `users`: Represents user accounts. +- `roles`: Represents user roles. +- `dashboard`: Represents the dashboard. -Wildcards can also be used to specify resources: +## Conditions -- **Global Wildcard (`*`)**: A standalone `*` in the `Resource` field applies the policy to all resources in the system. - ```json - "Resource": ["*"] - ``` -- **Partial Wildcards**: Some services allow wildcards at specific points in the resource path to refer to all resources of a certain type. For example, to target all ingestion sources: - ```json - "Resource": ["ingestion-source/*"] - ``` +Conditions are used to create fine-grained access control rules. They are defined as a JSON object where the keys are the fields of the subject and the values are the conditions to be met. -## 3. Actions and Resources by Service +Conditions support the following MongoDB-style operators: -The following sections define the available actions and resources, categorized by their respective services. +- `$eq`: Equal to +- `$ne`: Not equal to +- `$in`: In an array of values +- `$nin`: Not in an array of values +- `$lt`: Less than +- `$lte`: Less than or equal to +- `$gt`: Greater than +- `$gte`: Greater than or equal to +- `$exists`: Field exists -### Service: `archive` +## Dynamic Policies with Placeholders -The `archive` service pertains to all actions related to accessing and managing archived emails. +To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime. -**Actions:** +## Examples -| 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. | +### End-User Policy -**Resources:** +This policy allows a user to create ingestions and manage their own resources. -| Resource | Description | -| :------------------------------------ | :--------------------------------------------------------------------------------------- | -| `archive/*` | Represents the entire email archive. | -| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. | -| `archive/mailbox/{email}` | Scopes the action to a single, specific mailbox, usually identified by an email address. | -| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. | +```json +[ + { + "action": "create", + "subject": "ingestion" + }, + { + "action": "manage", + "subject": "ingestion", + "conditions": { + "userId": "${user.id}" + } + } +] +``` ---- +### Auditor Policy -### Service: `ingestion` +This policy allows a user to read all archived emails and ingestion sources, but not to modify or delete them. -The `ingestion` service covers the management of email ingestion sources. +```json +[ + { + "action": "read", + "subject": "archive" + }, + { + "action": "read", + "subject": "ingestion" + } +] +``` -**Actions:** +### Administrator Policy -| 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. | +This policy grants a user full access to all resources. -**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. | +```json +[ + { + "action": "manage", + "subject": "all" + } +] +``` diff --git a/packages/backend/package.json b/packages/backend/package.json index 76929af..600564b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,6 +22,7 @@ "@aws-sdk/client-s3": "^3.844.0", "@aws-sdk/lib-storage": "^3.844.0", "@azure/msal-node": "^3.6.3", + "@casl/ability": "^6.7.3", "@microsoft/microsoft-graph-client": "^3.0.7", "@open-archiver/types": "workspace:*", "archiver": "^7.0.1", diff --git a/packages/backend/src/api/controllers/archived-email.controller.ts b/packages/backend/src/api/controllers/archived-email.controller.ts index 32492ff..dc64c7f 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -30,7 +30,13 @@ export class ArchivedEmailController { public getArchivedEmailById = async (req: Request, res: Response): Promise => { try { const { id } = req.params; - const email = await ArchivedEmailService.getArchivedEmailById(id); + const userId = req.user?.sub; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const email = await ArchivedEmailService.getArchivedEmailById(id, userId); if (!email) { return res.status(404).json({ message: 'Archived email not found' }); } diff --git a/packages/backend/src/api/controllers/iam.controller.ts b/packages/backend/src/api/controllers/iam.controller.ts index 6c3b71a..65e4b0d 100644 --- a/packages/backend/src/api/controllers/iam.controller.ts +++ b/packages/backend/src/api/controllers/iam.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { IamService } from '../../services/IamService'; import { PolicyValidator } from '../../iam-policy/policy-validator'; -import type { PolicyStatement } from '@open-archiver/types'; +import type { CaslPolicy } from '@open-archiver/types'; export class IamController { #iamService: IamService; @@ -43,7 +43,7 @@ export class IamController { } for (const statement of policies) { - const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement); + const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy); if (!valid) { res.status(400).json({ message: `Invalid policy statement: ${reason}` }); return; @@ -54,7 +54,7 @@ export class IamController { const role = await this.#iamService.createRole(name, policies); res.status(201).json(role); } catch (error) { - console.log(error) + console.log(error); res.status(500).json({ message: 'Failed to create role.' }); } }; @@ -81,7 +81,7 @@ export class IamController { if (policies) { for (const statement of policies) { - const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement); + const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy); if (!valid) { res.status(400).json({ message: `Invalid policy statement: ${reason}` }); return; diff --git a/packages/backend/src/api/controllers/search.controller.ts b/packages/backend/src/api/controllers/search.controller.ts index 9f6d4e6..7917f93 100644 --- a/packages/backend/src/api/controllers/search.controller.ts +++ b/packages/backend/src/api/controllers/search.controller.ts @@ -12,18 +12,27 @@ export class SearchController { public search = async (req: Request, res: Response): Promise => { try { const { keywords, page, limit, matchingStrategy } = req.query; + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } if (!keywords) { res.status(400).json({ message: 'Keywords are required' }); return; } - const results = await this.searchService.searchEmails({ - query: keywords as string, - page: page ? parseInt(page as string) : 1, - limit: limit ? parseInt(limit as string) : 10, - matchingStrategy: matchingStrategy as MatchingStrategies, - }); + const results = await this.searchService.searchEmails( + { + query: keywords as string, + page: page ? parseInt(page as string) : 1, + limit: limit ? parseInt(limit as string) : 10, + matchingStrategy: matchingStrategy as MatchingStrategies, + }, + userId + ); res.status(200).json(results); } catch (error) { diff --git a/packages/backend/src/api/controllers/user.controller.ts b/packages/backend/src/api/controllers/user.controller.ts index 0d8c39d..6c99b42 100644 --- a/packages/backend/src/api/controllers/user.controller.ts +++ b/packages/backend/src/api/controllers/user.controller.ts @@ -1,48 +1,55 @@ import { Request, Response } from 'express'; import { UserService } from '../../services/UserService'; -import * as schema from '../../database/schema' +import * as schema from '../../database/schema'; import { sql } from 'drizzle-orm'; import { db } from '../../database'; const userService = new UserService(); export const getUsers = async (req: Request, res: Response) => { - const users = await userService.findAll(); - res.json(users); + const users = await userService.findAll(); + res.json(users); }; export const getUser = async (req: Request, res: Response) => { - const user = await userService.findById(req.params.id); - if (!user) { - return res.status(404).json({ message: 'User not found' }); - } - res.json(user); + const user = await userService.findById(req.params.id); + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + res.json(user); }; export const createUser = async (req: Request, res: Response) => { - const { email, first_name, last_name, password, roleId } = req.body; - const newUser = await userService.createUser({ email, first_name, last_name, password }, roleId); - res.status(201).json(newUser); + const { email, first_name, last_name, password, roleId } = req.body; + const newUser = await userService.createUser( + { email, first_name, last_name, password }, + roleId + ); + res.status(201).json(newUser); }; export const updateUser = async (req: Request, res: Response) => { - const { email, first_name, last_name, roleId } = req.body; - const updatedUser = await userService.updateUser(req.params.id, { email, first_name, last_name }, roleId); - if (!updatedUser) { - return res.status(404).json({ message: 'User not found' }); - } - res.json(updatedUser); + const { email, first_name, last_name, roleId } = req.body; + const updatedUser = await userService.updateUser( + req.params.id, + { email, first_name, last_name }, + roleId + ); + if (!updatedUser) { + return res.status(404).json({ message: 'User not found' }); + } + res.json(updatedUser); }; export const deleteUser = async (req: Request, res: Response) => { - const userCountResult = await db - .select({ count: sql`count(*)` }) - .from(schema.users); - console.log('iusercount,', userCountResult[0].count) - const isOnlyUser = Number(userCountResult[0].count) === 1; - if (isOnlyUser) { - return res.status(400).json({ message: 'You are trying to delete the only user in the database, this is not allowed.' }); - } - await userService.deleteUser(req.params.id); - res.status(204).send(); + const userCountResult = await db.select({ count: sql`count(*)` }).from(schema.users); + console.log('iusercount,', userCountResult[0].count); + const isOnlyUser = Number(userCountResult[0].count) === 1; + if (isOnlyUser) { + return res.status(400).json({ + message: 'You are trying to delete the only user in the database, this is not allowed.', + }); + } + await userService.deleteUser(req.params.id); + res.status(204).send(); }; diff --git a/packages/backend/src/api/middleware/requirePermission.ts b/packages/backend/src/api/middleware/requirePermission.ts index 6781a98..3d42533 100644 --- a/packages/backend/src/api/middleware/requirePermission.ts +++ b/packages/backend/src/api/middleware/requirePermission.ts @@ -1,22 +1,36 @@ import { AuthorizationService } from '../../services/AuthorizationService'; import type { Request, Response, NextFunction } from 'express'; +import { AppActions, AppSubjects } from '@open-archiver/types'; -export const requirePermission = (action: string, resource: string) => { - return async (req: Request, res: Response, next: NextFunction) => { - const userId = req.user?.sub; +export const requirePermission = ( + action: AppActions, + subjectName: AppSubjects, + rejectMessage?: string +) => { + return async (req: Request, res: Response, next: NextFunction) => { + const userId = req.user?.sub; - if (!userId) { - return res.status(401).json({ message: 'Unauthorized' }); - } + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } - resource = resource.replace('{sourceId}', req.params.id) + let resourceObject = undefined; + // Logic to fetch resourceObject if needed for condition-based checks... + const authorizationService = new AuthorizationService(); + const hasPermission = await authorizationService.can( + userId, + action, + subjectName, + resourceObject + ); - const hasPermission = await AuthorizationService.can(userId, action, resource); + if (!hasPermission) { + return res.status(403).json({ + message: + rejectMessage || `You don't have the permission to perform the current action.`, + }); + } - if (!hasPermission) { - return res.status(403).json({ message: 'You are not allowed to perform this operation with your current role.' }); - } - - next(); - }; + next(); + }; }; diff --git a/packages/backend/src/api/routes/archived-email.routes.ts b/packages/backend/src/api/routes/archived-email.routes.ts index 2c33915..174d1ea 100644 --- a/packages/backend/src/api/routes/archived-email.routes.ts +++ b/packages/backend/src/api/routes/archived-email.routes.ts @@ -13,17 +13,21 @@ export const createArchivedEmailRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); - router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails); + router.get( + '/ingestion-source/:ingestionSourceId', + requirePermission('read', 'archive'), + archivedEmailController.getArchivedEmails + ); router.get( '/:id', - requirePermission('archive:read', 'archive/all'), + requirePermission('read', 'archive'), archivedEmailController.getArchivedEmailById ); router.delete( '/:id', - requirePermission('archive:write', 'archive/all'), + requirePermission('delete', 'archive'), archivedEmailController.deleteArchivedEmail ); diff --git a/packages/backend/src/api/routes/dashboard.routes.ts b/packages/backend/src/api/routes/dashboard.routes.ts index f03ade4..8c360fe 100644 --- a/packages/backend/src/api/routes/dashboard.routes.ts +++ b/packages/backend/src/api/routes/dashboard.routes.ts @@ -9,25 +9,49 @@ export const createDashboardRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); - router.get('/stats', requirePermission('dashboard:read', 'dashboard/*'), dashboardController.getStats); + router.get( + '/stats', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard stats.' + ), + dashboardController.getStats + ); router.get( '/ingestion-history', - requirePermission('dashboard:read', 'dashboard/*'), + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), dashboardController.getIngestionHistory ); router.get( '/ingestion-sources', - requirePermission('dashboard:read', 'dashboard/*'), + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), dashboardController.getIngestionSources ); router.get( '/recent-syncs', - requirePermission('dashboard:read', 'dashboard/*'), + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), dashboardController.getRecentSyncs ); router.get( '/indexed-insights', - requirePermission('dashboard:read', 'dashboard/*'), + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), dashboardController.getIndexedInsights ); diff --git a/packages/backend/src/api/routes/iam.routes.ts b/packages/backend/src/api/routes/iam.routes.ts index 0e3211d..f425934 100644 --- a/packages/backend/src/api/routes/iam.routes.ts +++ b/packages/backend/src/api/routes/iam.routes.ts @@ -14,37 +14,14 @@ export const createIamRouter = (iamController: IamController, authService: AuthS * @description Gets all roles. * @access Private */ - router.get( - '/roles', - requirePermission('system:readUsers', 'system/users'), - iamController.getRoles - ); + router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles); + router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById); - router.get( - '/roles/:id', - requirePermission('system:readUsers', 'system/users'), - iamController.getRoleById - ); + router.post('/roles', requirePermission('create', 'roles'), iamController.createRole); + router.delete('/roles/:id', requirePermission('delete', 'roles'), iamController.deleteRole); - router.post( - '/roles', - requirePermission('system:assignRole', 'system/users'), - iamController.createRole - ); - - - router.delete( - '/roles/:id', - requirePermission('system:deleteRole', 'system/users'), - iamController.deleteRole - ); - - router.put( - '/roles/:id', - requirePermission('system:updateUser', 'system/users'), - iamController.updateRole - ); + router.put('/roles/:id', requirePermission('update', 'roles'), iamController.updateRole); return router; }; diff --git a/packages/backend/src/api/routes/ingestion.routes.ts b/packages/backend/src/api/routes/ingestion.routes.ts index 5d3c14d..ad4071c 100644 --- a/packages/backend/src/api/routes/ingestion.routes.ts +++ b/packages/backend/src/api/routes/ingestion.routes.ts @@ -13,47 +13,27 @@ export const createIngestionRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); - router.post( - '/', - requirePermission('ingestion:create', 'ingestion-source/*'), - ingestionController.create - ); + router.post('/', requirePermission('create', 'ingestion'), ingestionController.create); - router.get('/', ingestionController.findAll); + router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll); - router.get( - '/:id', - requirePermission('ingestion:read', 'ingestion-source/{sourceId}'), - ingestionController.findById - ); + router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById); - router.put( - '/:id', - requirePermission('ingestion:update', 'ingestion-source/{sourceId}'), - ingestionController.update - ); + router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update); - router.delete( - '/:id', - requirePermission('ingestion:delete', 'ingestion-source/{sourceId}'), - ingestionController.delete - ); + router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete); router.post( '/:id/import', - requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'), + requirePermission('create', 'ingestion'), ingestionController.triggerInitialImport ); - router.post( - '/:id/pause', - requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'), - ingestionController.pause - ); + router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause); router.post( '/:id/sync', - requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'), + requirePermission('sync', 'ingestion'), ingestionController.triggerForceSync ); diff --git a/packages/backend/src/api/routes/search.routes.ts b/packages/backend/src/api/routes/search.routes.ts index 82f1f7b..78ee9a3 100644 --- a/packages/backend/src/api/routes/search.routes.ts +++ b/packages/backend/src/api/routes/search.routes.ts @@ -12,7 +12,7 @@ export const createSearchRouter = ( router.use(requireAuth(authService)); - router.get('/', requirePermission('archive:search', 'archive/all'), searchController.search); + router.get('/', requirePermission('search', 'archive'), searchController.search); return router; }; diff --git a/packages/backend/src/api/routes/storage.routes.ts b/packages/backend/src/api/routes/storage.routes.ts index b648c99..e2b88f6 100644 --- a/packages/backend/src/api/routes/storage.routes.ts +++ b/packages/backend/src/api/routes/storage.routes.ts @@ -13,11 +13,7 @@ export const createStorageRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); - router.get( - '/download', - requirePermission('archive:read', 'archive/all'), - storageController.downloadFile - ); + router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile); return router; }; diff --git a/packages/backend/src/api/routes/upload.routes.ts b/packages/backend/src/api/routes/upload.routes.ts index e4111d5..10f61e7 100644 --- a/packages/backend/src/api/routes/upload.routes.ts +++ b/packages/backend/src/api/routes/upload.routes.ts @@ -2,13 +2,14 @@ import { Router } from 'express'; import { uploadFile } from '../controllers/upload.controller'; import { requireAuth } from '../middleware/requireAuth'; import { AuthService } from '../../services/AuthService'; +import { requirePermission } from '../middleware/requirePermission'; export const createUploadRouter = (authService: AuthService): Router => { const router = Router(); router.use(requireAuth(authService)); - router.post('/', uploadFile); + router.post('/', requirePermission('create', 'ingestion'), uploadFile); return router; }; diff --git a/packages/backend/src/api/routes/user.routes.ts b/packages/backend/src/api/routes/user.routes.ts index 08ae480..d883945 100644 --- a/packages/backend/src/api/routes/user.routes.ts +++ b/packages/backend/src/api/routes/user.routes.ts @@ -5,39 +5,19 @@ import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createUserRouter = (authService: AuthService): Router => { - const router = Router(); + const router = Router(); - router.use(requireAuth(authService)); + router.use(requireAuth(authService)); - router.get( - '/', - requirePermission('system:readUsers', 'system/users'), - userController.getUsers - ); + router.get('/', requirePermission('read', 'users'), userController.getUsers); - router.get( - '/:id', - requirePermission('system:readUsers', 'system/user/{userId}'), - userController.getUser - ); + router.get('/:id', requirePermission('read', 'users'), userController.getUser); - router.post( - '/', - requirePermission('system:createUser', 'system/users'), - userController.createUser - ); + router.post('/', requirePermission('create', 'users'), userController.createUser); - router.put( - '/:id', - requirePermission('system:updateUser', 'system/user/{userId}'), - userController.updateUser - ); + router.put('/:id', requirePermission('update', 'users'), userController.updateUser); - router.delete( - '/:id', - requirePermission('system:deleteUser', 'system/user/{userId}'), - userController.deleteUser - ); + router.delete('/:id', requirePermission('delete', 'users'), userController.deleteUser); - return router; + return router; }; diff --git a/packages/backend/src/database/migrations/meta/0015_snapshot.json b/packages/backend/src/database/migrations/meta/0015_snapshot.json index 563e3b8..2cb7fa5 100644 --- a/packages/backend/src/database/migrations/meta/0015_snapshot.json +++ b/packages/backend/src/database/migrations/meta/0015_snapshot.json @@ -1,1128 +1,1067 @@ { - "id": "be930de6-8ff2-45e4-affe-7555ca5c5c3d", - "prevId": "ad5204da-bb82-4a19-abfa-d30cc284ab27", - "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" - }, - "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", - "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 + "id": "be930de6-8ff2-45e4-affe-7555ca5c5c3d", + "prevId": "ad5204da-bb82-4a19-abfa-d30cc284ab27", + "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" + }, + "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", + "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": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/_journal.json b/packages/backend/src/database/migrations/meta/_journal.json index 6a36f21..d4ae03e 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -1,118 +1,118 @@ { - "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 - } - ] -} \ No newline at end of file + "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 + } + ] +} diff --git a/packages/backend/src/database/schema/users.ts b/packages/backend/src/database/schema/users.ts index 1a5e6b2..844bae5 100644 --- a/packages/backend/src/database/schema/users.ts +++ b/packages/backend/src/database/schema/users.ts @@ -1,6 +1,6 @@ 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'; +import type { CaslPolicy } from '@open-archiver/types'; /** * The `users` table stores the core user information for authentication and identification. @@ -40,7 +40,7 @@ export const roles = pgTable('roles', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull().unique(), policies: jsonb('policies') - .$type() + .$type() .notNull() .default(sql`'[]'::jsonb`), createdAt: timestamp('created_at').defaultNow().notNull(), diff --git a/packages/backend/src/helpers/mongoToDrizzle.ts b/packages/backend/src/helpers/mongoToDrizzle.ts new file mode 100644 index 0000000..452eb55 --- /dev/null +++ b/packages/backend/src/helpers/mongoToDrizzle.ts @@ -0,0 +1,95 @@ +import { SQL, and, or, not, eq, gt, gte, lt, lte, inArray, isNull, sql } from 'drizzle-orm'; + +const camelToSnakeCase = (str: string) => + str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + +const relationToTableMap: Record = { + ingestionSource: 'ingestion_sources', + // Add other relations here as needed +}; + +function getDrizzleColumn(key: string): SQL { + const keyParts = key.split('.'); + if (keyParts.length > 1) { + const relationName = keyParts[0]; + const columnName = camelToSnakeCase(keyParts[1]); + const tableName = relationToTableMap[relationName]; + if (tableName) { + return sql.raw(`"${tableName}"."${columnName}"`); + } + } + return sql`${sql.identifier(camelToSnakeCase(key))}`; +} + +export function mongoToDrizzle(query: Record): SQL | undefined { + const conditions: (SQL | undefined)[] = []; + + for (const key in query) { + const value = query[key]; + + if (key === '$or') { + conditions.push(or(...(value as any[]).map(mongoToDrizzle).filter(Boolean))); + continue; + } + + if (key === '$and') { + conditions.push(and(...(value as any[]).map(mongoToDrizzle).filter(Boolean))); + continue; + } + + if (key === '$not') { + const subQuery = mongoToDrizzle(value); + if (subQuery) { + conditions.push(not(subQuery)); + } + continue; + } + + const column = getDrizzleColumn(key); + + if (typeof value === 'object' && value !== null) { + const operator = Object.keys(value)[0]; + const operand = value[operator]; + + switch (operator) { + case '$eq': + conditions.push(eq(column, operand)); + break; + case '$ne': + conditions.push(not(eq(column, operand))); + break; + case '$gt': + conditions.push(gt(column, operand)); + break; + case '$gte': + conditions.push(gte(column, operand)); + break; + case '$lt': + conditions.push(lt(column, operand)); + break; + case '$lte': + conditions.push(lte(column, operand)); + break; + case '$in': + conditions.push(inArray(column, operand)); + break; + case '$nin': + conditions.push(not(inArray(column, operand))); + break; + case '$exists': + conditions.push(operand ? not(isNull(column)) : isNull(column)); + break; + default: + // Unsupported operator + } + } else { + conditions.push(eq(column, value)); + } + } + + if (conditions.length === 0) { + return undefined; + } + + return and(...conditions.filter((c): c is SQL => c !== undefined)); +} diff --git a/packages/backend/src/helpers/mongoToMeli.ts b/packages/backend/src/helpers/mongoToMeli.ts new file mode 100644 index 0000000..166686f --- /dev/null +++ b/packages/backend/src/helpers/mongoToMeli.ts @@ -0,0 +1,75 @@ +function getMeliColumn(key: string): string { + const keyParts = key.split('.'); + if (keyParts.length > 1) { + const relationName = keyParts[0]; + const columnName = keyParts[1]; + return `${relationName}.${columnName}`; + } + return key; +} + +export function mongoToMeli(query: Record): string { + const conditions: string[] = []; + + for (const key in query) { + const value = query[key]; + + if (key === '$or') { + conditions.push(`(${value.map(mongoToMeli).join(' OR ')})`); + continue; + } + + if (key === '$and') { + conditions.push(`(${value.map(mongoToMeli).join(' AND ')})`); + continue; + } + + if (key === '$not') { + conditions.push(`NOT (${mongoToMeli(value)})`); + continue; + } + + const column = getMeliColumn(key); + + if (typeof value === 'object' && value !== null) { + const operator = Object.keys(value)[0]; + const operand = value[operator]; + + switch (operator) { + case '$eq': + conditions.push(`${column} = ${operand}`); + break; + case '$ne': + conditions.push(`${column} != ${operand}`); + break; + case '$gt': + conditions.push(`${column} > ${operand}`); + break; + case '$gte': + conditions.push(`${column} >= ${operand}`); + break; + case '$lt': + conditions.push(`${column} < ${operand}`); + break; + case '$lte': + conditions.push(`${column} <= ${operand}`); + break; + case '$in': + conditions.push(`${column} IN [${operand.join(', ')}]`); + break; + case '$nin': + conditions.push(`${column} NOT IN [${operand.join(', ')}]`); + break; + case '$exists': + conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`); + break; + default: + // Unsupported operator + } + } else { + conditions.push(`${column} = ${value}`); + } + } + + return conditions.join(' AND '); +} diff --git a/packages/backend/src/iam-policy/ability.ts b/packages/backend/src/iam-policy/ability.ts new file mode 100644 index 0000000..237ad85 --- /dev/null +++ b/packages/backend/src/iam-policy/ability.ts @@ -0,0 +1,92 @@ +// packages/backend/src/iam-policy/ability.ts +import { createMongoAbility, MongoAbility, RawRuleOf } from '@casl/ability'; +import { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types'; +import { ingestionSources, archivedEmails, users, roles } from '../database/schema'; +import { InferSelectModel } from 'drizzle-orm'; + +// Define the application's ability type +export type AppAbility = MongoAbility<[AppActions, AppSubjects]>; + +// Helper type for raw rules +export type AppRawRule = RawRuleOf; + +// Represents the possible object types that can be passed as subjects for permission checks. +export type SubjectObject = + | InferSelectModel + | InferSelectModel + | InferSelectModel + | InferSelectModel + | AppSubjects; +/** + * Translates conditions on an 'ingestion' subject to equivalent conditions on an 'archive' subject. + * This is used to implement inherent permissions, where permission on an ingestion source + * implies permission on the emails it has ingested. + * @param conditions The original conditions object for the 'ingestion' subject. + * @returns A new conditions object for the 'archive' subject. + */ +function translateIngestionConditionsToArchive( + conditions: Record +): Record { + if (!conditions || typeof conditions !== 'object') { + return conditions; + } + + const translated: Record = {}; + for (const key in conditions) { + const value = conditions[key]; + + // Handle logical operators recursively + if (['$or', '$and', '$nor'].includes(key) && Array.isArray(value)) { + translated[key] = value.map((v) => translateIngestionConditionsToArchive(v)); + continue; + } + if (key === '$not' && typeof value === 'object' && value !== null) { + translated[key] = translateIngestionConditionsToArchive(value); + continue; + } + + // Translate field names + let newKey = key; + if (key === 'id') { + newKey = 'ingestionSourceId'; + } else if (['userId', 'name', 'provider', 'status'].includes(key)) { + newKey = `ingestionSource.${key}`; + } + + translated[newKey] = value; + } + return translated; +} + +/** + * Expands the given set of policies to include inherent permissions. + * For example, a permission on an 'ingestion' source is expanded to grant + * the same permission on 'archive' records related to that source. + * @param policies The original array of CASL policies. + * @returns A new array of policies including the expanded, inherent permissions. + */ +function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] { + const expandedPolicies: CaslPolicy[] = JSON.parse(JSON.stringify(policies)); + + policies.forEach((policy) => { + if (policy.subject === 'ingestion') { + const archivePolicy: CaslPolicy = { + ...JSON.parse(JSON.stringify(policy)), + subject: 'archive', + }; + if (policy.conditions) { + archivePolicy.conditions = translateIngestionConditionsToArchive(policy.conditions); + } + expandedPolicies.push(archivePolicy); + } + }); + + return expandedPolicies; +} + +// Function to create an ability instance from policies stored in the database +export function createAbilityFor(policies: CaslPolicy[]) { + const allPolicies = expandPolicies(policies); + + return createMongoAbility(allPolicies as AppRawRule[]); +} diff --git a/packages/backend/src/iam-policy/iam-definitions.ts b/packages/backend/src/iam-policy/iam-definitions.ts deleted file mode 100644 index ee9aa7a..0000000 --- a/packages/backend/src/iam-policy/iam-definitions.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @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. - */ - -/** - * Possible action verbs: - - CRUD: read, update, create, delete - - Special: export, search, manage, assign - - Resource ranges: - - * : all resources - - own: resources owned by / created by the requesting user - - {{id}}: resource with certain ID - */ - -/** - * Rules: - * - If a user has access to upper level resource, it has access to resources that depends on it. eg: If a user has access to ingestion XYZ, it will have access to all the archived emails created by ingestion XYZ. The permission should be inherent: if the user can delete ingestion XYZ, it can delete archived emails created by ingestion XYZ. - * 2. - * - */ - - -// =================================================================================== -// SERVICE: archive -// =================================================================================== - -const ARCHIVE_ACTIONS = { - READ: 'archive:read', - SEARCH: 'archive:search', - DELETE: 'archive:delete', - EXPORT: 'archive:export', -}; - -const ARCHIVE_RESOURCES = { - ALL: 'archive/*', - INGESTION: 'archive/ingestion/*', - MAILBOX: 'archive/mailbox/{email}', //Scopes the action to a single, specific mailbox, usually identified by an email address. | - CUSTODIAN: 'archive/custodian/{custodianId}',// Scopes the action to emails belonging to a specific custodian. -}; - -// =================================================================================== -// SERVICE: ingestion -// =================================================================================== - -const INGESTION_ACTIONS = { - CREATE_SOURCE: 'ingestion:create', - READ_SOURCE: 'ingestion:read', - UPDATE_SOURCE: 'ingestion:update', - DELETE_SOURCE: 'ingestion:delete', - MANAGE_SYNC: 'ingestion:manage', // Covers triggering, pausing, and forcing syncs -}; - -const INGESTION_RESOURCES = { - ALL: 'ingestion/*', - SOURCE: 'ingestion/{sourceId}', - OWN: 'ingestion/own', -}; - -// =================================================================================== -// SERVICE: system -// =================================================================================== - -const SYSTEM_ACTIONS = { - READ_SETTINGS: 'settings:read', - UPDATE_SETTINGS: 'settings:update', - READ_USERS: 'users:read', - CREATE_USER: 'users:create', - UPDATE_USER: 'users:update', - DELETE_USER: 'users:delete', - ASSIGN_ROLE: 'roles:assign', - UPDATE_ROLE: 'roles:update', - CREATE_ROLE: 'roles:create', - DELETE_ROLE: 'roles:delete', - READ_ROLES: 'system:read', -}; - -const SYSTEM_RESOURCES = { - ALL_SETTINGS: 'system/settings/*', - ALL_USERS: 'system/users/*', - USER: 'system/user/{userId}', - ALL_ROLES: 'system/roles/*' -}; - -// =================================================================================== -// SERVICE: dashboard -// =================================================================================== - -const DASHBOARD_ACTIONS = { - READ: 'dashboard:read', -}; - -const DASHBOARD_RESOURCES = { - ALL: 'dashboard/*', -}; - -// =================================================================================== -// 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\/(\*|ingestion\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/, - ingestion: /^ingestion\/(\*|own|[^\/]+)$/, - system: /^system\/(settings|users|user\/[^\/]+)$/, - dashboard: /^dashboard\/\*$/, -}; diff --git a/packages/backend/src/iam-policy/policy-validator.ts b/packages/backend/src/iam-policy/policy-validator.ts index 736c783..36dca3d 100644 --- a/packages/backend/src/iam-policy/policy-validator.ts +++ b/packages/backend/src/iam-policy/policy-validator.ts @@ -1,103 +1,100 @@ -import type { PolicyStatement } from '@open-archiver/types'; -import { ValidActions, ValidResourcePatterns } from './iam-definitions'; +import type { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types'; + +// Create sets of valid actions and subjects for efficient validation +const validActions: Set = new Set([ + 'manage', + 'create', + 'read', + 'update', + 'delete', + 'search', + 'export', + 'assign', + 'sync', +]); + +const validSubjects: Set = new Set([ + 'archive', + 'ingestion', + 'settings', + 'users', + 'roles', + 'dashboard', + 'all', +]); /** * @class PolicyValidator * - * This class provides a static method to validate an IAM policy statement. + * This class provides a static method to validate a CASL policy. * 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`. + * The verification logic is based on the centralized definitions in `packages/types/src/iam.types.ts`. */ export class PolicyValidator { /** - * Validates a single policy statement to ensure its actions and resources are valid. + * Validates a single policy statement to ensure its actions and subjects are valid. * - * @param {PolicyStatement} statement - The policy statement to validate. + * @param {CaslPolicy} policy - The policy 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.' }; + public static isValid(policy: CaslPolicy): { valid: boolean; reason: string } { + if (!policy || !policy.action || !policy.subject) { + return { + valid: false, + reason: 'Policy is missing required fields "action" or "subject".', + }; } // 1. Validate Actions - for (const action of statement.Action) { + const actions = Array.isArray(policy.action) ? policy.action : [policy.action]; + for (const action of actions) { 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); + // 2. Validate Subjects + const subjects = Array.isArray(policy.subject) ? policy.subject : [policy.subject]; + for (const subject of subjects) { + const { valid, reason } = this.isSubjectValid(subject); if (!valid) { return { valid: false, reason }; } } + // 3. (Optional) Validate Conditions, Fields, etc. in the future if needed. + 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. + * Checks if a single action string is a valid AppAction. * * @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)) { + private static isActionValid(action: AppActions): { valid: boolean; reason: string } { + 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. + * Checks if a single subject string is a valid AppSubject. * - * 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. + * @param {string} subject - The subject 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 } { - if (resource === '*') { + private static isSubjectValid(subject: AppSubjects): { valid: boolean; reason: string } { + if (validSubjects.has(subject)) { return { valid: true, reason: 'valid' }; } - for (const 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 any valid resource format.` }; + return { valid: false, reason: `Subject '${subject}' is not a valid subject.` }; } } diff --git a/packages/backend/src/iam-policy/test-policies/admin.json b/packages/backend/src/iam-policy/test-policies/admin.json index 09a37b0..f4554ba 100644 --- a/packages/backend/src/iam-policy/test-policies/admin.json +++ b/packages/backend/src/iam-policy/test-policies/admin.json @@ -1,7 +1,6 @@ [ { - "Effect": "Allow", - "Action": ["*"], - "Resource": ["*"] + "action": "all", + "subject": "all" } ] diff --git a/packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json b/packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json index 9cfef70..da195eb 100644 --- a/packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json +++ b/packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json @@ -1,12 +1,16 @@ [ { - "Effect": "Allow", - "Action": ["ingestion:readSource", "archive:read", "archive:search"], - "Resource": [ - "ingestion-source/INGESTION_SOURCE_ID_1", - "ingestion-source/INGESTION_SOURCE_ID_2", - "archive/ingestion-source/INGESTION_SOURCE_ID_1", - "archive/ingestion-source/INGESTION_SOURCE_ID_2" - ] + "action": ["read", "search"], + "subject": "ingestion", + "conditions": { + "id": { "$in": ["INGESTION_SOURCE_ID_1", "INGESTION_SOURCE_ID_2"] } + } + }, + { + "action": ["read", "search"], + "subject": "archive", + "conditions": { + "ingestionSourceId": { "$in": ["INGESTION_SOURCE_ID_1", "INGESTION_SOURCE_ID_2"] } + } } ] diff --git a/packages/backend/src/iam-policy/test-policies/end-user.json b/packages/backend/src/iam-policy/test-policies/end-user.json new file mode 100644 index 0000000..6221db5 --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/end-user.json @@ -0,0 +1,17 @@ +[ + { + "action": "create", + "subject": "ingestion" + }, + { + "action": "read", + "subject": "dashboard" + }, + { + "action": "manage", + "subject": "ingestion", + "conditions": { + "userId": "${user.id}" + } + } +] diff --git a/packages/backend/src/iam-policy/test-policies/ingestion-admin.json b/packages/backend/src/iam-policy/test-policies/ingestion-admin.json index 6a86ed5..649da47 100644 --- a/packages/backend/src/iam-policy/test-policies/ingestion-admin.json +++ b/packages/backend/src/iam-policy/test-policies/ingestion-admin.json @@ -1,7 +1,6 @@ [ { - "Effect": "Allow", - "Action": ["ingestion:*"], - "Resource": ["ingestion-source/*"] + "action": "all", + "subject": "ingestion" } ] diff --git a/packages/backend/src/iam-policy/test-policies/read-only-all.json b/packages/backend/src/iam-policy/test-policies/read-only-all.json index 9fff420..a728471 100644 --- a/packages/backend/src/iam-policy/test-policies/read-only-all.json +++ b/packages/backend/src/iam-policy/test-policies/read-only-all.json @@ -1,17 +1,11 @@ [ { - "Effect": "Allow", - "Action": ["ingestion:readSource", "archive:read", "archive:search", "dashboard:read"], - "Resource": ["ingestion-source/*", "archive/*", "dashboard/*"] + "action": ["read", "search"], + "subject": ["ingestion", "archive", "dashboard"] }, { - "Effect": "Deny", - "Action": [ - "ingestion:createSource", - "ingestion:updateSource", - "ingestion:deleteSource", - "system:*" - ], - "Resource": ["*"] + "inverted": true, + "action": ["create", "update", "delete"], + "subject": ["ingestion", "users", "roles"] } ] diff --git a/packages/backend/src/iam-policy/test-policies/read-only-own.json b/packages/backend/src/iam-policy/test-policies/read-only-own.json index 9f9cc42..63302d6 100644 --- a/packages/backend/src/iam-policy/test-policies/read-only-own.json +++ b/packages/backend/src/iam-policy/test-policies/read-only-own.json @@ -1,16 +1,6 @@ [ { - "Effect": "Allow", - "Action": [ - "ingestion:readSource", - "archive:read", - "archive:search", - "dashboard:read", - "ingestion:createSource" - ], - "Resource": ["ingestion-source/*", "archive/*", "dashboard/*"], - "Condition": { - "owner": true - } + "action": "read", + "subject": "all" } ] diff --git a/packages/backend/src/iam-policy/test-policies/single-ingestion-access.json b/packages/backend/src/iam-policy/test-policies/single-ingestion-access.json new file mode 100644 index 0000000..d2bef78 --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/single-ingestion-access.json @@ -0,0 +1,9 @@ +[ + { + "action": "manage", + "subject": "ingestion", + "conditions": { + "id": "f3d7c025-060f-4f1f-a0e6-cdd32e6e07af" + } + } +] diff --git a/packages/backend/src/iam-policy/test-policies/user-manager.json b/packages/backend/src/iam-policy/test-policies/user-manager.json index 3ddb1ed..3f3338a 100644 --- a/packages/backend/src/iam-policy/test-policies/user-manager.json +++ b/packages/backend/src/iam-policy/test-policies/user-manager.json @@ -1,13 +1,10 @@ [ { - "Effect": "Allow", - "Action": [ - "system:readUsers", - "system:createUser", - "system:updateUser", - "system:deleteUser", - "system:assignRole" - ], - "Resource": ["system/users", "system/user/*"] + "action": "all", + "subject": "users" + }, + { + "action": "read", + "subject": "roles" } ] diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 43acab2..9212027 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -1,7 +1,8 @@ import { count, desc, eq, asc, and } from 'drizzle-orm'; import { db } from '../database'; -import { archivedEmails, attachments, emailAttachments } from '../database/schema'; +import { archivedEmails, attachments, emailAttachments, ingestionSources } from '../database/schema'; import { FilterBuilder } from './FilterBuilder'; +import { AuthorizationService } from './AuthorizationService'; import type { PaginatedArchivedEmails, ArchivedEmail, @@ -46,32 +47,40 @@ export class ArchivedEmailService { userId: string ): Promise { const offset = (page - 1) * limit; - const filterBuilder = await FilterBuilder.create( - userId, - archivedEmails, - 'archive', - 'archive:read' - ); + const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read'); const where = and( eq(archivedEmails.ingestionSourceId, ingestionSourceId), - filterBuilder.build() + drizzleFilter ); - const [total] = await db + const countQuery = db .select({ count: count(archivedEmails.id), }) .from(archivedEmails) - .where(where); + .leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id)); - const items = await db + if (where) { + countQuery.where(where); + } + + const [total] = await countQuery; + + const itemsQuery = db .select() .from(archivedEmails) - .where(where) + .leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id)) .orderBy(desc(archivedEmails.sentAt)) .limit(limit) .offset(offset); + if (where) { + itemsQuery.where(where); + } + + const results = await itemsQuery; + const items = results.map((r) => r.archived_emails); + return { items: items.map((item) => ({ ...item, @@ -85,16 +94,28 @@ export class ArchivedEmailService { }; } - public static async getArchivedEmailById(emailId: string): Promise { - const [email] = await db - .select() - .from(archivedEmails) - .where(eq(archivedEmails.id, emailId)); + public static async getArchivedEmailById( + emailId: string, + userId: string + ): Promise { + const email = await db.query.archivedEmails.findFirst({ + where: eq(archivedEmails.id, emailId), + with: { + ingestionSource: true, + }, + }); if (!email) { return null; } + const authorizationService = new AuthorizationService(); + const canRead = await authorizationService.can(userId, 'read', 'archive', email); + + if (!canRead) { + return null; + } + let threadEmails: ThreadEmail[] = []; if (email.threadId) { diff --git a/packages/backend/src/services/AuthService.ts b/packages/backend/src/services/AuthService.ts index f5d71b1..fa995c3 100644 --- a/packages/backend/src/services/AuthService.ts +++ b/packages/backend/src/services/AuthService.ts @@ -64,10 +64,11 @@ export class AuthService { }); return { - accessToken, user: { + accessToken, + user: { ...userWithoutPassword, - role: null - } + role: null, + }, }; } diff --git a/packages/backend/src/services/AuthorizationService.ts b/packages/backend/src/services/AuthorizationService.ts index 53ba9d2..3e0c304 100644 --- a/packages/backend/src/services/AuthorizationService.ts +++ b/packages/backend/src/services/AuthorizationService.ts @@ -1,77 +1,25 @@ import { IamService } from './IamService'; -import { db } from '../database'; -import { ingestionSources } from '../database/schema/ingestion-sources'; -import { eq } from 'drizzle-orm'; +import { createAbilityFor, SubjectObject } from '../iam-policy/ability'; +import { subject, Subject } from '@casl/ability'; +import { AppActions, AppSubjects } from '@open-archiver/types'; export class AuthorizationService { - public static async can(userId: string, action: string, resource: string): Promise { - const iamService = new IamService(); - const userRoles = await iamService.getRolesForUser(userId); - const allPolicies = userRoles.flatMap((role) => role.policies || []); + private iamService: IamService; - // 1. Check for explicit DENY policies first. - const isDenied = allPolicies.some( - (policy) => - policy.Effect === 'Deny' && - this.matches(action, policy.Action) && - this.matches(resource, policy.Resource) - ); + constructor() { + this.iamService = new IamService(); + } - if (isDenied) { - return false; - } - - // 2. If not denied, check for an explicit ALLOW policy. - for (const policy of allPolicies) { - if (policy.Effect === 'Allow' && this.matches(action, policy.Action)) { - if (action.includes('create')) { - return true; - } - - if (this.matches(resource, policy.Resource)) { - return true - } - } - } - - return false; - } - - private static matches(value: string, patterns: string[]): boolean { - return patterns.some((pattern) => { - if (pattern === '*') return true; - if (pattern.endsWith('*')) { - const prefix = pattern.slice(0, -1); - return value.startsWith(prefix); - } - const regex = new RegExp(`^${pattern.replace(/\{[^}]+\}/g, '[^/]+')}$`); - return regex.test(value); - }); - } - - private static async isOwner(userId: string, resource: string): Promise { - const resourceParts = resource.split('/'); - const service = resourceParts[0]; - const resourceId = resourceParts[1]; - - if (service === 'ingestion-source' && resourceId) { - if (resourceId === 'own') return true; - const [source] = await db - .select() - .from(ingestionSources) - .where(eq(ingestionSources.id, resourceId)); - return source?.userId === userId; - } - - if (service === 'archive' && resourceParts[1] === 'ingestion-source' && resourceParts[2]) { - const ingestionSourceId = resourceParts[2]; - const [source] = await db - .select() - .from(ingestionSources) - .where(eq(ingestionSources.id, ingestionSourceId)); - return source?.userId === userId; - } - - return false; - } + public async can( + userId: string, + action: AppActions, + resource: AppSubjects, + resourceObject?: SubjectObject + ): Promise { + const ability = await this.iamService.getAbilityForUser(userId); + const subjectInstance = resourceObject + ? subject(resource, resourceObject as Record) + : resource; + return ability.can(action, subjectInstance as AppSubjects); + } } diff --git a/packages/backend/src/services/FilterBuilder.ts b/packages/backend/src/services/FilterBuilder.ts index d8e3870..5afdf68 100644 --- a/packages/backend/src/services/FilterBuilder.ts +++ b/packages/backend/src/services/FilterBuilder.ts @@ -1,67 +1,41 @@ -import { SQL, or, and, eq, inArray, sql } from 'drizzle-orm'; -import { PgTableWithColumns } from 'drizzle-orm/pg-core'; +import { SQL, sql } from 'drizzle-orm'; import { IamService } from './IamService'; +import { rulesToQuery } from '@casl/ability/extra'; +import { mongoToDrizzle } from '../helpers/mongoToDrizzle'; +import { AppActions, AppSubjects } from '@open-archiver/types'; export class FilterBuilder { - private constructor( - private userId: string, - private table: PgTableWithColumns, - private policies: any[], - private resourceType: string, - private readAction: string - ) { } + public static async create( + userId: string, + resourceType: AppSubjects, + action: AppActions + ): Promise<{ + drizzleFilter: SQL | undefined; + mongoFilter: Record | null; + }> { + const iamService = new IamService(); + const ability = await iamService.getAbilityForUser(userId); - public static async create( - userId: string, - table: PgTableWithColumns, - resourceType: string, - readAction: string - ): Promise { - const iamService = new IamService(); - const userRoles = await iamService.getRolesForUser(userId); - const allPolicies = userRoles.flatMap((role) => role.policies || []); - return new FilterBuilder(userId, table, allPolicies, resourceType, readAction); - } + // If the user can perform the action on any instance of the resource type + // without any specific conditions, they have full access. + if (ability.can(action, resourceType)) { + const rules = ability.rulesFor(action, resourceType); + const hasUnconditionalRule = rules.some((rule) => !rule.conditions); + if (hasUnconditionalRule) { + return { drizzleFilter: undefined, mongoFilter: null }; // Full access + } + } - public build(): SQL | undefined { - const canReadAll = this.policies.some( - (policy) => - policy.Effect === 'Allow' && - (policy.Action.includes(this.readAction) || policy.Action.includes('*')) && - (policy.Resource.includes(`${this.resourceType}/*`) || - policy.Resource.includes('*')) - ); + const query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions); - if (canReadAll) { - return undefined; - } + if (query === null) { + return { drizzleFilter: undefined, mongoFilter: null }; // Full access + } - const allowedResources = this.policies - .filter( - (policy) => - policy.Effect === 'Allow' && - (policy.Action.includes(this.readAction) || policy.Action.includes('*')) - ) - .flatMap((policy) => policy.Resource) - .filter((resource) => resource.startsWith(`${this.resourceType}/`)); + if (Object.keys(query).length === 0) { + return { drizzleFilter: sql`1=0`, mongoFilter: {} }; // No access + } - const canReadOwn = allowedResources.some((resource) => resource.endsWith('/own')); - const sourceIds = allowedResources - .map((resource) => resource.split('/')[1]) - .filter((id) => id !== 'own'); - - const conditions: SQL[] = []; - if (canReadOwn) { - conditions.push(eq(this.table.userId, this.userId)); - } - if (sourceIds.length > 0) { - conditions.push(inArray(this.table.id, sourceIds)); - } - - if (conditions.length === 0) { - return eq(this.table.id, sql`NULL`); - } - - return or(...conditions); - } + return { drizzleFilter: mongoToDrizzle(query), mongoFilter: query }; + } } diff --git a/packages/backend/src/services/IamService.ts b/packages/backend/src/services/IamService.ts index 9090d09..986ae5f 100644 --- a/packages/backend/src/services/IamService.ts +++ b/packages/backend/src/services/IamService.ts @@ -1,7 +1,8 @@ import { db } from '../database'; -import { roles, userRoles } from '../database/schema/users'; -import type { Role, PolicyStatement } from '@open-archiver/types'; +import { roles, userRoles, users } from '../database/schema/users'; +import type { Role, CaslPolicy, User } from '@open-archiver/types'; import { eq } from 'drizzle-orm'; +import { createAbilityFor, AppAbility } from '../iam-policy/ability'; export class IamService { /** @@ -27,7 +28,7 @@ export class IamService { return role; } - public async createRole(name: string, policy: PolicyStatement[]): Promise { + public async createRole(name: string, policy: CaslPolicy[]): Promise { const [role] = await db.insert(roles).values({ name, policies: policy }).returning(); return role; } @@ -47,4 +48,32 @@ export class IamService { .returning(); return role; } + + public async getAbilityForUser(userId: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (!user) { + // Or handle this case as you see fit, maybe return an ability with no permissions + throw new Error('User not found'); + } + + const userRoles = await this.getRolesForUser(userId); + const allPolicies = userRoles.flatMap((role) => role.policies || []); + + // Interpolate policies + const interpolatedPolicies = this.interpolatePolicies(allPolicies, { + ...user, + role: null, + } as User); + + return createAbilityFor(interpolatedPolicies); + } + + private interpolatePolicies(policies: CaslPolicy[], user: User): CaslPolicy[] { + const userPoliciesString = JSON.stringify(policies); + const interpolatedPoliciesString = userPoliciesString.replace(/\$\{user\.id\}/g, user.id); + return JSON.parse(interpolatedPoliciesString); + } } diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index df80217..8881358 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -87,17 +87,11 @@ export class IngestionService { } public static async findAll(userId: string): Promise { - const filterBuilder = await FilterBuilder.create( - userId, - ingestionSources, - 'ingestion-source', - 'ingestion:readSource' - ); - const where = filterBuilder.build(); + const { drizzleFilter } = await FilterBuilder.create(userId, 'ingestion', 'read'); let query = db.select().from(ingestionSources).$dynamic(); - if (where) { - query = query.where(where); + if (drizzleFilter) { + query = query.where(drizzleFilter); } const sources = await query.orderBy(desc(ingestionSources.createdAt)); diff --git a/packages/backend/src/services/SearchService.ts b/packages/backend/src/services/SearchService.ts index a0a0bf5..ac261cd 100644 --- a/packages/backend/src/services/SearchService.ts +++ b/packages/backend/src/services/SearchService.ts @@ -1,6 +1,8 @@ import { Index, MeiliSearch, SearchParams } from 'meilisearch'; import { config } from '../config'; import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types'; +import { FilterBuilder } from './FilterBuilder'; +import { mongoToMeli } from '../helpers/mongoToMeli'; export class SearchService { private client: MeiliSearch; @@ -47,7 +49,7 @@ export class SearchService { return index.deleteDocuments({ filter }); } - public async searchEmails(dto: SearchQuery): Promise { + public async searchEmails(dto: SearchQuery, userId: string): Promise { const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto; const index = await this.getIndex('emails'); @@ -70,6 +72,21 @@ export class SearchService { searchParams.filter = filterStrings.join(' AND '); } + // Create a filter based on the user's permissions. + // This ensures that the user can only search for emails they are allowed to see. + const { mongoFilter } = await FilterBuilder.create(userId, 'archive', 'read'); + if (mongoFilter) { + // Convert the MongoDB-style filter from CASL to a MeiliSearch filter string. + const meliFilter = mongoToMeli(mongoFilter); + if (searchParams.filter) { + // If there are existing filters, append the access control filter. + searchParams.filter = `${searchParams.filter} AND ${meliFilter}`; + } else { + // Otherwise, just use the access control filter. + searchParams.filter = meliFilter; + } + } + const searchResults = await index.search(query, searchParams); return { diff --git a/packages/backend/src/services/UserService.ts b/packages/backend/src/services/UserService.ts index c4209bf..7ade574 100644 --- a/packages/backend/src/services/UserService.ts +++ b/packages/backend/src/services/UserService.ts @@ -2,7 +2,7 @@ import { db } from '../database'; import * as schema from '../database/schema'; import { eq, sql } from 'drizzle-orm'; import { hash } from 'bcryptjs'; -import type { PolicyStatement, User } from '@open-archiver/types'; +import type { CaslPolicy, User } from '@open-archiver/types'; export class UserService { /** @@ -28,16 +28,16 @@ export class UserService { with: { userRoles: { with: { - role: true - } - } - } + role: true, + }, + }, + }, }); if (!user) return null; return { ...user, - role: user.userRoles[0]?.role || null + role: user.userRoles[0]?.role || null, }; } @@ -46,15 +46,15 @@ export class UserService { with: { userRoles: { with: { - role: true - } - } - } + role: true, + }, + }, + }, }); return users.map((u) => ({ ...u, - role: u.userRoles[0]?.role || null + role: u.userRoles[0]?.role || null, })); } @@ -151,11 +151,10 @@ export class UserService { }); if (!superAdminRole) { - const suerAdminPolicies: PolicyStatement[] = [ + const suerAdminPolicies: CaslPolicy[] = [ { - Effect: 'Allow', - Action: ['*'], - Resource: ['*'], + action: 'manage', + subject: 'all', }, ]; superAdminRole = ( diff --git a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts index 46cfed9..a097b5c 100644 --- a/packages/backend/src/services/ingestion-connectors/ImapConnector.ts +++ b/packages/backend/src/services/ingestion-connectors/ImapConnector.ts @@ -193,7 +193,6 @@ export class ImapConnector implements IEmailConnector { // Initialize with last synced UID, not the maximum UID in mailbox this.newMaxUids[mailboxPath] = lastUid || 0; - // Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers. if (mailbox.exists > 0) { const BATCH_SIZE = 250; // A configurable batch size diff --git a/packages/frontend/src/lib/components/custom/RoleForm.svelte b/packages/frontend/src/lib/components/custom/RoleForm.svelte index ff51e1d..561bf76 100644 --- a/packages/frontend/src/lib/components/custom/RoleForm.svelte +++ b/packages/frontend/src/lib/components/custom/RoleForm.svelte @@ -1,5 +1,5 @@