Switch to CASL, secure search, resource-level access control

This commit is contained in:
Wayne
2025-08-21 23:39:02 +03:00
parent d81abc657b
commit db38dde86f
39 changed files with 1516 additions and 335 deletions

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

@@ -0,0 +1,27 @@
# Contributor License Agreement (CLA)
Version: 2
This Agreement is for your protection as a Contributor as well as the protection of the maintainers of the Open Archiver software; it does not change your rights to use your own Contributions for any other purpose. In this Agreement, "Open Archiver" refers to LogicLabs OÜ, a private limited company established under the laws of the Republic of Estonia.
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to "Open Archiver". Except for the license granted herein to Open Archiver and recipients of software distributed by "Open Archiver", You reserve all right, title, and interest in and to Your Contributions.
1. Definitions.
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with "Open Archiver". For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor.
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to "Open Archiver" for inclusion in, or documentation of, any of the products owned or managed by "Open Archiver" (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to "Open Archiver" or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, "Open Archiver" for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to "Open Archiver", or that your employer has executed a separate Contributor License Agreement with "Open Archiver".
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may submit it to "Open Archiver" separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
8. You agree to notify "Open Archiver" of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.

View File

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

View File

@@ -1,119 +0,0 @@
# IAM Policies
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.
## Policy Structure
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
```json
{
"action": "action_name",
"subject": "subject_name",
"conditions": {
"field_name": "value"
}
}
```
- `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.
## Actions
The following actions are available for use in IAM policies:
- `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.
## Subjects
The following subjects are available for use in IAM policies:
- `all`: A wildcard subject that represents all resources.
- `archive`: Represents archived emails.
- `ingestion`: Represents ingestion sources.
- `settings`: Represents system settings.
- `users`: Represents user accounts.
- `roles`: Represents user roles.
- `dashboard`: Represents the dashboard.
## Conditions
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.
Conditions support the following MongoDB-style operators:
- `$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
## Dynamic Policies with Placeholders
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
## Examples
### End-User Policy
This policy allows a user to create ingestions and manage their own resources.
```json
[
{
"action": "create",
"subject": "ingestion"
},
{
"action": "manage",
"subject": "ingestion",
"conditions": {
"userId": "${user.id}"
}
}
]
```
### Auditor Policy
This policy allows a user to read all archived emails and ingestion sources, but not to modify or delete them.
```json
[
{
"action": "read",
"subject": "archive"
},
{
"action": "read",
"subject": "ingestion"
}
]
```
### Administrator Policy
This policy grants a user full access to all resources.
```json
[
{
"action": "manage",
"subject": "all"
}
]
```

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { CaslPolicy } from '@open-archiver/types';
import { logger } from '../../config/logger';
export class IamController {
#iamService: IamService;
@@ -12,7 +13,12 @@ export class IamController {
public getRoles = async (req: Request, res: Response): Promise<void> => {
try {
const roles = await this.#iamService.getRoles();
let roles = await this.#iamService.getRoles();
if (!roles.some((r) => r.slug?.includes('predefined_'))) {
// create pre defined roles
logger.info({}, 'Creating predefined roles');
await this.createDefaultRoles();
}
res.status(200).json(roles);
} catch (error) {
res.status(500).json({ message: 'Failed to get roles.' });
@@ -96,4 +102,44 @@ export class IamController {
res.status(500).json({ message: 'Failed to update role.' });
}
};
private createDefaultRoles = async () => {
try {
// end user who can manage its own data, and create new ingestions.
await this.#iamService.createRole(
'End user',
[
{
action: 'create',
subject: 'ingestion',
},
{
action: 'read',
subject: 'dashboard',
},
{
action: 'manage',
subject: 'ingestion',
conditions: {
userId: '${user.id}',
},
},
],
'predefined_end_user'
);
// read only
await this.#iamService.createRole(
'Read only',
[
{
action: ['read', 'search'],
subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'],
},
],
'predefined_read_only_user'
);
} catch (error) {
logger.error({}, 'Failed to create default roles');
}
};
}

View File

@@ -21,6 +21,7 @@ export const getUser = async (req: Request, res: Response) => {
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

View File

@@ -18,10 +18,25 @@ export const createIamRouter = (iamController: IamController, authService: AuthS
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
router.post('/roles', requirePermission('create', 'roles'), iamController.createRole);
/**
* Only super admin has the ability to modify existing roles or create new roles.
*/
router.post(
'/roles',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.createRole
);
router.delete('/roles/:id', requirePermission('delete', 'roles'), iamController.deleteRole);
router.delete(
'/roles/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.deleteRole
);
router.put('/roles/:id', requirePermission('update', 'roles'), iamController.updateRole);
router.put(
'/roles/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.updateRole
);
return router;
};

View File

@@ -13,11 +13,26 @@ export const createUserRouter = (authService: AuthService): Router => {
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
router.post('/', requirePermission('create', 'users'), userController.createUser);
/**
* Only super admin has the ability to modify existing users or create new users.
*/
router.post(
'/',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.createUser
);
router.put('/:id', requirePermission('update', 'users'), userController.updateUser);
router.put(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.updateUser
);
router.delete('/:id', requirePermission('delete', 'users'), userController.deleteUser);
router.delete(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.deleteUser
);
return router;
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
}
]
}

View File

@@ -43,6 +43,7 @@ export const roles = pgTable('roles', {
.$type<CaslPolicy[]>()
.notNull()
.default(sql`'[]'::jsonb`),
slug: text('slug').unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View File

@@ -5,7 +5,7 @@ const camelToSnakeCase = (str: string) =>
const relationToTableMap: Record<string, string> = {
ingestionSource: 'ingestion_sources',
// Add other relations here as needed
// TBD: Add other relations here as needed
};
function getDrizzleColumn(key: string): SQL {

View File

@@ -1,75 +1,100 @@
import { db } from '../database';
import { ingestionSources } from '../database/schema';
import { eq } from 'drizzle-orm';
const snakeToCamelCase = (str: string): string => {
return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
};
function getMeliColumn(key: string): string {
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = keyParts[1];
return `${relationName}.${columnName}`;
}
return key;
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = keyParts[1];
return `${relationName}.${columnName}`;
}
return snakeToCamelCase(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 ');
function quoteIfString(value: any): any {
if (typeof value === 'string') {
return `"${value}"`;
}
return value;
}
export async function mongoToMeli(query: Record<string, any>): Promise<string> {
const conditions: string[] = [];
for (const key of Object.keys(query)) {
const value = query[key];
if (key === '$or') {
const orConditions = await Promise.all(value.map(mongoToMeli));
conditions.push(`(${orConditions.join(' OR ')})`);
continue;
}
if (key === '$and') {
const andConditions = await Promise.all(value.map(mongoToMeli));
conditions.push(`(${andConditions.join(' AND ')})`);
continue;
}
if (key === '$not') {
conditions.push(`NOT (${await mongoToMeli(value)})`);
continue;
}
const column = getMeliColumn(key);
if (typeof value === 'object' && value !== null) {
const operator = Object.keys(value)[0];
const operand = value[operator];
switch (operator) {
case '$eq':
conditions.push(`${column} = ${quoteIfString(operand)}`);
break;
case '$ne':
conditions.push(`${column} != ${quoteIfString(operand)}`);
break;
case '$gt':
conditions.push(`${column} > ${operand}`);
break;
case '$gte':
conditions.push(`${column} >= ${operand}`);
break;
case '$lt':
conditions.push(`${column} < ${operand}`);
break;
case '$lte':
conditions.push(`${column} <= ${operand}`);
break;
case '$in':
conditions.push(`${column} IN [${operand.map(quoteIfString).join(', ')}]`);
break;
case '$nin':
conditions.push(`${column} NOT IN [${operand.map(quoteIfString).join(', ')}]`);
break;
case '$exists':
conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`);
break;
default:
// Unsupported operator
}
} else {
if (column === 'ingestionSource.userId') {
// for the userId placeholder. (Await for a more elegant solution)
const ingestionsIds = await db
.select({ id: ingestionSources.id })
.from(ingestionSources)
.where(eq(ingestionSources.userId, value));
conditions.push(
`ingestionSourceId IN [${ingestionsIds.map((i) => quoteIfString(i.id)).join(', ')}]`
);
} else {
conditions.push(`${column} = ${quoteIfString(value)}`);
}
}
}
return conditions.join(' AND ');
}

View File

@@ -68,19 +68,41 @@ function translateIngestionConditionsToArchive(
function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] {
const expandedPolicies: CaslPolicy[] = JSON.parse(JSON.stringify(policies));
policies.forEach((policy) => {
if (policy.subject === 'ingestion') {
// Create a set of all actions that are already explicitly defined for the 'archive' subject.
const existingArchiveActions = new Set<string>();
policies.forEach((p) => {
if (p.subject === 'archive') {
const actions = Array.isArray(p.action) ? p.action : [p.action];
actions.forEach((a) => existingArchiveActions.add(a));
}
// Only expand `can` rules for the 'ingestion' subject.
if (p.subject === 'ingestion' && !p.inverted) {
const policyActions = Array.isArray(p.action) ? p.action : [p.action];
// Check if any action in the current ingestion policy already has an explicit archive policy.
const hasExplicitArchiveRule = policyActions.some(
(a) => existingArchiveActions.has(a) || existingArchiveActions.has('manage')
);
// If a more specific rule for 'archive' already exists, do not expand this ingestion rule,
// as it would create a conflicting, overly permissive rule.
if (hasExplicitArchiveRule) {
return;
}
const archivePolicy: CaslPolicy = {
...JSON.parse(JSON.stringify(policy)),
...JSON.parse(JSON.stringify(p)),
subject: 'archive',
};
if (policy.conditions) {
archivePolicy.conditions = translateIngestionConditionsToArchive(policy.conditions);
if (p.conditions) {
archivePolicy.conditions = translateIngestionConditionsToArchive(p.conditions);
}
expandedPolicies.push(archivePolicy);
}
});
policies.forEach((policy) => {});
return expandedPolicies;
}

View File

@@ -9,7 +9,6 @@ const validActions: Set<AppActions> = new Set([
'delete',
'search',
'export',
'assign',
'sync',
]);

View File

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

View File

@@ -0,0 +1,17 @@
[
{
"action": ["read", "search"],
"subject": "ingestion",
"conditions": {
"id": "f16b7ed2-4e54-4283-9556-c633726f9405"
}
},
{
"inverted": true,
"action": ["read", "search"],
"subject": "archive",
"conditions": {
"userEmail": "dev@openarchiver.com"
}
}
]

View File

@@ -3,14 +3,12 @@
"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"] }
"id": {
"$in": [
"aeafbe44-d41c-4015-ac27-504f6e0c511a",
"f16b7ed2-4e54-4283-9556-c633726f9405"
]
}
}
}
]

View File

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

View File

@@ -1,11 +1,6 @@
[
{
"action": ["read", "search"],
"subject": ["ingestion", "archive", "dashboard"]
},
{
"inverted": true,
"action": ["create", "update", "delete"],
"subject": ["ingestion", "users", "roles"]
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
}
]

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { count, desc, eq, asc, and } from 'drizzle-orm';
import { db } from '../database';
import { archivedEmails, attachments, emailAttachments, ingestionSources } from '../database/schema';
import {
archivedEmails,
attachments,
emailAttachments,
ingestionSources,
} from '../database/schema';
import { FilterBuilder } from './FilterBuilder';
import { AuthorizationService } from './AuthorizationService';
import type {
@@ -48,10 +53,7 @@ export class ArchivedEmailService {
): Promise<PaginatedArchivedEmails> {
const offset = (page - 1) * limit;
const { drizzleFilter } = await FilterBuilder.create(userId, 'archive', 'read');
const where = and(
eq(archivedEmails.ingestionSourceId, ingestionSourceId),
drizzleFilter
);
const where = and(eq(archivedEmails.ingestionSourceId, ingestionSourceId), drizzleFilter);
const countQuery = db
.select({

View File

@@ -2,6 +2,7 @@ import { SQL, sql } from 'drizzle-orm';
import { IamService } from './IamService';
import { rulesToQuery } from '@casl/ability/extra';
import { mongoToDrizzle } from '../helpers/mongoToDrizzle';
import { mongoToMeli } from '../helpers/mongoToMeli';
import { AppActions, AppSubjects } from '@open-archiver/types';
export class FilterBuilder {
@@ -11,31 +12,47 @@ export class FilterBuilder {
action: AppActions
): Promise<{
drizzleFilter: SQL | undefined;
mongoFilter: Record<string, any> | null;
searchFilter: string | undefined;
}> {
const iamService = new IamService();
const ability = await iamService.getAbilityForUser(userId);
// 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
}
// If the user has an unconditional `can` rule and no `cannot` rules,
// they have full access and we can skip building a complex query.
const rules = ability.rulesFor(action, resourceType);
const hasUnconditionalCan = rules.some(
(rule) => rule.inverted === false && !rule.conditions
);
const cannotConditions = rules
.filter((rule) => rule.inverted === true && rule.conditions)
.map((rule) => rule.conditions as object);
if (hasUnconditionalCan && cannotConditions.length === 0) {
return { drizzleFilter: undefined, searchFilter: undefined }; // Full access
}
let query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions);
if (hasUnconditionalCan && cannotConditions.length > 0) {
// If there's a broad `can` rule, the final query should be an AND of all
// the `cannot` conditions, effectively excluding them.
const andConditions = cannotConditions.map((condition) => {
const newCondition: Record<string, any> = {};
for (const key in condition) {
newCondition[key] = { $ne: (condition as any)[key] };
}
return newCondition;
});
query = { $and: andConditions };
}
const query = rulesToQuery(ability, action, resourceType, (rule) => rule.conditions);
if (query === null) {
return { drizzleFilter: undefined, mongoFilter: null }; // Full access
return { drizzleFilter: undefined, searchFilter: undefined }; // Full access
}
if (Object.keys(query).length === 0) {
return { drizzleFilter: sql`1=0`, mongoFilter: {} }; // No access
return { drizzleFilter: sql`1=0`, searchFilter: 'ingestionSourceId = "-1"' }; // No access
}
return { drizzleFilter: mongoToDrizzle(query), mongoFilter: query };
return { drizzleFilter: mongoToDrizzle(query), searchFilter: await mongoToMeli(query) };
}
}

View File

@@ -28,8 +28,15 @@ export class IamService {
return role;
}
public async createRole(name: string, policy: CaslPolicy[]): Promise<Role> {
const [role] = await db.insert(roles).values({ name, policies: policy }).returning();
public async createRole(name: string, policy: CaslPolicy[], slug?: string): Promise<Role> {
const [role] = await db
.insert(roles)
.values({
name: name,
slug: slug || name.toLocaleLowerCase().replaceAll('', '_'),
policies: policy,
})
.returning();
return role;
}
@@ -61,13 +68,11 @@ export class IamService {
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);
}

View File

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

View File

@@ -407,6 +407,8 @@ export class IngestionService {
searchService,
storageService
);
//assign userEmail
email.userEmail = userEmail
await indexingService.indexByEmail(email, source.id, archivedEmail.id);
} catch (error) {
logger.error({

View File

@@ -2,7 +2,6 @@ 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;
@@ -74,19 +73,18 @@ export class SearchService {
// 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) {
const { searchFilter } = await FilterBuilder.create(userId, 'archive', 'read');
if (searchFilter) {
// 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}`;
searchParams.filter = `${searchParams.filter} AND ${searchFilter}`;
} else {
// Otherwise, just use the access control filter.
searchParams.filter = meliFilter;
searchParams.filter = searchFilter;
}
}
console.log('searchParams', searchParams);
const searchResults = await index.search(query, searchParams);
return {
@@ -133,8 +131,17 @@ export class SearchService {
'bcc',
'attachments.filename',
'attachments.content',
'userEmail',
],
filterableAttributes: [
'from',
'to',
'cc',
'bcc',
'timestamp',
'ingestionSourceId',
'userEmail',
],
filterableAttributes: ['from', 'to', 'cc', 'bcc', 'timestamp', 'ingestionSourceId'],
sortableAttributes: ['timestamp'],
});
}

View File

@@ -162,6 +162,7 @@ export class UserService {
.insert(schema.roles)
.values({
name: 'Super Admin',
slug: 'predefined_super_admin',
policies: suerAdminPolicies,
})
.returning()

View File

@@ -34,7 +34,12 @@
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="policies" class="text-right">Policies (JSON)</Label>
<Textarea id="policies" bind:value={policies} class="col-span-3" rows={10} />
<Textarea
id="policies"
bind:value={policies}
class="col-span-3 max-h-96 overflow-y-auto"
rows={10}
/>
</div>
<div class="flex justify-end">
<Button type="submit">Save</Button>

View File

@@ -1,19 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import * as Alert from '$lib/components/ui/alert/index.js';
</script>
<div class="flex h-full w-full flex-col items-center justify-center">
<div class="bg-card space-y-4 rounded-lg border p-10 text-center shadow-sm">
<h1 class="text-destructive text-4xl font-bold">{page.status}</h1>
<p class="text-muted-foreground mt-4 text-lg">Oops! Something went wrong.</p>
<p class=" bg-muted mt-2 rounded-md p-4 font-mono text-base">
{page.error?.message}
</p>
<div>
<a href="/dashboard" class="mt-6">
<Button>Go to Dashboard</Button>
</a>
</div>
</div>
<div class="flex h-full w-full flex-col items-center justify-center space-y-4">
<Alert.Root variant="destructive">
<CircleAlertIcon class="size-4" />
<Alert.Title>
<h1 class=" font-bold">Error: {page.status}</h1>
</Alert.Title>
<Alert.Description>
<div class=" space-y-2">
<div>
{page.error?.message}
</div>
</div>
</Alert.Description>
</Alert.Root>
</div>

View File

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

View File

@@ -7,9 +7,12 @@ export const load: PageServerLoad = async (event) => {
try {
const { id } = event.params;
const response = await api(`/archived-emails/${id}`, event);
const responseText = await response.json()
const responseText = await response.json();
if (!response.ok) {
return error(response.status, responseText.message || 'You do not have permission to read this email.')
return error(
response.status,
responseText.message || 'You do not have permission to read this email.'
);
}
const email: ArchivedEmail = responseText;
return {

View File

@@ -71,6 +71,10 @@
}
</script>
<svelte:head>
<title>{email?.subject} | Archived emails - OpenArchiver</title>
</svelte:head>
{#if email}
<div class="grid grid-cols-3 gap-6">
<div class="col-span-3 md:col-span-2">

View File

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

View File

@@ -188,7 +188,9 @@
Viewing policy for role: {selectedRole?.name}
</Dialog.Description>
</Dialog.Header>
<div class="rounded-md bg-gray-900 p-2 text-white">
<div
class=" max-h-98 overflow-x-auto overflow-y-auto rounded-md bg-gray-900 p-2 text-white"
>
<pre>{JSON.stringify(selectedRole?.policies, null, 2)}</pre>
</div>
</Dialog.Content>

View File

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

View File

@@ -7,7 +7,6 @@ export type AppActions =
| 'delete'
| 'search'
| 'export'
| 'assign'
| 'sync';
export type AppSubjects =

View File

@@ -29,6 +29,7 @@ export interface Session {
*/
export interface Role {
id: string;
slug: string | null;
name: string;
policies: CaslPolicy[];
createdAt: Date;