From 6acb3610d5a11ae12b6dbc67f9f54c22510e74c0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 14 May 2026 07:31:05 -0400 Subject: [PATCH 1/2] fix(integrations): remove fragile heredoc from AWS auditor setup script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AWS Auditor CloudShell script used `cat < --- .../src/manifests/aws/credentials.ts | 87 ++++++++----------- 1 file changed, 35 insertions(+), 52 deletions(-) diff --git a/packages/integration-platform/src/manifests/aws/credentials.ts b/packages/integration-platform/src/manifests/aws/credentials.ts index da2537afe..efe999b5b 100644 --- a/packages/integration-platform/src/manifests/aws/credentials.ts +++ b/packages/integration-platform/src/manifests/aws/credentials.ts @@ -173,58 +173,41 @@ export function getAwsCloudShellScript(environment: AwsEnvironment = 'aws'): str 'job-function/ViewOnlyAccess', ); - return [ - '#!/bin/bash', - '(', - 'set -euo pipefail', - '', - 'ROLE_NAME="CompAI-Auditor"', - 'EXTERNAL_ID="YOUR_EXTERNAL_ID"', - '', - 'echo "Creating IAM role $ROLE_NAME..."', - '', - 'TRUST_POLICY=$(cat < Date: Thu, 14 May 2026 13:14:35 -0400 Subject: [PATCH 2/2] fix(portal): resolve RBAC permissions instead of checking role names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(portal): resolve RBAC permissions instead of checking role names The previous hasPortalAccess checked role names against BUILT_IN_ROLE_PERMISSIONS — unrecognized roles (like better-auth's default "member") caused false denials. Now resolves the user's full RBAC permissions by merging all roles (built-in + custom from DB), then checks for portal:read or compliance obligation. Also removes incorrect 'use server' directive. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auth): remove compliance obligation from owner and admin roles Owner and admin roles manage compliance — they don't complete compliance tasks themselves. Users who need to complete employee tasks (sign policies, watch training, install device agent) should have the employee role added explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auth): restore compliance obligation for owner role Owners are typically the initial user and should complete compliance tasks by default. Admins still need the employee role added explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auth): remove portal permissions from admin role Admins manage compliance through the main app — they don't need portal access by default. Add the employee role to grant portal access and compliance tasks, matching the CX team's workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(portal): show access denied message instead of redirect loop When a user lacks portal access, render a clear message telling them to ask their admin for the employee role — instead of redirecting to / which would loop back to the org page. Co-Authored-By: Claude Opus 4.6 (1M context) * test(rbac): add portal access matrix tests and fix permission regression tests Add portal-access.spec.ts verifying the role → portal access matrix: employee/contractor/owner → ALLOW, admin/auditor/member → DENY, admin+employee → ALLOW. Prevents regressions in portal gating logic. Update permissions-regression.spec.ts: admin no longer has portal or compliance, finding is read/update only for owner/admin (not full CRUD), fix ESM mocks for better-auth server imports. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .../training/permissions-regression.spec.ts | 34 ++++- apps/api/src/training/portal-access.spec.ts | 141 ++++++++++++++++++ .../src/app/(app)/(home)/[orgId]/page.tsx | 10 +- apps/portal/src/utils/portal-access.ts | 58 ++++--- packages/auth/src/permissions.ts | 4 +- 5 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/training/portal-access.spec.ts diff --git a/apps/api/src/training/permissions-regression.spec.ts b/apps/api/src/training/permissions-regression.spec.ts index d563c6722..82f1010d4 100644 --- a/apps/api/src/training/permissions-regression.spec.ts +++ b/apps/api/src/training/permissions-regression.spec.ts @@ -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) => ({ newRole: (statements: Record) => ({ @@ -66,7 +76,6 @@ describe('Built-in role permissions — regression', () => { 'vendor', 'task', 'framework', - 'finding', 'questionnaire', 'integration', ]) { @@ -74,6 +83,13 @@ describe('Built-in role permissions — regression', () => { } }); + 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']), @@ -148,7 +164,6 @@ describe('Built-in role permissions — regression', () => { 'vendor', 'task', 'framework', - 'finding', 'questionnaire', 'integration', ]) { @@ -156,6 +171,13 @@ describe('Built-in role permissions — regression', () => { } }); + 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( @@ -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', () => { @@ -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', () => { diff --git a/apps/api/src/training/portal-access.spec.ts b/apps/api/src/training/portal-access.spec.ts new file mode 100644 index 000000000..90c64ded1 --- /dev/null +++ b/apps/api/src/training/portal-access.spec.ts @@ -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) => ({ + newRole: (statements: Record) => ({ + 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 = {}; + 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, + ); + }); + }); +}); diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index f3aefcada..400e9d23d 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -1,5 +1,3 @@ -'use server'; - import { auth } from '@/app/lib/auth'; import { getFleetInstance } from '@/utils/fleet'; import type { FleetPolicyResult, Member } from '@db'; @@ -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'; @@ -59,7 +58,12 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o organizationId: orgId, }); if (!canAccessPortal) { - redirect('/'); + return ( + + + + + ); } // Fleet policies - only fetch if member has a fleet device label diff --git a/apps/portal/src/utils/portal-access.ts b/apps/portal/src/utils/portal-access.ts index 76aff2d8a..14016f0de 100644 --- a/apps/portal/src/utils/portal-access.ts +++ b/apps/portal/src/utils/portal-access.ts @@ -13,33 +13,57 @@ export async function hasPortalAccess({ roleString: string; organizationId: string; }): Promise { - 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 = {}; + 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, + source: Record, +): 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); + } + } + } + } +} diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts index 765129253..39216ee88 100644 --- a/packages/auth/src/permissions.ts +++ b/packages/auth/src/permissions.ts @@ -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'], }); @@ -253,7 +251,7 @@ export interface RoleObligations { */ export const BUILT_IN_ROLE_OBLIGATIONS: Record = { owner: { compliance: true }, - admin: { compliance: true }, + admin: {}, auditor: {}, employee: { compliance: true }, contractor: { compliance: true },