Skip to content

Commit d47cd5d

Browse files
github-actions[bot]Marfuenclaude
authored
fix(rbac): enforce app:read for app access, use permissions for member invites, gate portal
* fix(rbac): enforce app:read for app access, use permissions for member invites, gate portal Remove the APP_IMPLYING_RESOURCES fallback that let custom roles bypass the App Access toggle. Replace hardcoded role string checks in the member invite flow with RBAC permission checks (member:create/update), and add privilege escalation prevention for non-admin callers. Add portal:read / compliance-obligation check to the portal so unapproved roles are redirected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(rbac): resolve caller member permissions instead of hardcoded role checks Replace role string matching (isAdmin/isAuditor) with actual RBAC permission resolution — resolves the caller's member actions from both built-in and custom roles via BUILT_IN_ROLE_PERMISSIONS + DB lookup. Uses member:delete as the signal for full control (can assign any role) vs restricted (can only assign employee/contractor/custom roles). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(rbac): let the permission guard handle member invite authorization Remove redundant validateAssignableRoles — the @RequirePermission guard on the controller already checks member:create. If the admin gave a custom role Members: Write, that role can invite. No second layer of role-string checks needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(rbac): add server-side role assignment validation based on member permission level Resolve the caller's member actions from RBAC (built-in + custom roles). Write-level access (all CRUD) can assign any role. Partial access (e.g. auditor with create+read only) can only assign restricted roles (employee/contractor) and custom roles — cannot assign privileged built-in roles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(rbac): use permission checks for allowedBuiltInRoles on people page Replace hardcoded isAdminOrOwner/isAuditor role string checks with Write-level member permission check (all CRUD actions). Mirrors the backend validation logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(auth): add parseRolePermissions/parseRoleObligations helpers Extract the repeated typeof/JSON.parse pattern for OrganizationRole fields into typed helpers in the auth package. Replaces verbose defensive checks with one-liner calls that return typed objects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: clean up parse helpers and role checks Add try/catch to JSON parse helpers, extract generic parseJsonField, add isRestrictedRole() to eliminate verbose readonly casts, and make portal-access checks consistent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add cleanup skill for mandatory post-implementation code review Committed to the repo so all Claude agents working in this codebase will have it available and are required to run it after writing code. Checks for verbose patterns, inconsistent idioms, missing error handling, and readability issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update PostToolUse hook to remind about cleanup skill The hook now fires for all TS files in apps/ and packages/ and reminds agents to run the cleanup skill before committing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48830d8 commit d47cd5d

10 files changed

Lines changed: 285 additions & 69 deletions

File tree

.claude/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/api|apps/app)/'; then echo 'TypeScript file modified — remember to run typecheck before committing'; fi"
9+
"command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/|packages/)'; then echo 'TypeScript file modified — run the cleanup skill and typecheck before committing'; fi"
1010
}
1111
]
1212
},

.claude/skills/cleanup/SKILL.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
name: cleanup
3+
description: "MUST run after writing or modifying code — reviews changed files for verbose patterns, inconsistencies, and readability issues before considering work done"
4+
---
5+
6+
# Post-Implementation Cleanup
7+
8+
**This skill is mandatory.** After writing or modifying code, you MUST review all changed files before reporting the task as complete. Code must be readable at a glance.
9+
10+
## When to Run
11+
12+
- After completing any implementation work
13+
- After fixing bugs
14+
- After refactoring
15+
- Before committing
16+
17+
## Checklist
18+
19+
For every file you changed, verify:
20+
21+
### 1. No Verbose Defensive Checks
22+
23+
Extract repeated patterns into typed helpers.
24+
25+
```tsx
26+
// ❌ Verbose and repeated
27+
const perms = typeof role.permissions === 'string'
28+
? JSON.parse(role.permissions) : role.permissions;
29+
if (perms && typeof perms === 'object' && Array.isArray(perms.portal) && perms.portal.length > 0) {
30+
31+
// ✅ Typed helper
32+
const perms = parseRolePermissions(role.permissions);
33+
if (perms?.portal?.length) {
34+
```
35+
36+
### 2. Consistent Idioms Across Files
37+
38+
The same check must use the same pattern everywhere.
39+
40+
```tsx
41+
// ❌ Inconsistent
42+
file1: perms?.portal?.length > 0
43+
file2: perms?.portal?.length
44+
45+
// ✅ Pick one
46+
perms?.portal?.length
47+
```
48+
49+
### 3. No Redundant Type Casts
50+
51+
If you need a cast to satisfy TypeScript, extract a helper function instead.
52+
53+
```tsx
54+
// ❌ Verbose cast repeated in every file
55+
const restrictedRoles: readonly string[] = RESTRICTED_ROLES;
56+
restrictedRoles.includes(role);
57+
58+
// ✅ Helper in shared package
59+
export function isRestrictedRole(role: string): boolean {
60+
return (RESTRICTED_ROLES as readonly string[]).includes(role);
61+
}
62+
```
63+
64+
### 4. Error Handling on Boundaries
65+
66+
`JSON.parse`, external API calls, and DB queries at system boundaries need error handling.
67+
68+
```tsx
69+
// ❌ Unguarded parse
70+
const parsed = JSON.parse(value);
71+
72+
// ✅ Safe parse
73+
try {
74+
return JSON.parse(value);
75+
} catch {
76+
return null;
77+
}
78+
```
79+
80+
### 5. Shared Patterns Belong in Shared Packages
81+
82+
If the same logic appears in 2+ apps (api, app, portal), extract it to a shared package (`packages/auth`, `packages/db`, etc.).
83+
84+
### 6. No Dead Code
85+
86+
- Remove unused imports
87+
- Remove unused variables
88+
- Remove unused function parameters
89+
- Remove props that are always null/false
90+
91+
### 7. Readable at a Glance
92+
93+
- Function and variable names should convey intent without reading the implementation
94+
- One-liner expressions over multi-line when equally clear
95+
- No nested ternaries
96+
97+
## How to Run
98+
99+
1. List all files you modified: `git diff --name-only`
100+
2. Read each file and check against this checklist
101+
3. Fix any issues found
102+
4. Typecheck after fixes: `npx tsc --noEmit`

apps/api/src/people/people-invite.service.ts

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import {
22
Injectable,
33
Logger,
44
BadRequestException,
5-
ForbiddenException,
65
} from '@nestjs/common';
76
import { db } from '@db';
87
import { triggerEmail } from '../email/trigger-email';
98
import { InviteEmail } from '../email/templates/invite-member';
109
import { InvitePortalEmail } from '@trycompai/email';
1110
import {
1211
BUILT_IN_ROLE_OBLIGATIONS,
13-
RESTRICTED_ROLES,
12+
BUILT_IN_ROLE_PERMISSIONS,
13+
isRestrictedRole,
14+
parseRoleObligations,
15+
parseRolePermissions,
1416
} from '@trycompai/auth';
1517
import type { InviteItemDto } from './dto/invite-people.dto';
1618
import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper';
@@ -37,38 +39,26 @@ export class PeopleInviteService {
3739
}): Promise<InviteResult[]> {
3840
const { organizationId, invites, callerUserId, callerRole } = params;
3941

40-
const isAdmin =
41-
callerRole.includes('admin') || callerRole.includes('owner');
42-
const isAuditor = callerRole.includes('auditor');
43-
44-
if (!isAdmin && !isAuditor) {
45-
throw new ForbiddenException(
46-
"You don't have permission to invite members.",
47-
);
48-
}
42+
const callerMemberActions = await this.resolveCallerMemberActions(
43+
callerRole,
44+
organizationId,
45+
);
4946

5047
const results: InviteResult[] = [];
5148

5249
for (const invite of invites) {
5350
try {
54-
// Auditors can only invite auditors
55-
if (isAuditor && !isAdmin) {
56-
const onlyAuditor =
57-
invite.roles.length === 1 && invite.roles[0] === 'auditor';
58-
if (!onlyAuditor) {
59-
results.push({
60-
email: invite.email,
61-
success: false,
62-
error: "Auditors can only invite users with the 'auditor' role.",
63-
});
64-
continue;
65-
}
51+
const roleError = this.validateAssignableRoles(
52+
invite.roles,
53+
callerMemberActions,
54+
);
55+
if (roleError) {
56+
results.push({ email: invite.email, success: false, error: roleError });
57+
continue;
6658
}
6759

6860
const email = invite.email.toLowerCase();
69-
const restrictedRoles: readonly string[] = RESTRICTED_ROLES;
70-
const isStrictlyEmployee =
71-
invite.roles.every((role) => restrictedRoles.includes(role));
61+
const isStrictlyEmployee = invite.roles.every(isRestrictedRole);
7262

7363
const hasCompliance = await this.rolesHaveComplianceObligation(
7464
invite.roles,
@@ -436,13 +426,64 @@ export class PeopleInviteService {
436426
select: { obligations: true },
437427
});
438428

439-
return customRoles.some((role) => {
440-
const obligations =
441-
typeof role.obligations === 'string'
442-
? JSON.parse(role.obligations)
443-
: role.obligations || {};
444-
return !!obligations.compliance;
445-
});
429+
return customRoles.some((role) =>
430+
parseRoleObligations(role.obligations).compliance,
431+
);
432+
}
433+
434+
/**
435+
* Write-level = all CRUD actions. Callers with Write can assign any role.
436+
* Partial access (e.g. auditor with create+read) can only assign
437+
* restricted roles (employee/contractor) and custom roles.
438+
*/
439+
private validateAssignableRoles(
440+
targetRoles: string[],
441+
callerMemberActions: Set<string>,
442+
): string | null {
443+
const hasWriteAccess = ['create', 'read', 'update', 'delete'].every((a) =>
444+
callerMemberActions.has(a),
445+
);
446+
if (hasWriteAccess) return null;
447+
448+
const disallowed = targetRoles.filter(
449+
(r) => !isRestrictedRole(r) && Object.hasOwn(BUILT_IN_ROLE_PERMISSIONS, r),
450+
);
451+
if (disallowed.length > 0) {
452+
return `You cannot assign privileged roles: ${disallowed.join(', ')}.`;
453+
}
454+
return null;
455+
}
456+
457+
private async resolveCallerMemberActions(
458+
callerRole: string,
459+
organizationId: string,
460+
): Promise<Set<string>> {
461+
const roles = callerRole.split(',').map((r) => r.trim());
462+
const actions = new Set<string>();
463+
const customRoleNames: string[] = [];
464+
465+
for (const role of roles) {
466+
const builtIn = BUILT_IN_ROLE_PERMISSIONS[role];
467+
if (builtIn?.member) {
468+
for (const a of builtIn.member) actions.add(a);
469+
}
470+
if (!builtIn) customRoleNames.push(role);
471+
}
472+
473+
if (customRoleNames.length > 0) {
474+
const customRoles = await db.organizationRole.findMany({
475+
where: { organizationId, name: { in: customRoleNames } },
476+
select: { permissions: true },
477+
});
478+
for (const role of customRoles) {
479+
const perms = parseRolePermissions(role.permissions);
480+
if (perms?.member) {
481+
for (const a of perms.member) actions.add(a);
482+
}
483+
}
484+
}
485+
486+
return actions;
446487
}
447488

448489
private buildPortalUrl(organizationId: string): string {

apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
TabsTrigger,
1111
} from '@trycompai/design-system';
1212
import { Add } from '@trycompai/design-system/icons';
13+
import type { Role } from '@db';
1314
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
1415
import type { ReactNode } from 'react';
1516
import { useCallback, useState } from 'react';
@@ -28,6 +29,7 @@ interface PeoplePageTabsProps {
2829
showSettings: boolean;
2930
canInviteUsers: boolean;
3031
canManageMembers: boolean;
32+
allowedBuiltInRoles: Role[];
3133
organizationId: string;
3234
}
3335

@@ -89,6 +91,7 @@ export function PeoplePageTabs({
8991
showSettings,
9092
canInviteUsers,
9193
canManageMembers,
94+
allowedBuiltInRoles,
9295
organizationId,
9396
}: PeoplePageTabsProps) {
9497
const pathname = usePathname();
@@ -164,9 +167,7 @@ export function PeoplePageTabs({
164167
open={isInviteModalOpen}
165168
onOpenChange={setIsInviteModalOpen}
166169
organizationId={organizationId}
167-
allowedBuiltInRoles={
168-
canManageMembers ? ['admin', 'auditor', 'employee', 'contractor'] : ['auditor']
169-
}
170+
allowedBuiltInRoles={allowedBuiltInRoles}
170171
/>
171172
</Tabs>
172173
);

apps/app/src/app/(app)/[orgId]/people/page.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolveUserPermissions } from '@/lib/permissions.server';
33
import { auth } from '@/utils/auth';
44
import { db } from '@db/server';
55
import type { Metadata } from 'next';
6+
import type { Role } from '@db';
67
import { headers } from 'next/headers';
78
import { redirect } from 'next/navigation';
89
import { Suspense } from 'react';
@@ -32,15 +33,23 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId:
3233
where: { organizationId: orgId, userId: session.user.id },
3334
});
3435
const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? [];
35-
const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role));
36-
const isAuditor = currentUserRoles.includes('auditor');
37-
const canInviteUsers = canManageMembers || isAuditor;
3836
const isCurrentUserOwner = currentUserRoles.includes('owner');
3937

4038
const userPermissions = await resolveUserPermissions(
4139
currentUserMember?.role ?? null,
4240
orgId,
4341
);
42+
const canManageMembers = hasPermission(userPermissions, 'member', 'update');
43+
const canInviteUsers = hasPermission(userPermissions, 'member', 'create');
44+
45+
const hasWriteMemberAccess =
46+
canInviteUsers &&
47+
hasPermission(userPermissions, 'member', 'read') &&
48+
canManageMembers &&
49+
hasPermission(userPermissions, 'member', 'delete');
50+
const allowedBuiltInRoles: Role[] = hasWriteMemberAccess
51+
? ['admin', 'auditor', 'employee', 'contractor']
52+
: ['employee', 'contractor'];
4453
const canManageOrgSettings = hasPermission(
4554
userPermissions,
4655
'organization',
@@ -85,6 +94,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId:
8594
showEmployeeTasks
8695
canInviteUsers={canInviteUsers}
8796
canManageMembers={canManageMembers}
97+
allowedBuiltInRoles={allowedBuiltInRoles}
8898
organizationId={orgId}
8999
/>
90100
);

apps/app/src/lib/permissions.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,6 @@ export function getDefaultRoute(permissions: UserPermissions, orgId: string): st
153153
return null;
154154
}
155155

156-
/**
157-
* Resources that imply the user should have access to the main app.
158-
* Portal-only resources (policy, compliance) are excluded — employees/contractors
159-
* have those but should NOT enter the app.
160-
*/
161-
const APP_IMPLYING_RESOURCES = new Set([
162-
'organization', 'member', 'control', 'evidence', 'risk', 'vendor',
163-
'task', 'framework', 'audit', 'finding', 'questionnaire', 'integration',
164-
'apiKey', 'trust', 'pentest',
165-
]);
166-
167156
/** Compliance route segments — used to determine if the Compliance rail icon should show. */
168157
const COMPLIANCE_ROUTE_SEGMENTS = [
169158
'overview', 'frameworks', 'controls', 'policies', 'tasks', 'documents', 'people',
@@ -181,22 +170,11 @@ export function canAccessCompliance(permissions: UserPermissions): boolean {
181170
/**
182171
* Check if user can access the main app (as opposed to portal-only).
183172
*
184-
* Returns true if the user has explicit `app:read` (built-in roles like owner/admin/auditor),
185-
* OR if they have any permission on a resource that implies app access (e.g. a custom role
186-
* with only `pentest:read`).
187-
*
188-
* Employees and contractors are portal-only — they only have `policy:read`
189-
* and compliance obligations, neither of which is in APP_IMPLYING_RESOURCES.
173+
* Requires explicit `app:read` — controlled by the "App Access" toggle
174+
* on custom roles, and included by default in owner/admin/auditor.
190175
*/
191176
export function canAccessApp(permissions: UserPermissions): boolean {
192-
if (hasPermission(permissions, 'app', 'read')) return true;
193-
194-
for (const resource of Object.keys(permissions)) {
195-
if (APP_IMPLYING_RESOURCES.has(resource) && permissions[resource]?.length > 0) {
196-
return true;
197-
}
198-
}
199-
return false;
177+
return hasPermission(permissions, 'app', 'read');
200178
}
201179

202180
/**

0 commit comments

Comments
 (0)