From 8c33b63bdf45f93c315444acca933a671c249dfd Mon Sep 17 00:00:00 2001 From: "Wei S." <5291640+wayneshn@users.noreply.github.com> Date: Thu, 21 Aug 2025 23:45:06 +0300 Subject: [PATCH] feat: Role based access control (#58) * Format checked, contributing.md update * Middleware setup * IAP API, create user/roles in frontend * RBAC using CASL library * Switch to CASL, secure search, resource-level access control --------- Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com> --- .github/CLA-v2.md | 27 + .github/workflows/cla.yml | 4 +- docker/Dockerfile | 2 - docs/services/IAM-service/iam-policies.md | 141 --- packages/backend/package.json | 1 + .../controllers/archived-email.controller.ts | 16 +- .../src/api/controllers/iam.controller.ts | 102 +- .../api/controllers/ingestion.controller.ts | 12 +- .../src/api/controllers/search.controller.ts | 21 +- .../src/api/controllers/user.controller.ts | 56 + .../src/api/middleware/requirePermission.ts | 36 + .../src/api/routes/archived-email.routes.ts | 19 +- .../src/api/routes/dashboard.routes.ts | 51 +- packages/backend/src/api/routes/iam.routes.ts | 42 +- .../src/api/routes/ingestion.routes.ts | 25 +- .../backend/src/api/routes/search.routes.ts | 3 +- .../backend/src/api/routes/storage.routes.ts | 3 +- .../backend/src/api/routes/upload.routes.ts | 3 +- .../backend/src/api/routes/user.routes.ts | 38 + .../migrations/0015_wakeful_norman_osborn.sql | 2 + .../migrations/0016_lonely_mariko_yashida.sql | 2 + .../migrations/meta/0015_snapshot.json | 1067 ++++++++++++++++ .../migrations/meta/0016_snapshot.json | 1078 +++++++++++++++++ .../database/migrations/meta/_journal.json | 14 + .../src/database/schema/ingestion-sources.ts | 10 + packages/backend/src/database/schema/users.ts | 5 +- .../backend/src/helpers/mongoToDrizzle.ts | 95 ++ packages/backend/src/helpers/mongoToMeli.ts | 100 ++ packages/backend/src/iam-policy/ability.ts | 114 ++ .../backend/src/iam-policy/iam-definitions.ts | 116 -- .../src/iam-policy/policy-validator.ts | 107 +- .../src/iam-policy/test-policies/admin.json | 6 + .../auditor-specific-mailbox.json | 17 + .../auditor-specific-sources.json | 14 + .../iam-policy/test-policies/end-user.json | 17 + .../test-policies/ingestion-admin.json | 6 + .../test-policies/read-only-all.json | 6 + .../single-ingestion-access.json | 9 + .../test-policies/user-manager.json | 10 + packages/backend/src/index.ts | 5 +- .../src/services/ArchivedEmailService.ts | 57 +- packages/backend/src/services/AuthService.ts | 8 +- .../src/services/AuthorizationService.ts | 25 + .../backend/src/services/FilterBuilder.ts | 58 + packages/backend/src/services/IamService.ts | 68 +- .../backend/src/services/IndexingService.ts | 17 +- .../backend/src/services/IngestionService.ts | 23 +- .../backend/src/services/SearchService.ts | 28 +- packages/backend/src/services/UserService.ts | 97 +- .../src/lib/components/custom/RoleForm.svelte | 47 + .../src/lib/components/custom/UserForm.svelte | 101 ++ .../src/routes/dashboard/+error.svelte | 22 + .../src/routes/dashboard/+layout.svelte | 65 +- .../src/routes/dashboard/+page.server.ts | 70 +- .../dashboard/archived-emails/+page.server.ts | 92 +- .../archived-emails/[id]/+page.server.ts | 9 +- .../archived-emails/[id]/+page.svelte | 13 +- .../dashboard/ingestions/+page.server.ts | 25 +- .../routes/dashboard/ingestions/+page.svelte | 14 +- .../src/routes/dashboard/search/+page.svelte | 8 +- .../dashboard/settings/roles/+page.server.ts | 18 + .../dashboard/settings/roles/+page.svelte | 233 ++++ .../dashboard/settings/users/+page.server.ts | 27 + .../dashboard/settings/users/+page.svelte | 210 ++++ packages/types/src/email.types.ts | 1 + packages/types/src/iam.types.ts | 36 +- packages/types/src/user.types.ts | 7 +- pnpm-lock.yaml | 39 +- 68 files changed, 4277 insertions(+), 543 deletions(-) create mode 100644 .github/CLA-v2.md delete mode 100644 docs/services/IAM-service/iam-policies.md create mode 100644 packages/backend/src/api/controllers/user.controller.ts create mode 100644 packages/backend/src/api/middleware/requirePermission.ts create mode 100644 packages/backend/src/api/routes/user.routes.ts create mode 100644 packages/backend/src/database/migrations/0015_wakeful_norman_osborn.sql create mode 100644 packages/backend/src/database/migrations/0016_lonely_mariko_yashida.sql create mode 100644 packages/backend/src/database/migrations/meta/0015_snapshot.json create mode 100644 packages/backend/src/database/migrations/meta/0016_snapshot.json create mode 100644 packages/backend/src/helpers/mongoToDrizzle.ts create mode 100644 packages/backend/src/helpers/mongoToMeli.ts create mode 100644 packages/backend/src/iam-policy/ability.ts delete mode 100644 packages/backend/src/iam-policy/iam-definitions.ts create mode 100644 packages/backend/src/iam-policy/test-policies/admin.json create mode 100644 packages/backend/src/iam-policy/test-policies/auditor-specific-mailbox.json create mode 100644 packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json create mode 100644 packages/backend/src/iam-policy/test-policies/end-user.json create mode 100644 packages/backend/src/iam-policy/test-policies/ingestion-admin.json create mode 100644 packages/backend/src/iam-policy/test-policies/read-only-all.json create mode 100644 packages/backend/src/iam-policy/test-policies/single-ingestion-access.json create mode 100644 packages/backend/src/iam-policy/test-policies/user-manager.json create mode 100644 packages/backend/src/services/AuthorizationService.ts create mode 100644 packages/backend/src/services/FilterBuilder.ts create mode 100644 packages/frontend/src/lib/components/custom/RoleForm.svelte create mode 100644 packages/frontend/src/lib/components/custom/UserForm.svelte create mode 100644 packages/frontend/src/routes/dashboard/+error.svelte create mode 100644 packages/frontend/src/routes/dashboard/settings/roles/+page.server.ts create mode 100644 packages/frontend/src/routes/dashboard/settings/roles/+page.svelte create mode 100644 packages/frontend/src/routes/dashboard/settings/users/+page.server.ts create mode 100644 packages/frontend/src/routes/dashboard/settings/users/+page.svelte diff --git a/.github/CLA-v2.md b/.github/CLA-v2.md new file mode 100644 index 0000000..7c825fd --- /dev/null +++ b/.github/CLA-v2.md @@ -0,0 +1,27 @@ +# Contributor License Agreement (CLA) + +Version: 2 + +This Agreement is for your protection as a Contributor as well as the protection of the maintainers of the Open Archiver software; it does not change your rights to use your own Contributions for any other purpose. In this Agreement, "Open Archiver" refers to LogicLabs OÜ, a private limited company established under the laws of the Republic of Estonia. + +You accept and agree to the following terms and conditions for Your present and future Contributions submitted to "Open Archiver". Except for the license granted herein to Open Archiver and recipients of software distributed by "Open Archiver", You reserve all right, title, and interest in and to Your Contributions. + +1. Definitions. + + "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with "Open Archiver". For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. + + "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to "Open Archiver" for inclusion in, or documentation of, any of the products owned or managed by "Open Archiver" (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to "Open Archiver" or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, "Open Archiver" for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. + +3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. + +4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to "Open Archiver", or that your employer has executed a separate Contributor License Agreement with "Open Archiver". + +5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. + +6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. Should You wish to submit work that is not Your original creation, You may submit it to "Open Archiver" separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". + +8. You agree to notify "Open Archiver" of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index fef867c..133ec2d 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -23,8 +23,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} with: - path-to-signatures: 'signatures/version1/cla.json' - path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/tree/main/.github/CLA.md' + path-to-signatures: 'signatures/version2/cla.json' + path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/.github/CLA-v2.md' branch: 'main' allowlist: 'wayneshn' diff --git a/docker/Dockerfile b/docker/Dockerfile index da577cc..11b8341 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,8 +34,6 @@ RUN pnpm build # 2. Production Stage: Install only production dependencies and copy built artifacts FROM base AS production -# Install production dependencies -# RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=true # Copy built application from build stage COPY --from=build /app/packages/backend/dist ./packages/backend/dist diff --git a/docs/services/IAM-service/iam-policies.md b/docs/services/IAM-service/iam-policies.md deleted file mode 100644 index 50547b0..0000000 --- a/docs/services/IAM-service/iam-policies.md +++ /dev/null @@ -1,141 +0,0 @@ -# IAM Policies Guide - -This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions. - -## 1. Policy Structure - -A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`. - -```json -{ - "Effect": "Allow", - "Action": ["archive:read", "archive:search"], - "Resource": ["archive/all"] -} -``` - -- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`. -- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`. -- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used. - -## 2. Wildcard Support - -Our IAM system supports wildcards (`*`) in both `Action` and `Resource` fields to provide flexible permission management, as defined in the `PolicyValidator`. - -### Action Wildcards - -You can use wildcards to grant broad permissions for actions: - -- **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:*"] - ``` - -### Resource Wildcards - -Wildcards can also be used to specify resources: - -- **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/*"] - ``` - -## 3. Actions and Resources by Service - -The following sections define the available actions and resources, categorized by their respective services. - -### Service: `archive` - -The `archive` service pertains to all actions related to accessing and managing archived emails. - -**Actions:** - -| Action | Description | -| :--------------- | :--------------------------------------------------------------------- | -| `archive:read` | Grants permission to read the content and metadata of archived emails. | -| `archive:search` | Grants permission to perform search queries against the email archive. | -| `archive:export` | Grants permission to export search results or individual emails. | - -**Resources:** - -| Resource | Description | -| :------------------------------------ | :--------------------------------------------------------------------------------------- | -| `archive/all` | Represents the entire email archive. | -| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. | -| `archive/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. | - ---- - -### Service: `ingestion` - -The `ingestion` service covers the management of email ingestion sources. - -**Actions:** - -| Action | Description | -| :----------------------- | :--------------------------------------------------------------------------- | -| `ingestion:createSource` | Grants permission to create a new ingestion source. | -| `ingestion:readSource` | Grants permission to view the details of ingestion sources. | -| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. | -| `ingestion:deleteSource` | Grants permission to delete an ingestion source. | -| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. | - -**Resources:** - -| Resource | Description | -| :---------------------------- | :-------------------------------------------------------- | -| `ingestion-source/*` | Represents all ingestion sources. | -| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. | - ---- - -### Service: `system` - -The `system` service is for managing system-level settings, users, and roles. - -**Actions:** - -| Action | Description | -| :---------------------- | :-------------------------------------------------- | -| `system:readSettings` | Grants permission to view system settings. | -| `system:updateSettings` | Grants permission to modify system settings. | -| `system:readUsers` | Grants permission to list and view user accounts. | -| `system:createUser` | Grants permission to create new user accounts. | -| `system:updateUser` | Grants permission to modify existing user accounts. | -| `system:deleteUser` | Grants permission to delete user accounts. | -| `system:assignRole` | Grants permission to assign roles to users. | - -**Resources:** - -| Resource | Description | -| :--------------------- | :---------------------------------------------------- | -| `system/settings` | Represents the system configuration. | -| `system/users` | Represents all user accounts within the system. | -| `system/user/{userId}` | Scopes the action to a single, specific user account. | - ---- - -### Service: `dashboard` - -The `dashboard` service relates to viewing analytics and overview information. - -**Actions:** - -| Action | Description | -| :--------------- | :-------------------------------------------------------------- | -| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. | - -**Resources:** - -| Resource | Description | -| :------------ | :------------------------------------------ | -| `dashboard/*` | Represents all components of the dashboard. | diff --git a/packages/backend/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 648deec..dc64c7f 100644 --- a/packages/backend/src/api/controllers/archived-email.controller.ts +++ b/packages/backend/src/api/controllers/archived-email.controller.ts @@ -8,11 +8,17 @@ export class ArchivedEmailController { const { ingestionSourceId } = req.params; const page = parseInt(req.query.page as string, 10) || 1; const limit = parseInt(req.query.limit as string, 10) || 10; + const userId = req.user?.sub; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } const result = await ArchivedEmailService.getArchivedEmails( ingestionSourceId, page, - limit + limit, + userId ); return res.status(200).json(result); } catch (error) { @@ -24,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 77984eb..bde29de 100644 --- a/packages/backend/src/api/controllers/iam.controller.ts +++ b/packages/backend/src/api/controllers/iam.controller.ts @@ -1,7 +1,8 @@ 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'; +import { logger } from '../../config/logger'; export class IamController { #iamService: IamService; @@ -12,10 +13,15 @@ export class IamController { public getRoles = async (req: Request, res: Response): Promise => { try { - const roles = await this.#iamService.getRoles(); + let roles = await this.#iamService.getRoles(); + if (!roles.some((r) => r.slug?.includes('predefined_'))) { + // create pre defined roles + logger.info({}, 'Creating predefined roles'); + await this.createDefaultRoles(); + } res.status(200).json(roles); } catch (error) { - res.status(500).json({ error: 'Failed to get roles.' }); + res.status(500).json({ message: 'Failed to get roles.' }); } }; @@ -27,34 +33,35 @@ export class IamController { if (role) { res.status(200).json(role); } else { - res.status(404).json({ error: 'Role not found.' }); + res.status(404).json({ message: 'Role not found.' }); } } catch (error) { - res.status(500).json({ error: 'Failed to get role.' }); + res.status(500).json({ message: 'Failed to get role.' }); } }; public createRole = async (req: Request, res: Response): Promise => { - const { name, policy } = req.body; + const { name, policies } = req.body; - if (!name || !policy) { - res.status(400).json({ error: 'Missing required fields: name and policy.' }); + if (!name || !policies) { + res.status(400).json({ message: 'Missing required fields: name and policy.' }); return; } - for (const statement of policy) { - const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement); + for (const statement of policies) { + const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy); if (!valid) { - res.status(400).json({ error: `Invalid policy statement: ${reason}` }); + res.status(400).json({ message: `Invalid policy statement: ${reason}` }); return; } } try { - const role = await this.#iamService.createRole(name, policy); + const role = await this.#iamService.createRole(name, policies); res.status(201).json(role); } catch (error) { - res.status(500).json({ error: 'Failed to create role.' }); + console.log(error); + res.status(500).json({ message: 'Failed to create role.' }); } }; @@ -65,7 +72,74 @@ export class IamController { await this.#iamService.deleteRole(id); res.status(204).send(); } catch (error) { - res.status(500).json({ error: 'Failed to delete role.' }); + res.status(500).json({ message: 'Failed to delete role.' }); + } + }; + + public updateRole = async (req: Request, res: Response): Promise => { + const { id } = req.params; + const { name, policies } = req.body; + + if (!name && !policies) { + res.status(400).json({ message: 'Missing fields to update: name or policies.' }); + return; + } + + if (policies) { + for (const statement of policies) { + const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy); + if (!valid) { + res.status(400).json({ message: `Invalid policy statement: ${reason}` }); + return; + } + } + } + + try { + const role = await this.#iamService.updateRole(id, { name, policies }); + res.status(200).json(role); + } catch (error) { + res.status(500).json({ message: 'Failed to update role.' }); + } + }; + + private createDefaultRoles = async () => { + try { + // end user who can manage its own data, and create new ingestions. + await this.#iamService.createRole( + 'End user', + [ + { + action: 'create', + subject: 'ingestion', + }, + { + action: 'read', + subject: 'dashboard', + }, + { + action: 'manage', + subject: 'ingestion', + conditions: { + userId: '${user.id}', + }, + }, + ], + 'predefined_end_user' + ); + // read only + await this.#iamService.createRole( + 'Read only', + [ + { + action: ['read', 'search'], + subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'], + }, + ], + 'predefined_read_only_user' + ); + } catch (error) { + logger.error({}, 'Failed to create default roles'); } }; } diff --git a/packages/backend/src/api/controllers/ingestion.controller.ts b/packages/backend/src/api/controllers/ingestion.controller.ts index c9d3ed2..b7e4d2a 100644 --- a/packages/backend/src/api/controllers/ingestion.controller.ts +++ b/packages/backend/src/api/controllers/ingestion.controller.ts @@ -27,7 +27,11 @@ export class IngestionController { } try { const dto: CreateIngestionSourceDto = req.body; - const newSource = await IngestionService.create(dto); + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + const newSource = await IngestionService.create(dto, userId); const safeSource = this.toSafeIngestionSource(newSource); return res.status(201).json(safeSource); } catch (error: any) { @@ -42,7 +46,11 @@ export class IngestionController { public findAll = async (req: Request, res: Response): Promise => { try { - const sources = await IngestionService.findAll(); + const userId = req.user?.sub; + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + const sources = await IngestionService.findAll(userId); const safeSources = sources.map(this.toSafeIngestionSource); return res.status(200).json(safeSources); } catch (error) { 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 new file mode 100644 index 0000000..8f7065b --- /dev/null +++ b/packages/backend/src/api/controllers/user.controller.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import { UserService } from '../../services/UserService'; +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); +}; + +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); +}; + +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); +}; + +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); +}; + +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(); +}; diff --git a/packages/backend/src/api/middleware/requirePermission.ts b/packages/backend/src/api/middleware/requirePermission.ts new file mode 100644 index 0000000..3d42533 --- /dev/null +++ b/packages/backend/src/api/middleware/requirePermission.ts @@ -0,0 +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: 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' }); + } + + 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 + ); + + if (!hasPermission) { + return res.status(403).json({ + message: + rejectMessage || `You don't have the permission to perform the current action.`, + }); + } + + next(); + }; +}; diff --git a/packages/backend/src/api/routes/archived-email.routes.ts b/packages/backend/src/api/routes/archived-email.routes.ts index 5c0948a..174d1ea 100644 --- a/packages/backend/src/api/routes/archived-email.routes.ts +++ b/packages/backend/src/api/routes/archived-email.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { ArchivedEmailController } from '../controllers/archived-email.controller'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createArchivedEmailRouter = ( @@ -12,11 +13,23 @@ 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', archivedEmailController.getArchivedEmailById); + router.get( + '/:id', + requirePermission('read', 'archive'), + archivedEmailController.getArchivedEmailById + ); - router.delete('/:id', archivedEmailController.deleteArchivedEmail); + router.delete( + '/:id', + requirePermission('delete', 'archive'), + archivedEmailController.deleteArchivedEmail + ); return router; }; diff --git a/packages/backend/src/api/routes/dashboard.routes.ts b/packages/backend/src/api/routes/dashboard.routes.ts index 2c4f5e0..8c360fe 100644 --- a/packages/backend/src/api/routes/dashboard.routes.ts +++ b/packages/backend/src/api/routes/dashboard.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { dashboardController } from '../controllers/dashboard.controller'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createDashboardRouter = (authService: AuthService): Router => { @@ -8,11 +9,51 @@ export const createDashboardRouter = (authService: AuthService): Router => { router.use(requireAuth(authService)); - router.get('/stats', dashboardController.getStats); - router.get('/ingestion-history', dashboardController.getIngestionHistory); - router.get('/ingestion-sources', dashboardController.getIngestionSources); - router.get('/recent-syncs', dashboardController.getRecentSyncs); - router.get('/indexed-insights', dashboardController.getIndexedInsights); + router.get( + '/stats', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard stats.' + ), + dashboardController.getStats + ); + router.get( + '/ingestion-history', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), + dashboardController.getIngestionHistory + ); + router.get( + '/ingestion-sources', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), + dashboardController.getIngestionSources + ); + router.get( + '/recent-syncs', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), + dashboardController.getRecentSyncs + ); + router.get( + '/indexed-insights', + requirePermission( + 'read', + 'dashboard', + 'You need the dashboard read permission to view dashboard data.' + ), + dashboardController.getIndexedInsights + ); return router; }; diff --git a/packages/backend/src/api/routes/iam.routes.ts b/packages/backend/src/api/routes/iam.routes.ts index f427985..b882854 100644 --- a/packages/backend/src/api/routes/iam.routes.ts +++ b/packages/backend/src/api/routes/iam.routes.ts @@ -1,36 +1,42 @@ import { Router } from 'express'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import type { IamController } from '../controllers/iam.controller'; +import type { AuthService } from '../../services/AuthService'; -export const createIamRouter = (iamController: IamController): Router => { +export const createIamRouter = (iamController: IamController, authService: AuthService): Router => { const router = Router(); + router.use(requireAuth(authService)); + /** * @route GET /api/v1/iam/roles * @description Gets all roles. * @access Private */ - router.get('/roles', requireAuth, iamController.getRoles); + router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles); + + router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById); /** - * @route GET /api/v1/iam/roles/:id - * @description Gets a role by ID. - * @access Private + * Only super admin has the ability to modify existing roles or create new roles. */ - router.get('/roles/:id', requireAuth, iamController.getRoleById); + router.post( + '/roles', + requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'), + iamController.createRole + ); - /** - * @route POST /api/v1/iam/roles - * @description Creates a new role. - * @access Private - */ - router.post('/roles', requireAuth, iamController.createRole); + router.delete( + '/roles/:id', + requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'), + iamController.deleteRole + ); - /** - * @route DELETE /api/v1/iam/roles/:id - * @description Deletes a role. - * @access Private - */ - router.delete('/roles/:id', requireAuth, iamController.deleteRole); + router.put( + '/roles/:id', + requirePermission('manage', 'all', 'Super Admin role is required to manage 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 76b7491..ad4071c 100644 --- a/packages/backend/src/api/routes/ingestion.routes.ts +++ b/packages/backend/src/api/routes/ingestion.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { IngestionController } from '../controllers/ingestion.controller'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createIngestionRouter = ( @@ -12,21 +13,29 @@ export const createIngestionRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); - router.post('/', ingestionController.create); + router.post('/', requirePermission('create', 'ingestion'), ingestionController.create); - router.get('/', ingestionController.findAll); + router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll); - router.get('/:id', ingestionController.findById); + router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById); - router.put('/:id', ingestionController.update); + router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update); - router.delete('/:id', ingestionController.delete); + router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete); - router.post('/:id/import', ingestionController.triggerInitialImport); + router.post( + '/:id/import', + requirePermission('create', 'ingestion'), + ingestionController.triggerInitialImport + ); - router.post('/:id/pause', ingestionController.pause); + router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause); - router.post('/:id/sync', ingestionController.triggerForceSync); + router.post( + '/:id/sync', + requirePermission('sync', 'ingestion'), + ingestionController.triggerForceSync + ); return router; }; diff --git a/packages/backend/src/api/routes/search.routes.ts b/packages/backend/src/api/routes/search.routes.ts index 9495548..78ee9a3 100644 --- a/packages/backend/src/api/routes/search.routes.ts +++ b/packages/backend/src/api/routes/search.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { SearchController } from '../controllers/search.controller'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createSearchRouter = ( @@ -11,7 +12,7 @@ export const createSearchRouter = ( router.use(requireAuth(authService)); - router.get('/', 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 7996743..e2b88f6 100644 --- a/packages/backend/src/api/routes/storage.routes.ts +++ b/packages/backend/src/api/routes/storage.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { StorageController } from '../controllers/storage.controller'; import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; import { AuthService } from '../../services/AuthService'; export const createStorageRouter = ( @@ -12,7 +13,7 @@ export const createStorageRouter = ( // Secure all routes in this module router.use(requireAuth(authService)); - router.get('/download', 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 new file mode 100644 index 0000000..9cdb57a --- /dev/null +++ b/packages/backend/src/api/routes/user.routes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import * as userController from '../controllers/user.controller'; +import { requireAuth } from '../middleware/requireAuth'; +import { requirePermission } from '../middleware/requirePermission'; +import { AuthService } from '../../services/AuthService'; + +export const createUserRouter = (authService: AuthService): Router => { + const router = Router(); + + router.use(requireAuth(authService)); + + router.get('/', requirePermission('read', 'users'), userController.getUsers); + + router.get('/:id', requirePermission('read', 'users'), userController.getUser); + + /** + * Only super admin has the ability to modify existing users or create new users. + */ + router.post( + '/', + requirePermission('manage', 'all', 'Super Admin role is required to manage users.'), + userController.createUser + ); + + router.put( + '/:id', + requirePermission('manage', 'all', 'Super Admin role is required to manage users.'), + userController.updateUser + ); + + router.delete( + '/:id', + requirePermission('manage', 'all', 'Super Admin role is required to manage users.'), + userController.deleteUser + ); + + return router; +}; diff --git a/packages/backend/src/database/migrations/0015_wakeful_norman_osborn.sql b/packages/backend/src/database/migrations/0015_wakeful_norman_osborn.sql new file mode 100644 index 0000000..26e134d --- /dev/null +++ b/packages/backend/src/database/migrations/0015_wakeful_norman_osborn.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ingestion_sources" ADD COLUMN "user_id" uuid;--> statement-breakpoint +ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/src/database/migrations/0016_lonely_mariko_yashida.sql b/packages/backend/src/database/migrations/0016_lonely_mariko_yashida.sql new file mode 100644 index 0000000..71bb594 --- /dev/null +++ b/packages/backend/src/database/migrations/0016_lonely_mariko_yashida.sql @@ -0,0 +1,2 @@ +ALTER TABLE "roles" ADD COLUMN "slug" text;--> statement-breakpoint +ALTER TABLE "roles" ADD CONSTRAINT "roles_slug_unique" UNIQUE("slug"); \ No newline at end of file diff --git a/packages/backend/src/database/migrations/meta/0015_snapshot.json b/packages/backend/src/database/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..2cb7fa5 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0015_snapshot.json @@ -0,0 +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": {} + } +} diff --git a/packages/backend/src/database/migrations/meta/0016_snapshot.json b/packages/backend/src/database/migrations/meta/0016_snapshot.json new file mode 100644 index 0000000..b0fe6c7 --- /dev/null +++ b/packages/backend/src/database/migrations/meta/0016_snapshot.json @@ -0,0 +1,1078 @@ +{ + "id": "535faba9-e8ae-4096-899f-ed9ae242394d", + "prevId": "be930de6-8ff2-45e4-affe-7555ca5c5c3d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.archived_emails": { + "name": "archived_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingestion_source_id": { + "name": "ingestion_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_hash_sha256": { + "name": "storage_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "is_indexed": { + "name": "is_indexed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_on_legal_hold": { + "name": "is_on_legal_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "thread_id_idx": { + "name": "thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archived_emails_ingestion_source_id_ingestion_sources_id_fk": { + "name": "archived_emails_ingestion_source_id_ingestion_sources_id_fk", + "tableFrom": "archived_emails", + "tableTo": "ingestion_sources", + "columnsFrom": ["ingestion_source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_hash_sha256": { + "name": "content_hash_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_content_hash_sha256_unique": { + "name": "attachments_content_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["content_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_attachments": { + "name": "email_attachments", + "schema": "", + "columns": { + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_attachments_email_id_archived_emails_id_fk": { + "name": "email_attachments_email_id_archived_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "archived_emails", + "columnsFrom": ["email_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_attachment_id_attachments_id_fk": { + "name": "email_attachments_attachment_id_attachments_id_fk", + "tableFrom": "email_attachments", + "tableTo": "attachments", + "columnsFrom": ["attachment_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_attachments_email_id_attachment_id_pk": { + "name": "email_attachments_email_id_attachment_id_pk", + "columns": ["email_id", "attachment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_identifier": { + "name": "actor_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_tamper_evident": { + "name": "is_tamper_evident", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ediscovery_cases": { + "name": "ediscovery_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ediscovery_cases_name_unique": { + "name": "ediscovery_cases_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.export_jobs": { + "name": "export_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "query": { + "name": "query", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_identifier": { + "name": "created_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "export_jobs_case_id_ediscovery_cases_id_fk": { + "name": "export_jobs_case_id_ediscovery_cases_id_fk", + "tableFrom": "export_jobs", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custodian_id": { + "name": "custodian_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "hold_criteria": { + "name": "hold_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_by_identifier": { + "name": "applied_by_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "legal_holds_case_id_ediscovery_cases_id_fk": { + "name": "legal_holds_case_id_ediscovery_cases_id_fk", + "tableFrom": "legal_holds", + "tableTo": "ediscovery_cases", + "columnsFrom": ["case_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "legal_holds_custodian_id_custodians_id_fk": { + "name": "legal_holds_custodian_id_custodians_id_fk", + "tableFrom": "legal_holds", + "tableTo": "custodians", + "columnsFrom": ["custodian_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retention_period_days": { + "name": "retention_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action_on_expiry": { + "name": "action_on_expiry", + "type": "retention_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "retention_policies_name_unique": { + "name": "retention_policies_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custodians": { + "name": "custodians", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custodians_email_unique": { + "name": "custodians_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingestion_sources": { + "name": "ingestion_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ingestion_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ingestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_auth'" + }, + "last_sync_started_at": { + "name": "last_sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_finished_at": { + "name": "last_sync_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_status_message": { + "name": "last_sync_status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_state": { + "name": "sync_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ingestion_sources_user_id_users_id_fk": { + "name": "ingestion_sources_user_id_users_id_fk", + "tableFrom": "ingestion_sources", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_id_pk": { + "name": "user_roles_user_id_role_id_pk", + "columns": ["user_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "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 b0d891e..7155541 100644 --- a/packages/backend/src/database/migrations/meta/_journal.json +++ b/packages/backend/src/database/migrations/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1754831765718, "tag": "0014_foamy_vapor", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1755443936046, + "tag": "0015_wakeful_norman_osborn", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1755780572342, + "tag": "0016_lonely_mariko_yashida", + "breakpoints": true } ] } diff --git a/packages/backend/src/database/schema/ingestion-sources.ts b/packages/backend/src/database/schema/ingestion-sources.ts index ee326e1..15f9bc3 100644 --- a/packages/backend/src/database/schema/ingestion-sources.ts +++ b/packages/backend/src/database/schema/ingestion-sources.ts @@ -1,4 +1,6 @@ import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { users } from './users'; +import { relations } from 'drizzle-orm'; export const ingestionProviderEnum = pgEnum('ingestion_provider', [ 'google_workspace', @@ -21,6 +23,7 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [ export const ingestionSources = pgTable('ingestion_sources', { id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), name: text('name').notNull(), provider: ingestionProviderEnum('provider').notNull(), credentials: text('credentials'), @@ -32,3 +35,10 @@ export const ingestionSources = pgTable('ingestion_sources', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); + +export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({ + user: one(users, { + fields: [ingestionSources.userId], + references: [users.id], + }), +})); diff --git a/packages/backend/src/database/schema/users.ts b/packages/backend/src/database/schema/users.ts index 1a5e6b2..5532d4e 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,9 +40,10 @@ export const roles = pgTable('roles', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull().unique(), policies: jsonb('policies') - .$type() + .$type() .notNull() .default(sql`'[]'::jsonb`), + slug: text('slug').unique(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_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..9ea0254 --- /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', + // TBD: 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..4e88845 --- /dev/null +++ b/packages/backend/src/helpers/mongoToMeli.ts @@ -0,0 +1,100 @@ +import { db } from '../database'; +import { ingestionSources } from '../database/schema'; +import { eq } from 'drizzle-orm'; +const snakeToCamelCase = (str: string): string => { + return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase()); +}; + +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 snakeToCamelCase(key); +} + +function quoteIfString(value: any): any { + if (typeof value === 'string') { + return `"${value}"`; + } + return value; +} + +export async function mongoToMeli(query: Record): Promise { + const conditions: string[] = []; + for (const key of Object.keys(query)) { + const value = query[key]; + + if (key === '$or') { + const orConditions = await Promise.all(value.map(mongoToMeli)); + conditions.push(`(${orConditions.join(' OR ')})`); + continue; + } + + if (key === '$and') { + const andConditions = await Promise.all(value.map(mongoToMeli)); + conditions.push(`(${andConditions.join(' AND ')})`); + continue; + } + + if (key === '$not') { + conditions.push(`NOT (${await 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} = ${quoteIfString(operand)}`); + break; + case '$ne': + conditions.push(`${column} != ${quoteIfString(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.map(quoteIfString).join(', ')}]`); + break; + case '$nin': + conditions.push(`${column} NOT IN [${operand.map(quoteIfString).join(', ')}]`); + break; + case '$exists': + conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`); + break; + default: + // Unsupported operator + } + } else { + if (column === 'ingestionSource.userId') { + // for the userId placeholder. (Await for a more elegant solution) + const ingestionsIds = await db + .select({ id: ingestionSources.id }) + .from(ingestionSources) + .where(eq(ingestionSources.userId, value)); + conditions.push( + `ingestionSourceId IN [${ingestionsIds.map((i) => quoteIfString(i.id)).join(', ')}]` + ); + } else { + conditions.push(`${column} = ${quoteIfString(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..93a6835 --- /dev/null +++ b/packages/backend/src/iam-policy/ability.ts @@ -0,0 +1,114 @@ +// 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)); + + // Create a set of all actions that are already explicitly defined for the 'archive' subject. + const existingArchiveActions = new Set(); + policies.forEach((p) => { + if (p.subject === 'archive') { + const actions = Array.isArray(p.action) ? p.action : [p.action]; + actions.forEach((a) => existingArchiveActions.add(a)); + } + // Only expand `can` rules for the 'ingestion' subject. + if (p.subject === 'ingestion' && !p.inverted) { + const policyActions = Array.isArray(p.action) ? p.action : [p.action]; + + // Check if any action in the current ingestion policy already has an explicit archive policy. + const hasExplicitArchiveRule = policyActions.some( + (a) => existingArchiveActions.has(a) || existingArchiveActions.has('manage') + ); + + // If a more specific rule for 'archive' already exists, do not expand this ingestion rule, + // as it would create a conflicting, overly permissive rule. + if (hasExplicitArchiveRule) { + return; + } + + const archivePolicy: CaslPolicy = { + ...JSON.parse(JSON.stringify(p)), + subject: 'archive', + }; + if (p.conditions) { + archivePolicy.conditions = translateIngestionConditionsToArchive(p.conditions); + } + expandedPolicies.push(archivePolicy); + } + }); + + policies.forEach((policy) => {}); + + 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 e99bf7f..0000000 --- a/packages/backend/src/iam-policy/iam-definitions.ts +++ /dev/null @@ -1,116 +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. - */ - -// =================================================================================== -// SERVICE: archive -// =================================================================================== - -const ARCHIVE_ACTIONS = { - READ: 'archive:read', - SEARCH: 'archive:search', - EXPORT: 'archive:export', -} as const; - -const ARCHIVE_RESOURCES = { - ALL: 'archive/all', - INGESTION_SOURCE: 'archive/ingestion-source/*', - MAILBOX: 'archive/mailbox/*', - CUSTODIAN: 'archive/custodian/*', -} as const; - -// =================================================================================== -// SERVICE: ingestion -// =================================================================================== - -const INGESTION_ACTIONS = { - CREATE_SOURCE: 'ingestion:createSource', - READ_SOURCE: 'ingestion:readSource', - UPDATE_SOURCE: 'ingestion:updateSource', - DELETE_SOURCE: 'ingestion:deleteSource', - MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs -} as const; - -const INGESTION_RESOURCES = { - ALL: 'ingestion-source/*', - SOURCE: 'ingestion-source/{sourceId}', -} as const; - -// =================================================================================== -// SERVICE: system -// =================================================================================== - -const SYSTEM_ACTIONS = { - READ_SETTINGS: 'system:readSettings', - UPDATE_SETTINGS: 'system:updateSettings', - READ_USERS: 'system:readUsers', - CREATE_USER: 'system:createUser', - UPDATE_USER: 'system:updateUser', - DELETE_USER: 'system:deleteUser', - ASSIGN_ROLE: 'system:assignRole', -} as const; - -const SYSTEM_RESOURCES = { - SETTINGS: 'system/settings', - USERS: 'system/users', - USER: 'system/user/{userId}', -} as const; - -// =================================================================================== -// SERVICE: dashboard -// =================================================================================== - -const DASHBOARD_ACTIONS = { - READ: 'dashboard:read', -} as const; - -const DASHBOARD_RESOURCES = { - ALL: 'dashboard/*', -} as const; - -// =================================================================================== -// EXPORTED DEFINITIONS -// =================================================================================== - -/** - * A comprehensive set of all valid IAM actions in the system. - * This is used by the policy validator to ensure that any action in a policy is recognized. - */ -export const ValidActions: Set = new Set([ - ...Object.values(ARCHIVE_ACTIONS), - ...Object.values(INGESTION_ACTIONS), - ...Object.values(SYSTEM_ACTIONS), - ...Object.values(DASHBOARD_ACTIONS), -]); - -/** - * An object containing regular expressions for validating resource formats. - * The validator uses these patterns to ensure that resource strings in a policy - * conform to the expected structure. - * - * Logic: - * - The key represents the service (e.g., 'archive'). - * - The value is a RegExp that matches all valid resource formats for that service. - * - This allows for flexible validation. For example, `archive/*` is a valid pattern, - * as is `archive/email/123-abc`. - */ -export const ValidResourcePatterns = { - archive: /^archive\/(all|ingestion-source\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/, - ingestion: /^ingestion-source\/(\*|[^\/]+)$/, - 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 3490ebe..b7335a9 100644 --- a/packages/backend/src/iam-policy/policy-validator.ts +++ b/packages/backend/src/iam-policy/policy-validator.ts @@ -1,106 +1,99 @@ -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', + '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 } { - const service = resource.split('/')[0]; - if (service === '*') { + private static isSubjectValid(subject: AppSubjects): { valid: boolean; reason: string } { + if (validSubjects.has(subject)) { return { valid: true, reason: 'valid' }; } - if (service in ValidResourcePatterns) { - const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns]; - if (pattern.test(resource)) { - return { valid: true, reason: 'valid' }; - } - return { - valid: false, - reason: `Resource '${resource}' does not match the expected format for the '${service}' service.`, - }; - } - return { valid: false, reason: `Invalid service '${service}' in resource '${resource}'.` }; + + 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 new file mode 100644 index 0000000..5f0a886 --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/admin.json @@ -0,0 +1,6 @@ +[ + { + "action": "manage", + "subject": "all" + } +] diff --git a/packages/backend/src/iam-policy/test-policies/auditor-specific-mailbox.json b/packages/backend/src/iam-policy/test-policies/auditor-specific-mailbox.json new file mode 100644 index 0000000..d0acbbc --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/auditor-specific-mailbox.json @@ -0,0 +1,17 @@ +[ + { + "action": ["read", "search"], + "subject": "ingestion", + "conditions": { + "id": "f16b7ed2-4e54-4283-9556-c633726f9405" + } + }, + { + "inverted": true, + "action": ["read", "search"], + "subject": "archive", + "conditions": { + "userEmail": "dev@openarchiver.com" + } + } +] 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 new file mode 100644 index 0000000..f4ac51d --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/auditor-specific-sources.json @@ -0,0 +1,14 @@ +[ + { + "action": ["read", "search"], + "subject": "ingestion", + "conditions": { + "id": { + "$in": [ + "aeafbe44-d41c-4015-ac27-504f6e0c511a", + "f16b7ed2-4e54-4283-9556-c633726f9405" + ] + } + } + } +] 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 new file mode 100644 index 0000000..64f9ae2 --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/ingestion-admin.json @@ -0,0 +1,6 @@ +[ + { + "action": "manage", + "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 new file mode 100644 index 0000000..309377c --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/read-only-all.json @@ -0,0 +1,6 @@ +[ + { + "action": ["read", "search"], + "subject": ["ingestion", "archive", "dashboard", "users", "roles"] + } +] 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 new file mode 100644 index 0000000..fcf61d3 --- /dev/null +++ b/packages/backend/src/iam-policy/test-policies/user-manager.json @@ -0,0 +1,10 @@ +[ + { + "action": "manage", + "subject": "users" + }, + { + "action": "read", + "subject": "roles" + } +] diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9b87aff..395287b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -15,6 +15,7 @@ import { createStorageRouter } from './api/routes/storage.routes'; import { createSearchRouter } from './api/routes/search.routes'; import { createDashboardRouter } from './api/routes/dashboard.routes'; import { createUploadRouter } from './api/routes/upload.routes'; +import { createUserRouter } from './api/routes/user.routes'; import testRouter from './api/routes/test.routes'; import { AuthService } from './services/AuthService'; import { UserService } from './services/UserService'; @@ -58,8 +59,9 @@ const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, a const storageRouter = createStorageRouter(storageController, authService); const searchRouter = createSearchRouter(searchController, authService); const dashboardRouter = createDashboardRouter(authService); -const iamRouter = createIamRouter(iamController); +const iamRouter = createIamRouter(iamController, authService); const uploadRouter = createUploadRouter(authService); +const userRouter = createUserRouter(authService); // upload route is added before middleware because it doesn't use the json middleware. app.use('/v1/upload', uploadRouter); @@ -74,6 +76,7 @@ app.use('/v1/archived-emails', archivedEmailRouter); app.use('/v1/storage', storageRouter); app.use('/v1/search', searchRouter); app.use('/v1/dashboard', dashboardRouter); +app.use('/v1/users', userRouter); app.use('/v1/test', testRouter); // Example of a protected route diff --git a/packages/backend/src/services/ArchivedEmailService.ts b/packages/backend/src/services/ArchivedEmailService.ts index 1084b71..76abc55 100644 --- a/packages/backend/src/services/ArchivedEmailService.ts +++ b/packages/backend/src/services/ArchivedEmailService.ts @@ -1,6 +1,13 @@ 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, @@ -41,25 +48,41 @@ export class ArchivedEmailService { public static async getArchivedEmails( ingestionSourceId: string, page: number, - limit: number + limit: number, + userId: string ): Promise { const offset = (page - 1) * limit; + const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read'); + const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter); - const [total] = await db + const countQuery = db .select({ count: count(archivedEmails.id), }) .from(archivedEmails) - .where(eq(archivedEmails.ingestionSourceId, ingestionSourceId)); + .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(eq(archivedEmails.ingestionSourceId, ingestionSourceId)) + .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, @@ -73,16 +96,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 fd52fd1..fa995c3 100644 --- a/packages/backend/src/services/AuthService.ts +++ b/packages/backend/src/services/AuthService.ts @@ -63,7 +63,13 @@ export class AuthService { roles: roles, }); - return { accessToken, user: userWithoutPassword }; + return { + accessToken, + user: { + ...userWithoutPassword, + role: null, + }, + }; } public async verifyToken(token: string): Promise { diff --git a/packages/backend/src/services/AuthorizationService.ts b/packages/backend/src/services/AuthorizationService.ts new file mode 100644 index 0000000..3e0c304 --- /dev/null +++ b/packages/backend/src/services/AuthorizationService.ts @@ -0,0 +1,25 @@ +import { IamService } from './IamService'; +import { createAbilityFor, SubjectObject } from '../iam-policy/ability'; +import { subject, Subject } from '@casl/ability'; +import { AppActions, AppSubjects } from '@open-archiver/types'; + +export class AuthorizationService { + private iamService: IamService; + + constructor() { + this.iamService = new IamService(); + } + + 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 new file mode 100644 index 0000000..f827b84 --- /dev/null +++ b/packages/backend/src/services/FilterBuilder.ts @@ -0,0 +1,58 @@ +import { SQL, sql } from 'drizzle-orm'; +import { IamService } from './IamService'; +import { rulesToQuery } from '@casl/ability/extra'; +import { mongoToDrizzle } from '../helpers/mongoToDrizzle'; +import { mongoToMeli } from '../helpers/mongoToMeli'; +import { AppActions, AppSubjects } from '@open-archiver/types'; + +export class FilterBuilder { + public static async create( + userId: string, + resourceType: AppSubjects, + action: AppActions + ): Promise<{ + drizzleFilter: SQL | undefined; + searchFilter: string | undefined; + }> { + const iamService = new IamService(); + const ability = await iamService.getAbilityForUser(userId); + + // If the user has an unconditional `can` rule and no `cannot` rules, + // they have full access and we can skip building a complex query. + const rules = ability.rulesFor(action, resourceType); + + const hasUnconditionalCan = rules.some( + (rule) => rule.inverted === false && !rule.conditions + ); + const cannotConditions = rules + .filter((rule) => rule.inverted === true && rule.conditions) + .map((rule) => rule.conditions as object); + + if (hasUnconditionalCan && cannotConditions.length === 0) { + return { drizzleFilter: undefined, searchFilter: undefined }; // Full access + } + let query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions); + + if (hasUnconditionalCan && cannotConditions.length > 0) { + // If there's a broad `can` rule, the final query should be an AND of all + // the `cannot` conditions, effectively excluding them. + const andConditions = cannotConditions.map((condition) => { + const newCondition: Record = {}; + for (const key in condition) { + newCondition[key] = { $ne: (condition as any)[key] }; + } + return newCondition; + }); + query = { $and: andConditions }; + } + + if (query === null) { + return { drizzleFilter: undefined, searchFilter: undefined }; // Full access + } + + if (Object.keys(query).length === 0) { + return { drizzleFilter: sql`1=0`, searchFilter: 'ingestionSourceId = "-1"' }; // No access + } + return { drizzleFilter: mongoToDrizzle(query), searchFilter: await mongoToMeli(query) }; + } +} diff --git a/packages/backend/src/services/IamService.ts b/packages/backend/src/services/IamService.ts index 1489f03..a6c7052 100644 --- a/packages/backend/src/services/IamService.ts +++ b/packages/backend/src/services/IamService.ts @@ -1,9 +1,24 @@ import { db } from '../database'; -import { roles } 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 { + /** + * Retrieves all roles associated with a given user. + * @param userId The ID of the user. + * @returns A promise that resolves to an array of Role objects. + */ + public async getRolesForUser(userId: string): Promise { + const userRolesResult = await db + .select() + .from(userRoles) + .where(eq(userRoles.userId, userId)) + .leftJoin(roles, eq(userRoles.roleId, roles.id)); + + return userRolesResult.map((r) => r.roles).filter((r): r is Role => r !== null); + } public async getRoles(): Promise { return db.select().from(roles); } @@ -13,12 +28,57 @@ export class IamService { return role; } - public async createRole(name: string, policy: PolicyStatement[]): Promise { - const [role] = await db.insert(roles).values({ name, policies: policy }).returning(); + public async createRole(name: string, policy: CaslPolicy[], slug?: string): Promise { + const [role] = await db + .insert(roles) + .values({ + name: name, + slug: slug || name.toLocaleLowerCase().replaceAll('', '_'), + policies: policy, + }) + .returning(); return role; } public async deleteRole(id: string): Promise { await db.delete(roles).where(eq(roles.id, id)); } + + public async updateRole( + id: string, + { name, policies }: Partial> + ): Promise { + const [role] = await db + .update(roles) + .set({ name, policies }) + .where(eq(roles.id, id)) + .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/IndexingService.ts b/packages/backend/src/services/IndexingService.ts index 6567d72..81de49c 100644 --- a/packages/backend/src/services/IndexingService.ts +++ b/packages/backend/src/services/IndexingService.ts @@ -66,7 +66,7 @@ export class IndexingService { .where(eq(emailAttachments.emailId, emailId)); } - const document = await this.createEmailDocument(email, emailAttachmentsResult); + const document = await this.createEmailDocument(email, emailAttachmentsResult, email.userEmail); await this.searchService.addDocuments('emails', [document], 'id'); } @@ -92,8 +92,10 @@ export class IndexingService { email, attachments, ingestionSourceId, - archivedEmailId + archivedEmailId, + email.userEmail || '' ); + console.log(document) await this.searchService.addDocuments('emails', [document], 'id'); } @@ -104,7 +106,8 @@ export class IndexingService { email: EmailObject, attachments: AttachmentsType, ingestionSourceId: string, - archivedEmailId: string + archivedEmailId: string, + userEmail: string //the owner of the email inbox ): Promise { const extractedAttachments = []; for (const attachment of attachments) { @@ -122,8 +125,10 @@ export class IndexingService { // skip attachment or fail the job } } + console.log('email.userEmail', userEmail) return { id: archivedEmailId, + userEmail: userEmail, from: email.from[0]?.address, to: email.to.map((i: EmailAddress) => i.address) || [], cc: email.cc?.map((i: EmailAddress) => i.address) || [], @@ -141,7 +146,8 @@ export class IndexingService { */ private async createEmailDocument( email: typeof archivedEmails.$inferSelect, - attachments: Attachment[] + attachments: Attachment[], + userEmail: string,//the owner of the email inbox ): Promise { const attachmentContents = await this.extractAttachmentContents(attachments); @@ -155,9 +161,10 @@ export class IndexingService { ''; const recipients = email.recipients as DbRecipients; - + console.log('email.userEmail', email.userEmail) return { id: email.id, + userEmail: userEmail, from: email.senderEmail, to: recipients.to?.map((r) => r.address) || [], cc: recipients.cc?.map((r) => r.address) || [], diff --git a/packages/backend/src/services/IngestionService.ts b/packages/backend/src/services/IngestionService.ts index c423b60..8932e2b 100644 --- a/packages/backend/src/services/IngestionService.ts +++ b/packages/backend/src/services/IngestionService.ts @@ -25,6 +25,7 @@ import { IndexingService } from './IndexingService'; import { SearchService } from './SearchService'; import { DatabaseService } from './DatabaseService'; import { config } from '../config/index'; +import { FilterBuilder } from './FilterBuilder'; export class IngestionService { private static decryptSource( @@ -49,11 +50,15 @@ export class IngestionService { return ['pst_import', 'eml_import']; } - public static async create(dto: CreateIngestionSourceDto): Promise { + public static async create( + dto: CreateIngestionSourceDto, + userId: string + ): Promise { const { providerConfig, ...rest } = dto; const encryptedCredentials = CryptoService.encryptObject(providerConfig); const valuesToInsert = { + userId, ...rest, status: 'pending_auth' as const, credentials: encryptedCredentials, @@ -81,11 +86,15 @@ export class IngestionService { } } - public static async findAll(): Promise { - const sources = await db - .select() - .from(ingestionSources) - .orderBy(desc(ingestionSources.createdAt)); + public static async findAll(userId: string): Promise { + const { drizzleFilter } = await FilterBuilder.create(userId, 'ingestion', 'read'); + let query = db.select().from(ingestionSources).$dynamic(); + + if (drizzleFilter) { + query = query.where(drizzleFilter); + } + + const sources = await query.orderBy(desc(ingestionSources.createdAt)); return sources.flatMap((source) => { const decrypted = this.decryptSource(source); return decrypted ? [decrypted] : []; @@ -398,6 +407,8 @@ export class IngestionService { searchService, storageService ); + //assign userEmail + email.userEmail = userEmail await indexingService.indexByEmail(email, source.id, archivedEmail.id); } catch (error) { logger.error({ diff --git a/packages/backend/src/services/SearchService.ts b/packages/backend/src/services/SearchService.ts index a0a0bf5..b57efc2 100644 --- a/packages/backend/src/services/SearchService.ts +++ b/packages/backend/src/services/SearchService.ts @@ -1,6 +1,7 @@ import { Index, MeiliSearch, SearchParams } from 'meilisearch'; import { config } from '../config'; import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types'; +import { FilterBuilder } from './FilterBuilder'; export class SearchService { private client: MeiliSearch; @@ -47,7 +48,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 +71,20 @@ 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 { searchFilter } = await FilterBuilder.create(userId, 'archive', 'read'); + if (searchFilter) { + // Convert the MongoDB-style filter from CASL to a MeiliSearch filter string. + if (searchParams.filter) { + // If there are existing filters, append the access control filter. + searchParams.filter = `${searchParams.filter} AND ${searchFilter}`; + } else { + // Otherwise, just use the access control filter. + searchParams.filter = searchFilter; + } + } + console.log('searchParams', searchParams); const searchResults = await index.search(query, searchParams); return { @@ -116,8 +131,17 @@ export class SearchService { 'bcc', 'attachments.filename', 'attachments.content', + 'userEmail', + ], + filterableAttributes: [ + 'from', + 'to', + 'cc', + 'bcc', + 'timestamp', + 'ingestionSourceId', + 'userEmail', ], - filterableAttributes: ['from', 'to', 'cc', 'bcc', 'timestamp', 'ingestionSourceId'], sortableAttributes: ['timestamp'], }); } diff --git a/packages/backend/src/services/UserService.ts b/packages/backend/src/services/UserService.ts index 7305478..20d9517 100644 --- a/packages/backend/src/services/UserService.ts +++ b/packages/backend/src/services/UserService.ts @@ -1,9 +1,8 @@ import { db } from '../database'; import * as schema from '../database/schema'; -import { and, eq, asc, sql } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { hash } from 'bcryptjs'; -import type { PolicyStatement, User } from '@open-archiver/types'; -import { PolicyValidator } from '../iam-policy/policy-validator'; +import type { CaslPolicy, User } from '@open-archiver/types'; export class UserService { /** @@ -23,11 +22,91 @@ export class UserService { * @param id The ID of the user to find. * @returns The user object if found, otherwise null. */ - public async findById(id: string): Promise { + public async findById(id: string): Promise { const user = await db.query.users.findFirst({ where: eq(schema.users.id, id), + with: { + userRoles: { + with: { + role: true, + }, + }, + }, }); - return user || null; + if (!user) return null; + + return { + ...user, + role: user.userRoles[0]?.role || null, + }; + } + + public async findAll(): Promise { + const users = await db.query.users.findMany({ + with: { + userRoles: { + with: { + role: true, + }, + }, + }, + }); + + return users.map((u) => ({ + ...u, + role: u.userRoles[0]?.role || null, + })); + } + + public async createUser( + userDetails: Pick & { password?: string }, + roleId: string + ): Promise { + const { email, first_name, last_name, password } = userDetails; + const hashedPassword = password ? await hash(password, 10) : undefined; + + const newUser = await db + .insert(schema.users) + .values({ + email, + first_name, + last_name, + password: hashedPassword, + }) + .returning(); + + await db.insert(schema.userRoles).values({ + userId: newUser[0].id, + roleId: roleId, + }); + + return newUser[0]; + } + + public async updateUser( + id: string, + userDetails: Partial>, + roleId?: string + ): Promise { + const updatedUser = await db + .update(schema.users) + .set(userDetails) + .where(eq(schema.users.id, id)) + .returning(); + + if (roleId) { + await db.delete(schema.userRoles).where(eq(schema.userRoles.userId, id)); + await db.insert(schema.userRoles).values({ + userId: id, + roleId: roleId, + }); + } + + return updatedUser[0] || null; + } + + public async deleteUser(id: string): Promise { + await db.delete(schema.users).where(eq(schema.users.id, id)); } /** @@ -72,11 +151,10 @@ export class UserService { }); if (!superAdminRole) { - const suerAdminPolicies: PolicyStatement[] = [ + const suerAdminPolicies: CaslPolicy[] = [ { - Effect: 'Allow', - Action: ['*'], - Resource: ['*'], + action: 'manage', + subject: 'all', }, ]; superAdminRole = ( @@ -84,6 +162,7 @@ export class UserService { .insert(schema.roles) .values({ name: 'Super Admin', + slug: 'predefined_super_admin', policies: suerAdminPolicies, }) .returning() diff --git a/packages/frontend/src/lib/components/custom/RoleForm.svelte b/packages/frontend/src/lib/components/custom/RoleForm.svelte new file mode 100644 index 0000000..c8a1585 --- /dev/null +++ b/packages/frontend/src/lib/components/custom/RoleForm.svelte @@ -0,0 +1,47 @@ + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="grid gap-4 py-4" +> +
+ + +
+
+ +