RBAC using CASL library

This commit is contained in:
Wayne
2025-08-20 01:08:51 +03:00
parent 720160a3d8
commit d81abc657b
51 changed files with 2127 additions and 2033 deletions

View File

@@ -1,141 +1,119 @@
# IAM Policies Guide
# IAM Policies
This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions.
This document provides a comprehensive guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
## 1. Policy Structure
## Policy Structure
A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`.
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
```json
{
"Effect": "Allow",
"Action": ["archive:read", "archive:search"],
"Resource": ["archive/*"]
"action": "action_name",
"subject": "subject_name",
"conditions": {
"field_name": "value"
}
}
```
- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`.
- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`.
- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used.
- `action`: The action to be performed on the subject.
- `subject`: The resource or entity on which the action is to be performed.
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
## 2. Wildcard Support
## Actions
Our IAM system supports wildcards (`*`) in both `Action` and `Resource` fields to provide flexible permission management, as defined in the `PolicyValidator`.
The following actions are available for use in IAM policies:
### Action Wildcards
- `manage`: A wildcard action that grants all permissions on a subject.
- `create`: Allows the user to create a new resource.
- `read`: Allows the user to view a resource.
- `update`: Allows the user to modify an existing resource.
- `delete`: Allows the user to delete a resource.
- `search`: Allows the user to search for resources.
- `export`: Allows the user to export resources.
- `assign`: Allows the user to assign a resource to another user.
- `sync`: Allows the user to synchronize a resource.
You can use wildcards to grant broad permissions for actions:
## Subjects
- **Global Wildcard (`*`)**: A standalone `*` in the `Action` field grants permission for all possible actions across all services.
```json
"Action": ["*"]
```
- **Service-Level Wildcard (`service:*`)**: A wildcard at the end of an action string grants permission for all actions within that specific service.
```json
"Action": ["archive:*"]
```
The following subjects are available for use in IAM policies:
### Resource Wildcards
- `all`: A wildcard subject that represents all resources.
- `archive`: Represents archived emails.
- `ingestion`: Represents ingestion sources.
- `settings`: Represents system settings.
- `users`: Represents user accounts.
- `roles`: Represents user roles.
- `dashboard`: Represents the dashboard.
Wildcards can also be used to specify resources:
## Conditions
- **Global Wildcard (`*`)**: A standalone `*` in the `Resource` field applies the policy to all resources in the system.
```json
"Resource": ["*"]
```
- **Partial Wildcards**: Some services allow wildcards at specific points in the resource path to refer to all resources of a certain type. For example, to target all ingestion sources:
```json
"Resource": ["ingestion-source/*"]
```
Conditions are used to create fine-grained access control rules. They are defined as a JSON object where the keys are the fields of the subject and the values are the conditions to be met.
## 3. Actions and Resources by Service
Conditions support the following MongoDB-style operators:
The following sections define the available actions and resources, categorized by their respective services.
- `$eq`: Equal to
- `$ne`: Not equal to
- `$in`: In an array of values
- `$nin`: Not in an array of values
- `$lt`: Less than
- `$lte`: Less than or equal to
- `$gt`: Greater than
- `$gte`: Greater than or equal to
- `$exists`: Field exists
### Service: `archive`
## Dynamic Policies with Placeholders
The `archive` service pertains to all actions related to accessing and managing archived emails.
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
**Actions:**
## Examples
| Action | Description |
| :--------------- | :--------------------------------------------------------------------- |
| `archive:read` | Grants permission to read the content and metadata of archived emails. |
| `archive:search` | Grants permission to perform search queries against the email archive. |
| `archive:export` | Grants permission to export search results or individual emails. |
### End-User Policy
**Resources:**
This policy allows a user to create ingestions and manage their own resources.
| Resource | Description |
| :------------------------------------ | :--------------------------------------------------------------------------------------- |
| `archive/*` | Represents the entire email archive. |
| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. |
| `archive/mailbox/{email}` | Scopes the action to a single, specific mailbox, usually identified by an email address. |
| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. |
```json
[
{
"action": "create",
"subject": "ingestion"
},
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"userId": "${user.id}"
}
}
]
```
---
### Auditor Policy
### Service: `ingestion`
This policy allows a user to read all archived emails and ingestion sources, but not to modify or delete them.
The `ingestion` service covers the management of email ingestion sources.
```json
[
{
"action": "read",
"subject": "archive"
},
{
"action": "read",
"subject": "ingestion"
}
]
```
**Actions:**
### Administrator Policy
| Action | Description |
| :----------------------- | :--------------------------------------------------------------------------- |
| `ingestion:createSource` | Grants permission to create a new ingestion source. |
| `ingestion:readSource` | Grants permission to view the details of ingestion sources. |
| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. |
| `ingestion:deleteSource` | Grants permission to delete an ingestion source. |
| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. |
This policy grants a user full access to all resources.
**Resources:**
| Resource | Description |
| :---------------------------- | :-------------------------------------------------------- |
| `ingestion-source/*` | Represents all ingestion sources. |
| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. |
---
### Service: `system`
The `system` service is for managing system-level settings, users, and roles.
**Actions:**
| Action | Description |
| :---------------------- | :-------------------------------------------------- |
| `system:readSettings` | Grants permission to view system settings. |
| `system:updateSettings` | Grants permission to modify system settings. |
| `system:readUsers` | Grants permission to list and view user accounts. |
| `system:createUser` | Grants permission to create new user accounts. |
| `system:updateUser` | Grants permission to modify existing user accounts. |
| `system:deleteUser` | Grants permission to delete user accounts. |
| `system:assignRole` | Grants permission to assign roles to users. |
**Resources:**
| Resource | Description |
| :--------------------- | :---------------------------------------------------- |
| `system/settings` | Represents the system configuration. |
| `system/users` | Represents all user accounts within the system. |
| `system/user/{userId}` | Scopes the action to a single, specific user account. |
---
### Service: `dashboard`
The `dashboard` service relates to viewing analytics and overview information.
**Actions:**
| Action | Description |
| :--------------- | :-------------------------------------------------------------- |
| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. |
**Resources:**
| Resource | Description |
| :------------ | :------------------------------------------ |
| `dashboard/*` | Represents all components of the dashboard. |
```json
[
{
"action": "manage",
"subject": "all"
}
]
```

View File

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

View File

@@ -30,7 +30,13 @@ export class ArchivedEmailController {
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
try {
const { id } = req.params;
const email = await ArchivedEmailService.getArchivedEmailById(id);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const email = await ArchivedEmailService.getArchivedEmailById(id, userId);
if (!email) {
return res.status(404).json({ message: 'Archived email not found' });
}

View File

@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { PolicyStatement } from '@open-archiver/types';
import type { CaslPolicy } from '@open-archiver/types';
export class IamController {
#iamService: IamService;
@@ -43,7 +43,7 @@ export class IamController {
}
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;
@@ -54,7 +54,7 @@ export class IamController {
const role = await this.#iamService.createRole(name, policies);
res.status(201).json(role);
} catch (error) {
console.log(error)
console.log(error);
res.status(500).json({ message: 'Failed to create role.' });
}
};
@@ -81,7 +81,7 @@ export class IamController {
if (policies) {
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;

View File

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

View File

@@ -1,48 +1,55 @@
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
import * as schema from '../../database/schema'
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
import { db } from '../../database';
const userService = new UserService();
export const getUsers = async (req: Request, res: Response) => {
const users = await userService.findAll();
res.json(users);
const users = await userService.findAll();
res.json(users);
};
export const getUser = async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
};
export const createUser = async (req: Request, res: Response) => {
const { email, first_name, last_name, password, roleId } = req.body;
const newUser = await userService.createUser({ email, first_name, last_name, password }, roleId);
res.status(201).json(newUser);
const { email, first_name, last_name, password, roleId } = req.body;
const newUser = await userService.createUser(
{ email, first_name, last_name, password },
roleId
);
res.status(201).json(newUser);
};
export const updateUser = async (req: Request, res: Response) => {
const { email, first_name, last_name, roleId } = req.body;
const updatedUser = await userService.updateUser(req.params.id, { email, first_name, last_name }, roleId);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.json(updatedUser);
const { email, first_name, last_name, roleId } = req.body;
const updatedUser = await userService.updateUser(
req.params.id,
{ email, first_name, last_name },
roleId
);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.json(updatedUser);
};
export const deleteUser = async (req: Request, res: Response) => {
const userCountResult = await db
.select({ count: sql<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();
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

@@ -1,22 +1,36 @@
import { AuthorizationService } from '../../services/AuthorizationService';
import type { Request, Response, NextFunction } from 'express';
import { AppActions, AppSubjects } from '@open-archiver/types';
export const requirePermission = (action: string, resource: string) => {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.sub;
export const requirePermission = (
action: AppActions,
subjectName: AppSubjects,
rejectMessage?: string
) => {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
resource = resource.replace('{sourceId}', req.params.id)
let resourceObject = undefined;
// Logic to fetch resourceObject if needed for condition-based checks...
const authorizationService = new AuthorizationService();
const hasPermission = await authorizationService.can(
userId,
action,
subjectName,
resourceObject
);
const hasPermission = await AuthorizationService.can(userId, action, resource);
if (!hasPermission) {
return res.status(403).json({
message:
rejectMessage || `You don't have the permission to perform the current action.`,
});
}
if (!hasPermission) {
return res.status(403).json({ message: 'You are not allowed to perform this operation with your current role.' });
}
next();
};
next();
};
};

View File

@@ -13,17 +13,21 @@ export const createArchivedEmailRouter = (
// Secure all routes in this module
router.use(requireAuth(authService));
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
router.get(
'/ingestion-source/:ingestionSourceId',
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmails
);
router.get(
'/:id',
requirePermission('archive:read', 'archive/all'),
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmailById
);
router.delete(
'/:id',
requirePermission('archive:write', 'archive/all'),
requirePermission('delete', 'archive'),
archivedEmailController.deleteArchivedEmail
);

View File

@@ -9,25 +9,49 @@ export const createDashboardRouter = (authService: AuthService): Router => {
router.use(requireAuth(authService));
router.get('/stats', requirePermission('dashboard:read', 'dashboard/*'), dashboardController.getStats);
router.get(
'/stats',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard stats.'
),
dashboardController.getStats
);
router.get(
'/ingestion-history',
requirePermission('dashboard:read', 'dashboard/*'),
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIngestionHistory
);
router.get(
'/ingestion-sources',
requirePermission('dashboard:read', 'dashboard/*'),
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIngestionSources
);
router.get(
'/recent-syncs',
requirePermission('dashboard:read', 'dashboard/*'),
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getRecentSyncs
);
router.get(
'/indexed-insights',
requirePermission('dashboard:read', 'dashboard/*'),
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIndexedInsights
);

View File

@@ -14,37 +14,14 @@ export const createIamRouter = (iamController: IamController, authService: AuthS
* @description Gets all roles.
* @access Private
*/
router.get(
'/roles',
requirePermission('system:readUsers', 'system/users'),
iamController.getRoles
);
router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles);
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
router.get(
'/roles/:id',
requirePermission('system:readUsers', 'system/users'),
iamController.getRoleById
);
router.post('/roles', requirePermission('create', 'roles'), iamController.createRole);
router.delete('/roles/:id', requirePermission('delete', 'roles'), iamController.deleteRole);
router.post(
'/roles',
requirePermission('system:assignRole', 'system/users'),
iamController.createRole
);
router.delete(
'/roles/:id',
requirePermission('system:deleteRole', 'system/users'),
iamController.deleteRole
);
router.put(
'/roles/:id',
requirePermission('system:updateUser', 'system/users'),
iamController.updateRole
);
router.put('/roles/:id', requirePermission('update', 'roles'), iamController.updateRole);
return router;
};

View File

@@ -13,47 +13,27 @@ export const createIngestionRouter = (
// Secure all routes in this module
router.use(requireAuth(authService));
router.post(
'/',
requirePermission('ingestion:create', 'ingestion-source/*'),
ingestionController.create
);
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
router.get('/', ingestionController.findAll);
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
router.get(
'/:id',
requirePermission('ingestion:read', 'ingestion-source/{sourceId}'),
ingestionController.findById
);
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
router.put(
'/:id',
requirePermission('ingestion:update', 'ingestion-source/{sourceId}'),
ingestionController.update
);
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
router.delete(
'/:id',
requirePermission('ingestion:delete', 'ingestion-source/{sourceId}'),
ingestionController.delete
);
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
router.post(
'/:id/import',
requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'),
requirePermission('create', 'ingestion'),
ingestionController.triggerInitialImport
);
router.post(
'/:id/pause',
requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'),
ingestionController.pause
);
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
router.post(
'/:id/sync',
requirePermission('ingestion:manage', 'ingestion-source/{sourceId}'),
requirePermission('sync', 'ingestion'),
ingestionController.triggerForceSync
);

View File

@@ -12,7 +12,7 @@ export const createSearchRouter = (
router.use(requireAuth(authService));
router.get('/', requirePermission('archive:search', 'archive/all'), searchController.search);
router.get('/', requirePermission('search', 'archive'), searchController.search);
return router;
};

View File

@@ -13,11 +13,7 @@ export const createStorageRouter = (
// Secure all routes in this module
router.use(requireAuth(authService));
router.get(
'/download',
requirePermission('archive:read', 'archive/all'),
storageController.downloadFile
);
router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
return router;
};

View File

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

View File

@@ -5,39 +5,19 @@ import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createUserRouter = (authService: AuthService): Router => {
const router = Router();
const router = Router();
router.use(requireAuth(authService));
router.use(requireAuth(authService));
router.get(
'/',
requirePermission('system:readUsers', 'system/users'),
userController.getUsers
);
router.get('/', requirePermission('read', 'users'), userController.getUsers);
router.get(
'/:id',
requirePermission('system:readUsers', 'system/user/{userId}'),
userController.getUser
);
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
router.post(
'/',
requirePermission('system:createUser', 'system/users'),
userController.createUser
);
router.post('/', requirePermission('create', 'users'), userController.createUser);
router.put(
'/:id',
requirePermission('system:updateUser', 'system/user/{userId}'),
userController.updateUser
);
router.put('/:id', requirePermission('update', 'users'), userController.updateUser);
router.delete(
'/:id',
requirePermission('system:deleteUser', 'system/user/{userId}'),
userController.deleteUser
);
router.delete('/:id', requirePermission('delete', 'users'), userController.deleteUser);
return router;
return router;
};

View File

@@ -1,118 +1,118 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
import { relations, sql } from 'drizzle-orm';
import { pgTable, text, timestamp, uuid, primaryKey, jsonb } from 'drizzle-orm/pg-core';
import type { PolicyStatement } from '@open-archiver/types';
import type { CaslPolicy } from '@open-archiver/types';
/**
* The `users` table stores the core user information for authentication and identification.
@@ -40,7 +40,7 @@ export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
policies: jsonb('policies')
.$type<PolicyStatement[]>()
.$type<CaslPolicy[]>()
.notNull()
.default(sql`'[]'::jsonb`),
createdAt: timestamp('created_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',
// 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,75 @@
function getMeliColumn(key: string): string {
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = keyParts[1];
return `${relationName}.${columnName}`;
}
return key;
}
export function mongoToMeli(query: Record<string, any>): string {
const conditions: string[] = [];
for (const key in query) {
const value = query[key];
if (key === '$or') {
conditions.push(`(${value.map(mongoToMeli).join(' OR ')})`);
continue;
}
if (key === '$and') {
conditions.push(`(${value.map(mongoToMeli).join(' AND ')})`);
continue;
}
if (key === '$not') {
conditions.push(`NOT (${mongoToMeli(value)})`);
continue;
}
const column = getMeliColumn(key);
if (typeof value === 'object' && value !== null) {
const operator = Object.keys(value)[0];
const operand = value[operator];
switch (operator) {
case '$eq':
conditions.push(`${column} = ${operand}`);
break;
case '$ne':
conditions.push(`${column} != ${operand}`);
break;
case '$gt':
conditions.push(`${column} > ${operand}`);
break;
case '$gte':
conditions.push(`${column} >= ${operand}`);
break;
case '$lt':
conditions.push(`${column} < ${operand}`);
break;
case '$lte':
conditions.push(`${column} <= ${operand}`);
break;
case '$in':
conditions.push(`${column} IN [${operand.join(', ')}]`);
break;
case '$nin':
conditions.push(`${column} NOT IN [${operand.join(', ')}]`);
break;
case '$exists':
conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`);
break;
default:
// Unsupported operator
}
} else {
conditions.push(`${column} = ${value}`);
}
}
return conditions.join(' AND ');
}

View File

@@ -0,0 +1,92 @@
// packages/backend/src/iam-policy/ability.ts
import { createMongoAbility, MongoAbility, RawRuleOf } from '@casl/ability';
import { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
import { ingestionSources, archivedEmails, users, roles } from '../database/schema';
import { InferSelectModel } from 'drizzle-orm';
// Define the application's ability type
export type AppAbility = MongoAbility<[AppActions, AppSubjects]>;
// Helper type for raw rules
export type AppRawRule = RawRuleOf<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;
/**
* 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;
}
/**
* Expands the given set of policies to include inherent permissions.
* For example, a permission on an 'ingestion' source is expanded to grant
* the same permission on 'archive' records related to that source.
* @param policies The original array of CASL policies.
* @returns A new array of policies including the expanded, inherent permissions.
*/
function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] {
const expandedPolicies: CaslPolicy[] = JSON.parse(JSON.stringify(policies));
policies.forEach((policy) => {
if (policy.subject === 'ingestion') {
const archivePolicy: CaslPolicy = {
...JSON.parse(JSON.stringify(policy)),
subject: 'archive',
};
if (policy.conditions) {
archivePolicy.conditions = translateIngestionConditionsToArchive(policy.conditions);
}
expandedPolicies.push(archivePolicy);
}
});
return expandedPolicies;
}
// Function to create an ability instance from policies stored in the database
export function createAbilityFor(policies: CaslPolicy[]) {
const allPolicies = expandPolicies(policies);
return createMongoAbility<AppAbility>(allPolicies as AppRawRule[]);
}

View File

@@ -1,142 +0,0 @@
/**
* @file This file serves as the single source of truth for all Identity and Access Management (IAM)
* definitions within Open Archiver. Centralizing these definitions is an industry-standard practice
* that offers several key benefits:
*
* 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources
* throughout the codebase, reducing the risk of typos and inconsistencies.
* 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all
* possible permissions in the system.
* 3. **Enables Validation**: Allows for the creation of a robust validation function that can
* programmatically check if a policy statement is valid before it is saved.
* 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions
* that can be granted.
*
* The structure is inspired by AWS IAM, using a `service:operation` format for actions and a
* hierarchical, slash-separated path for resources.
*/
/**
* Possible action verbs:
- CRUD: read, update, create, delete
- Special: export, search, manage, assign
Resource ranges:
- * : all resources
- own: resources owned by / created by the requesting user
- {{id}}: resource with certain ID
*/
/**
* Rules:
* - If a user has access to upper level resource, it has access to resources that depends on it. eg: If a user has access to ingestion XYZ, it will have access to all the archived emails created by ingestion XYZ. The permission should be inherent: if the user can delete ingestion XYZ, it can delete archived emails created by ingestion XYZ.
* 2.
*
*/
// ===================================================================================
// SERVICE: archive
// ===================================================================================
const ARCHIVE_ACTIONS = {
READ: 'archive:read',
SEARCH: 'archive:search',
DELETE: 'archive:delete',
EXPORT: 'archive:export',
};
const ARCHIVE_RESOURCES = {
ALL: 'archive/*',
INGESTION: 'archive/ingestion/*',
MAILBOX: 'archive/mailbox/{email}', //Scopes the action to a single, specific mailbox, usually identified by an email address. |
CUSTODIAN: 'archive/custodian/{custodianId}',// Scopes the action to emails belonging to a specific custodian.
};
// ===================================================================================
// SERVICE: ingestion
// ===================================================================================
const INGESTION_ACTIONS = {
CREATE_SOURCE: 'ingestion:create',
READ_SOURCE: 'ingestion:read',
UPDATE_SOURCE: 'ingestion:update',
DELETE_SOURCE: 'ingestion:delete',
MANAGE_SYNC: 'ingestion:manage', // Covers triggering, pausing, and forcing syncs
};
const INGESTION_RESOURCES = {
ALL: 'ingestion/*',
SOURCE: 'ingestion/{sourceId}',
OWN: 'ingestion/own',
};
// ===================================================================================
// SERVICE: system
// ===================================================================================
const SYSTEM_ACTIONS = {
READ_SETTINGS: 'settings:read',
UPDATE_SETTINGS: 'settings:update',
READ_USERS: 'users:read',
CREATE_USER: 'users:create',
UPDATE_USER: 'users:update',
DELETE_USER: 'users:delete',
ASSIGN_ROLE: 'roles:assign',
UPDATE_ROLE: 'roles:update',
CREATE_ROLE: 'roles:create',
DELETE_ROLE: 'roles:delete',
READ_ROLES: 'system:read',
};
const SYSTEM_RESOURCES = {
ALL_SETTINGS: 'system/settings/*',
ALL_USERS: 'system/users/*',
USER: 'system/user/{userId}',
ALL_ROLES: 'system/roles/*'
};
// ===================================================================================
// SERVICE: dashboard
// ===================================================================================
const DASHBOARD_ACTIONS = {
READ: 'dashboard:read',
};
const DASHBOARD_RESOURCES = {
ALL: 'dashboard/*',
};
// ===================================================================================
// EXPORTED DEFINITIONS
// ===================================================================================
/**
* A comprehensive set of all valid IAM actions in the system.
* This is used by the policy validator to ensure that any action in a policy is recognized.
*/
export const ValidActions: Set<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\/(\*|ingestion\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/,
ingestion: /^ingestion\/(\*|own|[^\/]+)$/,
system: /^system\/(settings|users|user\/[^\/]+)$/,
dashboard: /^dashboard\/\*$/,
};

View File

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

View File

@@ -1,7 +1,6 @@
[
{
"Effect": "Allow",
"Action": ["*"],
"Resource": ["*"]
"action": "all",
"subject": "all"
}
]

View File

@@ -1,12 +1,16 @@
[
{
"Effect": "Allow",
"Action": ["ingestion:readSource", "archive:read", "archive:search"],
"Resource": [
"ingestion-source/INGESTION_SOURCE_ID_1",
"ingestion-source/INGESTION_SOURCE_ID_2",
"archive/ingestion-source/INGESTION_SOURCE_ID_1",
"archive/ingestion-source/INGESTION_SOURCE_ID_2"
]
"action": ["read", "search"],
"subject": "ingestion",
"conditions": {
"id": { "$in": ["INGESTION_SOURCE_ID_1", "INGESTION_SOURCE_ID_2"] }
}
},
{
"action": ["read", "search"],
"subject": "archive",
"conditions": {
"ingestionSourceId": { "$in": ["INGESTION_SOURCE_ID_1", "INGESTION_SOURCE_ID_2"] }
}
}
]

View File

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

View File

@@ -1,7 +1,6 @@
[
{
"Effect": "Allow",
"Action": ["ingestion:*"],
"Resource": ["ingestion-source/*"]
"action": "all",
"subject": "ingestion"
}
]

View File

@@ -1,17 +1,11 @@
[
{
"Effect": "Allow",
"Action": ["ingestion:readSource", "archive:read", "archive:search", "dashboard:read"],
"Resource": ["ingestion-source/*", "archive/*", "dashboard/*"]
"action": ["read", "search"],
"subject": ["ingestion", "archive", "dashboard"]
},
{
"Effect": "Deny",
"Action": [
"ingestion:createSource",
"ingestion:updateSource",
"ingestion:deleteSource",
"system:*"
],
"Resource": ["*"]
"inverted": true,
"action": ["create", "update", "delete"],
"subject": ["ingestion", "users", "roles"]
}
]

View File

@@ -1,16 +1,6 @@
[
{
"Effect": "Allow",
"Action": [
"ingestion:readSource",
"archive:read",
"archive:search",
"dashboard:read",
"ingestion:createSource"
],
"Resource": ["ingestion-source/*", "archive/*", "dashboard/*"],
"Condition": {
"owner": true
}
"action": "read",
"subject": "all"
}
]

View File

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

View File

@@ -1,13 +1,10 @@
[
{
"Effect": "Allow",
"Action": [
"system:readUsers",
"system:createUser",
"system:updateUser",
"system:deleteUser",
"system:assignRole"
],
"Resource": ["system/users", "system/user/*"]
"action": "all",
"subject": "users"
},
{
"action": "read",
"subject": "roles"
}
]

View File

@@ -1,7 +1,8 @@
import { count, desc, eq, asc, and } from 'drizzle-orm';
import { db } from '../database';
import { archivedEmails, attachments, emailAttachments } from '../database/schema';
import { archivedEmails, attachments, emailAttachments, ingestionSources } from '../database/schema';
import { FilterBuilder } from './FilterBuilder';
import { AuthorizationService } from './AuthorizationService';
import type {
PaginatedArchivedEmails,
ArchivedEmail,
@@ -46,32 +47,40 @@ export class ArchivedEmailService {
userId: string
): Promise<PaginatedArchivedEmails> {
const offset = (page - 1) * limit;
const filterBuilder = await FilterBuilder.create(
userId,
archivedEmails,
'archive',
'archive:read'
);
const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read');
const where = and(
eq(archivedEmails.ingestionSourceId, ingestionSourceId),
filterBuilder.build()
drizzleFilter
);
const [total] = await db
const countQuery = db
.select({
count: count(archivedEmails.id),
})
.from(archivedEmails)
.where(where);
.leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id));
const items = await db
if (where) {
countQuery.where(where);
}
const [total] = await countQuery;
const itemsQuery = db
.select()
.from(archivedEmails)
.where(where)
.leftJoin(ingestionSources, eq(archivedEmails.ingestionSourceId, ingestionSources.id))
.orderBy(desc(archivedEmails.sentAt))
.limit(limit)
.offset(offset);
if (where) {
itemsQuery.where(where);
}
const results = await itemsQuery;
const items = results.map((r) => r.archived_emails);
return {
items: items.map((item) => ({
...item,
@@ -85,16 +94,28 @@ export class ArchivedEmailService {
};
}
public static async getArchivedEmailById(emailId: string): Promise<ArchivedEmail | null> {
const [email] = await db
.select()
.from(archivedEmails)
.where(eq(archivedEmails.id, emailId));
public static async getArchivedEmailById(
emailId: string,
userId: string
): Promise<ArchivedEmail | null> {
const email = await db.query.archivedEmails.findFirst({
where: eq(archivedEmails.id, emailId),
with: {
ingestionSource: true,
},
});
if (!email) {
return null;
}
const authorizationService = new AuthorizationService();
const canRead = await authorizationService.can(userId, 'read', 'archive', email);
if (!canRead) {
return null;
}
let threadEmails: ThreadEmail[] = [];
if (email.threadId) {

View File

@@ -64,10 +64,11 @@ export class AuthService {
});
return {
accessToken, user: {
accessToken,
user: {
...userWithoutPassword,
role: null
}
role: null,
},
};
}

View File

@@ -1,77 +1,25 @@
import { IamService } from './IamService';
import { db } from '../database';
import { ingestionSources } from '../database/schema/ingestion-sources';
import { eq } from 'drizzle-orm';
import { createAbilityFor, SubjectObject } from '../iam-policy/ability';
import { subject, Subject } from '@casl/ability';
import { AppActions, AppSubjects } from '@open-archiver/types';
export class AuthorizationService {
public static async can(userId: string, action: string, resource: string): Promise<boolean> {
const iamService = new IamService();
const userRoles = await iamService.getRolesForUser(userId);
const allPolicies = userRoles.flatMap((role) => role.policies || []);
private iamService: IamService;
// 1. Check for explicit DENY policies first.
const isDenied = allPolicies.some(
(policy) =>
policy.Effect === 'Deny' &&
this.matches(action, policy.Action) &&
this.matches(resource, policy.Resource)
);
constructor() {
this.iamService = new IamService();
}
if (isDenied) {
return false;
}
// 2. If not denied, check for an explicit ALLOW policy.
for (const policy of allPolicies) {
if (policy.Effect === 'Allow' && this.matches(action, policy.Action)) {
if (action.includes('create')) {
return true;
}
if (this.matches(resource, policy.Resource)) {
return true
}
}
}
return false;
}
private static matches(value: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
if (pattern === '*') return true;
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
return value.startsWith(prefix);
}
const regex = new RegExp(`^${pattern.replace(/\{[^}]+\}/g, '[^/]+')}$`);
return regex.test(value);
});
}
private static async isOwner(userId: string, resource: string): Promise<boolean> {
const resourceParts = resource.split('/');
const service = resourceParts[0];
const resourceId = resourceParts[1];
if (service === 'ingestion-source' && resourceId) {
if (resourceId === 'own') return true;
const [source] = await db
.select()
.from(ingestionSources)
.where(eq(ingestionSources.id, resourceId));
return source?.userId === userId;
}
if (service === 'archive' && resourceParts[1] === 'ingestion-source' && resourceParts[2]) {
const ingestionSourceId = resourceParts[2];
const [source] = await db
.select()
.from(ingestionSources)
.where(eq(ingestionSources.id, ingestionSourceId));
return source?.userId === userId;
}
return false;
}
public async can(
userId: string,
action: AppActions,
resource: AppSubjects,
resourceObject?: SubjectObject
): Promise<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

@@ -1,67 +1,41 @@
import { SQL, or, and, eq, inArray, sql } from 'drizzle-orm';
import { PgTableWithColumns } from 'drizzle-orm/pg-core';
import { SQL, sql } from 'drizzle-orm';
import { IamService } from './IamService';
import { rulesToQuery } from '@casl/ability/extra';
import { mongoToDrizzle } from '../helpers/mongoToDrizzle';
import { AppActions, AppSubjects } from '@open-archiver/types';
export class FilterBuilder {
private constructor(
private userId: string,
private table: PgTableWithColumns<any>,
private policies: any[],
private resourceType: string,
private readAction: string
) { }
public static async create(
userId: string,
resourceType: AppSubjects,
action: AppActions
): Promise<{
drizzleFilter: SQL | undefined;
mongoFilter: Record<string, any> | null;
}> {
const iamService = new IamService();
const ability = await iamService.getAbilityForUser(userId);
public static async create(
userId: string,
table: PgTableWithColumns<any>,
resourceType: string,
readAction: string
): Promise<FilterBuilder> {
const iamService = new IamService();
const userRoles = await iamService.getRolesForUser(userId);
const allPolicies = userRoles.flatMap((role) => role.policies || []);
return new FilterBuilder(userId, table, allPolicies, resourceType, readAction);
}
// If the user can perform the action on any instance of the resource type
// without any specific conditions, they have full access.
if (ability.can(action, resourceType)) {
const rules = ability.rulesFor(action, resourceType);
const hasUnconditionalRule = rules.some((rule) => !rule.conditions);
if (hasUnconditionalRule) {
return { drizzleFilter: undefined, mongoFilter: null }; // Full access
}
}
public build(): SQL | undefined {
const canReadAll = this.policies.some(
(policy) =>
policy.Effect === 'Allow' &&
(policy.Action.includes(this.readAction) || policy.Action.includes('*')) &&
(policy.Resource.includes(`${this.resourceType}/*`) ||
policy.Resource.includes('*'))
);
const query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions);
if (canReadAll) {
return undefined;
}
if (query === null) {
return { drizzleFilter: undefined, mongoFilter: null }; // Full access
}
const allowedResources = this.policies
.filter(
(policy) =>
policy.Effect === 'Allow' &&
(policy.Action.includes(this.readAction) || policy.Action.includes('*'))
)
.flatMap((policy) => policy.Resource)
.filter((resource) => resource.startsWith(`${this.resourceType}/`));
if (Object.keys(query).length === 0) {
return { drizzleFilter: sql`1=0`, mongoFilter: {} }; // No access
}
const canReadOwn = allowedResources.some((resource) => resource.endsWith('/own'));
const sourceIds = allowedResources
.map((resource) => resource.split('/')[1])
.filter((id) => id !== 'own');
const conditions: SQL[] = [];
if (canReadOwn) {
conditions.push(eq(this.table.userId, this.userId));
}
if (sourceIds.length > 0) {
conditions.push(inArray(this.table.id, sourceIds));
}
if (conditions.length === 0) {
return eq(this.table.id, sql`NULL`);
}
return or(...conditions);
}
return { drizzleFilter: mongoToDrizzle(query), mongoFilter: query };
}
}

View File

@@ -1,7 +1,8 @@
import { db } from '../database';
import { roles, userRoles } from '../database/schema/users';
import type { Role, PolicyStatement } from '@open-archiver/types';
import { roles, userRoles, users } from '../database/schema/users';
import type { Role, CaslPolicy, User } from '@open-archiver/types';
import { eq } from 'drizzle-orm';
import { createAbilityFor, AppAbility } from '../iam-policy/ability';
export class IamService {
/**
@@ -27,7 +28,7 @@ export class IamService {
return role;
}
public async createRole(name: string, policy: PolicyStatement[]): Promise<Role> {
public async createRole(name: string, policy: CaslPolicy[]): Promise<Role> {
const [role] = await db.insert(roles).values({ name, policies: policy }).returning();
return role;
}
@@ -47,4 +48,32 @@ export class IamService {
.returning();
return role;
}
public async getAbilityForUser(userId: string): Promise<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

@@ -87,17 +87,11 @@ export class IngestionService {
}
public static async findAll(userId: string): Promise<IngestionSource[]> {
const filterBuilder = await FilterBuilder.create(
userId,
ingestionSources,
'ingestion-source',
'ingestion:readSource'
);
const where = filterBuilder.build();
const { drizzleFilter } = await FilterBuilder.create(userId, 'ingestion', 'read');
let query = db.select().from(ingestionSources).$dynamic();
if (where) {
query = query.where(where);
if (drizzleFilter) {
query = query.where(drizzleFilter);
}
const sources = await query.orderBy(desc(ingestionSources.createdAt));

View File

@@ -1,6 +1,8 @@
import { Index, MeiliSearch, SearchParams } from 'meilisearch';
import { config } from '../config';
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
import { FilterBuilder } from './FilterBuilder';
import { mongoToMeli } from '../helpers/mongoToMeli';
export class SearchService {
private client: MeiliSearch;
@@ -47,7 +49,7 @@ export class SearchService {
return index.deleteDocuments({ filter });
}
public async searchEmails(dto: SearchQuery): Promise<SearchResult> {
public async searchEmails(dto: SearchQuery, userId: string): Promise<SearchResult> {
const { query, filters, page = 1, limit = 10, matchingStrategy = 'last' } = dto;
const index = await this.getIndex<EmailDocument>('emails');
@@ -70,6 +72,21 @@ export class SearchService {
searchParams.filter = filterStrings.join(' AND ');
}
// Create a filter based on the user's permissions.
// This ensures that the user can only search for emails they are allowed to see.
const { mongoFilter } = await FilterBuilder.create(userId, 'archive', 'read');
if (mongoFilter) {
// Convert the MongoDB-style filter from CASL to a MeiliSearch filter string.
const meliFilter = mongoToMeli(mongoFilter);
if (searchParams.filter) {
// If there are existing filters, append the access control filter.
searchParams.filter = `${searchParams.filter} AND ${meliFilter}`;
} else {
// Otherwise, just use the access control filter.
searchParams.filter = meliFilter;
}
}
const searchResults = await index.search(query, searchParams);
return {

View File

@@ -2,7 +2,7 @@ import { db } from '../database';
import * as schema from '../database/schema';
import { eq, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import type { PolicyStatement, User } from '@open-archiver/types';
import type { CaslPolicy, User } from '@open-archiver/types';
export class UserService {
/**
@@ -28,16 +28,16 @@ export class UserService {
with: {
userRoles: {
with: {
role: true
}
}
}
role: true,
},
},
},
});
if (!user) return null;
return {
...user,
role: user.userRoles[0]?.role || null
role: user.userRoles[0]?.role || null,
};
}
@@ -46,15 +46,15 @@ export class UserService {
with: {
userRoles: {
with: {
role: true
}
}
}
role: true,
},
},
},
});
return users.map((u) => ({
...u,
role: u.userRoles[0]?.role || null
role: u.userRoles[0]?.role || null,
}));
}
@@ -151,11 +151,10 @@ export class UserService {
});
if (!superAdminRole) {
const suerAdminPolicies: PolicyStatement[] = [
const suerAdminPolicies: CaslPolicy[] = [
{
Effect: 'Allow',
Action: ['*'],
Resource: ['*'],
action: 'manage',
subject: 'all',
},
];
superAdminRole = (

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Role, PolicyStatement } from '@open-archiver/types';
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';
@@ -13,7 +13,7 @@
const handleSubmit = () => {
try {
const parsedPolicies: PolicyStatement[] = JSON.parse(policies);
const parsedPolicies: CaslPolicy[] = JSON.parse(policies);
onSubmit({ name, policies: parsedPolicies });
} catch (error) {
alert('Invalid JSON format for policies.');

View File

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

View File

@@ -1,4 +1,5 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { IngestionSource, PaginatedArchivedEmails } from '@open-archiver/types';
@@ -29,10 +30,11 @@ export const load: PageServerLoad = async (event) => {
`/archived-emails/ingestion-source/${selectedIngestionSourceId}?page=${page}&limit=${limit}`,
event
);
const responseText = await emailsResponse.json()
if (!emailsResponse.ok) {
throw new Error(`Failed to fetch archived emails: ${emailsResponse.statusText}`);
return error(emailsResponse.status, responseText.message || 'You do not have access to the requested resource.')
}
archivedEmails = await emailsResponse.json();
archivedEmails = responseText;
}
return {
@@ -40,17 +42,18 @@ export const load: PageServerLoad = async (event) => {
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',
};
} catch (e) {
// console.error('Failed to load archived emails page:', error);
// return {
// ingestionSources: [],
// archivedEmails: {
// items: [],
// total: 0,
// page: 1,
// limit: 10,
// },
// error: 'Failed to load data',
// };
return error(599, 'Failed to load data')
}
};

View File

@@ -1,4 +1,5 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { ArchivedEmail } from '@open-archiver/types';
@@ -6,10 +7,11 @@ export const load: PageServerLoad = async (event) => {
try {
const { id } = event.params;
const response = await api(`/archived-emails/${id}`, event);
const responseText = await response.json()
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 {
email,
};

View File

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

View File

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

View File

@@ -4,15 +4,15 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const rolesResponse = await api('/iam/roles', 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');
}
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
};
const roles: Role[] = await rolesResponse.json();
return {
roles,
};
};

View File

@@ -4,24 +4,24 @@ 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)
]);
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');
}
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
};
const users: User[] = await usersResponse.json();
const roles: Role[] = await rolesResponse.json();
return {
users,
roles,
};
};

View File

@@ -1,9 +1,34 @@
export type Action = string;
// Define all possible actions and subjects for type safety
export type AppActions =
| 'manage'
| 'create'
| 'read'
| 'update'
| 'delete'
| 'search'
| 'export'
| 'assign'
| 'sync';
export type Resource = string;
export type AppSubjects =
| 'archive'
| 'ingestion'
| 'settings'
| 'users'
| 'roles'
| 'dashboard'
| 'all';
export interface PolicyStatement {
Effect: 'Allow' | 'Deny';
Action: Action[];
Resource: Resource[];
// This structure will be stored in the `roles.policies` column
export interface CaslPolicy {
action: AppActions | AppActions[];
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.
@@ -30,7 +30,7 @@ export interface Session {
export interface Role {
id: string;
name: string;
policies: PolicyStatement[];
policies: CaslPolicy[];
createdAt: Date;
updatedAt: Date;
}

39
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
'@azure/msal-node':
specifier: ^3.6.3
version: 3.6.3
'@casl/ability':
specifier: ^6.7.3
version: 6.7.3
'@microsoft/microsoft-graph-client':
specifier: ^3.0.7
version: 3.0.7
@@ -564,6 +567,9 @@ packages:
'@bull-board/ui@6.11.0':
resolution: {integrity: sha512-NB2mYr8l850BOLzytUyeYl8T3M9ZgPDDfT9WTOCVCDPr77kFF7iEM5jSE9AZg86bmZyWAgO/ogOUJaPSCNHY7g==}
'@casl/ability@6.7.3':
resolution: {integrity: sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -1797,6 +1803,18 @@ packages:
'@types/yauzl@2.10.3':
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':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -3707,7 +3725,6 @@ packages:
resolution: {integrity: sha512-Nkwo9qeCvqVH0ZgYRUfPyj6o4o7StvNIxMFECeiz4y0uMOVyqc5Y9hjsdFVxdYCeiUjjXLQXA8KIz0iJL3HM0w==}
engines: {node: '>=20.18.0'}
hasBin: true
bundledDependencies: []
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -5378,6 +5395,10 @@ snapshots:
dependencies:
'@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':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -6510,6 +6531,22 @@ snapshots:
dependencies:
'@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': {}
'@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))':