diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx index 297b5e66f4..32ae250c0e 100644 --- a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx @@ -3,7 +3,7 @@ import { parseRolesString } from '@/lib/permissions'; import { PageHeader, PageLayout } from '@trycompai/design-system'; import { Role } from '@db'; import type { Metadata } from 'next'; -import { notFound, redirect } from 'next/navigation'; +import { redirect } from 'next/navigation'; import { AuditorView } from './components/AuditorView'; import { ExportEvidenceButton } from './components/ExportEvidenceButton'; @@ -75,10 +75,10 @@ export default async function AuditorPage({ redirect('/auth/unauthorized'); } - const roles = parseRolesString(currentMember.role); - if (!roles.includes(Role.auditor)) { - notFound(); - } + // CS-189: auditor/layout.tsx already calls requireRoutePermission('auditor', + // orgId) which enforces audit:read. The prior literal-role check + // (roles.includes(Role.auditor)) was redundant AND wrong — it would 404 for + // owners/admins/custom roles that legitimately have audit:read. const organizationName = orgRes.data?.name ?? 'Organization'; const logoUrl = orgRes.data?.logoUrl ?? null; diff --git a/apps/app/src/app/(app)/[orgId]/auditor/layout.tsx b/apps/app/src/app/(app)/[orgId]/auditor/layout.tsx index 842432ee70..76cc7dd1b9 100644 --- a/apps/app/src/app/(app)/[orgId]/auditor/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/auditor/layout.tsx @@ -1,4 +1,4 @@ -import { requireRoutePermission } from '@/lib/permissions.server'; +import { requireAuditorViewAccess } from '@/lib/permissions.server'; export default async function Layout({ children, @@ -8,6 +8,10 @@ export default async function Layout({ params: Promise<{ orgId: string }>; }) { const { orgId } = await params; - await requireRoutePermission('auditor', orgId); + // CS-189: stricter than `requireRoutePermission('auditor', orgId)` — the + // plain check let owner/admin through via their implicit audit:read. + // requireAuditorViewAccess enforces "built-in auditor OR custom role with + // explicit audit:read" to match the sidebar tab visibility. + await requireAuditorViewAccess(orgId); return <>{children}; } diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx index c336b4bc88..e6a234a9b5 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -68,6 +68,8 @@ interface AppShellWrapperProps { isSecurityEnabled: boolean; hasAuditorRole: boolean; isOnlyAuditor: boolean; + /** CS-189: server-resolved Auditor View visibility — see resolveAuditorViewAccess. */ + canAccessAuditorView: boolean; permissions: UserPermissions; user: { name: string | null; @@ -99,6 +101,7 @@ function AppShellWrapperContent({ isSecurityEnabled, hasAuditorRole, isOnlyAuditor, + canAccessAuditorView, permissions, user, isAdmin, @@ -144,6 +147,7 @@ function AppShellWrapperContent({ permissions, hasAuditorRole, isOnlyAuditor, + canAccessAuditorView, isQuestionnaireEnabled, isTrustNdaEnabled, isSecurityEnabled, @@ -319,6 +323,7 @@ function AppShellWrapperContent({ hasAuditorRole={hasAuditorRole} isOnlyAuditor={isOnlyAuditor} permissions={permissions} + canAccessAuditorView={canAccessAuditorView} /> )} diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx index 830ca29e41..004be27c00 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -19,6 +19,13 @@ interface AppSidebarProps { hasAuditorRole: boolean; isOnlyAuditor: boolean; permissions: UserPermissions; + /** + * CS-189: Whether this user should see the Auditor View tab. Computed + * server-side (see resolveAuditorViewAccess) because it needs to + * distinguish owner/admin's implicit audit:read from a custom role's + * explicit audit:read. + */ + canAccessAuditorView: boolean; } export function AppSidebar({ @@ -27,6 +34,7 @@ export function AppSidebar({ hasAuditorRole, isOnlyAuditor, permissions, + canAccessAuditorView, }: AppSidebarProps) { const pathname = usePathname() ?? ''; @@ -47,7 +55,11 @@ export function AppSidebar({ id: 'auditor', path: `/${organization.id}/auditor`, name: 'Auditor View', - hidden: !hasAuditorRole || !canAccessRoute(permissions, 'auditor'), + // CS-189: visibility is scoped to built-in `auditor` role or a custom + // org role that explicitly grants audit:read. Owner/admin's implicit + // permissions alone are not enough — see `canAccessAuditorView` in + // lib/permissions.ts for the full rule. + hidden: !canAccessAuditorView, }, { id: 'policies', diff --git a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx index 11e4359961..1423888be9 100644 --- a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx @@ -26,6 +26,8 @@ interface AppShellSearchGroupsParams { permissions: UserPermissions; hasAuditorRole: boolean; isOnlyAuditor: boolean; + /** CS-189: resolved server-side — see AppShellWrapper. */ + canAccessAuditorView: boolean; isQuestionnaireEnabled: boolean; isTrustNdaEnabled: boolean; isSecurityEnabled: boolean; @@ -64,6 +66,7 @@ export const getAppShellSearchGroups = ({ permissions, hasAuditorRole, isOnlyAuditor, + canAccessAuditorView, isQuestionnaireEnabled, isTrustNdaEnabled, isSecurityEnabled, @@ -96,7 +99,9 @@ export const getAppShellSearchGroups = ({ }), ] : []), - ...(hasAuditorRole && can('auditor') + // CS-189: gate on the server-resolved canAccessAuditorView flag so + // owner/admin are hidden unless they explicitly opt in via a custom role. + ...(canAccessAuditorView ? [ createNavItem({ id: 'auditor', diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 869c58e6e4..24fae1573e 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -2,8 +2,15 @@ import { getFeatureFlags } from '@/app/posthog'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; import { serverApi } from '@/lib/api-server'; -import { canAccessApp, parseRolesString } from '@/lib/permissions'; -import { resolveUserPermissions } from '@/lib/permissions.server'; +import { + canAccessApp, + canAccessAuditorView, + parseRolesString, +} from '@/lib/permissions'; +import { + resolveCustomRolePermissions, + resolveUserPermissions, +} from '@/lib/permissions.server'; import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; @@ -156,6 +163,19 @@ export default async function Layout({ const hasAuditorRole = roles.includes(Role.auditor); const isOnlyAuditor = hasAuditorRole && roles.length === 1; + // CS-189: the Auditor View tab follows a stricter rule than bare + // audit:read — built-in `auditor` role OR a custom role with explicit + // audit:read. Resolve the custom-role permissions once so we don't + // second-guess the owner/admin's implicit all-permissions in the UI. + const customRolePermissions = await resolveCustomRolePermissions( + member.role, + requestedOrgId, + ); + const auditorViewVisible = canAccessAuditorView( + member.role, + customRolePermissions, + ); + // User data for navbar const user = { name: session.user.name, @@ -180,6 +200,7 @@ export default async function Layout({ isSecurityEnabled={isSecurityEnabled} hasAuditorRole={hasAuditorRole} isOnlyAuditor={isOnlyAuditor} + canAccessAuditorView={auditorViewVisible} permissions={permissions} user={user} isAdmin={isUserAdmin} diff --git a/apps/app/src/components/main-menu.tsx b/apps/app/src/components/main-menu.tsx index b02c2ec677..25117f3408 100644 --- a/apps/app/src/components/main-menu.tsx +++ b/apps/app/src/components/main-menu.tsx @@ -1,5 +1,6 @@ 'use client'; +import { usePermissions } from '@/hooks/use-permissions'; import { Badge } from '@trycompai/ui/badge'; import { Button } from '@trycompai/ui/button'; import { cn } from '@trycompai/ui/cn'; @@ -54,7 +55,6 @@ export type Props = { onItemClick?: () => void; isQuestionnaireEnabled?: boolean; isTrustNdaEnabled?: boolean; - hasAuditorRole?: boolean; isOnlyAuditor?: boolean; }; @@ -65,12 +65,16 @@ export function MainMenu({ onItemClick, isQuestionnaireEnabled = false, isTrustNdaEnabled = false, - hasAuditorRole = false, isOnlyAuditor = false, }: Props) { const pathname = usePathname(); const [activeStyle, setActiveStyle] = useState({ top: '0px', height: '0px' }); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + // CS-189: Auditor View visibility is scoped to the built-in `auditor` + // role or a custom org role that explicitly grants audit:read — NOT to + // owner/admin's implicit all-permissions. See `canAccessAuditorView` in + // lib/permissions.ts for the full rule. + const { canAccessAuditorView } = usePermissions(); const items: MenuItem[] = [ { @@ -96,7 +100,7 @@ export function MainMenu({ disabled: false, icon: ClipboardCheck, protected: false, - hidden: !hasAuditorRole, + hidden: !canAccessAuditorView, }, { id: 'controls', diff --git a/apps/app/src/components/sidebar.tsx b/apps/app/src/components/sidebar.tsx index 88b29f7d91..494760d7eb 100644 --- a/apps/app/src/components/sidebar.tsx +++ b/apps/app/src/components/sidebar.tsx @@ -99,7 +99,6 @@ export async function Sidebar({ isCollapsed={isCollapsed} isQuestionnaireEnabled={isQuestionnaireEnabled} isTrustNdaEnabled={isTrustNdaEnabled} - hasAuditorRole={hasAuditorRole} isOnlyAuditor={isOnlyAuditor} /> diff --git a/apps/app/src/hooks/use-permissions.ts b/apps/app/src/hooks/use-permissions.ts index bbbd82af6b..95feb67493 100644 --- a/apps/app/src/hooks/use-permissions.ts +++ b/apps/app/src/hooks/use-permissions.ts @@ -10,6 +10,7 @@ import { hasPermission, parseRolesString, isBuiltInRole, + canAccessAuditorView, type UserPermissions, } from '@/lib/permissions'; @@ -75,11 +76,18 @@ export function usePermissions() { } } + // CS-189: separate "did a custom role grant this permission" from the + // merged permissions, so the Auditor View visibility check can distinguish + // an owner's implicit audit:read from a custom role's explicit audit:read. + const customPermissions = customData?.permissions ?? {}; + return { permissions, + customPermissions, obligations, roles: roleNames, hasPermission: (resource: string, action: string) => hasPermission(permissions, resource, action), + canAccessAuditorView: canAccessAuditorView(roleString, customPermissions), }; } diff --git a/apps/app/src/lib/permissions.server.ts b/apps/app/src/lib/permissions.server.ts index 10beb4c323..8412341ca5 100644 --- a/apps/app/src/lib/permissions.server.ts +++ b/apps/app/src/lib/permissions.server.ts @@ -6,6 +6,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { type UserPermissions, + canAccessAuditorView, canAccessRoute, getDefaultRoute, mergePermissions, @@ -92,3 +93,82 @@ export async function requireRoutePermission( redirect(defaultRoute ?? '/no-access'); } } + +/** + * CS-189: Resolve only the permissions granted by the user's CUSTOM org + * roles (i.e. not from built-in roles). Needed for the Auditor View + * visibility rule, which wants to know whether a custom role explicitly + * grants `audit:read` — owner/admin's implicit all-permissions don't count. + */ +export async function resolveCustomRolePermissions( + roleString: string | null | undefined, + orgId: string, +): Promise { + const { customRoleNames } = resolveBuiltInPermissions(roleString); + const result: UserPermissions = {}; + if (customRoleNames.length === 0) return result; + + const customRoles = await db.organizationRole.findMany({ + where: { organizationId: orgId, name: { in: customRoleNames } }, + select: { permissions: true }, + }); + + for (const role of customRoles) { + if (!role.permissions) continue; + const parsed = + typeof role.permissions === 'string' + ? JSON.parse(role.permissions) + : role.permissions; + if (parsed && typeof parsed === 'object') { + mergePermissions(result, parsed as Record); + } + } + return result; +} + +/** + * Server-side Auditor View access check. Mirrors the client-side + * `canAccessAuditorView` but pulls the custom-role permissions from the + * DB for the current user. Returns null if the user isn't in the org. + */ +export async function resolveAuditorViewAccess( + orgId: string, +): Promise<{ canAccess: boolean; roleString: string | null } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session?.user?.id) return null; + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + select: { role: true }, + }); + if (!member) return null; + + const customPerms = await resolveCustomRolePermissions(member.role, orgId); + return { + canAccess: canAccessAuditorView(member.role, customPerms), + roleString: member.role, + }; +} + +/** + * Route guard for the Auditor View page. Replaces `requireRoutePermission( + * 'auditor', orgId)` — the plain permission check let owner/admin through + * via their implicit `audit:read`. This helper enforces the stricter + * "built-in auditor OR custom role with audit:read" rule. + */ +export async function requireAuditorViewAccess(orgId: string): Promise { + const result = await resolveAuditorViewAccess(orgId); + if (result?.canAccess) return; + + const permissions = await resolveCurrentUserPermissions(orgId); + const defaultRoute = permissions + ? getDefaultRoute(permissions, orgId) + : null; + redirect(defaultRoute ?? '/no-access'); +} diff --git a/apps/app/src/lib/permissions.test.ts b/apps/app/src/lib/permissions.test.ts index 17f6210eae..180db8bf85 100644 --- a/apps/app/src/lib/permissions.test.ts +++ b/apps/app/src/lib/permissions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { canAccessApp, + canAccessAuditorView, canAccessCompliance, canAccessRoute, getDefaultRoute, @@ -62,6 +63,7 @@ describe('canAccessRoute', () => { const permissions: UserPermissions = {}; expect(canAccessRoute(permissions, 'nonexistent-route')).toBe(true); }); + }); describe('getDefaultRoute', () => { @@ -131,3 +133,61 @@ describe('hasPermission', () => { expect(hasPermission(permissions, 'pentest', 'create')).toBe(false); }); }); + +// CS-189: Auditor View visibility is intentionally stricter than bare +// `audit:read` — owner and admin implicitly have every permission, but the +// CTO's product decision is that the tab only appears for users whose role +// explicitly scopes them to audit work (built-in auditor OR a custom role +// that specifically grants audit:read). +describe('canAccessAuditorView', () => { + const noCustom: UserPermissions = {}; + + it('shows for the built-in auditor role', () => { + expect(canAccessAuditorView('auditor', noCustom)).toBe(true); + }); + + it('shows when auditor is one of several roles (e.g. owner,auditor)', () => { + expect(canAccessAuditorView('owner,auditor', noCustom)).toBe(true); + expect(canAccessAuditorView('admin, auditor', noCustom)).toBe(true); + }); + + it('hides for owner alone (implicit audit:read does NOT count)', () => { + expect(canAccessAuditorView('owner', noCustom)).toBe(false); + }); + + it('hides for admin alone (implicit audit:read does NOT count)', () => { + expect(canAccessAuditorView('admin', noCustom)).toBe(false); + }); + + it('hides for employee / contractor', () => { + expect(canAccessAuditorView('employee', noCustom)).toBe(false); + expect(canAccessAuditorView('contractor', noCustom)).toBe(false); + }); + + it('shows when a custom role explicitly grants audit:read', () => { + const customRolePerms: UserPermissions = { audit: ['read'] }; + expect(canAccessAuditorView('CompAI', customRolePerms)).toBe(true); + }); + + it('shows when owner is combined with a custom role that grants audit:read', () => { + const customRolePerms: UserPermissions = { audit: ['read'] }; + expect(canAccessAuditorView('owner,CompAI', customRolePerms)).toBe(true); + }); + + it('hides when owner has a custom role that does NOT grant audit:read', () => { + // Owner's implicit audit:read is what `permissions` would carry, but + // `canAccessAuditorView` only looks at custom-role permissions — so if + // the custom role is something like "ReadOnlyViewer" without audit, the + // tab stays hidden even though the merged permissions would pass. + const customRolePerms: UserPermissions = { evidence: ['read'] }; + expect(canAccessAuditorView('owner,ReadOnlyViewer', customRolePerms)).toBe( + false, + ); + }); + + it('hides when role string is empty / null / undefined', () => { + expect(canAccessAuditorView('', noCustom)).toBe(false); + expect(canAccessAuditorView(null, noCustom)).toBe(false); + expect(canAccessAuditorView(undefined, noCustom)).toBe(false); + }); +}); diff --git a/apps/app/src/lib/permissions.ts b/apps/app/src/lib/permissions.ts index 348368e196..79c96e4db3 100644 --- a/apps/app/src/lib/permissions.ts +++ b/apps/app/src/lib/permissions.ts @@ -96,6 +96,31 @@ export function canAccessRoute(permissions: UserPermissions, routeSegment: strin return hasAnyPermission(permissions, required); } +/** + * CS-189: Auditor View visibility rule (product decision). + * + * "Auditor View" is scoped to audit work — it should only appear for users + * whose role explicitly grants audit access, not for owners/admins whose + * implicit all-permissions include `audit:read`. + * + * Show the tab iff: + * - user has the built-in `auditor` role, OR + * - user has a custom org role that explicitly grants `audit:read`. + * + * Owners/admins who want this tab can opt in by adding the auditor role + * to their membership or by creating a custom role that includes + * `audit:read`. Multi-role users are handled because `auditor` can be + * one of several roles on a membership. + */ +export function canAccessAuditorView( + roleString: string | null | undefined, + customRolePermissions: UserPermissions, +): boolean { + const roles = parseRolesString(roleString); + if (roles.includes('auditor')) return true; + return hasPermission(customRolePermissions, 'audit', 'read'); +} + /** * Ordered list of main navigation routes used to find a user's default landing page. * Order matches sidebar priority — the first accessible route becomes the default.