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
10 changes: 5 additions & 5 deletions apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions apps/app/src/app/(app)/[orgId]/auditor/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { requireRoutePermission } from '@/lib/permissions.server';
import { requireAuditorViewAccess } from '@/lib/permissions.server';

export default async function Layout({
children,
Expand All @@ -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}</>;
}
5 changes: 5 additions & 0 deletions apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +101,7 @@ function AppShellWrapperContent({
isSecurityEnabled,
hasAuditorRole,
isOnlyAuditor,
canAccessAuditorView,
permissions,
user,
isAdmin,
Expand Down Expand Up @@ -144,6 +147,7 @@ function AppShellWrapperContent({
permissions,
hasAuditorRole,
isOnlyAuditor,
canAccessAuditorView,
isQuestionnaireEnabled,
isTrustNdaEnabled,
isSecurityEnabled,
Expand Down Expand Up @@ -319,6 +323,7 @@ function AppShellWrapperContent({
hasAuditorRole={hasAuditorRole}
isOnlyAuditor={isOnlyAuditor}
permissions={permissions}
canAccessAuditorView={canAccessAuditorView}
/>
)}
</AppShellSidebar>
Expand Down
14 changes: 13 additions & 1 deletion apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -27,6 +34,7 @@ export function AppSidebar({
hasAuditorRole,
isOnlyAuditor,
permissions,
canAccessAuditorView,
}: AppSidebarProps) {
const pathname = usePathname() ?? '';

Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +66,7 @@ export const getAppShellSearchGroups = ({
permissions,
hasAuditorRole,
isOnlyAuditor,
canAccessAuditorView,
isQuestionnaireEnabled,
isTrustNdaEnabled,
isSecurityEnabled,
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 23 additions & 2 deletions apps/app/src/app/(app)/[orgId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -180,6 +200,7 @@ export default async function Layout({
isSecurityEnabled={isSecurityEnabled}
hasAuditorRole={hasAuditorRole}
isOnlyAuditor={isOnlyAuditor}
canAccessAuditorView={auditorViewVisible}
permissions={permissions}
user={user}
isAdmin={isUserAdmin}
Expand Down
10 changes: 7 additions & 3 deletions apps/app/src/components/main-menu.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,7 +55,6 @@ export type Props = {
onItemClick?: () => void;
isQuestionnaireEnabled?: boolean;
isTrustNdaEnabled?: boolean;
hasAuditorRole?: boolean;
isOnlyAuditor?: boolean;
};

Expand All @@ -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[] = [
{
Expand All @@ -96,7 +100,7 @@ export function MainMenu({
disabled: false,
icon: ClipboardCheck,
protected: false,
hidden: !hasAuditorRole,
hidden: !canAccessAuditorView,
},
{
id: 'controls',
Expand Down
1 change: 0 additions & 1 deletion apps/app/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ export async function Sidebar({
isCollapsed={isCollapsed}
isQuestionnaireEnabled={isQuestionnaireEnabled}
isTrustNdaEnabled={isTrustNdaEnabled}
hasAuditorRole={hasAuditorRole}
isOnlyAuditor={isOnlyAuditor}
/>
</div>
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/hooks/use-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
hasPermission,
parseRolesString,
isBuiltInRole,
canAccessAuditorView,
type UserPermissions,
} from '@/lib/permissions';

Expand Down Expand Up @@ -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),
};
}
80 changes: 80 additions & 0 deletions apps/app/src/lib/permissions.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import {
type UserPermissions,
canAccessAuditorView,
canAccessRoute,
getDefaultRoute,
mergePermissions,
Expand Down Expand Up @@ -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<UserPermissions> {
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<string, string[]>);
}
}
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<void> {
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');
}
Loading
Loading