diff --git a/packages/backend/src/helpers/mongoToDrizzle.ts b/packages/backend/src/helpers/mongoToDrizzle.ts index 9ea0254..dbcfe0b 100644 --- a/packages/backend/src/helpers/mongoToDrizzle.ts +++ b/packages/backend/src/helpers/mongoToDrizzle.ts @@ -47,6 +47,49 @@ export function mongoToDrizzle(query: Record): SQL | undefined { const column = getDrizzleColumn(key); + // Handle case-insensitive comparison for email fields. + const keyLower = key.replace(/\./g, '').toLowerCase(); + if (keyLower === 'useremail') { + // Normalize comparison for equality and inequality + if (typeof value === 'object' && value !== null) { + const operator = Object.keys(value)[0]; + const operand = (value as any)[operator]; + const lowerOperand = + typeof operand === 'string' ? operand.toLowerCase() : operand; + switch (operator) { + case '$eq': + conditions.push(sql`lower(${column}) = ${lowerOperand}`); + break; + case '$ne': + conditions.push(not(sql`lower(${column}) = ${lowerOperand}`)); + break; + case '$in': + if (Array.isArray(operand)) { + const lowerArray = operand.map((item: any) => + typeof item === 'string' ? item.toLowerCase() : item, + ); + conditions.push(inArray(sql`lower(${column})`, lowerArray)); + } + break; + case '$nin': + if (Array.isArray(operand)) { + const lowerArray = operand.map((item: any) => + typeof item === 'string' ? item.toLowerCase() : item, + ); + conditions.push(not(inArray(sql`lower(${column})`, lowerArray))); + } + break; + default: + // For unsupported operators on userEmail fall back to simple equality + conditions.push(sql`lower(${column}) = ${lowerOperand}`); + } + } else { + const lowerValue = typeof value === 'string' ? value.toLowerCase() : value; + conditions.push(sql`lower(${column}) = ${lowerValue}`); + } + continue; + } + if (typeof value === 'object' && value !== null) { const operator = Object.keys(value)[0]; const operand = value[operator]; diff --git a/packages/backend/src/helpers/mongoToMeli.ts b/packages/backend/src/helpers/mongoToMeli.ts index 4e88845..bf794f1 100644 --- a/packages/backend/src/helpers/mongoToMeli.ts +++ b/packages/backend/src/helpers/mongoToMeli.ts @@ -46,6 +46,54 @@ export async function mongoToMeli(query: Record): Promise { const column = getMeliColumn(key); + // Normalize case for userEmail fields. + const keyLower = key.replace(/\./g, '').toLowerCase(); + if (keyLower === 'useremail') { + if (typeof value === 'object' && value !== null) { + const operator = Object.keys(value)[0]; + const operand = (value as any)[operator]; + const lowerOperand = + typeof operand === 'string' ? operand.toLowerCase() : operand; + switch (operator) { + case '$eq': + conditions.push(`lower(${column}) = ${quoteIfString(lowerOperand)}`); + break; + case '$ne': + conditions.push(`lower(${column}) != ${quoteIfString(lowerOperand)}`); + break; + case '$in': + if (Array.isArray(operand)) { + const lowerArray = operand.map((item: any) => + typeof item === 'string' ? item.toLowerCase() : item, + ); + conditions.push( + `lower(${column}) IN [${lowerArray.map(quoteIfString).join(', ')}]`, + ); + } + break; + case '$nin': + if (Array.isArray(operand)) { + const lowerArray = operand.map((item: any) => + typeof item === 'string' ? item.toLowerCase() : item, + ); + conditions.push( + `lower(${column}) NOT IN [${lowerArray + .map(quoteIfString) + .join(', ')}]`, + ); + } + break; + default: + // Fallback for unsupported operators on userEmail + conditions.push(`lower(${column}) = ${quoteIfString(lowerOperand)}`); + } + } else { + const lowerValue = typeof value === 'string' ? value.toLowerCase() : value; + conditions.push(`lower(${column}) = ${quoteIfString(lowerValue)}`); + } + continue; + } + if (typeof value === 'object' && value !== null) { const operator = Object.keys(value)[0]; const operand = value[operator]; diff --git a/packages/backend/src/services/AuthorizationService.ts b/packages/backend/src/services/AuthorizationService.ts index 3e0c304..cefe876 100644 --- a/packages/backend/src/services/AuthorizationService.ts +++ b/packages/backend/src/services/AuthorizationService.ts @@ -10,16 +10,42 @@ export class AuthorizationService { this.iamService = new IamService(); } - public async can( - userId: string, - action: AppActions, - resource: AppSubjects, - resourceObject?: SubjectObject - ): Promise { - const ability = await this.iamService.getAbilityForUser(userId); - const subjectInstance = resourceObject - ? subject(resource, resourceObject as Record) - : resource; - return ability.can(action, subjectInstance as AppSubjects); - } + + public async can( + userId: string, + action: AppActions, + resource: AppSubjects, + resourceObject?: SubjectObject + ): Promise { + const ability = await this.iamService.getAbilityForUser(userId); + // Make a copy of the resource so we don’t modify the original. + // We lowercase the `userEmail` field (if present) to ensure case-insensitive + // comparisons when checking permissions. This helps when policies compare + // the user's email to placeholders like `${user.email}`, since CASL's + // equality checks are case-sensitive by default. + + let subjectInstance: any; + if (resourceObject) { + let normalizedResource = resourceObject; + if ( + resource === 'archive' && + typeof resourceObject === 'object' && + resourceObject !== null + ) { + if ( + 'userEmail' in resourceObject && + typeof (resourceObject as any).userEmail === 'string' + ) { + normalizedResource = { + ...resourceObject, + userEmail: (resourceObject as any).userEmail.toLowerCase(), + }; + } + } + subjectInstance = subject(resource, normalizedResource as Record); + } else { + subjectInstance = resource; + } + return ability.can(action, subjectInstance as AppSubjects); + } } diff --git a/packages/backend/src/services/IamService.ts b/packages/backend/src/services/IamService.ts index a6c7052..c6027fc 100644 --- a/packages/backend/src/services/IamService.ts +++ b/packages/backend/src/services/IamService.ts @@ -62,7 +62,7 @@ export class IamService { }); if (!user) { - // Or handle this case as you see fit, maybe return an ability with no permissions + // Handle this case as appropraite throw new Error('User not found'); } @@ -76,9 +76,31 @@ export class IamService { 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); - } + private interpolatePolicies(policies: CaslPolicy[], user: User): CaslPolicy[] { + // Convert the policies to a JSON string for a simple search/replace + const userPoliciesString = JSON.stringify(policies); + + // Set up replacements for supported variables. We lowercase the user's email + // so that access checks aren’t affected by case differences when matching + // archived records. If the user’s email isn’t available, we leave the placeholder + // as-is and log a warning. + const replacements: Record = { + '${user.id}': user.id, + }; + if (user.email) { + // Normalize email to lower case + replacements['${user.email}'] = user.email.toLowerCase(); + } else { + // Log a warning when email is unavailable; + console.warn('IAM interpolation: user.email is undefined, leaving placeholder intact'); + } + + let interpolated = userPoliciesString; + for (const placeholder of Object.keys(replacements)) { + const value = replacements[placeholder]; + + interpolated = interpolated.split(placeholder).join(value); + } + return JSON.parse(interpolated); + } }