feat: Role based access control (#58)

* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

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

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
This commit is contained in:
Wei S.
2025-08-21 23:45:06 +03:00
committed by GitHub
parent 2b325f3461
commit 8c33b63bdf
68 changed files with 4277 additions and 543 deletions

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { Request, Response } from 'express';
import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { PolicyStatement } from '@open-archiver/types';
import type { CaslPolicy } from '@open-archiver/types';
import { logger } from '../../config/logger';
export class IamController {
#iamService: IamService;
@@ -12,10 +13,15 @@ export class IamController {
public getRoles = async (req: Request, res: Response): Promise<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({ error: 'Failed to get roles.' });
res.status(500).json({ message: 'Failed to get roles.' });
}
};
@@ -27,34 +33,35 @@ export class IamController {
if (role) {
res.status(200).json(role);
} else {
res.status(404).json({ error: 'Role not found.' });
res.status(404).json({ message: 'Role not found.' });
}
} catch (error) {
res.status(500).json({ error: 'Failed to get role.' });
res.status(500).json({ message: 'Failed to get role.' });
}
};
public createRole = async (req: Request, res: Response): Promise<void> => {
const { name, policy } = req.body;
const { name, policies } = req.body;
if (!name || !policy) {
res.status(400).json({ error: 'Missing required fields: name and policy.' });
if (!name || !policies) {
res.status(400).json({ message: 'Missing required fields: name and policy.' });
return;
}
for (const statement of policy) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;
}
}
try {
const role = await this.#iamService.createRole(name, policy);
const role = await this.#iamService.createRole(name, policies);
res.status(201).json(role);
} catch (error) {
res.status(500).json({ error: 'Failed to create role.' });
console.log(error);
res.status(500).json({ message: 'Failed to create role.' });
}
};
@@ -65,7 +72,74 @@ export class IamController {
await this.#iamService.deleteRole(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete role.' });
res.status(500).json({ message: 'Failed to delete role.' });
}
};
public updateRole = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
const { name, policies } = req.body;
if (!name && !policies) {
res.status(400).json({ message: 'Missing fields to update: name or policies.' });
return;
}
if (policies) {
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;
}
}
}
try {
const role = await this.#iamService.updateRole(id, { name, policies });
res.status(200).json(role);
} catch (error) {
res.status(500).json({ message: 'Failed to update role.' });
}
};
private createDefaultRoles = async () => {
try {
// end user who can manage its own data, and create new ingestions.
await this.#iamService.createRole(
'End user',
[
{
action: 'create',
subject: 'ingestion',
},
{
action: 'read',
subject: 'dashboard',
},
{
action: 'manage',
subject: 'ingestion',
conditions: {
userId: '${user.id}',
},
},
],
'predefined_end_user'
);
// read only
await this.#iamService.createRole(
'Read only',
[
{
action: ['read', 'search'],
subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'],
},
],
'predefined_read_only_user'
);
} catch (error) {
logger.error({}, 'Failed to create default roles');
}
};
}

View File

@@ -27,7 +27,11 @@ export class IngestionController {
}
try {
const dto: CreateIngestionSourceDto = req.body;
const newSource = await IngestionService.create(dto);
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const newSource = await IngestionService.create(dto, userId);
const safeSource = this.toSafeIngestionSource(newSource);
return res.status(201).json(safeSource);
} catch (error: any) {
@@ -42,7 +46,11 @@ export class IngestionController {
public findAll = async (req: Request, res: Response): Promise<Response> => {
try {
const sources = await IngestionService.findAll();
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const sources = await IngestionService.findAll(userId);
const safeSources = sources.map(this.toSafeIngestionSource);
return res.status(200).json(safeSources);
} catch (error) {

View File

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

View File

@@ -0,0 +1,56 @@
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
import { db } from '../../database';
const userService = new UserService();
export const getUsers = async (req: Request, res: Response) => {
const users = await userService.findAll();
res.json(users);
};
export const getUser = async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
};
export const createUser = async (req: Request, res: Response) => {
const { email, first_name, last_name, password, roleId } = req.body;
const newUser = await userService.createUser(
{ email, first_name, last_name, password },
roleId
);
res.status(201).json(newUser);
};
export const updateUser = async (req: Request, res: Response) => {
const { email, first_name, last_name, roleId } = req.body;
const updatedUser = await userService.updateUser(
req.params.id,
{ email, first_name, last_name },
roleId
);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.json(updatedUser);
};
export const deleteUser = async (req: Request, res: Response) => {
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
console.log('iusercount,', userCountResult[0].count);
const isOnlyUser = Number(userCountResult[0].count) === 1;
if (isOnlyUser) {
return res.status(400).json({
message: 'You are trying to delete the only user in the database, this is not allowed.',
});
}
await userService.deleteUser(req.params.id);
res.status(204).send();
};

View File

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

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { ArchivedEmailController } from '../controllers/archived-email.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createArchivedEmailRouter = (
@@ -12,11 +13,23 @@ export const createArchivedEmailRouter = (
// Secure all routes in this module
router.use(requireAuth(authService));
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
router.get(
'/ingestion-source/:ingestionSourceId',
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmails
);
router.get('/:id', archivedEmailController.getArchivedEmailById);
router.get(
'/:id',
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmailById
);
router.delete('/:id', archivedEmailController.deleteArchivedEmail);
router.delete(
'/:id',
requirePermission('delete', 'archive'),
archivedEmailController.deleteArchivedEmail
);
return router;
};

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { IngestionController } from '../controllers/ingestion.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createIngestionRouter = (
@@ -12,21 +13,29 @@ export const createIngestionRouter = (
// Secure all routes in this module
router.use(requireAuth(authService));
router.post('/', ingestionController.create);
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
router.get('/', ingestionController.findAll);
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
router.get('/:id', ingestionController.findById);
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
router.put('/:id', ingestionController.update);
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
router.delete('/:id', ingestionController.delete);
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
router.post('/:id/import', ingestionController.triggerInitialImport);
router.post(
'/:id/import',
requirePermission('create', 'ingestion'),
ingestionController.triggerInitialImport
);
router.post('/:id/pause', ingestionController.pause);
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
router.post('/:id/sync', ingestionController.triggerForceSync);
router.post(
'/:id/sync',
requirePermission('sync', 'ingestion'),
ingestionController.triggerForceSync
);
return router;
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import * as userController from '../controllers/user.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createUserRouter = (authService: AuthService): Router => {
const router = Router();
router.use(requireAuth(authService));
router.get('/', requirePermission('read', 'users'), userController.getUsers);
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
/**
* Only super admin has the ability to modify existing users or create new users.
*/
router.post(
'/',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.createUser
);
router.put(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.updateUser
);
router.delete(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.deleteUser
);
return router;
};

View File

@@ -0,0 +1,2 @@
ALTER TABLE "ingestion_sources" ADD COLUMN "user_id" uuid;--> statement-breakpoint
ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,20 @@
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
}
]
}

View File

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

View File

@@ -1,6 +1,6 @@
import { relations, sql } from 'drizzle-orm';
import { pgTable, text, timestamp, uuid, primaryKey, jsonb } from 'drizzle-orm/pg-core';
import type { PolicyStatement } from '@open-archiver/types';
import type { CaslPolicy } from '@open-archiver/types';
/**
* The `users` table stores the core user information for authentication and identification.
@@ -40,9 +40,10 @@ 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`),
slug: text('slug').unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

View File

@@ -0,0 +1,95 @@
import { SQL, and, or, not, eq, gt, gte, lt, lte, inArray, isNull, sql } from 'drizzle-orm';
const camelToSnakeCase = (str: string) =>
str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
const relationToTableMap: Record<string, string> = {
ingestionSource: 'ingestion_sources',
// TBD: Add other relations here as needed
};
function getDrizzleColumn(key: string): SQL {
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = camelToSnakeCase(keyParts[1]);
const tableName = relationToTableMap[relationName];
if (tableName) {
return sql.raw(`"${tableName}"."${columnName}"`);
}
}
return sql`${sql.identifier(camelToSnakeCase(key))}`;
}
export function mongoToDrizzle(query: Record<string, any>): SQL | undefined {
const conditions: (SQL | undefined)[] = [];
for (const key in query) {
const value = query[key];
if (key === '$or') {
conditions.push(or(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
continue;
}
if (key === '$and') {
conditions.push(and(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
continue;
}
if (key === '$not') {
const subQuery = mongoToDrizzle(value);
if (subQuery) {
conditions.push(not(subQuery));
}
continue;
}
const column = getDrizzleColumn(key);
if (typeof value === 'object' && value !== null) {
const operator = Object.keys(value)[0];
const operand = value[operator];
switch (operator) {
case '$eq':
conditions.push(eq(column, operand));
break;
case '$ne':
conditions.push(not(eq(column, operand)));
break;
case '$gt':
conditions.push(gt(column, operand));
break;
case '$gte':
conditions.push(gte(column, operand));
break;
case '$lt':
conditions.push(lt(column, operand));
break;
case '$lte':
conditions.push(lte(column, operand));
break;
case '$in':
conditions.push(inArray(column, operand));
break;
case '$nin':
conditions.push(not(inArray(column, operand)));
break;
case '$exists':
conditions.push(operand ? not(isNull(column)) : isNull(column));
break;
default:
// Unsupported operator
}
} else {
conditions.push(eq(column, value));
}
}
if (conditions.length === 0) {
return undefined;
}
return and(...conditions.filter((c): c is SQL => c !== undefined));
}

View File

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

View File

@@ -0,0 +1,114 @@
// packages/backend/src/iam-policy/ability.ts
import { createMongoAbility, MongoAbility, RawRuleOf } from '@casl/ability';
import { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
import { ingestionSources, archivedEmails, users, roles } from '../database/schema';
import { InferSelectModel } from 'drizzle-orm';
// Define the application's ability type
export type AppAbility = MongoAbility<[AppActions, AppSubjects]>;
// Helper type for raw rules
export type AppRawRule = RawRuleOf<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));
// Create a set of all actions that are already explicitly defined for the 'archive' subject.
const existingArchiveActions = new Set<string>();
policies.forEach((p) => {
if (p.subject === 'archive') {
const actions = Array.isArray(p.action) ? p.action : [p.action];
actions.forEach((a) => existingArchiveActions.add(a));
}
// Only expand `can` rules for the 'ingestion' subject.
if (p.subject === 'ingestion' && !p.inverted) {
const policyActions = Array.isArray(p.action) ? p.action : [p.action];
// Check if any action in the current ingestion policy already has an explicit archive policy.
const hasExplicitArchiveRule = policyActions.some(
(a) => existingArchiveActions.has(a) || existingArchiveActions.has('manage')
);
// If a more specific rule for 'archive' already exists, do not expand this ingestion rule,
// as it would create a conflicting, overly permissive rule.
if (hasExplicitArchiveRule) {
return;
}
const archivePolicy: CaslPolicy = {
...JSON.parse(JSON.stringify(p)),
subject: 'archive',
};
if (p.conditions) {
archivePolicy.conditions = translateIngestionConditionsToArchive(p.conditions);
}
expandedPolicies.push(archivePolicy);
}
});
policies.forEach((policy) => {});
return expandedPolicies;
}
// Function to create an ability instance from policies stored in the database
export function createAbilityFor(policies: CaslPolicy[]) {
const allPolicies = expandPolicies(policies);
return createMongoAbility<AppAbility>(allPolicies as AppRawRule[]);
}

View File

@@ -1,116 +0,0 @@
/**
* @file This file serves as the single source of truth for all Identity and Access Management (IAM)
* definitions within Open Archiver. Centralizing these definitions is an industry-standard practice
* that offers several key benefits:
*
* 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources
* throughout the codebase, reducing the risk of typos and inconsistencies.
* 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all
* possible permissions in the system.
* 3. **Enables Validation**: Allows for the creation of a robust validation function that can
* programmatically check if a policy statement is valid before it is saved.
* 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions
* that can be granted.
*
* The structure is inspired by AWS IAM, using a `service:operation` format for actions and a
* hierarchical, slash-separated path for resources.
*/
// ===================================================================================
// SERVICE: archive
// ===================================================================================
const ARCHIVE_ACTIONS = {
READ: 'archive:read',
SEARCH: 'archive:search',
EXPORT: 'archive:export',
} as const;
const ARCHIVE_RESOURCES = {
ALL: 'archive/all',
INGESTION_SOURCE: 'archive/ingestion-source/*',
MAILBOX: 'archive/mailbox/*',
CUSTODIAN: 'archive/custodian/*',
} as const;
// ===================================================================================
// SERVICE: ingestion
// ===================================================================================
const INGESTION_ACTIONS = {
CREATE_SOURCE: 'ingestion:createSource',
READ_SOURCE: 'ingestion:readSource',
UPDATE_SOURCE: 'ingestion:updateSource',
DELETE_SOURCE: 'ingestion:deleteSource',
MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs
} as const;
const INGESTION_RESOURCES = {
ALL: 'ingestion-source/*',
SOURCE: 'ingestion-source/{sourceId}',
} as const;
// ===================================================================================
// SERVICE: system
// ===================================================================================
const SYSTEM_ACTIONS = {
READ_SETTINGS: 'system:readSettings',
UPDATE_SETTINGS: 'system:updateSettings',
READ_USERS: 'system:readUsers',
CREATE_USER: 'system:createUser',
UPDATE_USER: 'system:updateUser',
DELETE_USER: 'system:deleteUser',
ASSIGN_ROLE: 'system:assignRole',
} as const;
const SYSTEM_RESOURCES = {
SETTINGS: 'system/settings',
USERS: 'system/users',
USER: 'system/user/{userId}',
} as const;
// ===================================================================================
// SERVICE: dashboard
// ===================================================================================
const DASHBOARD_ACTIONS = {
READ: 'dashboard:read',
} as const;
const DASHBOARD_RESOURCES = {
ALL: 'dashboard/*',
} as const;
// ===================================================================================
// EXPORTED DEFINITIONS
// ===================================================================================
/**
* A comprehensive set of all valid IAM actions in the system.
* This is used by the policy validator to ensure that any action in a policy is recognized.
*/
export const ValidActions: Set<string> = new Set([
...Object.values(ARCHIVE_ACTIONS),
...Object.values(INGESTION_ACTIONS),
...Object.values(SYSTEM_ACTIONS),
...Object.values(DASHBOARD_ACTIONS),
]);
/**
* An object containing regular expressions for validating resource formats.
* The validator uses these patterns to ensure that resource strings in a policy
* conform to the expected structure.
*
* Logic:
* - The key represents the service (e.g., 'archive').
* - The value is a RegExp that matches all valid resource formats for that service.
* - This allows for flexible validation. For example, `archive/*` is a valid pattern,
* as is `archive/email/123-abc`.
*/
export const ValidResourcePatterns = {
archive: /^archive\/(all|ingestion-source\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/,
ingestion: /^ingestion-source\/(\*|[^\/]+)$/,
system: /^system\/(settings|users|user\/[^\/]+)$/,
dashboard: /^dashboard\/\*$/,
};

View File

@@ -1,106 +1,99 @@
import type { PolicyStatement } from '@open-archiver/types';
import { 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',
'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 } {
const service = resource.split('/')[0];
if (service === '*') {
private static isSubjectValid(subject: AppSubjects): { valid: boolean; reason: string } {
if (validSubjects.has(subject)) {
return { valid: true, reason: 'valid' };
}
if (service in ValidResourcePatterns) {
const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns];
if (pattern.test(resource)) {
return { valid: true, reason: 'valid' };
}
return {
valid: false,
reason: `Resource '${resource}' does not match the expected format for the '${service}' service.`,
};
}
return { valid: false, reason: `Invalid service '${service}' in resource '${resource}'.` };
return { valid: false, reason: `Subject '${subject}' is not a valid subject.` };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { IamService } from './IamService';
import { createAbilityFor, SubjectObject } from '../iam-policy/ability';
import { subject, Subject } from '@casl/ability';
import { AppActions, AppSubjects } from '@open-archiver/types';
export class AuthorizationService {
private iamService: IamService;
constructor() {
this.iamService = new IamService();
}
public async can(
userId: string,
action: AppActions,
resource: AppSubjects,
resourceObject?: SubjectObject
): Promise<boolean> {
const ability = await this.iamService.getAbilityForUser(userId);
const subjectInstance = resourceObject
? subject(resource, resourceObject as Record<PropertyKey, any>)
: resource;
return ability.can(action, subjectInstance as AppSubjects);
}
}

View File

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

View File

@@ -1,9 +1,24 @@
import { db } from '../database';
import { roles } from '../database/schema/users';
import type { Role, PolicyStatement } from '@open-archiver/types';
import { roles, userRoles, users } from '../database/schema/users';
import type { Role, CaslPolicy, User } from '@open-archiver/types';
import { eq } from 'drizzle-orm';
import { createAbilityFor, AppAbility } from '../iam-policy/ability';
export class IamService {
/**
* Retrieves all roles associated with a given user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of Role objects.
*/
public async getRolesForUser(userId: string): Promise<Role[]> {
const userRolesResult = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userId))
.leftJoin(roles, eq(userRoles.roleId, roles.id));
return userRolesResult.map((r) => r.roles).filter((r): r is Role => r !== null);
}
public async getRoles(): Promise<Role[]> {
return db.select().from(roles);
}
@@ -13,12 +28,57 @@ export class IamService {
return role;
}
public async createRole(name: string, policy: PolicyStatement[]): 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;
}
public async deleteRole(id: string): Promise<void> {
await db.delete(roles).where(eq(roles.id, id));
}
public async updateRole(
id: string,
{ name, policies }: Partial<Pick<Role, 'name' | 'policies'>>
): Promise<Role> {
const [role] = await db
.update(roles)
.set({ name, policies })
.where(eq(roles.id, id))
.returning();
return role;
}
public async getAbilityForUser(userId: string): Promise<AppAbility> {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!user) {
// Or handle this case as you see fit, maybe return an ability with no permissions
throw new Error('User not found');
}
const userRoles = await this.getRolesForUser(userId);
const allPolicies = userRoles.flatMap((role) => role.policies || []);
// Interpolate policies
const interpolatedPolicies = this.interpolatePolicies(allPolicies, {
...user,
role: null,
} as User);
return createAbilityFor(interpolatedPolicies);
}
private interpolatePolicies(policies: CaslPolicy[], user: User): CaslPolicy[] {
const userPoliciesString = JSON.stringify(policies);
const interpolatedPoliciesString = userPoliciesString.replace(/\$\{user\.id\}/g, user.id);
return JSON.parse(interpolatedPoliciesString);
}
}

View File

@@ -66,7 +66,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

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

View File

@@ -1,6 +1,7 @@
import { Index, MeiliSearch, SearchParams } from 'meilisearch';
import { config } from '../config';
import type { SearchQuery, SearchResult, EmailDocument, TopSender } from '@open-archiver/types';
import { FilterBuilder } from './FilterBuilder';
export class SearchService {
private client: MeiliSearch;
@@ -47,7 +48,7 @@ export class SearchService {
return index.deleteDocuments({ filter });
}
public async searchEmails(dto: SearchQuery): Promise<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 +71,20 @@ export class SearchService {
searchParams.filter = filterStrings.join(' AND ');
}
// Create a filter based on the user's permissions.
// This ensures that the user can only search for emails they are allowed to see.
const { searchFilter } = await FilterBuilder.create(userId, 'archive', 'read');
if (searchFilter) {
// Convert the MongoDB-style filter from CASL to a MeiliSearch filter string.
if (searchParams.filter) {
// If there are existing filters, append the access control filter.
searchParams.filter = `${searchParams.filter} AND ${searchFilter}`;
} else {
// Otherwise, just use the access control filter.
searchParams.filter = searchFilter;
}
}
console.log('searchParams', searchParams);
const searchResults = await index.search(query, searchParams);
return {
@@ -116,8 +131,17 @@ export class SearchService {
'bcc',
'attachments.filename',
'attachments.content',
'userEmail',
],
filterableAttributes: [
'from',
'to',
'cc',
'bcc',
'timestamp',
'ingestionSourceId',
'userEmail',
],
filterableAttributes: ['from', 'to', 'cc', 'bcc', 'timestamp', 'ingestionSourceId'],
sortableAttributes: ['timestamp'],
});
}

View File

@@ -1,9 +1,8 @@
import { db } from '../database';
import * as schema from '../database/schema';
import { and, eq, asc, sql } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import type { PolicyStatement, User } from '@open-archiver/types';
import { PolicyValidator } from '../iam-policy/policy-validator';
import type { CaslPolicy, User } from '@open-archiver/types';
export class UserService {
/**
@@ -23,11 +22,91 @@ export class UserService {
* @param id The ID of the user to find.
* @returns The user object if found, otherwise null.
*/
public async findById(id: string): Promise<typeof schema.users.$inferSelect | null> {
public async findById(id: string): Promise<User | null> {
const user = await db.query.users.findFirst({
where: eq(schema.users.id, id),
with: {
userRoles: {
with: {
role: true,
},
},
},
});
return user || null;
if (!user) return null;
return {
...user,
role: user.userRoles[0]?.role || null,
};
}
public async findAll(): Promise<User[]> {
const users = await db.query.users.findMany({
with: {
userRoles: {
with: {
role: true,
},
},
},
});
return users.map((u) => ({
...u,
role: u.userRoles[0]?.role || null,
}));
}
public async createUser(
userDetails: Pick<User, 'email' | 'first_name' | 'last_name'> & { password?: string },
roleId: string
): Promise<typeof schema.users.$inferSelect> {
const { email, first_name, last_name, password } = userDetails;
const hashedPassword = password ? await hash(password, 10) : undefined;
const newUser = await db
.insert(schema.users)
.values({
email,
first_name,
last_name,
password: hashedPassword,
})
.returning();
await db.insert(schema.userRoles).values({
userId: newUser[0].id,
roleId: roleId,
});
return newUser[0];
}
public async updateUser(
id: string,
userDetails: Partial<Pick<User, 'email' | 'first_name' | 'last_name'>>,
roleId?: string
): Promise<typeof schema.users.$inferSelect | null> {
const updatedUser = await db
.update(schema.users)
.set(userDetails)
.where(eq(schema.users.id, id))
.returning();
if (roleId) {
await db.delete(schema.userRoles).where(eq(schema.userRoles.userId, id));
await db.insert(schema.userRoles).values({
userId: id,
roleId: roleId,
});
}
return updatedUser[0] || null;
}
public async deleteUser(id: string): Promise<void> {
await db.delete(schema.users).where(eq(schema.users.id, id));
}
/**
@@ -72,11 +151,10 @@ export class UserService {
});
if (!superAdminRole) {
const suerAdminPolicies: PolicyStatement[] = [
const suerAdminPolicies: CaslPolicy[] = [
{
Effect: 'Allow',
Action: ['*'],
Resource: ['*'],
action: 'manage',
subject: 'all',
},
];
superAdminRole = (
@@ -84,6 +162,7 @@ export class UserService {
.insert(schema.roles)
.values({
name: 'Super Admin',
slug: 'predefined_super_admin',
policies: suerAdminPolicies,
})
.returning()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,56 +1,52 @@
import { api } from '$lib/server/api';
import { error } from '@sveltejs/kit';
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
);
if (!emailsResponse.ok) {
throw new Error(`Failed to fetch archived emails: ${emailsResponse.statusText}`);
}
archivedEmails = await emailsResponse.json();
}
return {
ingestionSources,
archivedEmails,
selectedIngestionSourceId,
};
} catch (error) {
console.error('Failed to load archived emails page:', error);
return {
ingestionSources: [],
archivedEmails: {
items: [],
total: 0,
page: 1,
limit: 10,
},
error: 'Failed to load data',
};
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

@@ -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,14 @@ export const load: PageServerLoad = async (event) => {
try {
const { id } = event.params;
const response = await api(`/archived-emails/${id}`, event);
const responseText = await response.json();
if (!response.ok) {
throw new Error(`Failed to fetch archived email: ${response.statusText}`);
return error(
response.status,
responseText.message || 'You do not have permission to read this email.'
);
}
const email: ArchivedEmail = await response.json();
const email: ArchivedEmail = responseText;
return {
email,
};

View File

@@ -9,6 +9,7 @@
import { formatBytes } from '$lib/utils';
import { goto } from '$app/navigation';
import * as Dialog from '$lib/components/ui/dialog';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
let { data }: { data: PageData } = $props();
let email = $derived(data.email);
@@ -51,7 +52,13 @@
const errorData = await response.json().catch(() => null);
const message = errorData?.message || 'Failed to delete email';
console.error('Delete failed:', message);
alert(message);
setAlert({
type: 'error',
title: 'Failed to delete archived email',
message: message,
duration: 5000,
show: true,
});
return;
}
await goto('/dashboard/archived-emails', { invalidateAll: true });
@@ -64,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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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