Compare commits

...

25 Commits
dev ... wip

Author SHA1 Message Date
Wayne
3b16223caa Merge branch 'main' into wip 2025-08-24 16:09:31 +02:00
Wayne
19c26a8c24 fix storage legend overflow 2025-08-24 16:04:12 +02:00
Wayne
3c6254504b Fix storage chart legend overflow 2025-08-24 16:02:58 +02:00
Wei S.
a2c55f36ee Cla v2 (#68)
* 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

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

* CLA v2

* cla-v2

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-24 15:03:05 +02:00
Wayne
9873ab263a Merge branch 'main' into CLA-v2 2025-08-24 15:02:13 +02:00
Wei S.
9fdba4cd61 Role based access: Adding docs to docs site (#67)
* 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

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

* Adding IAM policy documentation to Docs site

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-24 14:52:08 +02:00
Wayne
c2cfd96b6f cla-v2 2025-08-24 14:07:25 +02:00
Wei S.
108c646596 CLA-v2
CLA-v2: Clarifying LogicLabs OÜ is the entity contributors are signing the agreement with.
2025-08-24 15:05:15 +03:00
Wei S.
61e44c81f7 Role based access (#61)
* 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

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-23 23:19:51 +03:00
Wayne
a5478c416a CLA v2 2025-08-23 20:37:24 +03:00
Wayne
32c016dbfe Resolve conflict 2025-08-22 13:51:33 +03:00
Wayne
317f034c56 Format 2025-08-22 13:47:48 +03:00
Wayne
faadc2fad6 Remove inherent behavior, index userEmail, adding docs for IAM policies 2025-08-22 13:43:00 +03:00
Wei S.
f651aeab0e Role based access (#60)
* 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>
2025-08-22 00:51:56 +03:00
Wayne
3ab76f5c2d Fix: fix old "Super Admin" role in existing db 2025-08-22 00:47:51 +03:00
Wei S.
3fb4290934 Role based access (#59)
* 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>
2025-08-21 23:53:21 +03:00
Wayne
5b5bb019fc Merge branch 'main' into role-based-access 2025-08-21 23:52:26 +03:00
Wei S.
8c33b63bdf 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>
2025-08-21 23:45:06 +03:00
Wayne
db38dde86f Switch to CASL, secure search, resource-level access control 2025-08-21 23:39:02 +03:00
Wayne
d81abc657b RBAC using CASL library 2025-08-20 01:08:51 +03:00
David Girón
2b325f3461 feat: optimize Dockerfile (#47)
* define base image arg

* create base stage with common content

* chmod executable entrypoint file

this avoids re-copying the same file as is being modified in the docker
layer

* cache npm downloaded packages

avoids re-downloading deps if cache content is available
2025-08-19 12:17:32 +03:00
Wayne
720160a3d8 IAP API, create user/roles in frontend 2025-08-19 11:20:30 +03:00
Til Wegener
4d3c164bc0 Fix UI size display and ingestion history graph (#50)
* fix: unify size display, improve graph interpolation & time readability

* fix display human-readable sizes in ingestion chart

* display human-readable sizes in ingestion chart

* fix: format code

* fix keep fallback for item.name
2025-08-19 11:06:31 +03:00
Wayne
2987f159dd Middleware setup 2025-08-18 14:09:02 +03:00
Wei S.
7288286fd9 Format checked, contributing.md update (#49)
Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-17 17:42:49 +03:00
78 changed files with 5008 additions and 602 deletions

27
.github/CLA-v2.md vendored Normal file
View File

@@ -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. Open Archiver is developed and maintained by 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 LogicLabs OÜ. Except for the license granted herein to LogicLabs OÜ and recipients of software distributed by LogicLabs OÜ, 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 LogicLabs OÜ. 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 LogicLabs OÜ for inclusion in, or documentation of, any of the products owned or managed by LogicLabs OÜ (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to LogicLabs OÜ 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, LogicLabs OÜ 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 LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ 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 LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ 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 LogicLabs OÜ, or that your employer has executed a separate Contributor License Agreement with LogicLabs OÜ.
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 LogicLabs OÜ 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 LogicLabs OÜ of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.

View File

@@ -23,8 +23,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with: with:
path-to-signatures: 'signatures/version1/cla.json' path-to-signatures: 'signatures/version2/cla.json'
path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/tree/main/.github/CLA.md' path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/.github/CLA-v2.md'
branch: 'main' branch: 'main'
allowlist: 'wayneshn' allowlist: 'wayneshn'

View File

@@ -1,21 +1,29 @@
# Dockerfile for Open Archiver # Dockerfile for Open Archiver
# 1. Build Stage: Install all dependencies and build the project ARG BASE_IMAGE=node:22-alpine
FROM node:22-alpine AS build
# 0. Base Stage: Define all common dependencies and setup
FROM ${BASE_IMAGE} AS base
WORKDIR /app WORKDIR /app
# Install pnpm # Install pnpm
RUN npm install -g pnpm RUN --mount=type=cache,target=/root/.npm \
npm install -g pnpm
# Copy manifests and lockfile # Copy manifests and lockfile
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/backend/package.json ./packages/backend/ COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/ COPY packages/frontend/package.json ./packages/frontend/
COPY packages/types/package.json ./packages/types/ COPY packages/types/package.json ./packages/types/
# 1. Build Stage: Install all dependencies and build the project
FROM base AS build
COPY packages/frontend/svelte.config.js ./packages/frontend/ COPY packages/frontend/svelte.config.js ./packages/frontend/
# Install all dependencies. Use --shamefully-hoist to create a flat node_modules structure # Install all dependencies. Use --shamefully-hoist to create a flat node_modules structure
RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=false ENV PNPM_HOME="/pnpm"
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --shamefully-hoist --frozen-lockfile --prod=false
# Copy the rest of the source code # Copy the rest of the source code
COPY . . COPY . .
@@ -24,20 +32,8 @@ COPY . .
RUN pnpm build RUN pnpm build
# 2. Production Stage: Install only production dependencies and copy built artifacts # 2. Production Stage: Install only production dependencies and copy built artifacts
FROM node:22-alpine AS production FROM base AS production
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy manifests and lockfile
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/
COPY packages/types/package.json ./packages/types/
# Install production dependencies
# RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=true
# Copy built application from build stage # Copy built application from build stage
COPY --from=build /app/packages/backend/dist ./packages/backend/dist COPY --from=build /app/packages/backend/dist ./packages/backend/dist
@@ -48,7 +44,6 @@ COPY --from=build /app/packages/backend/src/database/migrations ./packages/backe
# Copy the entrypoint script and make it executable # Copy the entrypoint script and make it executable
COPY docker/docker-entrypoint.sh /usr/local/bin/ COPY docker/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 4000 EXPOSE 4000

0
docker/docker-entrypoint.sh Normal file → Executable file
View File

View File

@@ -10,6 +10,7 @@ export default defineConfig({
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f', 'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
}, },
], ],
['link', { rel: 'icon', href: '/logo-sq.svg' }],
], ],
title: 'Open Archiver', title: 'Open Archiver',
description: 'Official documentation for the Open Archiver project.', description: 'Official documentation for the Open Archiver project.',
@@ -73,6 +74,11 @@ export default defineConfig({
items: [ items: [
{ text: 'Overview', link: '/services/' }, { text: 'Overview', link: '/services/' },
{ text: 'Storage Service', link: '/services/storage-service' }, { text: 'Storage Service', link: '/services/storage-service' },
{
text: 'IAM Service', items: [
{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }
]
},
], ],
}, },
], ],

View File

@@ -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. |

View File

@@ -0,0 +1,289 @@
# IAM Policies
This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
## Policy Structure
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
```json
{
"action": "read" OR ["read", "create"],
"subject": "ingestion" OR ["ingestion", "dashboard"],
"conditions": {
"field_name": "value"
},
"inverted": false OR true,
}
```
- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings.
- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings.
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions.
## Actions
The following actions are available for use in IAM policies:
- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`).
- `create`: Allows the user to create a new resource.
- `read`: Allows the user to view a resource.
- `update`: Allows the user to modify an existing resource.
- `delete`: Allows the user to delete a resource.
- `search`: Allows the user to search for resources.
- `sync`: Allows the user to synchronize a resource.
## Subjects
The following subjects are available for use in IAM policies:
- `all`: A wildcard subject that represents all resources.
- `archive`: Represents archived emails.
- `ingestion`: Represents ingestion sources.
- `settings`: Represents system settings.
- `users`: Represents user accounts.
- `roles`: Represents user roles.
- `dashboard`: Represents the dashboard.
## Advanced Conditions with MongoDB-Style Queries
Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field.
All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions.
The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine.
### Supported Operators and Examples
Here is a detailed breakdown of the supported operators with examples.
#### `$eq` (Equal)
This is the default operator. If you provide a simple key-value pair, it is treated as an equality check.
```json
// This rule...
{ "status": "active" }
// ...is equivalent to this:
{ "status": { "$eq": "active" } }
```
**Use Case**: Grant access to an ingestion source only if its status is `active`.
#### `$ne` (Not Equal)
Matches documents where the field value is not equal to the specified value.
```json
{ "provider": { "$ne": "pst_import" } }
```
**Use Case**: Allow a user to see all ingestion sources except for PST imports.
#### `$in` (In Array)
Matches documents where the field value is one of the values in the specified array.
```json
{
"id": {
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
}
}
```
**Use Case**: Grant an auditor access to a specific list of ingestion sources.
#### `$nin` (Not In Array)
Matches documents where the field value is not one of the values in the specified array.
```json
{ "provider": { "$nin": ["pst_import", "eml_import"] } }
```
**Use Case**: Hide all manual import sources from a specific user role.
#### `$lt` / `$lte` (Less Than / Less Than or Equal)
Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons.
```json
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
```
#### `$gt` / `$gte` (Greater Than / Greater Than or Equal)
Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value.
```json
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
```
#### `$exists`
Matches documents that have (or do not have) the specified field.
```json
// Grant access only if a 'lastSyncStatusMessage' exists
{ "lastSyncStatusMessage": { "$exists": true } }
```
## Inverted Rules: Creating Exceptions with `cannot`
By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions.
A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction.
**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source.
This is achieved with two rules:
1. A "can" rule that grants `read` access to the `ingestion` subject.
2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`.
```json
[
{
"action": "read",
"subject": "ingestion"
},
{
"inverted": true,
"action": "read",
"subject": "ingestion",
"conditions": {
"id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE"
}
}
]
```
## Policy Evaluation Logic
The system evaluates policies by combining all relevant rules for a user. The logic is simple:
- A user has permission if at least one `can` rule allows it.
- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence.
### Dynamic Policies with Placeholders
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
## Special Permissions for User and Role Management
It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins.
A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions.
## Policy Examples
Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios.
### Administrator
This policy grants a user full access to all resources using wildcards.
```json
[
{
"action": "manage",
"subject": "all"
}
]
```
### End-User
This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own.
```json
[
{
"action": "read",
"subject": "dashboard"
},
{
"action": "create",
"subject": "ingestion"
},
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"userId": "${user.id}"
}
},
{
"action": "manage",
"subject": "archive",
"conditions": {
"ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user
}
}
]
```
### Global Read-Only Auditor
This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it.
```json
[
{
"action": ["read", "search"],
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
}
]
```
### Ingestion Admin
This policy grants full control over all ingestion sources and archives, but no other resources.
```json
[
{
"action": "manage",
"subject": "ingestion"
}
]
```
### Auditor for Specific Ingestion Sources
This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator.
```json
[
{
"action": ["read", "search"],
"subject": "ingestion",
"conditions": {
"id": {
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
}
}
}
]
```
### Limit Access to a Specific Mailbox
This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source.
This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match.
```json
[
{
"action": ["read", "search"],
"subject": "archive",
"conditions": {
"userEmail": "user1@example.com"
}
}
]
```

View File

@@ -0,0 +1,289 @@
# IAM Policy
This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
## Policy Structure
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
```json
{
"action": "read" OR ["read", "create"],
"subject": "ingestion" OR ["ingestion", "dashboard"],
"conditions": {
"field_name": "value"
},
"inverted": false OR true,
}
```
- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings.
- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings.
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions.
## Actions
The following actions are available for use in IAM policies:
- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`).
- `create`: Allows the user to create a new resource.
- `read`: Allows the user to view a resource.
- `update`: Allows the user to modify an existing resource.
- `delete`: Allows the user to delete a resource.
- `search`: Allows the user to search for resources.
- `sync`: Allows the user to synchronize a resource.
## Subjects
The following subjects are available for use in IAM policies:
- `all`: A wildcard subject that represents all resources.
- `archive`: Represents archived emails.
- `ingestion`: Represents ingestion sources.
- `settings`: Represents system settings.
- `users`: Represents user accounts.
- `roles`: Represents user roles.
- `dashboard`: Represents the dashboard.
## Advanced Conditions with MongoDB-Style Queries
Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field.
All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions.
The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine.
### Supported Operators and Examples
Here is a detailed breakdown of the supported operators with examples.
#### `$eq` (Equal)
This is the default operator. If you provide a simple key-value pair, it is treated as an equality check.
```json
// This rule...
{ "status": "active" }
// ...is equivalent to this:
{ "status": { "$eq": "active" } }
```
**Use Case**: Grant access to an ingestion source only if its status is `active`.
#### `$ne` (Not Equal)
Matches documents where the field value is not equal to the specified value.
```json
{ "provider": { "$ne": "pst_import" } }
```
**Use Case**: Allow a user to see all ingestion sources except for PST imports.
#### `$in` (In Array)
Matches documents where the field value is one of the values in the specified array.
```json
{
"id": {
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
}
}
```
**Use Case**: Grant an auditor access to a specific list of ingestion sources.
#### `$nin` (Not In Array)
Matches documents where the field value is not one of the values in the specified array.
```json
{ "provider": { "$nin": ["pst_import", "eml_import"] } }
```
**Use Case**: Hide all manual import sources from a specific user role.
#### `$lt` / `$lte` (Less Than / Less Than or Equal)
Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons.
```json
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
```
#### `$gt` / `$gte` (Greater Than / Greater Than or Equal)
Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value.
```json
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
```
#### `$exists`
Matches documents that have (or do not have) the specified field.
```json
// Grant access only if a 'lastSyncStatusMessage' exists
{ "lastSyncStatusMessage": { "$exists": true } }
```
## Inverted Rules: Creating Exceptions with `cannot`
By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions.
A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction.
**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source.
This is achieved with two rules:
1. A "can" rule that grants `read` access to the `ingestion` subject.
2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`.
```json
[
{
"action": "read",
"subject": "ingestion"
},
{
"inverted": true,
"action": "read",
"subject": "ingestion",
"conditions": {
"id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE"
}
}
]
```
## Policy Evaluation Logic
The system evaluates policies by combining all relevant rules for a user. The logic is simple:
- A user has permission if at least one `can` rule allows it.
- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence.
### Dynamic Policies with Placeholders
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
## Special Permissions for User and Role Management
It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins.
A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions.
## Policy Examples
Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios.
### Administrator
This policy grants a user full access to all resources using wildcards.
```json
[
{
"action": "manage",
"subject": "all"
}
]
```
### End-User
This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own.
```json
[
{
"action": "read",
"subject": "dashboard"
},
{
"action": "create",
"subject": "ingestion"
},
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"userId": "${user.id}"
}
},
{
"action": "manage",
"subject": "archive",
"conditions": {
"ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user
}
}
]
```
### Global Read-Only Auditor
This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it.
```json
[
{
"action": ["read", "search"],
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
}
]
```
### Ingestion Admin
This policy grants full control over all ingestion sources and archives, but no other resources.
```json
[
{
"action": "manage",
"subject": "ingestion"
}
]
```
### Auditor for Specific Ingestion Sources
This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator.
```json
[
{
"action": ["read", "search"],
"subject": "ingestion",
"conditions": {
"id": {
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
}
}
}
]
```
### Limit Access to a Specific Mailbox
This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source.
This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match.
```json
[
{
"action": ["read", "search"],
"subject": "archive",
"conditions": {
"userEmail": "user1@example.com"
}
}
]
```

View File

@@ -22,6 +22,7 @@
"@aws-sdk/client-s3": "^3.844.0", "@aws-sdk/client-s3": "^3.844.0",
"@aws-sdk/lib-storage": "^3.844.0", "@aws-sdk/lib-storage": "^3.844.0",
"@azure/msal-node": "^3.6.3", "@azure/msal-node": "^3.6.3",
"@casl/ability": "^6.7.3",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"@open-archiver/types": "workspace:*", "@open-archiver/types": "workspace:*",
"archiver": "^7.0.1", "archiver": "^7.0.1",

View File

@@ -8,11 +8,17 @@ export class ArchivedEmailController {
const { ingestionSourceId } = req.params; const { ingestionSourceId } = req.params;
const page = parseInt(req.query.page as string, 10) || 1; const page = parseInt(req.query.page as string, 10) || 1;
const limit = parseInt(req.query.limit as string, 10) || 10; 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( const result = await ArchivedEmailService.getArchivedEmails(
ingestionSourceId, ingestionSourceId,
page, page,
limit limit,
userId
); );
return res.status(200).json(result); return res.status(200).json(result);
} catch (error) { } catch (error) {
@@ -24,7 +30,13 @@ export class ArchivedEmailController {
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => { public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
try { try {
const { id } = req.params; 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) { if (!email) {
return res.status(404).json({ message: 'Archived email not found' }); return res.status(404).json({ message: 'Archived email not found' });
} }

View File

@@ -1,10 +1,13 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
import { UserService } from '../../services/UserService'; import { UserService } from '../../services/UserService';
import { IamService } from '../../services/IamService';
import { db } from '../../database'; import { db } from '../../database';
import * as schema from '../../database/schema'; import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import 'dotenv/config'; import 'dotenv/config';
import { AuthorizationService } from '../../services/AuthorizationService';
import { CaslPolicy } from '@open-archiver/types';
export class AuthController { export class AuthController {
#authService: AuthService; #authService: AuthService;
@@ -72,13 +75,41 @@ export class AuthController {
public status = async (req: Request, res: Response): Promise<Response> => { public status = async (req: Request, res: Response): Promise<Response> => {
try { try {
const userCountResult = await db const users = await db.select().from(schema.users);
.select({ count: sql<number>`count(*)` })
.from(schema.users); /**
const userCount = Number(userCountResult[0].count); * Check the situation where the only user has "Super Admin" role, but they don't actually have Super Admin permission because the role was set up in an earlier version, we need to change that "Super Admin" role to the one used in the current version.
const needsSetup = userCount === 0; */
if (users.length === 1) {
const iamService = new IamService();
const userRoles = await iamService.getRolesForUser(users[0].id);
if (userRoles.some((r) => r.name === 'Super Admin')) {
const authorizationService = new AuthorizationService();
const hasAdminPermission = await authorizationService.can(
users[0].id,
'manage',
'all'
);
if (!hasAdminPermission) {
const suerAdminPolicies: CaslPolicy[] = [
{
action: 'manage',
subject: 'all',
},
];
await db
.update(schema.roles)
.set({
policies: suerAdminPolicies,
slug: 'predefined_super_admin',
})
.where(eq(schema.roles.name, 'Super Admin'));
}
}
}
// in case user uses older version with admin user variables, we will create the admin user using those variables. // in case user uses older version with admin user variables, we will create the admin user using those variables.
if (needsSetup && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) { const needsSetupUser = users.length === 0;
if (needsSetupUser && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
await this.#userService.createAdminUser( await this.#userService.createAdminUser(
{ {
email: process.env.ADMIN_EMAIL, email: process.env.ADMIN_EMAIL,
@@ -90,7 +121,7 @@ export class AuthController {
); );
return res.status(200).json({ needsSetup: false }); return res.status(200).json({ needsSetup: false });
} }
return res.status(200).json({ needsSetup }); return res.status(200).json({ needsSetupUser });
} catch (error) { } catch (error) {
console.error('Status check error:', error); console.error('Status check error:', error);
return res.status(500).json({ message: 'An internal server error occurred' }); return res.status(500).json({ message: 'An internal server error occurred' });

View File

@@ -1,7 +1,9 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IamService } from '../../services/IamService'; import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator'; 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';
import { config } from '../../config';
export class IamController { export class IamController {
#iamService: IamService; #iamService: IamService;
@@ -12,10 +14,15 @@ export class IamController {
public getRoles = async (req: Request, res: Response): Promise<void> => { public getRoles = async (req: Request, res: Response): Promise<void> => {
try { 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); res.status(200).json(roles);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to get roles.' }); res.status(500).json({ message: 'Failed to get roles.' });
} }
}; };
@@ -27,45 +34,128 @@ export class IamController {
if (role) { if (role) {
res.status(200).json(role); res.status(200).json(role);
} else { } else {
res.status(404).json({ error: 'Role not found.' }); res.status(404).json({ message: 'Role not found.' });
} }
} catch (error) { } 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<void> => { public createRole = async (req: Request, res: Response) => {
const { name, policy } = req.body; if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { name, policies } = req.body;
if (!name || !policy) { if (!name || !policies) {
res.status(400).json({ error: 'Missing required fields: name and policy.' }); res.status(400).json({ message: 'Missing required fields: name and policy.' });
return; return;
} }
for (const statement of policy) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
if (!valid) {
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
return;
}
}
try { try {
const role = await this.#iamService.createRole(name, policy); 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;
}
}
const role = await this.#iamService.createRole(name, policies);
res.status(201).json(role); res.status(201).json(role);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to create role.' }); console.log(error);
res.status(500).json({ message: 'Failed to create role.' });
} }
}; };
public deleteRole = async (req: Request, res: Response): Promise<void> => { public deleteRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { id } = req.params; const { id } = req.params;
try { try {
await this.#iamService.deleteRole(id); await this.#iamService.deleteRole(id);
res.status(204).send(); res.status(204).send();
} catch (error) { } 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) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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: 'read',
subject: 'dashboard',
},
{
action: 'create',
subject: 'ingestion',
},
{
action: 'manage',
subject: 'ingestion',
conditions: {
userId: '${user.id}',
},
},
{
action: 'manage',
subject: 'archive',
conditions: {
'ingestionSource.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');
} }
}; };
} }

View File

@@ -27,7 +27,11 @@ export class IngestionController {
} }
try { try {
const dto: CreateIngestionSourceDto = req.body; 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); const safeSource = this.toSafeIngestionSource(newSource);
return res.status(201).json(safeSource); return res.status(201).json(safeSource);
} catch (error: any) { } catch (error: any) {
@@ -42,7 +46,11 @@ export class IngestionController {
public findAll = async (req: Request, res: Response): Promise<Response> => { public findAll = async (req: Request, res: Response): Promise<Response> => {
try { 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); const safeSources = sources.map(this.toSafeIngestionSource);
return res.status(200).json(safeSources); return res.status(200).json(safeSources);
} catch (error) { } catch (error) {

View File

@@ -12,18 +12,27 @@ export class SearchController {
public search = async (req: Request, res: Response): Promise<void> => { public search = async (req: Request, res: Response): Promise<void> => {
try { try {
const { keywords, page, limit, matchingStrategy } = req.query; const { keywords, page, limit, matchingStrategy } = req.query;
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
if (!keywords) { if (!keywords) {
res.status(400).json({ message: 'Keywords are required' }); res.status(400).json({ message: 'Keywords are required' });
return; return;
} }
const results = await this.searchService.searchEmails({ const results = await this.searchService.searchEmails(
query: keywords as string, {
page: page ? parseInt(page as string) : 1, query: keywords as string,
limit: limit ? parseInt(limit as string) : 10, page: page ? parseInt(page as string) : 1,
matchingStrategy: matchingStrategy as MatchingStrategies, limit: limit ? parseInt(limit as string) : 10,
}); matchingStrategy: matchingStrategy as MatchingStrategies,
},
userId
);
res.status(200).json(results); res.status(200).json(results);
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,66 @@
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';
import { config } from '../../config';
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) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const userCountResult = await db.select({ count: sql<number>`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();
};

View File

@@ -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();
};
};

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { ArchivedEmailController } from '../controllers/archived-email.controller'; import { ArchivedEmailController } from '../controllers/archived-email.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
export const createArchivedEmailRouter = ( export const createArchivedEmailRouter = (
@@ -12,11 +13,23 @@ export const createArchivedEmailRouter = (
// Secure all routes in this module // Secure all routes in this module
router.use(requireAuth(authService)); 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; return router;
}; };

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { dashboardController } from '../controllers/dashboard.controller'; import { dashboardController } from '../controllers/dashboard.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
export const createDashboardRouter = (authService: AuthService): Router => { export const createDashboardRouter = (authService: AuthService): Router => {
@@ -8,11 +9,51 @@ export const createDashboardRouter = (authService: AuthService): Router => {
router.use(requireAuth(authService)); router.use(requireAuth(authService));
router.get('/stats', dashboardController.getStats); router.get(
router.get('/ingestion-history', dashboardController.getIngestionHistory); '/stats',
router.get('/ingestion-sources', dashboardController.getIngestionSources); requirePermission(
router.get('/recent-syncs', dashboardController.getRecentSyncs); 'read',
router.get('/indexed-insights', dashboardController.getIndexedInsights); '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; return router;
}; };

View File

@@ -1,36 +1,42 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import type { IamController } from '../controllers/iam.controller'; 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(); const router = Router();
router.use(requireAuth(authService));
/** /**
* @route GET /api/v1/iam/roles * @route GET /api/v1/iam/roles
* @description Gets all roles. * @description Gets all roles.
* @access Private * @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 * Only super admin has the ability to modify existing roles or create new roles.
* @description Gets a role by ID.
* @access Private
*/ */
router.get('/roles/:id', requireAuth, iamController.getRoleById); router.post(
'/roles',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.createRole
);
/** router.delete(
* @route POST /api/v1/iam/roles '/roles/:id',
* @description Creates a new role. requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
* @access Private iamController.deleteRole
*/ );
router.post('/roles', requireAuth, iamController.createRole);
/** router.put(
* @route DELETE /api/v1/iam/roles/:id '/roles/:id',
* @description Deletes a role. requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
* @access Private iamController.updateRole
*/ );
router.delete('/roles/:id', requireAuth, iamController.deleteRole);
return router; return router;
}; };

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { IngestionController } from '../controllers/ingestion.controller'; import { IngestionController } from '../controllers/ingestion.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
export const createIngestionRouter = ( export const createIngestionRouter = (
@@ -12,21 +13,29 @@ export const createIngestionRouter = (
// Secure all routes in this module // Secure all routes in this module
router.use(requireAuth(authService)); 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; return router;
}; };

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { SearchController } from '../controllers/search.controller'; import { SearchController } from '../controllers/search.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
export const createSearchRouter = ( export const createSearchRouter = (
@@ -11,7 +12,7 @@ export const createSearchRouter = (
router.use(requireAuth(authService)); router.use(requireAuth(authService));
router.get('/', searchController.search); router.get('/', requirePermission('search', 'archive'), searchController.search);
return router; return router;
}; };

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { StorageController } from '../controllers/storage.controller'; import { StorageController } from '../controllers/storage.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
export const createStorageRouter = ( export const createStorageRouter = (
@@ -12,7 +13,7 @@ export const createStorageRouter = (
// Secure all routes in this module // Secure all routes in this module
router.use(requireAuth(authService)); router.use(requireAuth(authService));
router.get('/download', storageController.downloadFile); router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
return router; return router;
}; };

View File

@@ -2,13 +2,14 @@ import { Router } from 'express';
import { uploadFile } from '../controllers/upload.controller'; import { uploadFile } from '../controllers/upload.controller';
import { requireAuth } from '../middleware/requireAuth'; import { requireAuth } from '../middleware/requireAuth';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
import { requirePermission } from '../middleware/requirePermission';
export const createUploadRouter = (authService: AuthService): Router => { export const createUploadRouter = (authService: AuthService): Router => {
const router = Router(); const router = Router();
router.use(requireAuth(authService)); router.use(requireAuth(authService));
router.post('/', uploadFile); router.post('/', requirePermission('create', 'ingestion'), uploadFile);
return router; return router;
}; };

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "roles" ADD COLUMN "slug" text;--> statement-breakpoint
ALTER TABLE "roles" ADD CONSTRAINT "roles_slug_unique" UNIQUE("slug");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,20 @@
"when": 1754831765718, "when": 1754831765718,
"tag": "0014_foamy_vapor", "tag": "0014_foamy_vapor",
"breakpoints": true "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
} }
] ]
} }

View File

@@ -1,4 +1,6 @@
import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; 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', [ export const ingestionProviderEnum = pgEnum('ingestion_provider', [
'google_workspace', 'google_workspace',
@@ -21,6 +23,7 @@ export const ingestionStatusEnum = pgEnum('ingestion_status', [
export const ingestionSources = pgTable('ingestion_sources', { export const ingestionSources = pgTable('ingestion_sources', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
provider: ingestionProviderEnum('provider').notNull(), provider: ingestionProviderEnum('provider').notNull(),
credentials: text('credentials'), credentials: text('credentials'),
@@ -32,3 +35,10 @@ export const ingestionSources = pgTable('ingestion_sources', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_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],
}),
}));

View File

@@ -1,6 +1,6 @@
import { relations, sql } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
import { pgTable, text, timestamp, uuid, primaryKey, jsonb } from 'drizzle-orm/pg-core'; 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. * 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(), id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
policies: jsonb('policies') policies: jsonb('policies')
.$type<PolicyStatement[]>() .$type<CaslPolicy[]>()
.notNull() .notNull()
.default(sql`'[]'::jsonb`), .default(sql`'[]'::jsonb`),
slug: text('slug').unique(),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),
}); });

View File

@@ -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<string, string> = {
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<string, any>): 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));
}

View File

@@ -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<string, any>): Promise<string> {
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 ');
}

View File

@@ -0,0 +1,118 @@
// 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<AppAbility>;
// Represents the possible object types that can be passed as subjects for permission checks.
export type SubjectObject =
| InferSelectModel<typeof ingestionSources>
| InferSelectModel<typeof archivedEmails>
| InferSelectModel<typeof users>
| InferSelectModel<typeof roles>
| AppSubjects;
// Function to create an ability instance from policies stored in the database
export function createAbilityFor(policies: CaslPolicy[]) {
// We will not expand policies, if a role needs access to ingestion X and its archived emails, the policy should also grant access to archives belonging to ingestion X
// const allPolicies = expandPolicies(policies);
return createMongoAbility<AppAbility>(policies as AppRawRule[]);
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* 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<string, any>
): Record<string, any> {
if (!conditions || typeof conditions !== 'object') {
return conditions;
}
const translated: Record<string, any> = {};
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;
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* 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<string>();
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;
}

View File

@@ -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<string> = 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\/\*$/,
};

View File

@@ -1,106 +1,99 @@
import type { PolicyStatement } from '@open-archiver/types'; import type { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
import { ValidActions, ValidResourcePatterns } from './iam-definitions';
// Create sets of valid actions and subjects for efficient validation
const validActions: Set<AppActions> = new Set([
'manage',
'create',
'read',
'update',
'delete',
'search',
'export',
'sync',
]);
const validSubjects: Set<AppSubjects> = new Set([
'archive',
'ingestion',
'settings',
'users',
'roles',
'dashboard',
'all',
]);
/** /**
* @class PolicyValidator * @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 * It is designed to be used before a policy is saved to the database, ensuring that
* only valid and well-formed policies are stored. * 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 { 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 * @returns {{valid: boolean; reason?: string}} - An object containing a boolean `valid` property
* and an optional `reason` string if validation fails. * and an optional `reason` string if validation fails.
*/ */
public static isValid(statement: PolicyStatement): { valid: boolean; reason: string } { public static isValid(policy: CaslPolicy): { valid: boolean; reason: string } {
if (!statement || !statement.Action || !statement.Resource || !statement.Effect) { if (!policy || !policy.action || !policy.subject) {
return { valid: false, reason: 'Policy statement is missing required fields.' }; return {
valid: false,
reason: 'Policy is missing required fields "action" or "subject".',
};
} }
// 1. Validate Actions // 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); const { valid, reason } = this.isActionValid(action);
if (!valid) { if (!valid) {
return { valid: false, reason }; return { valid: false, reason };
} }
} }
// 2. Validate Resources // 2. Validate Subjects
for (const resource of statement.Resource) { const subjects = Array.isArray(policy.subject) ? policy.subject : [policy.subject];
const { valid, reason } = this.isResourceValid(resource); for (const subject of subjects) {
const { valid, reason } = this.isSubjectValid(subject);
if (!valid) { if (!valid) {
return { valid: false, reason }; return { valid: false, reason };
} }
} }
// 3. (Optional) Validate Conditions, Fields, etc. in the future if needed.
return { valid: true, reason: 'valid' }; return { valid: true, reason: 'valid' };
} }
/** /**
* Checks if a single action string is valid. * Checks if a single action string is a valid AppAction.
*
* Logic:
* - If the action contains a wildcard (e.g., 'archive:*'), it checks if the service part
* (e.g., 'archive') is a recognized service.
* - If there is no wildcard, it checks if the full action string (e.g., 'archive:read')
* exists in the `ValidActions` set.
* *
* @param {string} action - The action string to validate. * @param {string} action - The action string to validate.
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure. * @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
*/ */
private static isActionValid(action: string): { valid: boolean; reason: string } { private static isActionValid(action: AppActions): { valid: boolean; reason: string } {
if (action === '*') { if (validActions.has(action)) {
return { valid: true, reason: 'valid' };
}
if (action.endsWith(':*')) {
const service = action.split(':')[0];
if (service in ValidResourcePatterns) {
return { valid: true, reason: 'valid' };
}
return {
valid: false,
reason: `Invalid service '${service}' in action wildcard '${action}'.`,
};
}
if (ValidActions.has(action)) {
return { valid: true, reason: 'valid' }; return { valid: true, reason: 'valid' };
} }
return { valid: false, reason: `Action '${action}' is not a valid action.` }; 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: * @param {string} subject - The subject string to validate.
* - It extracts the service name from the resource string (e.g., 'archive' from 'archive/all').
* - It looks up the corresponding regular expression for that service in `ValidResourcePatterns`.
* - It tests the resource string against the pattern. If the service does not exist or the
* pattern does not match, the resource is considered invalid.
*
* @param {string} resource - The resource string to validate.
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure. * @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
*/ */
private static isResourceValid(resource: string): { valid: boolean; reason: string } { private static isSubjectValid(subject: AppSubjects): { valid: boolean; reason: string } {
const service = resource.split('/')[0]; if (validSubjects.has(subject)) {
if (service === '*') {
return { valid: true, reason: 'valid' }; return { valid: true, reason: 'valid' };
} }
if (service in ValidResourcePatterns) {
const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns]; return { valid: false, reason: `Subject '${subject}' is not a valid subject.` };
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}'.` };
} }
} }

View File

@@ -0,0 +1,6 @@
[
{
"action": "manage",
"subject": "all"
}
]

View File

@@ -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"
}
}
]

View File

@@ -0,0 +1,14 @@
[
{
"action": ["read", "search"],
"subject": "ingestion",
"conditions": {
"id": {
"$in": [
"aeafbe44-d41c-4015-ac27-504f6e0c511a",
"f16b7ed2-4e54-4283-9556-c633726f9405"
]
}
}
}
]

View File

@@ -0,0 +1,17 @@
[
{
"action": "create",
"subject": "ingestion"
},
{
"action": "read",
"subject": "dashboard"
},
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"userId": "${user.id}"
}
}
]

View File

@@ -0,0 +1,6 @@
[
{
"action": "manage",
"subject": "ingestion"
}
]

View File

@@ -0,0 +1,6 @@
[
{
"action": ["read", "search"],
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
}
]

View File

@@ -0,0 +1,9 @@
[
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"id": "f3d7c025-060f-4f1f-a0e6-cdd32e6e07af"
}
}
]

View File

@@ -0,0 +1,10 @@
[
{
"action": "manage",
"subject": "users"
},
{
"action": "read",
"subject": "roles"
}
]

View File

@@ -15,6 +15,7 @@ import { createStorageRouter } from './api/routes/storage.routes';
import { createSearchRouter } from './api/routes/search.routes'; import { createSearchRouter } from './api/routes/search.routes';
import { createDashboardRouter } from './api/routes/dashboard.routes'; import { createDashboardRouter } from './api/routes/dashboard.routes';
import { createUploadRouter } from './api/routes/upload.routes'; import { createUploadRouter } from './api/routes/upload.routes';
import { createUserRouter } from './api/routes/user.routes';
import testRouter from './api/routes/test.routes'; import testRouter from './api/routes/test.routes';
import { AuthService } from './services/AuthService'; import { AuthService } from './services/AuthService';
import { UserService } from './services/UserService'; import { UserService } from './services/UserService';
@@ -58,8 +59,9 @@ const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, a
const storageRouter = createStorageRouter(storageController, authService); const storageRouter = createStorageRouter(storageController, authService);
const searchRouter = createSearchRouter(searchController, authService); const searchRouter = createSearchRouter(searchController, authService);
const dashboardRouter = createDashboardRouter(authService); const dashboardRouter = createDashboardRouter(authService);
const iamRouter = createIamRouter(iamController); const iamRouter = createIamRouter(iamController, authService);
const uploadRouter = createUploadRouter(authService); const uploadRouter = createUploadRouter(authService);
const userRouter = createUserRouter(authService);
// upload route is added before middleware because it doesn't use the json middleware. // upload route is added before middleware because it doesn't use the json middleware.
app.use('/v1/upload', uploadRouter); app.use('/v1/upload', uploadRouter);
@@ -74,6 +76,7 @@ app.use('/v1/archived-emails', archivedEmailRouter);
app.use('/v1/storage', storageRouter); app.use('/v1/storage', storageRouter);
app.use('/v1/search', searchRouter); app.use('/v1/search', searchRouter);
app.use('/v1/dashboard', dashboardRouter); app.use('/v1/dashboard', dashboardRouter);
app.use('/v1/users', userRouter);
app.use('/v1/test', testRouter); app.use('/v1/test', testRouter);
// Example of a protected route // Example of a protected route

View File

@@ -1,6 +1,13 @@
import { count, desc, eq, asc, and } from 'drizzle-orm'; import { count, desc, eq, asc, and } from 'drizzle-orm';
import { db } from '../database'; 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 { import type {
PaginatedArchivedEmails, PaginatedArchivedEmails,
ArchivedEmail, ArchivedEmail,
@@ -41,25 +48,41 @@ export class ArchivedEmailService {
public static async getArchivedEmails( public static async getArchivedEmails(
ingestionSourceId: string, ingestionSourceId: string,
page: number, page: number,
limit: number limit: number,
userId: string
): Promise<PaginatedArchivedEmails> { ): Promise<PaginatedArchivedEmails> {
const offset = (page - 1) * limit; 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({ .select({
count: count(archivedEmails.id), count: count(archivedEmails.id),
}) })
.from(archivedEmails) .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() .select()
.from(archivedEmails) .from(archivedEmails)
.where(eq(archivedEmails.ingestionSourceId, ingestionSourceId)) .leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id))
.orderBy(desc(archivedEmails.sentAt)) .orderBy(desc(archivedEmails.sentAt))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
if (where) {
itemsQuery.where(where);
}
const results = await itemsQuery;
const items = results.map((r) => r.archived_emails);
return { return {
items: items.map((item) => ({ items: items.map((item) => ({
...item, ...item,
@@ -73,16 +96,28 @@ export class ArchivedEmailService {
}; };
} }
public static async getArchivedEmailById(emailId: string): Promise<ArchivedEmail | null> { public static async getArchivedEmailById(
const [email] = await db emailId: string,
.select() userId: string
.from(archivedEmails) ): Promise<ArchivedEmail | null> {
.where(eq(archivedEmails.id, emailId)); const email = await db.query.archivedEmails.findFirst({
where: eq(archivedEmails.id, emailId),
with: {
ingestionSource: true,
},
});
if (!email) { if (!email) {
return null; return null;
} }
const authorizationService = new AuthorizationService();
const canRead = await authorizationService.can(userId, 'read', 'archive', email);
if (!canRead) {
return null;
}
let threadEmails: ThreadEmail[] = []; let threadEmails: ThreadEmail[] = [];
if (email.threadId) { if (email.threadId) {

View File

@@ -63,7 +63,13 @@ export class AuthService {
roles: roles, roles: roles,
}); });
return { accessToken, user: userWithoutPassword }; return {
accessToken,
user: {
...userWithoutPassword,
role: null,
},
};
} }
public async verifyToken(token: string): Promise<AuthTokenPayload | null> { public async verifyToken(token: string): Promise<AuthTokenPayload | null> {

View File

@@ -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<boolean> {
const ability = await this.iamService.getAbilityForUser(userId);
const subjectInstance = resourceObject
? subject(resource, resourceObject as Record<PropertyKey, any>)
: resource;
return ability.can(action, subjectInstance as AppSubjects);
}
}

View File

@@ -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<string, any> = {};
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) };
}
}

View File

@@ -1,9 +1,24 @@
import { db } from '../database'; import { db } from '../database';
import { roles } from '../database/schema/users'; import { roles, userRoles, users } from '../database/schema/users';
import type { Role, PolicyStatement } from '@open-archiver/types'; import type { Role, CaslPolicy, User } from '@open-archiver/types';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { createAbilityFor, AppAbility } from '../iam-policy/ability';
export class IamService { 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<Role[]> {
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<Role[]> { public async getRoles(): Promise<Role[]> {
return db.select().from(roles); return db.select().from(roles);
} }
@@ -13,12 +28,57 @@ export class IamService {
return role; return role;
} }
public async createRole(name: string, policy: PolicyStatement[]): Promise<Role> { public async createRole(name: string, policy: CaslPolicy[], slug?: string): Promise<Role> {
const [role] = await db.insert(roles).values({ name, policies: policy }).returning(); const [role] = await db
.insert(roles)
.values({
name: name,
slug: slug || name.toLocaleLowerCase().replaceAll('', '_'),
policies: policy,
})
.returning();
return role; return role;
} }
public async deleteRole(id: string): Promise<void> { public async deleteRole(id: string): Promise<void> {
await db.delete(roles).where(eq(roles.id, id)); await db.delete(roles).where(eq(roles.id, id));
} }
public async updateRole(
id: string,
{ name, policies }: Partial<Pick<Role, 'name' | 'policies'>>
): Promise<Role> {
const [role] = await db
.update(roles)
.set({ name, policies })
.where(eq(roles.id, id))
.returning();
return role;
}
public async getAbilityForUser(userId: string): Promise<AppAbility> {
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);
}
} }

View File

@@ -66,7 +66,11 @@ export class IndexingService {
.where(eq(emailAttachments.emailId, emailId)); .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'); await this.searchService.addDocuments('emails', [document], 'id');
} }
@@ -92,8 +96,10 @@ export class IndexingService {
email, email,
attachments, attachments,
ingestionSourceId, ingestionSourceId,
archivedEmailId archivedEmailId,
email.userEmail || ''
); );
console.log(document);
await this.searchService.addDocuments('emails', [document], 'id'); await this.searchService.addDocuments('emails', [document], 'id');
} }
@@ -104,7 +110,8 @@ export class IndexingService {
email: EmailObject, email: EmailObject,
attachments: AttachmentsType, attachments: AttachmentsType,
ingestionSourceId: string, ingestionSourceId: string,
archivedEmailId: string archivedEmailId: string,
userEmail: string //the owner of the email inbox
): Promise<EmailDocument> { ): Promise<EmailDocument> {
const extractedAttachments = []; const extractedAttachments = [];
for (const attachment of attachments) { for (const attachment of attachments) {
@@ -122,8 +129,10 @@ export class IndexingService {
// skip attachment or fail the job // skip attachment or fail the job
} }
} }
console.log('email.userEmail', userEmail);
return { return {
id: archivedEmailId, id: archivedEmailId,
userEmail: userEmail,
from: email.from[0]?.address, from: email.from[0]?.address,
to: email.to.map((i: EmailAddress) => i.address) || [], to: email.to.map((i: EmailAddress) => i.address) || [],
cc: email.cc?.map((i: EmailAddress) => i.address) || [], cc: email.cc?.map((i: EmailAddress) => i.address) || [],
@@ -141,7 +150,8 @@ export class IndexingService {
*/ */
private async createEmailDocument( private async createEmailDocument(
email: typeof archivedEmails.$inferSelect, email: typeof archivedEmails.$inferSelect,
attachments: Attachment[] attachments: Attachment[],
userEmail: string //the owner of the email inbox
): Promise<EmailDocument> { ): Promise<EmailDocument> {
const attachmentContents = await this.extractAttachmentContents(attachments); const attachmentContents = await this.extractAttachmentContents(attachments);
@@ -155,9 +165,10 @@ export class IndexingService {
''; '';
const recipients = email.recipients as DbRecipients; const recipients = email.recipients as DbRecipients;
console.log('email.userEmail', email.userEmail);
return { return {
id: email.id, id: email.id,
userEmail: userEmail,
from: email.senderEmail, from: email.senderEmail,
to: recipients.to?.map((r) => r.address) || [], to: recipients.to?.map((r) => r.address) || [],
cc: recipients.cc?.map((r) => r.address) || [], cc: recipients.cc?.map((r) => r.address) || [],

View File

@@ -25,6 +25,7 @@ import { IndexingService } from './IndexingService';
import { SearchService } from './SearchService'; import { SearchService } from './SearchService';
import { DatabaseService } from './DatabaseService'; import { DatabaseService } from './DatabaseService';
import { config } from '../config/index'; import { config } from '../config/index';
import { FilterBuilder } from './FilterBuilder';
export class IngestionService { export class IngestionService {
private static decryptSource( private static decryptSource(
@@ -49,11 +50,15 @@ export class IngestionService {
return ['pst_import', 'eml_import']; return ['pst_import', 'eml_import'];
} }
public static async create(dto: CreateIngestionSourceDto): Promise<IngestionSource> { public static async create(
dto: CreateIngestionSourceDto,
userId: string
): Promise<IngestionSource> {
const { providerConfig, ...rest } = dto; const { providerConfig, ...rest } = dto;
const encryptedCredentials = CryptoService.encryptObject(providerConfig); const encryptedCredentials = CryptoService.encryptObject(providerConfig);
const valuesToInsert = { const valuesToInsert = {
userId,
...rest, ...rest,
status: 'pending_auth' as const, status: 'pending_auth' as const,
credentials: encryptedCredentials, credentials: encryptedCredentials,
@@ -81,11 +86,15 @@ export class IngestionService {
} }
} }
public static async findAll(): Promise<IngestionSource[]> { public static async findAll(userId: string): Promise<IngestionSource[]> {
const sources = await db const { drizzleFilter } = await FilterBuilder.create(userId, 'ingestion', 'read');
.select() let query = db.select().from(ingestionSources).$dynamic();
.from(ingestionSources)
.orderBy(desc(ingestionSources.createdAt)); if (drizzleFilter) {
query = query.where(drizzleFilter);
}
const sources = await query.orderBy(desc(ingestionSources.createdAt));
return sources.flatMap((source) => { return sources.flatMap((source) => {
const decrypted = this.decryptSource(source); const decrypted = this.decryptSource(source);
return decrypted ? [decrypted] : []; return decrypted ? [decrypted] : [];
@@ -398,6 +407,8 @@ export class IngestionService {
searchService, searchService,
storageService storageService
); );
//assign userEmail
email.userEmail = userEmail;
await indexingService.indexByEmail(email, source.id, archivedEmail.id); await indexingService.indexByEmail(email, source.id, archivedEmail.id);
} catch (error) { } catch (error) {
logger.error({ logger.error({

View File

@@ -1,6 +1,7 @@
import { Index, MeiliSearch, SearchParams } from 'meilisearch'; import { Index, MeiliSearch, SearchParams } from 'meilisearch';
import { config } from '../config'; import { config } from '../config';
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types'; import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
import { FilterBuilder } from './FilterBuilder';
export class SearchService { export class SearchService {
private client: MeiliSearch; private client: MeiliSearch;
@@ -47,7 +48,7 @@ export class SearchService {
return index.deleteDocuments({ filter }); return index.deleteDocuments({ filter });
} }
public async searchEmails(dto: SearchQuery): Promise<SearchResult> { public async searchEmails(dto: SearchQuery, userId: string): Promise<SearchResult> {
const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto; const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto;
const index = await this.getIndex<EmailDocument>('emails'); const index = await this.getIndex<EmailDocument>('emails');
@@ -70,6 +71,20 @@ export class SearchService {
searchParams.filter = filterStrings.join(' AND '); 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); const searchResults = await index.search(query, searchParams);
return { return {
@@ -116,8 +131,17 @@ export class SearchService {
'bcc', 'bcc',
'attachments.filename', 'attachments.filename',
'attachments.content', 'attachments.content',
'userEmail',
],
filterableAttributes: [
'from',
'to',
'cc',
'bcc',
'timestamp',
'ingestionSourceId',
'userEmail',
], ],
filterableAttributes: ['from', 'to', 'cc', 'bcc', 'timestamp', 'ingestionSourceId'],
sortableAttributes: ['timestamp'], sortableAttributes: ['timestamp'],
}); });
} }

View File

@@ -1,9 +1,8 @@
import { db } from '../database'; import { db } from '../database';
import * as schema from '../database/schema'; 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 { hash } from 'bcryptjs';
import type { PolicyStatement, User } from '@open-archiver/types'; import type { CaslPolicy, User } from '@open-archiver/types';
import { PolicyValidator } from '../iam-policy/policy-validator';
export class UserService { export class UserService {
/** /**
@@ -23,11 +22,91 @@ export class UserService {
* @param id The ID of the user to find. * @param id The ID of the user to find.
* @returns The user object if found, otherwise null. * @returns The user object if found, otherwise null.
*/ */
public async findById(id: string): Promise<typeof schema.users.$inferSelect | null> { public async findById(id: string): Promise<User | null> {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(schema.users.id, id), 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<User[]> {
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<User, 'email' | 'first_name' | 'last_name'> & { password?: string },
roleId: string
): Promise<typeof schema.users.$inferSelect> {
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<Pick<User, 'email' | 'first_name' | 'last_name'>>,
roleId?: string
): Promise<typeof schema.users.$inferSelect | null> {
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<void> {
await db.delete(schema.users).where(eq(schema.users.id, id));
} }
/** /**
@@ -66,29 +145,7 @@ export class UserService {
}) })
.returning(); .returning();
// find super admin role const superAdminRole = await this.createAdminRole();
let superAdminRole = await db.query.roles.findFirst({
where: eq(schema.roles.name, 'Super Admin'),
});
if (!superAdminRole) {
const suerAdminPolicies: PolicyStatement[] = [
{
Effect: 'Allow',
Action: ['*'],
Resource: ['*'],
},
];
superAdminRole = (
await db
.insert(schema.roles)
.values({
name: 'Super Admin',
policies: suerAdminPolicies,
})
.returning()
)[0];
}
await db.insert(schema.userRoles).values({ await db.insert(schema.userRoles).values({
userId: newUser[0].id, userId: newUser[0].id,
@@ -97,4 +154,31 @@ export class UserService {
return newUser[0]; return newUser[0];
} }
public async createAdminRole() {
// find super admin role
let superAdminRole = await db.query.roles.findFirst({
where: eq(schema.roles.name, 'Super Admin'),
});
if (!superAdminRole) {
const suerAdminPolicies: CaslPolicy[] = [
{
action: 'manage',
subject: 'all',
},
];
superAdminRole = (
await db
.insert(schema.roles)
.values({
name: 'Super Admin',
slug: 'predefined_super_admin',
policies: suerAdminPolicies,
})
.returning()
)[0];
}
return superAdminRole;
}
} }

View File

@@ -193,7 +193,6 @@ export class ImapConnector implements IEmailConnector {
// Initialize with last synced UID, not the maximum UID in mailbox // Initialize with last synced UID, not the maximum UID in mailbox
this.newMaxUids[mailboxPath] = lastUid || 0; this.newMaxUids[mailboxPath] = lastUid || 0;
// Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers. // Only fetch if the mailbox has messages, to avoid errors on empty mailboxes with some IMAP servers.
if (mailbox.exists > 0) { if (mailbox.exists > 0) {
const BATCH_SIZE = 250; // A configurable batch size const BATCH_SIZE = 250; // A configurable batch size

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import type { Role, CaslPolicy } from '@open-archiver/types';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label';
let { role, onSubmit }: { role: Role | null; onSubmit: (formData: Partial<Role>) => void } =
$props();
let name = $state(role?.name || '');
let policies = $state(JSON.stringify(role?.policies || [], null, 2));
const handleSubmit = () => {
try {
const parsedPolicies: CaslPolicy[] = JSON.parse(policies);
onSubmit({ name, policies: parsedPolicies });
} catch (error) {
alert('Invalid JSON format for policies.');
}
};
</script>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="grid gap-4 py-4"
>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="name" class="text-right">Name</Label>
<Input id="name" bind:value={name} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="policies" class="text-right">Policies (JSON)</Label>
<Textarea
id="policies"
bind:value={policies}
class="col-span-3 max-h-96 overflow-y-auto"
rows={10}
/>
</div>
<div class="flex justify-end">
<Button type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import type { User, Role } from '@open-archiver/types';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import * as Dialog from '$lib/components/ui/dialog';
let {
user = null,
roles,
onSubmit,
}: {
user?: User | null;
roles: Role[];
onSubmit: (data: any) => Promise<void>;
} = $props();
let formData = $state({
first_name: user?.first_name ?? '',
last_name: user?.last_name ?? '',
email: user?.email ?? '',
password: '',
roleId: user?.role?.id ?? roles[0]?.id ?? '',
});
const triggerContent = $derived(
roles.find((r) => r.id === formData.roleId)?.name ?? 'Select a role'
);
let isSubmitting = $state(false);
const handleSubmit = async (event: Event) => {
event.preventDefault();
isSubmitting = true;
try {
const dataToSubmit: any = { ...formData };
if (!user) {
// only send password on create
dataToSubmit.password = formData.password;
} else {
delete dataToSubmit.password;
}
if (dataToSubmit.password === '') {
delete dataToSubmit.password;
}
await onSubmit(dataToSubmit);
} finally {
isSubmitting = false;
}
};
</script>
<form onsubmit={handleSubmit} class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="first_name" class="text-left">First Name</Label>
<Input id="first_name" bind:value={formData.first_name} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="last_name" class="text-left">Last Name</Label>
<Input id="last_name" bind:value={formData.last_name} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="email" class="text-left">Email</Label>
<Input id="email" type="email" bind:value={formData.email} class="col-span-3" />
</div>
{#if !user}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="password" class="text-left">Password</Label>
<Input
id="password"
type="password"
bind:value={formData.password}
class="col-span-3"
/>
</div>
{/if}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="role" class="text-left">Role</Label>
<Select.Root name="role" bind:value={formData.roleId} type="single">
<Select.Trigger class="col-span-3">
{triggerContent}
</Select.Trigger>
<Select.Content>
{#each roles as role}
<Select.Item value={role.id}>{role.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Dialog.Footer>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
Submitting...
{:else}
Submit
{/if}
</Button>
</Dialog.Footer>
</form>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Chart from '$lib/components/ui/chart/index.js'; import * as Chart from '$lib/components/ui/chart/index.js';
import { AreaChart } from 'layerchart'; import { AreaChart } from 'layerchart';
import { curveCatmullRom } from 'd3-shape'; import { curveMonotoneX } from 'd3-shape';
import type { ChartConfig } from '$lib/components/ui/chart'; import type { ChartConfig } from '$lib/components/ui/chart';
export let data: { date: Date; count: number }[]; export let data: { date: Date; count: number }[];
@@ -39,16 +39,24 @@
props={{ props={{
xAxis: { xAxis: {
format: (d) => format: (d) =>
new Date(d).toLocaleDateString('en-US', { new Date(d).toLocaleDateString(undefined, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}), }),
}, },
area: { curve: curveCatmullRom }, area: { curve: curveMonotoneX },
}} }}
> >
{#snippet tooltip()} {#snippet tooltip()}
<Chart.Tooltip /> <Chart.Tooltip
labelFormatter={(value) =>
(value instanceof Date ? value : new Date(value)).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
/>
{/snippet} {/snippet}
</AreaChart> </AreaChart>
</Chart.Container> </Chart.Container>

View File

@@ -3,6 +3,7 @@
import { PieChart } from 'layerchart'; import { PieChart } from 'layerchart';
import type { IngestionSourceStats } from '@open-archiver/types'; import type { IngestionSourceStats } from '@open-archiver/types';
import type { ChartConfig } from '$lib/components/ui/chart'; import type { ChartConfig } from '$lib/components/ui/chart';
import { formatBytes } from '$lib/utils';
export let data: IngestionSourceStats[]; export let data: IngestionSourceStats[];
@@ -13,7 +14,10 @@
} satisfies ChartConfig; } satisfies ChartConfig;
</script> </script>
<Chart.Container config={chartConfig} class="h-full min-h-[300px] w-full"> <Chart.Container
config={chartConfig}
class="flex h-full w-full flex-col overflow-y-auto [&_.lc-legend-swatch-group]:overflow-x-auto "
>
<PieChart <PieChart
{data} {data}
key="name" key="name"
@@ -29,7 +33,11 @@
]} ]}
> >
{#snippet tooltip()} {#snippet tooltip()}
<Chart.Tooltip></Chart.Tooltip> <Chart.Tooltip>
{#snippet formatter({ value, item })}
{item.payload.name}: {formatBytes(value as number)}
{/snippet}
</Chart.Tooltip>
{/snippet} {/snippet}
</PieChart> </PieChart>
</Chart.Container> </Chart.Container>

View File

@@ -105,10 +105,10 @@
indicator === "dot" && "items-center" indicator === "dot" && "items-center"
)} )}
> >
{#if formatter && item.value !== undefined && item.name} {#if formatter && item.value !== undefined}
{@render formatter({ {@render formatter({
value: item.value, value: item.value,
name: item.name, name: item.name || '',
item, item,
index: i, index: i,
payload: tooltipCtx.payload, payload: tooltipCtx.payload,

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import * as Alert from '$lib/components/ui/alert/index.js';
</script>
<div class="flex h-full w-full flex-col items-center justify-center space-y-4">
<Alert.Root variant="destructive">
<CircleAlertIcon class="size-4" />
<Alert.Title>
<h1 class=" font-bold">Error: {page.status}</h1>
</Alert.Title>
<Alert.Description>
<div class=" space-y-2">
<div>
{page.error?.message}
</div>
</div>
</Alert.Description>
</Alert.Root>
</div>

View File

@@ -5,11 +5,31 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte'; import ThemeSwitcher from '$lib/components/custom/ThemeSwitcher.svelte';
const navItems = [ const navItems: {
href?: string;
label: string;
subMenu?: {
href: string;
label: string;
}[];
}[] = [
{ href: '/dashboard', label: 'Dashboard' }, { href: '/dashboard', label: 'Dashboard' },
{ href: '/dashboard/ingestions', label: 'Ingestions' }, { href: '/dashboard/ingestions', label: 'Ingestions' },
{ href: '/dashboard/archived-emails', label: 'Archived emails' }, { href: '/dashboard/archived-emails', label: 'Archived emails' },
{ href: '/dashboard/search', label: 'Search' }, { href: '/dashboard/search', label: 'Search' },
{
label: 'Settings',
subMenu: [
{
href: '/dashboard/settings/users',
label: 'Users',
},
{
href: '/dashboard/settings/roles',
label: 'Roles',
},
],
},
]; ];
let { children } = $props(); let { children } = $props();
function handleLogout() { function handleLogout() {
@@ -24,16 +44,43 @@
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" /> <img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-8 w-8" />
<span>Open Archiver</span> <span>Open Archiver</span>
</a> </a>
<NavigationMenu.Root> <NavigationMenu.Root viewport={false}>
<NavigationMenu.List class="flex items-center space-x-4"> <NavigationMenu.List class="flex items-center space-x-4">
{#each navItems as item} {#each navItems as item}
<NavigationMenu.Item {#if item.subMenu && item.subMenu.length > 0}
class={page.url.pathname === item.href ? 'bg-accent rounded-md' : ''} <NavigationMenu.Item
> class={item.subMenu.some((sub) =>
<NavigationMenu.Link href={item.href}> page.url.pathname.startsWith(
{item.label} sub.href.substring(0, sub.href.lastIndexOf('/'))
</NavigationMenu.Link> )
</NavigationMenu.Item> )
? 'bg-accent rounded-md'
: ''}
>
<NavigationMenu.Trigger class="cursor-pointer font-normal">
{item.label}
</NavigationMenu.Trigger>
<NavigationMenu.Content>
<ul class="grid w-fit min-w-28 gap-1 p-1">
{#each item.subMenu as subItem}
<li>
<NavigationMenu.Link href={subItem.href}>
{subItem.label}
</NavigationMenu.Link>
</li>
{/each}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
{:else if item.href}
<NavigationMenu.Item
class={page.url.pathname === item.href ? 'bg-accent rounded-md' : ''}
>
<NavigationMenu.Link href={item.href}>
{item.label}
</NavigationMenu.Link>
</NavigationMenu.Item>
{/if}
{/each} {/each}
</NavigationMenu.List> </NavigationMenu.List>
</NavigationMenu.Root> </NavigationMenu.Root>

View File

@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { import type {
DashboardStats, DashboardStats,
IngestionHistory, IngestionHistory,
@@ -10,58 +11,57 @@ import type {
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const fetchStats = async (): Promise<DashboardStats | null> => { const fetchStats = async (): Promise<DashboardStats | null> => {
try { const response = await api('/dashboard/stats', event);
const response = await api('/dashboard/stats', event); const responseText = await response.json();
if (!response.ok) throw new Error('Failed to fetch stats'); if (!response.ok) {
return await response.json(); throw error(response.status, responseText.message || 'Failed to fetch data');
} catch (error) {
console.error('Dashboard Stats Error:', error);
return null;
} }
return responseText;
}; };
const fetchIngestionHistory = async (): Promise<IngestionHistory | null> => { const fetchIngestionHistory = async (): Promise<IngestionHistory | null> => {
try { const response = await api('/dashboard/ingestion-history', event);
const response = await api('/dashboard/ingestion-history', event); const responseText = await response.json();
if (!response.ok) throw new Error('Failed to fetch ingestion history'); if (!response.ok) {
return await response.json(); return error(
} catch (error) { response.status,
console.error('Ingestion History Error:', error); responseText.message || 'Failed to fetch ingestion history'
return null; );
} }
return responseText;
}; };
const fetchIngestionSources = async (): Promise<IngestionSourceStats[] | null> => { const fetchIngestionSources = async (): Promise<IngestionSourceStats[] | null> => {
try { const response = await api('/dashboard/ingestion-sources', event);
const response = await api('/dashboard/ingestion-sources', event); const responseText = await response.json();
if (!response.ok) throw new Error('Failed to fetch ingestion sources'); if (!response.ok) {
return await response.json(); return error(
} catch (error) { response.status,
console.error('Ingestion Sources Error:', error); responseText.message || 'Failed to fetch ingestion sources'
return null; );
} }
return responseText;
}; };
const fetchRecentSyncs = async (): Promise<RecentSync[] | null> => { const fetchRecentSyncs = async (): Promise<RecentSync[] | null> => {
try { const response = await api('/dashboard/recent-syncs', event);
const response = await api('/dashboard/recent-syncs', event); const responseText = await response.json();
if (!response.ok) throw new Error('Failed to fetch recent syncs'); if (!response.ok) {
return await response.json(); return error(response.status, responseText.message || 'Failed to fetch recent syncs');
} catch (error) {
console.error('Recent Syncs Error:', error);
return null;
} }
return responseText;
}; };
const fetchIndexedInsights = async (): Promise<IndexedInsights | null> => { const fetchIndexedInsights = async (): Promise<IndexedInsights | null> => {
try { const response = await api('/dashboard/indexed-insights', event);
const response = await api('/dashboard/indexed-insights', event); const responseText = await response.json();
if (!response.ok) throw new Error('Failed to fetch indexed insights'); if (!response.ok) {
return await response.json(); return error(
} catch (error) { response.status,
console.error('Indexed Insights Error:', error); responseText.message || 'Failed to fetch indexed insights'
return null; );
} }
return responseText;
}; };
const [stats, ingestionHistory, ingestionSources, recentSyncs, indexedInsights] = const [stats, ingestionHistory, ingestionSources, recentSyncs, indexedInsights] =

View File

@@ -111,7 +111,7 @@
<div class=" lg:col-span-1"> <div class=" lg:col-span-1">
<Card.Root class="h-full"> <Card.Root class="h-full">
<Card.Header> <Card.Header>
<Card.Title>Storage by Ingestion Source (Bytes)</Card.Title> <Card.Title>Storage by Ingestion Source</Card.Title>
</Card.Header> </Card.Header>
<Card.Content class="h-full"> <Card.Content class="h-full">
{#if data.ingestionSources && data.ingestionSources.length > 0} {#if data.ingestionSources && data.ingestionSources.length > 0}

View File

@@ -1,56 +1,56 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import type { IngestionSource, PaginatedArchivedEmails } from '@open-archiver/types'; import type { IngestionSource, PaginatedArchivedEmails } from '@open-archiver/types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
try { const { url } = event;
const { url } = event; const ingestionSourceId = url.searchParams.get('ingestionSourceId');
const ingestionSourceId = url.searchParams.get('ingestionSourceId'); const page = url.searchParams.get('page') || '1';
const page = url.searchParams.get('page') || '1'; const limit = url.searchParams.get('limit') || '10';
const limit = url.searchParams.get('limit') || '10';
const sourcesResponse = await api('/ingestion-sources', event); const sourcesResponse = await api('/ingestion-sources', event);
if (!sourcesResponse.ok) { const sourcesResponseText = await sourcesResponse.json();
throw new Error(`Failed to fetch ingestion sources: ${sourcesResponse.statusText}`); let ingestionSources: IngestionSource[] = sourcesResponseText;
} if (!sourcesResponse.ok) {
const ingestionSources: IngestionSource[] = await sourcesResponse.json(); if (sourcesResponse.status === 403) {
ingestionSources = [];
let archivedEmails: PaginatedArchivedEmails = { } else {
items: [], return error(
total: 0, sourcesResponse.status,
page: 1, sourcesResponseText.message || 'Failed to load ingestion source.'
limit: 10,
};
const selectedIngestionSourceId = ingestionSourceId || ingestionSources[0]?.id;
if (selectedIngestionSourceId) {
const emailsResponse = await api(
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
event
); );
if (!emailsResponse.ok) {
throw new Error(`Failed to fetch archived emails: ${emailsResponse.statusText}`);
}
archivedEmails = await emailsResponse.json();
} }
return {
ingestionSources,
archivedEmails,
selectedIngestionSourceId,
};
} catch (error) {
console.error('Failed to load archived emails page:', error);
return {
ingestionSources: [],
archivedEmails: {
items: [],
total: 0,
page: 1,
limit: 10,
},
error: 'Failed to load data',
};
} }
let archivedEmails: PaginatedArchivedEmails = {
items: [],
total: 0,
page: 1,
limit: 10,
};
// Use the provided ingestionSourceId, or default to the first one if it's not provided.
const selectedIngestionSourceId = ingestionSourceId || ingestionSources[0]?.id;
if (selectedIngestionSourceId) {
const emailsResponse = await api(
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
event
);
const responseText = await emailsResponse.json();
if (!emailsResponse.ok) {
return error(
emailsResponse.status,
responseText.message || 'Failed to load archived emails.'
);
}
archivedEmails = responseText;
}
return {
ingestionSources,
archivedEmails,
selectedIngestionSourceId,
};
}; };

View File

@@ -1,4 +1,5 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import type { ArchivedEmail } from '@open-archiver/types'; import type { ArchivedEmail } from '@open-archiver/types';
@@ -6,10 +7,14 @@ export const load: PageServerLoad = async (event) => {
try { try {
const { id } = event.params; const { id } = event.params;
const response = await api(`/archived-emails/${id}`, event); const response = await api(`/archived-emails/${id}`, event);
const responseText = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch archived email: ${response.statusText}`); return error(
response.status,
responseText.message || 'You do not have permission to read this email.'
);
} }
const email: ArchivedEmail = await response.json(); const email: ArchivedEmail = responseText;
return { return {
email, email,
}; };

View File

@@ -9,6 +9,7 @@
import { formatBytes } from '$lib/utils'; import { formatBytes } from '$lib/utils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let email = $derived(data.email); let email = $derived(data.email);
@@ -51,7 +52,13 @@
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
const message = errorData?.message || 'Failed to delete email'; const message = errorData?.message || 'Failed to delete email';
console.error('Delete failed:', message); console.error('Delete failed:', message);
alert(message); setAlert({
type: 'error',
title: 'Failed to delete archived email',
message: message,
duration: 5000,
show: true,
});
return; return;
} }
await goto('/dashboard/archived-emails', { invalidateAll: true }); await goto('/dashboard/archived-emails', { invalidateAll: true });
@@ -64,6 +71,10 @@
} }
</script> </script>
<svelte:head>
<title>{email?.subject} | Archived emails - OpenArchiver</title>
</svelte:head>
{#if email} {#if email}
<div class="grid grid-cols-3 gap-6"> <div class="grid grid-cols-3 gap-6">
<div class="col-span-3 md:col-span-2"> <div class="col-span-3 md:col-span-2">
@@ -128,7 +139,9 @@
class="flex items-center justify-between rounded-md border p-2" class="flex items-center justify-between rounded-md border p-2"
> >
<span <span
>{attachment.filename} ({attachment.sizeBytes} bytes)</span >{attachment.filename} ({formatBytes(
attachment.sizeBytes
)})</span
> >
<Button <Button
variant="outline" variant="outline"

View File

@@ -1,22 +1,15 @@
import { api } from '$lib/server/api'; import { api } from '$lib/server/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import type { IngestionSource } from '@open-archiver/types'; import type { IngestionSource } from '@open-archiver/types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
try { const response = await api('/ingestion-sources', event);
const response = await api('/ingestion-sources', event); const responseText = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ingestion sources: ${response.statusText}`); throw error(response.status, responseText.message || 'Failed to fetch ingestions.');
}
const ingestionSources: IngestionSource[] = await response.json();
return {
ingestionSources,
};
} catch (error) {
console.error('Failed to load ingestion sources:', error);
return {
ingestionSources: [],
error: 'Failed to load ingestion sources',
};
} }
const ingestionSources: IngestionSource[] = responseText;
return {
ingestionSources,
};
}; };

View File

@@ -103,12 +103,22 @@
throw Error('This operation is not allowed in demo mode.'); throw Error('This operation is not allowed in demo mode.');
} }
if (newStatus === 'paused') { if (newStatus === 'paused') {
await api(`/ingestion-sources/${source.id}/pause`, { method: 'POST' }); const response = await api(`/ingestion-sources/${source.id}/pause`, {
method: 'POST',
});
const responseText = await response.json();
if (!response.ok) {
throw Error(responseText.message || 'Operation failed');
}
} else { } else {
await api(`/ingestion-sources/${source.id}`, { const response = await api(`/ingestion-sources/${source.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ status: 'active' }), body: JSON.stringify({ status: 'active' }),
}); });
const responseText = await response.json();
if (!response.ok) {
throw Error(responseText.message || 'Operation failed');
}
} }
ingestionSources = ingestionSources.map((s) => { ingestionSources = ingestionSources.map((s) => {

View File

@@ -14,6 +14,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import type { MatchingStrategy } from '@open-archiver/types'; import type { MatchingStrategy } from '@open-archiver/types';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import * as Alert from '$lib/components/ui/alert/index.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let searchResult = $derived(data.searchResult); let searchResult = $derived(data.searchResult);
@@ -208,7 +210,11 @@
</form> </form>
{#if error} {#if error}
<p class="text-red-500">{error}</p> <Alert.Root variant="destructive">
<CircleAlertIcon class="size-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Root>
{/if} {/if}
{#if searchResult} {#if searchResult}

View File

@@ -0,0 +1,18 @@
import { api } from '$lib/server/api';
import type { Role } from '@open-archiver/types';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const rolesResponse = await api('/iam/roles', event);
if (!rolesResponse.ok) {
const { message } = await rolesResponse.json();
throw error(rolesResponse.status, message || 'Failed to fetch roles');
}
const roles: Role[] = await rolesResponse.json();
return {
roles,
};
};

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import type { PageData } from './$types';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { MoreHorizontal, Trash, Eye, Edit } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import RoleForm from '$lib/components/custom/RoleForm.svelte';
import { api } from '$lib/api.client';
import type { Role } from '@open-archiver/types';
let { data }: { data: PageData } = $props();
let roles = $state(data.roles);
let isViewPolicyDialogOpen = $state(false);
let isFormDialogOpen = $state(false);
let isDeleteDialogOpen = $state(false);
let selectedRole = $state<Role | null>(null);
let roleToDelete = $state<Role | null>(null);
let isDeleting = $state(false);
const openCreateDialog = () => {
selectedRole = null;
isFormDialogOpen = true;
};
const openEditDialog = (role: Role) => {
selectedRole = role;
isFormDialogOpen = true;
};
const openViewPolicyDialog = (role: Role) => {
selectedRole = role;
isViewPolicyDialogOpen = true;
};
const openDeleteDialog = (role: Role) => {
roleToDelete = role;
isDeleteDialogOpen = true;
};
const confirmDelete = async () => {
if (!roleToDelete) return;
isDeleting = true;
try {
const res = await api(`/iam/roles/${roleToDelete.id}`, { method: 'DELETE' });
if (!res.ok) {
const errorBody = await res.json();
setAlert({
type: 'error',
title: 'Failed to delete role',
message: errorBody.message || JSON.stringify(errorBody),
duration: 5000,
show: true,
});
return;
}
roles = roles.filter((r) => r.id !== roleToDelete!.id);
isDeleteDialogOpen = false;
roleToDelete = null;
} finally {
isDeleting = false;
}
};
const handleFormSubmit = async (formData: Partial<Role>) => {
try {
if (selectedRole) {
// Update
const response = await api(`/iam/roles/${selectedRole.id}`, {
method: 'PUT',
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update role.');
}
const updatedRole: Role = await response.json();
roles = roles.map((r: Role) => (r.id === updatedRole.id ? updatedRole : r));
} else {
// Create
const response = await api('/iam/roles', {
method: 'POST',
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create role.');
}
const newRole: Role = await response.json();
roles = [...roles, newRole];
}
isFormDialogOpen = false;
} catch (error) {
let message = 'An unknown error occurred.';
if (error instanceof Error) {
message = error.message;
}
setAlert({
type: 'error',
title: 'Operation Failed',
message,
duration: 5000,
show: true,
});
}
};
</script>
<svelte:head>
<title>Role Management - OpenArchiver</title>
</svelte:head>
<div class="">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Role Management</h1>
<Button onclick={openCreateDialog}>Create New</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Created At</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if roles.length > 0}
{#each roles as role (role.id)}
<Table.Row>
<Table.Cell>{role.name}</Table.Cell>
<Table.Cell>{new Date(role.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Actions</DropdownMenu.Label>
<DropdownMenu.Item
onclick={() => openViewPolicyDialog(role)}
class="cursor-pointer"
>
<Eye class="mr-2 h-4 w-4" />
View Policy
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => openEditDialog(role)}
class="cursor-pointer"
>
<Edit class="mr-2 h-4 w-4" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive cursor-pointer"
onclick={() => openDeleteDialog(role)}
>
<Trash class="mr-2 h-4 w-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={3} class="h-24 text-center">No roles found.</Table.Cell
>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<Dialog.Root bind:open={isViewPolicyDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Role Policy</Dialog.Title>
<Dialog.Description>
Viewing policy for role: {selectedRole?.name}
</Dialog.Description>
</Dialog.Header>
<div
class=" max-h-98 overflow-x-auto overflow-y-auto rounded-md bg-gray-900 p-2 text-white"
>
<pre>{JSON.stringify(selectedRole?.policies, null, 2)}</pre>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={isFormDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>{selectedRole ? 'Edit' : 'Create'} Role</Dialog.Title>
<Dialog.Description>
{selectedRole ? 'Make changes to the role here.' : 'Add a new role to the system.'}
</Dialog.Description>
</Dialog.Header>
<RoleForm role={selectedRole} onSubmit={handleFormSubmit} />
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={isDeleteDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Are you sure you want to delete this role?</Dialog.Title>
<Dialog.Description>
This action cannot be undone. This will permanently delete the role.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="sm:justify-start">
<Button
type="button"
variant="destructive"
onclick={confirmDelete}
disabled={isDeleting}
>
{#if isDeleting}Deleting...{:else}Confirm{/if}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">Cancel</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,27 @@
import { api } from '$lib/server/api';
import type { User, Role } from '@open-archiver/types';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const [usersResponse, rolesResponse] = await Promise.all([
api('/users', event),
api('/iam/roles', event),
]);
if (!usersResponse.ok) {
const { message } = await usersResponse.json();
throw error(usersResponse.status, message || 'Failed to fetch users');
}
if (!rolesResponse.ok) {
const { message } = await rolesResponse.json();
throw error(rolesResponse.status, message || 'Failed to fetch roles');
}
const users: User[] = await usersResponse.json();
const roles: Role[] = await rolesResponse.json();
return {
users,
roles,
};
};

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import type { PageData } from './$types';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { MoreHorizontal, Trash, Edit } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
import UserForm from '$lib/components/custom/UserForm.svelte';
import { api } from '$lib/api.client';
import type { User } from '@open-archiver/types';
let { data }: { data: PageData } = $props();
let users = $state(data.users);
let roles = $state(data.roles);
let isDialogOpen = $state(false);
let isDeleteDialogOpen = $state(false);
let selectedUser = $state<User | null>(null);
let userToDelete = $state<User | null>(null);
let isDeleting = $state(false);
const openCreateDialog = () => {
selectedUser = null;
isDialogOpen = true;
};
const openEditDialog = (user: User) => {
selectedUser = user;
isDialogOpen = true;
};
const openDeleteDialog = (user: User) => {
userToDelete = user;
isDeleteDialogOpen = true;
};
const confirmDelete = async () => {
if (!userToDelete) return;
isDeleting = true;
try {
const res = await api(`/users/${userToDelete.id}`, { method: 'DELETE' });
if (!res.ok) {
const errorBody = await res.json();
setAlert({
type: 'error',
title: 'Failed to delete user',
message: errorBody.message || JSON.stringify(errorBody),
duration: 5000,
show: true,
});
return;
}
users = users.filter((u) => u.id !== userToDelete!.id);
isDeleteDialogOpen = false;
userToDelete = null;
} finally {
isDeleting = false;
}
};
const handleFormSubmit = async (formData: Partial<User> & { roleId: string }) => {
try {
if (selectedUser) {
// Update
const response = await api(`/users/${selectedUser.id}`, {
method: 'PUT',
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update user.');
}
const updatedUser: User = await response.json();
users = users.map((u: User) => (u.id === updatedUser.id ? updatedUser : u));
} else {
// Create
const response = await api('/users', {
method: 'POST',
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create user.');
}
const newUser: User = await response.json();
users = [...users, newUser];
}
isDialogOpen = false;
} catch (error) {
let message = 'An unknown error occurred.';
if (error instanceof Error) {
message = error.message;
}
setAlert({
type: 'error',
title: 'Operation Failed',
message,
duration: 5000,
show: true,
});
}
};
</script>
<svelte:head>
<title>User Management - OpenArchiver</title>
</svelte:head>
<div class="">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">User Management</h1>
<Button onclick={openCreateDialog}>Create New</Button>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Created At</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.length > 0}
{#each users as user (user.id)}
<Table.Row>
<Table.Cell>{user.first_name} {user.last_name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>{user.role?.name || 'N/A'}</Table.Cell>
<Table.Cell>{new Date(user.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" class="h-8 w-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Actions</DropdownMenu.Label>
<DropdownMenu.Item
onclick={() => openEditDialog(user)}
class="cursor-pointer"
>
<Edit class="mr-2 h-4 w-4" />
Edit</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-destructive cursor-pointer"
onclick={() => openDeleteDialog(user)}
>
<Trash class="mr-2 h-4 w-4" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={5} class="h-24 text-center">No users found.</Table.Cell
>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<Dialog.Root bind:open={isDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>{selectedUser ? 'Edit' : 'Create'} User</Dialog.Title>
<Dialog.Description>
{selectedUser ? 'Make changes to the user here.' : 'Add a new user to the system.'}
</Dialog.Description>
</Dialog.Header>
<UserForm {roles} user={selectedUser} onSubmit={handleFormSubmit} />
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={isDeleteDialogOpen}>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>Are you sure you want to delete this user?</Dialog.Title>
<Dialog.Description>
This action cannot be undone. This will permanently delete the user and remove their
data from our servers.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="sm:justify-start">
<Button
type="button"
variant="destructive"
onclick={confirmDelete}
disabled={isDeleting}
>
{#if isDeleting}Deleting...{:else}Confirm{/if}
</Button>
<Dialog.Close>
<Button type="button" variant="secondary">Cancel</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -58,6 +58,7 @@ export interface EmailObject {
// Define the structure of the document to be indexed in Meilisearch // Define the structure of the document to be indexed in Meilisearch
export interface EmailDocument { export interface EmailDocument {
id: string; // The unique ID of the email id: string; // The unique ID of the email
userEmail: string;
from: string; from: string;
to: string[]; to: string[];
cc: string[]; cc: string[];

View File

@@ -1,9 +1,33 @@
export type Action = string; // Define all possible actions and subjects for type safety
export type AppActions =
| 'manage'
| 'create'
| 'read'
| 'update'
| 'delete'
| 'search'
| 'export'
| 'sync';
export type Resource = string; export type AppSubjects =
| 'archive'
| 'ingestion'
| 'settings'
| 'users'
| 'roles'
| 'dashboard'
| 'all';
export interface PolicyStatement { // This structure will be stored in the `roles.policies` column
Effect: 'Allow' | 'Deny'; export interface CaslPolicy {
Action: Action[]; action: AppActions | AppActions[];
Resource: Resource[]; subject: AppSubjects | AppSubjects[];
/**
* Conditions will be written using MongoDB query syntax (e.g., { status: { $in: ['active'] } })
* This leverages the full power of CASL's ucast library.
*/
conditions?: Record<string, any>;
fields?: string[];
inverted?: boolean; // true represents a 'Deny' effect
reason?: string;
} }

View File

@@ -1,4 +1,4 @@
import { PolicyStatement } from './iam.types'; import { CaslPolicy } from './iam.types';
/** /**
* Represents a user account in the system. * Represents a user account in the system.
@@ -9,6 +9,8 @@ export interface User {
first_name: string | null; first_name: string | null;
last_name: string | null; last_name: string | null;
email: string; email: string;
role: Role | null;
createdAt: Date;
} }
/** /**
@@ -27,8 +29,9 @@ export interface Session {
*/ */
export interface Role { export interface Role {
id: string; id: string;
slug: string | null;
name: string; name: string;
policies: PolicyStatement[]; policies: CaslPolicy[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

39
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
'@azure/msal-node': '@azure/msal-node':
specifier: ^3.6.3 specifier: ^3.6.3
version: 3.6.3 version: 3.6.3
'@casl/ability':
specifier: ^6.7.3
version: 6.7.3
'@microsoft/microsoft-graph-client': '@microsoft/microsoft-graph-client':
specifier: ^3.0.7 specifier: ^3.0.7
version: 3.0.7 version: 3.0.7
@@ -564,6 +567,9 @@ packages:
'@bull-board/ui@6.11.0': '@bull-board/ui@6.11.0':
resolution: {integrity: sha512-NB2mYr8l850BOLzytUyeYl8T3M9ZgPDDfT9WTOCVCDPr77kFF7iEM5jSE9AZg86bmZyWAgO/ogOUJaPSCNHY7g==} resolution: {integrity: sha512-NB2mYr8l850BOLzytUyeYl8T3M9ZgPDDfT9WTOCVCDPr77kFF7iEM5jSE9AZg86bmZyWAgO/ogOUJaPSCNHY7g==}
'@casl/ability@6.7.3':
resolution: {integrity: sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A==}
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1797,6 +1803,18 @@ packages:
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@ucast/core@1.10.2':
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
'@ucast/js@3.0.4':
resolution: {integrity: sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==}
'@ucast/mongo2js@1.4.0':
resolution: {integrity: sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==}
'@ucast/mongo@2.4.3':
resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==}
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -3707,7 +3725,6 @@ packages:
resolution: {integrity: sha512-Nkwo9qeCvqVH0ZgYRUfPyj6o4o7StvNIxMFECeiz4y0uMOVyqc5Y9hjsdFVxdYCeiUjjXLQXA8KIz0iJL3HM0w==} resolution: {integrity: sha512-Nkwo9qeCvqVH0ZgYRUfPyj6o4o7StvNIxMFECeiz4y0uMOVyqc5Y9hjsdFVxdYCeiUjjXLQXA8KIz0iJL3HM0w==}
engines: {node: '>=20.18.0'} engines: {node: '>=20.18.0'}
hasBin: true hasBin: true
bundledDependencies: []
peberminta@0.9.0: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -5378,6 +5395,10 @@ snapshots:
dependencies: dependencies:
'@bull-board/api': 6.11.0(@bull-board/ui@6.11.0) '@bull-board/api': 6.11.0(@bull-board/ui@6.11.0)
'@casl/ability@6.7.3':
dependencies:
'@ucast/mongo2js': 1.4.0
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.9 '@jridgewell/trace-mapping': 0.3.9
@@ -6510,6 +6531,22 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.0.13 '@types/node': 24.0.13
'@ucast/core@1.10.2': {}
'@ucast/js@3.0.4':
dependencies:
'@ucast/core': 1.10.2
'@ucast/mongo2js@1.4.0':
dependencies:
'@ucast/core': 1.10.2
'@ucast/js': 3.0.4
'@ucast/mongo': 2.4.3
'@ucast/mongo@2.4.3':
dependencies:
'@ucast/core': 1.10.2
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.19(@types/node@24.0.13)(lightningcss@1.30.1))(vue@3.5.18(typescript@5.8.3))': '@vitejs/plugin-vue@5.2.4(vite@5.4.19(@types/node@24.0.13)(lightningcss@1.30.1))(vue@3.5.18(typescript@5.8.3))':