Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/backend/src/helpers/mongoToDrizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,49 @@ export function mongoToDrizzle(query: Record<string, any>): 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];
Expand Down
48 changes: 48 additions & 0 deletions packages/backend/src/helpers/mongoToMeli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,54 @@ export async function mongoToMeli(query: Record<string, any>): Promise<string> {

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];
Expand Down
50 changes: 38 additions & 12 deletions packages/backend/src/services/AuthorizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,42 @@ export class AuthorizationService {
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);
}

public async can(
userId: string,
action: AppActions,
resource: AppSubjects,
resourceObject?: SubjectObject
): Promise<boolean> {
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<PropertyKey, any>);
} else {
subjectInstance = resource;
}
return ability.can(action, subjectInstance as AppSubjects);
}
}
34 changes: 28 additions & 6 deletions packages/backend/src/services/IamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand All @@ -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<string, string> = {
'${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);
}
}