Role based access (#61)

* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

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

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

* Format

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
This commit is contained in:
Wei S.
2025-08-23 23:19:51 +03:00
committed by GitHub
parent f651aeab0e
commit 61e44c81f7
9 changed files with 376 additions and 45 deletions

View File

@@ -75,17 +75,15 @@ export class AuthController {
public status = async (req: Request, res: Response): Promise<Response> => {
try {
const users = await db
.select()
.from(schema.users);
const users = await db.select().from(schema.users);
/**
* Check the situation where the only user has "Super Admin" role, but they don't actually have Super Admin permission because the role was set up in an earlier version, we need to change that "Super Admin" role to a the one used in the current version.
* Check the situation where the only user has "Super Admin" role, but they don't actually have Super Admin permission because the role was set up in an earlier version, we need to change that "Super Admin" role to the one used in the current version.
*/
if (users.length === 1) {
const iamService = new IamService()
const userRoles = await iamService.getRolesForUser(users[0].id)
if (userRoles.some(r => r.name === 'Super Admin')) {
const iamService = new IamService();
const userRoles = await iamService.getRolesForUser(users[0].id);
if (userRoles.some((r) => r.name === 'Super Admin')) {
const authorizationService = new AuthorizationService();
const hasAdminPermission = await authorizationService.can(
users[0].id,
@@ -99,12 +97,18 @@ export class AuthController {
subject: 'all',
},
];
await db.update(schema.roles).set({ policies: suerAdminPolicies }).where(eq(schema.roles.name, 'Super Admin'))
await db
.update(schema.roles)
.set({
policies: suerAdminPolicies,
slug: 'predefined_super_admin',
})
.where(eq(schema.roles.name, 'Super Admin'));
}
}
}
// in case user uses older version with admin user variables, we will create the admin user using those variables.
const needsSetupUser = users.length === 0
const needsSetupUser = users.length === 0;
if (needsSetupUser && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
await this.#userService.createAdminUser(
{

View File

@@ -3,6 +3,7 @@ import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { CaslPolicy } from '@open-archiver/types';
import { logger } from '../../config/logger';
import { config } from '../../config';
export class IamController {
#iamService: IamService;
@@ -40,7 +41,10 @@ export class IamController {
}
};
public createRole = async (req: Request, res: Response): Promise<void> => {
public createRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { name, policies } = req.body;
if (!name || !policies) {
@@ -48,15 +52,14 @@ export class IamController {
return;
}
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 {
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;
}
}
const role = await this.#iamService.createRole(name, policies);
res.status(201).json(role);
} catch (error) {
@@ -65,7 +68,10 @@ export class IamController {
}
};
public deleteRole = async (req: Request, res: Response): Promise<void> => {
public deleteRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { id } = req.params;
try {
@@ -76,7 +82,10 @@ export class IamController {
}
};
public updateRole = async (req: Request, res: Response): Promise<void> => {
public updateRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { id } = req.params;
const { name, policies } = req.body;
@@ -109,14 +118,14 @@ export class IamController {
await this.#iamService.createRole(
'End user',
[
{
action: 'create',
subject: 'ingestion',
},
{
action: 'read',
subject: 'dashboard',
},
{
action: 'create',
subject: 'ingestion',
},
{
action: 'manage',
subject: 'ingestion',
@@ -124,6 +133,13 @@ export class IamController {
userId: '${user.id}',
},
},
{
action: 'manage',
subject: 'archive',
conditions: {
'ingestionSource.userId': '${user.id}',
},
},
],
'predefined_end_user'
);

View File

@@ -3,6 +3,7 @@ import { UserService } from '../../services/UserService';
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
import { db } from '../../database';
import { config } from '../../config';
const userService = new UserService();
@@ -20,6 +21,9 @@ export const getUser = async (req: Request, res: Response) => {
};
export const createUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { email, first_name, last_name, password, roleId } = req.body;
const newUser = await userService.createUser(
@@ -30,6 +34,9 @@ export const createUser = async (req: Request, res: Response) => {
};
export const updateUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { email, first_name, last_name, roleId } = req.body;
const updatedUser = await userService.updateUser(
req.params.id,
@@ -43,6 +50,9 @@ export const updateUser = async (req: Request, res: Response) => {
};
export const deleteUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
console.log('iusercount,', userCountResult[0].count);
const isOnlyUser = Number(userCountResult[0].count) === 1;

View File

@@ -17,7 +17,17 @@ export type SubjectObject =
| InferSelectModel<typeof users>
| InferSelectModel<typeof roles>
| AppSubjects;
// Function to create an ability instance from policies stored in the database
export function createAbilityFor(policies: CaslPolicy[]) {
// We will not expand policies, if a role needs access to ingestion X and its archived emails, the policy should also grant access to archives belonging to ingestion X
// const allPolicies = expandPolicies(policies);
return createMongoAbility<AppAbility>(policies as AppRawRule[]);
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* Translates conditions on an 'ingestion' subject to equivalent conditions on an 'archive' subject.
* This is used to implement inherent permissions, where permission on an ingestion source
* implies permission on the emails it has ingested.
@@ -59,6 +69,7 @@ function translateIngestionConditionsToArchive(
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* Expands the given set of policies to include inherent permissions.
* For example, a permission on an 'ingestion' source is expanded to grant
* the same permission on 'archive' records related to that source.
@@ -105,10 +116,3 @@ function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] {
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

@@ -66,7 +66,11 @@ export class IndexingService {
.where(eq(emailAttachments.emailId, emailId));
}
const document = await this.createEmailDocument(email, emailAttachmentsResult, email.userEmail);
const document = await this.createEmailDocument(
email,
emailAttachmentsResult,
email.userEmail
);
await this.searchService.addDocuments('emails', [document], 'id');
}
@@ -95,7 +99,7 @@ export class IndexingService {
archivedEmailId,
email.userEmail || ''
);
console.log(document)
console.log(document);
await this.searchService.addDocuments('emails', [document], 'id');
}
@@ -107,7 +111,7 @@ export class IndexingService {
attachments: AttachmentsType,
ingestionSourceId: string,
archivedEmailId: string,
userEmail: string //the owner of the email inbox
userEmail: string //the owner of the email inbox
): Promise<EmailDocument> {
const extractedAttachments = [];
for (const attachment of attachments) {
@@ -125,7 +129,7 @@ export class IndexingService {
// skip attachment or fail the job
}
}
console.log('email.userEmail', userEmail)
console.log('email.userEmail', userEmail);
return {
id: archivedEmailId,
userEmail: userEmail,
@@ -147,7 +151,7 @@ export class IndexingService {
private async createEmailDocument(
email: typeof archivedEmails.$inferSelect,
attachments: Attachment[],
userEmail: string,//the owner of the email inbox
userEmail: string //the owner of the email inbox
): Promise<EmailDocument> {
const attachmentContents = await this.extractAttachmentContents(attachments);
@@ -161,7 +165,7 @@ export class IndexingService {
'';
const recipients = email.recipients as DbRecipients;
console.log('email.userEmail', email.userEmail)
console.log('email.userEmail', email.userEmail);
return {
id: email.id,
userEmail: userEmail,

View File

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

View File

@@ -145,7 +145,7 @@ export class UserService {
})
.returning();
const superAdminRole = await this.createAdminRole()
const superAdminRole = await this.createAdminRole();
await db.insert(schema.userRoles).values({
userId: newUser[0].id,
@@ -179,6 +179,6 @@ export class UserService {
.returning()
)[0];
}
return superAdminRole
return superAdminRole;
}
}

View File

@@ -11,13 +11,17 @@ export const load: PageServerLoad = async (event) => {
const sourcesResponse = await api('/ingestion-sources', event);
const sourcesResponseText = await sourcesResponse.json();
let ingestionSources: IngestionSource[] = sourcesResponseText;
if (!sourcesResponse.ok) {
return error(
sourcesResponseText.status,
sourcesResponseText.message || 'Failed to load ingestion source.'
);
if (sourcesResponse.status === 403) {
ingestionSources = [];
} else {
return error(
sourcesResponse.status,
sourcesResponseText.message || 'Failed to load ingestion source.'
);
}
}
const ingestionSources: IngestionSource[] = sourcesResponseText;
let archivedEmails: PaginatedArchivedEmails = {
items: [],