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
34 changes: 28 additions & 6 deletions apps/api/src/training/permissions-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
*/

// Mock better-auth ESM modules before importing @trycompai/auth
jest.mock('better-auth', () => ({ betterAuth: jest.fn() }));
jest.mock('better-auth/adapters/prisma', () => ({ prismaAdapter: jest.fn() }));
jest.mock('better-auth/plugins', () => ({
bearer: jest.fn(),
emailOTP: jest.fn(),
jwt: jest.fn(),
magicLink: jest.fn(),
multiSession: jest.fn(),
organization: jest.fn(),
}));
jest.mock('better-auth/plugins/access', () => ({
createAccessControl: (stmt: Record<string, readonly string[]>) => ({
newRole: (statements: Record<string, readonly string[]>) => ({
Expand Down Expand Up @@ -66,14 +76,20 @@ describe('Built-in role permissions — regression', () => {
'vendor',
'task',
'framework',
'finding',
'questionnaire',
'integration',
]) {
expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud));
}
});

it('should have finding read/update only', () => {
expect(perms.finding).toEqual(
expect.arrayContaining(['read', 'update']),
);
expect(perms.finding).not.toContain('create');
});

it('should have audit create/read/update (no delete)', () => {
expect(perms.audit).toEqual(
expect.arrayContaining(['create', 'read', 'update']),
Expand Down Expand Up @@ -148,14 +164,20 @@ describe('Built-in role permissions — regression', () => {
'vendor',
'task',
'framework',
'finding',
'questionnaire',
'integration',
]) {
expect(perms[resource]).toEqual(expect.arrayContaining(fullCrud));
}
});

it('should have finding read/update only', () => {
expect(perms.finding).toEqual(
expect.arrayContaining(['read', 'update']),
);
expect(perms.finding).not.toContain('create');
});

it('should NOT have organization:delete', () => {
expect(perms.organization).not.toContain('delete');
expect(perms.organization).toEqual(
Expand All @@ -173,8 +195,8 @@ describe('Built-in role permissions — regression', () => {
);
});

it('should have portal read/update', () => {
expect(perms.portal).toEqual(expect.arrayContaining(['read', 'update']));
it('should NOT have portal permissions', () => {
expect(perms.portal).toBeUndefined();
});

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

it('admin should have compliance obligation', () => {
expect(BUILT_IN_ROLE_OBLIGATIONS.admin).toEqual({ compliance: true });
it('admin should NOT have compliance obligation', () => {
expect(BUILT_IN_ROLE_OBLIGATIONS.admin).toEqual({});
});

it('auditor should have NO obligations', () => {
Expand Down
141 changes: 141 additions & 0 deletions apps/api/src/training/portal-access.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Portal access matrix tests.
*
* Verifies which role combinations grant portal access, matching the
* logic in apps/portal/src/utils/portal-access.ts. Tests the pure
* built-in role resolution — custom roles require DB and are tested
* separately in integration tests.
*/

jest.mock('better-auth', () => ({ betterAuth: jest.fn() }));
jest.mock('better-auth/adapters/prisma', () => ({ prismaAdapter: jest.fn() }));
jest.mock('better-auth/plugins', () => ({
bearer: jest.fn(),
emailOTP: jest.fn(),
jwt: jest.fn(),
magicLink: jest.fn(),
multiSession: jest.fn(),
organization: jest.fn(),
}));
jest.mock('better-auth/plugins/access', () => ({
createAccessControl: (stmt: Record<string, readonly string[]>) => ({
newRole: (statements: Record<string, readonly string[]>) => ({
statements,
}),
}),
}));
jest.mock('better-auth/plugins/organization/access', () => ({
defaultStatements: {
organization: ['update', 'delete'],
member: ['create', 'update', 'delete'],
invitation: ['create', 'delete'],
team: ['create', 'update', 'delete'],
},
ownerAc: {
statements: {
organization: ['update', 'delete'],
member: ['create', 'update', 'delete'],
invitation: ['create', 'delete'],
team: ['create', 'update', 'delete'],
},
},
adminAc: {
statements: {
organization: ['update'],
member: ['create', 'update', 'delete'],
invitation: ['create', 'delete'],
team: ['create', 'update', 'delete'],
},
},
}));

import {
BUILT_IN_ROLE_PERMISSIONS,
BUILT_IN_ROLE_OBLIGATIONS,
} from '@trycompai/auth';

function hasPortalAccessForBuiltInRoles(roleString: string): boolean {
const roles = roleString
.split(',')
.map((r) => r.trim())
.filter(Boolean);
const permissions: Record<string, string[]> = {};
let hasComplianceObligation = false;

for (const role of roles) {
const builtInPerms = BUILT_IN_ROLE_PERMISSIONS[role];
if (builtInPerms) {
for (const [resource, actions] of Object.entries(builtInPerms)) {
if (!permissions[resource]) {
permissions[resource] = [...actions];
} else {
for (const action of actions) {
if (!permissions[resource].includes(action)) {
permissions[resource].push(action);
}
}
}
}
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) {
hasComplianceObligation = true;
}
}
}

if (permissions.portal?.length) return true;
if (hasComplianceObligation) return true;
return false;
}

describe('Portal access matrix', () => {
describe('roles that SHOULD have portal access', () => {
it.each([
['employee'],
['contractor'],
['owner'],
['admin,employee'],
['owner,employee'],
['employee,contractor'],
])('%s → ALLOW', (roleString) => {
expect(hasPortalAccessForBuiltInRoles(roleString)).toBe(true);
});
});

describe('roles that should NOT have portal access', () => {
it.each([
['admin'],
['auditor'],
['member'],
['admin,member'],
['admin,auditor'],
[''],
])('%s → DENY', (roleString) => {
expect(hasPortalAccessForBuiltInRoles(roleString)).toBe(false);
});
});

describe('portal access relies on RBAC, not role names', () => {
it('admin gets portal access only through the employee role', () => {
expect(BUILT_IN_ROLE_PERMISSIONS.admin?.portal).toBeUndefined();
expect(BUILT_IN_ROLE_PERMISSIONS.employee?.portal).toEqual(
expect.arrayContaining(['read', 'update']),
);
});

it('admin does not have compliance obligation', () => {
expect(BUILT_IN_ROLE_OBLIGATIONS.admin?.compliance).toBeFalsy();
});

it('owner has portal access through its own permissions', () => {
expect(BUILT_IN_ROLE_PERMISSIONS.owner?.portal).toEqual(
expect.arrayContaining(['read', 'update']),
);
});

it('unrecognized roles are silently skipped, not denied', () => {
expect(hasPortalAccessForBuiltInRoles('employee,unknown_role')).toBe(
true,
);
});
});
});
10 changes: 7 additions & 3 deletions apps/portal/src/app/(app)/(home)/[orgId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use server';

import { auth } from '@/app/lib/auth';
import { getFleetInstance } from '@/utils/fleet';
import type { FleetPolicyResult, Member } from '@db';
Expand All @@ -8,6 +6,7 @@ import { PageHeader, PageLayout } from '@trycompai/design-system';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { hasPortalAccess } from '@/utils/portal-access';
import { NoAccessMessage } from '../components/NoAccessMessage';
import { OrganizationDashboard } from './components/OrganizationDashboard';
import type { FleetPolicy, Host } from './types';

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

// Fleet policies - only fetch if member has a fleet device label
Expand Down
58 changes: 41 additions & 17 deletions apps/portal/src/utils/portal-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,57 @@ export async function hasPortalAccess({
roleString: string;
organizationId: string;
}): Promise<boolean> {
const roles = roleString.split(',').map((r) => r.trim());
const builtInNames = new Set(Object.keys(BUILT_IN_ROLE_PERMISSIONS));
const roles = roleString.split(',').map((r) => r.trim()).filter(Boolean);
const permissions: Record<string, string[]> = {};
let hasComplianceObligation = false;
const customRoleNames: string[] = [];

for (const role of roles) {
if (builtInNames.has(role)) {
if (BUILT_IN_ROLE_PERMISSIONS[role]?.portal?.length) return true;
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) return true;
const builtInPerms = BUILT_IN_ROLE_PERMISSIONS[role];
if (builtInPerms) {
mergePermissions(permissions, builtInPerms);
if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) {
hasComplianceObligation = true;
}
} else {
customRoleNames.push(role);
}
}

if (customRoleNames.length === 0) return false;
if (customRoleNames.length > 0) {
const customRoles = await db.organizationRole.findMany({
where: { organizationId, name: { in: customRoleNames } },
select: { permissions: true, obligations: true },
});

const customRoles = await db.organizationRole.findMany({
where: { organizationId, name: { in: customRoleNames } },
select: { permissions: true, obligations: true },
});

for (const role of customRoles) {
const perms = parseRolePermissions(role.permissions);
if (perms?.portal?.length) return true;

const obligations = parseRoleObligations(role.obligations);
if (obligations.compliance) return true;
for (const role of customRoles) {
const perms = parseRolePermissions(role.permissions);
if (perms) mergePermissions(permissions, perms);
if (parseRoleObligations(role.obligations).compliance) {
hasComplianceObligation = true;
}
}
}

if (permissions.portal?.length) return true;
if (hasComplianceObligation) return true;

return false;
}

function mergePermissions(
target: Record<string, string[]>,
source: Record<string, string[]>,
): void {
for (const [resource, actions] of Object.entries(source)) {
if (!target[resource]) {
target[resource] = [...actions];
} else {
for (const action of actions) {
if (!target[resource].includes(action)) {
target[resource].push(action);
}
}
}
}
}
4 changes: 1 addition & 3 deletions packages/auth/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,6 @@ export const admin = ac.newRole({
pentest: ['create', 'read', 'delete'],
// Training management
training: ['read', 'update'],
// Portal self-service
portal: ['read', 'update'],
// Secrets manager — admin can fully manage decrypted credentials
secret: ['create', 'read', 'update', 'delete'],
});
Expand Down Expand Up @@ -253,7 +251,7 @@ export interface RoleObligations {
*/
export const BUILT_IN_ROLE_OBLIGATIONS: Record<string, RoleObligations> = {
owner: { compliance: true },
admin: { compliance: true },
admin: {},
auditor: {},
employee: { compliance: true },
contractor: { compliance: true },
Expand Down
Loading