mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
RBAC using CASL library
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
95
packages/backend/src/helpers/mongoToDrizzle.ts
Normal file
95
packages/backend/src/helpers/mongoToDrizzle.ts
Normal 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));
|
||||
}
|
||||
75
packages/backend/src/helpers/mongoToMeli.ts
Normal file
75
packages/backend/src/helpers/mongoToMeli.ts
Normal 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 ');
|
||||
}
|
||||
92
packages/backend/src/iam-policy/ability.ts
Normal file
92
packages/backend/src/iam-policy/ability.ts
Normal 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[]);
|
||||
}
|
||||
@@ -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\/\*$/,
|
||||
};
|
||||
@@ -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.` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["*"],
|
||||
"Resource": ["*"]
|
||||
"action": "all",
|
||||
"subject": "all"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
17
packages/backend/src/iam-policy/test-policies/end-user.json
Normal file
17
packages/backend/src/iam-policy/test-policies/end-user.json
Normal file
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"action": "create",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "dashboard"
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"userId": "${user.id}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
[
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ingestion:*"],
|
||||
"Resource": ["ingestion-source/*"]
|
||||
"action": "all",
|
||||
"subject": "ingestion"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "f3d7c025-060f-4f1f-a0e6-cdd32e6e07af"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -64,10 +64,11 @@ export class AuthService {
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken, user: {
|
||||
accessToken,
|
||||
user: {
|
||||
...userWithoutPassword,
|
||||
role: null
|
||||
}
|
||||
role: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
39
pnpm-lock.yaml
generated
@@ -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))':
|
||||
|
||||
Reference in New Issue
Block a user