Skip to content
Merged
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
193 changes: 193 additions & 0 deletions apps/api/src/roles/roles.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,4 +520,197 @@ describe('RolesService', () => {
);
});
});

describe('filterMembersWithPermission', () => {
const organizationId = 'org_1';

it('returns empty array when members list is empty', async () => {
const result = await service.filterMembersWithPermission(
organizationId,
[],
'task',
'update',
);
expect(result).toEqual([]);
expect(mockDb.organizationRole.findMany).not.toHaveBeenCalled();
});

it('keeps built-in roles that grant the permission (owner has task:update)', async () => {
const members = [
{ id: 'm1', role: 'owner' },
{ id: 'm2', role: 'admin' },
];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
});

it('excludes built-in roles that lack the permission (employee has no task perms)', async () => {
const members = [
{ id: 'm1', role: 'employee' },
{ id: 'm2', role: 'contractor' },
{ id: 'm3', role: 'owner' },
];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id)).toEqual(['m3']);
});

it('excludes auditor for task:update but keeps them for task:read', async () => {
const members = [{ id: 'm1', role: 'auditor' }];

const forUpdate = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(forUpdate).toEqual([]);

const forRead = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'read',
);
expect(forRead.map((m) => m.id)).toEqual(['m1']);
});

it('treats comma-separated roles as a union (employee,admin gets included)', async () => {
const members = [{ id: 'm1', role: 'employee,admin' }];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id)).toEqual(['m1']);
});

it('includes a member whose custom role grants the permission', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{
name: 'compliance-lead',
permissions: JSON.stringify({
task: ['read', 'update'],
app: ['read'],
}),
},
]);
const members = [{ id: 'm1', role: 'compliance-lead' }];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id)).toEqual(['m1']);
expect(mockDb.organizationRole.findMany).toHaveBeenCalledTimes(1);
});

it('excludes a member whose custom role lacks the permission', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{
name: 'readonly',
permissions: JSON.stringify({ task: ['read'] }),
},
]);
const members = [{ id: 'm1', role: 'readonly' }];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result).toEqual([]);
});

it('excludes members with null, empty, or unknown roles', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([]);
const members = [
{ id: 'm1', role: null },
{ id: 'm2', role: '' },
{ id: 'm3', role: 'nonexistent-role' },
];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result).toEqual([]);
});

it('makes exactly one DB query regardless of member count', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{
name: 'custom-a',
permissions: JSON.stringify({ task: ['update'] }),
},
]);
const members = Array.from({ length: 25 }, (_, i) => ({
id: `m${i}`,
role: i % 2 === 0 ? 'custom-a' : 'employee',
}));
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.length).toBe(13); // 0,2,4,...,24 → 13 members
expect(mockDb.organizationRole.findMany).toHaveBeenCalledTimes(1);
});

it('skips the DB query when all roles are built-in', async () => {
const members = [
{ id: 'm1', role: 'owner' },
{ id: 'm2', role: 'admin,auditor' },
{ id: 'm3', role: 'employee' },
];
await service.filterMembersWithPermission(
organizationId,
members,
'app',
'read',
);
expect(mockDb.organizationRole.findMany).not.toHaveBeenCalled();
});

it('parses permissions that are already objects (not strings)', async () => {
(mockDb.organizationRole.findMany as jest.Mock).mockResolvedValue([
{
name: 'object-role',
permissions: { task: ['update'] },
},
]);
const members = [{ id: 'm1', role: 'object-role' }];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id)).toEqual(['m1']);
});

it('trims whitespace around comma-separated role names', async () => {
const members = [{ id: 'm1', role: 'employee , admin' }];
const result = await service.filterMembersWithPermission(
organizationId,
members,
'task',
'update',
);
expect(result.map((m) => m.id)).toEqual(['m1']);
});
});
});
65 changes: 65 additions & 0 deletions apps/api/src/roles/roles.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,71 @@ export class RolesService {
return combined;
}

/**
* Filter a list of members down to those whose combined role permissions
* grant the given `resource:action`. Built-in role definitions come from
* `BUILT_IN_ROLE_PERMISSIONS`; custom roles are fetched in a single batched
* `organizationRole.findMany` keyed by the distinct role names present in
* the input.
*
* Matches better-auth's `hasPermissionFn` semantics: comma-separated roles
* in `member.role` are treated as a union (ANY role granting the permission
* is sufficient). Unknown role names are skipped silently.
*/
async filterMembersWithPermission<M extends { role: string | null }>(
organizationId: string,
members: M[],
resource: string,
action: string,
): Promise<M[]> {
if (members.length === 0) return [];

const distinctRoles = new Set<string>();
for (const m of members) {
if (!m.role) continue;
for (const r of m.role.split(',').map((r) => r.trim()).filter(Boolean)) {
distinctRoles.add(r);
}
}
if (distinctRoles.size === 0) return [];

const customRoleNames = [...distinctRoles].filter(
(r) => !BUILT_IN_ROLES.includes(r),
);

const customRoles =
customRoleNames.length > 0
? await db.organizationRole.findMany({
where: { organizationId, name: { in: customRoleNames } },
select: { name: true, permissions: true },
})
: [];

const permsByRole = new Map<string, Record<string, string[]>>();
for (const name of distinctRoles) {
if (BUILT_IN_ROLES.includes(name)) {
permsByRole.set(name, BUILT_IN_ROLE_PERMISSIONS[name] ?? {});
} else {
const custom = customRoles.find((c) => c.name === name);
if (!custom) continue;
const perms =
typeof custom.permissions === 'string'
? (JSON.parse(custom.permissions) as Record<string, string[]>)
: (custom.permissions as Record<string, string[]>);
permsByRole.set(name, perms);
}
}

return members.filter((m) => {
if (!m.role) return false;
const roles = m.role
.split(',')
.map((r) => r.trim())
.filter(Boolean);
return roles.some((r) => permsByRole.get(r)?.[resource]?.includes(action));
});
}

/**
* Get merged obligations for a list of custom role names.
* Used by the frontend to resolve effective obligations for custom roles.
Expand Down
Loading
Loading