Skip to content

Commit 543aa06

Browse files
authored
Merge pull request #2848 from trycompai/main
[comp] Production Deploy
2 parents 2cb970a + 9316c6d commit 543aa06

5 files changed

Lines changed: 218 additions & 29 deletions

File tree

‎apps/api/src/training/permissions-regression.spec.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
*/
1010

1111
// Mock better-auth ESM modules before importing @trycompai/auth
12+
jest.mock('better-auth', () => ({ betterAuth: jest.fn() }));
13+
jest.mock('better-auth/adapters/prisma', () => ({ prismaAdapter: jest.fn() }));
14+
jest.mock('better-auth/plugins', () => ({
15+
bearer: jest.fn(),
16+
emailOTP: jest.fn(),
17+
jwt: jest.fn(),
18+
magicLink: jest.fn(),
19+
multiSession: jest.fn(),
20+
organization: jest.fn(),
21+
}));
1222
jest.mock('better-auth/plugins/access', () => ({
1323
createAccessControl: (stmt: Record<string, readonly string[]>) => ({
1424
newRole: (statements: Record<string, readonly string[]>) => ({
@@ -66,14 +76,20 @@ describe('Built-in role permissions — regression', () => {
6676
'vendor',
6777
'task',
6878
'framework',
69-
'finding',
7079
'questionnaire',
7180
'integration',
7281
]) {
7382
expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud));
7483
}
7584
});
7685

86+
it('should have finding read/update only', () => {
87+
expect(perms.finding).toEqual(
88+
expect.arrayContaining(['read', 'update']),
89+
);
90+
expect(perms.finding).not.toContain('create');
91+
});
92+
7793
it('should have audit create/read/update (no delete)', () => {
7894
expect(perms.audit).toEqual(
7995
expect.arrayContaining(['create', 'read', 'update']),
@@ -148,14 +164,20 @@ describe('Built-in role permissions — regression', () => {
148164
'vendor',
149165
'task',
150166
'framework',
151-
'finding',
152167
'questionnaire',
153168
'integration',
154169
]) {
155170
expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud));
156171
}
157172
});
158173

174+
it('should have finding read/update only', () => {
175+
expect(perms.finding).toEqual(
176+
expect.arrayContaining(['read', 'update']),
177+
);
178+
expect(perms.finding).not.toContain('create');
179+
});
180+
159181
it('should NOT have organization:delete', () => {
160182
expect(perms.organization).not.toContain('delete');
161183
expect(perms.organization).toEqual(
@@ -173,8 +195,8 @@ describe('Built-in role permissions — regression', () => {
173195
);
174196
});
175197

176-
it('should have portal read/update', () => {
177-
expect(perms.portal).toEqual(expect.arrayContaining(['read', 'update']));
198+
it('should NOT have portal permissions', () => {
199+
expect(perms.portal).toBeUndefined();
178200
});
179201

180202
it('should have pentest create/read/delete', () => {
@@ -321,8 +343,8 @@ describe('Built-in role permissions — regression', () => {
321343
expect(BUILT_IN_ROLE_OBLIGATIONS.owner).toEqual({ compliance: true });
322344
});
323345

324-
it('admin should have compliance obligation', () => {
325-
expect(BUILT_IN_ROLE_OBLIGATIONS.admin).toEqual({ compliance: true });
346+
it('admin should NOT have compliance obligation', () => {
347+
expect(BUILT_IN_ROLE_OBLIGATIONS.admin).toEqual({});
326348
});
327349

328350
it('auditor should have NO obligations', () => {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Portal access matrix tests.
3+
*
4+
* Verifies which role combinations grant portal access, matching the
5+
* logic in apps/portal/src/utils/portal-access.ts. Tests the pure
6+
* built-in role resolution — custom roles require DB and are tested
7+
* separately in integration tests.
8+
*/
9+
10+
jest.mock('better-auth', () => ({ betterAuth: jest.fn() }));
11+
jest.mock('better-auth/adapters/prisma', () => ({ prismaAdapter: jest.fn() }));
12+
jest.mock('better-auth/plugins', () => ({
13+
bearer: jest.fn(),
14+
emailOTP: jest.fn(),
15+
jwt: jest.fn(),
16+
magicLink: jest.fn(),
17+
multiSession: jest.fn(),
18+
organization: jest.fn(),
19+
}));
20+
jest.mock('better-auth/plugins/access', () => ({
21+
createAccessControl: (stmt: Record<string, readonly string[]>) => ({
22+
newRole: (statements: Record<string, readonly string[]>) => ({
23+
statements,
24+
}),
25+
}),
26+
}));
27+
jest.mock('better-auth/plugins/organization/access', () => ({
28+
defaultStatements: {
29+
organization: ['update', 'delete'],
30+
member: ['create', 'update', 'delete'],
31+
invitation: ['create', 'delete'],
32+
team: ['create', 'update', 'delete'],
33+
},
34+
ownerAc: {
35+
statements: {
36+
organization: ['update', 'delete'],
37+
member: ['create', 'update', 'delete'],
38+
invitation: ['create', 'delete'],
39+
team: ['create', 'update', 'delete'],
40+
},
41+
},
42+
adminAc: {
43+
statements: {
44+
organization: ['update'],
45+
member: ['create', 'update', 'delete'],
46+
invitation: ['create', 'delete'],
47+
team: ['create', 'update', 'delete'],
48+
},
49+
},
50+
}));
51+
52+
import {
53+
BUILT_IN_ROLE_PERMISSIONS,
54+
BUILT_IN_ROLE_OBLIGATIONS,
55+
} from '@trycompai/auth';
56+
57+
function hasPortalAccessForBuiltInRoles(roleString: string): boolean {
58+
const roles = roleString
59+
.split(',')
60+
.map((r) => r.trim())
61+
.filter(Boolean);
62+
const permissions: Record<string, string[]> = {};
63+
let hasComplianceObligation = false;
64+
65+
for (const role of roles) {
66+
const builtInPerms = BUILT_IN_ROLE_PERMISSIONS[role];
67+
if (builtInPerms) {
68+
for (const [resource, actions] of Object.entries(builtInPerms)) {
69+
if (!permissions[resource]) {
70+
permissions[resource] = [...actions];
71+
} else {
72+
for (const action of actions) {
73+
if (!permissions[resource].includes(action)) {
74+
permissions[resource].push(action);
75+
}
76+
}
77+
}
78+
}
79+
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) {
80+
hasComplianceObligation = true;
81+
}
82+
}
83+
}
84+
85+
if (permissions.portal?.length) return true;
86+
if (hasComplianceObligation) return true;
87+
return false;
88+
}
89+
90+
describe('Portal access matrix', () => {
91+
describe('roles that SHOULD have portal access', () => {
92+
it.each([
93+
['employee'],
94+
['contractor'],
95+
['owner'],
96+
['admin,employee'],
97+
['owner,employee'],
98+
['employee,contractor'],
99+
])('%s → ALLOW', (roleString) => {
100+
expect(hasPortalAccessForBuiltInRoles(roleString)).toBe(true);
101+
});
102+
});
103+
104+
describe('roles that should NOT have portal access', () => {
105+
it.each([
106+
['admin'],
107+
['auditor'],
108+
['member'],
109+
['admin,member'],
110+
['admin,auditor'],
111+
[''],
112+
])('%s → DENY', (roleString) => {
113+
expect(hasPortalAccessForBuiltInRoles(roleString)).toBe(false);
114+
});
115+
});
116+
117+
describe('portal access relies on RBAC, not role names', () => {
118+
it('admin gets portal access only through the employee role', () => {
119+
expect(BUILT_IN_ROLE_PERMISSIONS.admin?.portal).toBeUndefined();
120+
expect(BUILT_IN_ROLE_PERMISSIONS.employee?.portal).toEqual(
121+
expect.arrayContaining(['read', 'update']),
122+
);
123+
});
124+
125+
it('admin does not have compliance obligation', () => {
126+
expect(BUILT_IN_ROLE_OBLIGATIONS.admin?.compliance).toBeFalsy();
127+
});
128+
129+
it('owner has portal access through its own permissions', () => {
130+
expect(BUILT_IN_ROLE_PERMISSIONS.owner?.portal).toEqual(
131+
expect.arrayContaining(['read', 'update']),
132+
);
133+
});
134+
135+
it('unrecognized roles are silently skipped, not denied', () => {
136+
expect(hasPortalAccessForBuiltInRoles('employee,unknown_role')).toBe(
137+
true,
138+
);
139+
});
140+
});
141+
});

‎apps/portal/src/app/(app)/(home)/[orgId]/page.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use server';
2-
31
import { auth } from '@/app/lib/auth';
42
import { getFleetInstance } from '@/utils/fleet';
53
import type { FleetPolicyResult, Member } from '@db';
@@ -8,6 +6,7 @@ import { PageHeader, PageLayout } from '@trycompai/design-system';
86
import { headers } from 'next/headers';
97
import { redirect } from 'next/navigation';
108
import { hasPortalAccess } from '@/utils/portal-access';
9+
import { NoAccessMessage } from '../components/NoAccessMessage';
1110
import { OrganizationDashboard } from './components/OrganizationDashboard';
1211
import type { FleetPolicy, Host } from './types';
1312

@@ -59,7 +58,12 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o
5958
organizationId: orgId,
6059
});
6160
if (!canAccessPortal) {
62-
redirect('/');
61+
return (
62+
<PageLayout>
63+
<PageHeader title="Comp AI - Employee Portal" />
64+
<NoAccessMessage message="Your role does not include employee portal access. Ask your administrator to add the employee role to your account." />
65+
</PageLayout>
66+
);
6367
}
6468

6569
// Fleet policies - only fetch if member has a fleet device label

‎apps/portal/src/utils/portal-access.ts‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,57 @@ export async function hasPortalAccess({
1313
roleString: string;
1414
organizationId: string;
1515
}): Promise<boolean> {
16-
const roles = roleString.split(',').map((r) => r.trim());
17-
const builtInNames = new Set(Object.keys(BUILT_IN_ROLE_PERMISSIONS));
16+
const roles = roleString.split(',').map((r) => r.trim()).filter(Boolean);
17+
const permissions: Record<string, string[]> = {};
18+
let hasComplianceObligation = false;
1819
const customRoleNames: string[] = [];
1920

2021
for (const role of roles) {
21-
if (builtInNames.has(role)) {
22-
if (BUILT_IN_ROLE_PERMISSIONS[role]?.portal?.length) return true;
23-
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) return true;
22+
const builtInPerms = BUILT_IN_ROLE_PERMISSIONS[role];
23+
if (builtInPerms) {
24+
mergePermissions(permissions, builtInPerms);
25+
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) {
26+
hasComplianceObligation = true;
27+
}
2428
} else {
2529
customRoleNames.push(role);
2630
}
2731
}
2832

29-
if (customRoleNames.length === 0) return false;
33+
if (customRoleNames.length > 0) {
34+
const customRoles = await db.organizationRole.findMany({
35+
where: { organizationId, name: { in: customRoleNames } },
36+
select: { permissions: true, obligations: true },
37+
});
3038

31-
const customRoles = await db.organizationRole.findMany({
32-
where: { organizationId, name: { in: customRoleNames } },
33-
select: { permissions: true, obligations: true },
34-
});
35-
36-
for (const role of customRoles) {
37-
const perms = parseRolePermissions(role.permissions);
38-
if (perms?.portal?.length) return true;
39-
40-
const obligations = parseRoleObligations(role.obligations);
41-
if (obligations.compliance) return true;
39+
for (const role of customRoles) {
40+
const perms = parseRolePermissions(role.permissions);
41+
if (perms) mergePermissions(permissions, perms);
42+
if (parseRoleObligations(role.obligations).compliance) {
43+
hasComplianceObligation = true;
44+
}
45+
}
4246
}
4347

48+
if (permissions.portal?.length) return true;
49+
if (hasComplianceObligation) return true;
50+
4451
return false;
4552
}
53+
54+
function mergePermissions(
55+
target: Record<string, string[]>,
56+
source: Record<string, string[]>,
57+
): void {
58+
for (const [resource, actions] of Object.entries(source)) {
59+
if (!target[resource]) {
60+
target[resource] = [...actions];
61+
} else {
62+
for (const action of actions) {
63+
if (!target[resource].includes(action)) {
64+
target[resource].push(action);
65+
}
66+
}
67+
}
68+
}
69+
}

‎packages/auth/src/permissions.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,6 @@ export const admin = ac.newRole({
125125
pentest: ['create', 'read', 'delete'],
126126
// Training management
127127
training: ['read', 'update'],
128-
// Portal self-service
129-
portal: ['read', 'update'],
130128
// Secrets manager — admin can fully manage decrypted credentials
131129
secret: ['create', 'read', 'update', 'delete'],
132130
});
@@ -253,7 +251,7 @@ export interface RoleObligations {
253251
*/
254252
export const BUILT_IN_ROLE_OBLIGATIONS: Record<string, RoleObligations> = {
255253
owner: { compliance: true },
256-
admin: { compliance: true },
254+
admin: {},
257255
auditor: {},
258256
employee: { compliance: true },
259257
contractor: { compliance: true },

0 commit comments

Comments
 (0)