Skip to content

Commit 4c7e291

Browse files
authored
Merge pull request #3028 from trycompai/main
[comp] Production Deploy
2 parents 4afbf0a + 561b03d commit 4c7e291

10 files changed

Lines changed: 165 additions & 29 deletions

File tree

‎apps/api/src/people/people.controller.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,24 @@ describe('PeopleController', () => {
305305
'mem_1',
306306
'org_123',
307307
'usr_123',
308+
{ skipOffboarding: false },
309+
);
310+
});
311+
312+
it('should pass skipOffboarding=true when query param is "true"', async () => {
313+
const deleteResult = {
314+
success: true,
315+
deletedMember: { id: 'mem_1', name: 'Alice', email: 'alice@test.com' },
316+
};
317+
mockPeopleService.deleteById.mockResolvedValue(deleteResult);
318+
319+
await controller.deleteMember('mem_1', 'org_123', mockAuthContext, 'true');
320+
321+
expect(peopleService.deleteById).toHaveBeenCalledWith(
322+
'mem_1',
323+
'org_123',
324+
'usr_123',
325+
{ skipOffboarding: true },
308326
);
309327
});
310328
});

‎apps/api/src/people/people.controller.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,11 +526,13 @@ export class PeopleController {
526526
@Param('id') memberId: string,
527527
@OrganizationId() organizationId: string,
528528
@AuthContext() authContext: AuthContextType,
529+
@Query('skipOffboarding') skipOffboarding?: string,
529530
) {
530531
const result = await this.peopleService.deleteById(
531532
memberId,
532533
organizationId,
533534
authContext.userId,
535+
{ skipOffboarding: skipOffboarding === 'true' },
534536
);
535537

536538
return {

‎apps/api/src/people/people.service.spec.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ jest.mock('@db', () => ({
4848
update: jest.fn(),
4949
},
5050
},
51+
BackgroundCheckStatus: {
52+
pending: 'pending',
53+
in_progress: 'in_progress',
54+
completed: 'completed',
55+
completed_with_flags: 'completed_with_flags',
56+
cancelled: 'cancelled',
57+
},
5158
FindingType: { soc2: 'soc2', iso27001: 'iso27001' },
5259
FindingStatus: { open: 'open', closed: 'closed' },
5360
PhaseCompletionType: { manual: 'manual', auto: 'auto' },
@@ -556,10 +563,14 @@ describe('PeopleService', () => {
556563

557564
expect(result.success).toBe(true);
558565
expect(result.deletedMember.id).toBe('mem_1');
559-
expect(db.member.update).toHaveBeenCalledWith({
560-
where: { id: 'mem_1', organizationId: 'org_123' },
561-
data: { deactivated: true, isActive: false },
566+
const updateCall = (db.member.update as jest.Mock).mock.calls[0]?.[0];
567+
expect(updateCall.where).toEqual({
568+
id: 'mem_1',
569+
organizationId: 'org_123',
562570
});
571+
expect(updateCall.data.deactivated).toBe(true);
572+
expect(updateCall.data.isActive).toBe(false);
573+
expect(updateCall.data.offboardDate).toBeInstanceOf(Date);
563574
expect(db.session.deleteMany).toHaveBeenCalledWith({
564575
where: { userId: 'usr_1' },
565576
});
@@ -633,6 +644,46 @@ describe('PeopleService', () => {
633644

634645
expect(fleetService.removeHostsByLabel).toHaveBeenCalledWith(42);
635646
});
647+
648+
describe('when skipOffboarding is true', () => {
649+
it('should not set offboardDate', async () => {
650+
await service.deleteById('mem_1', 'org_123', 'usr_actor', {
651+
skipOffboarding: true,
652+
});
653+
654+
const updateCall = (db.member.update as jest.Mock).mock.calls[0]?.[0];
655+
expect(updateCall.data).toEqual({ deactivated: true, isActive: false });
656+
expect(updateCall.data).not.toHaveProperty('offboardDate');
657+
});
658+
659+
it('should not collect assigned items or notify the owner', async () => {
660+
await service.deleteById('mem_1', 'org_123', 'usr_actor', {
661+
skipOffboarding: true,
662+
});
663+
664+
// collectAssignedItems is skipped — no findMany on tasks/policies/risks/vendors
665+
expect(db.task.findMany).not.toHaveBeenCalled();
666+
expect(db.policy.findMany).not.toHaveBeenCalled();
667+
expect(db.risk.findMany).not.toHaveBeenCalled();
668+
expect(db.vendor.findMany).not.toHaveBeenCalled();
669+
// notifyOwnerOfUnassignedItems is skipped — no owner lookup
670+
expect(db.organization.findUnique).not.toHaveBeenCalled();
671+
});
672+
673+
it('should still clear assignments and delete sessions', async () => {
674+
await service.deleteById('mem_1', 'org_123', 'usr_actor', {
675+
skipOffboarding: true,
676+
});
677+
678+
expect(db.task.updateMany).toHaveBeenCalledWith({
679+
where: { assigneeId: 'mem_1', organizationId: 'org_123' },
680+
data: { assigneeId: null },
681+
});
682+
expect(db.session.deleteMany).toHaveBeenCalledWith({
683+
where: { userId: 'usr_1' },
684+
});
685+
});
686+
});
636687
});
637688

638689
describe('unlinkDevice', () => {

‎apps/api/src/people/people.service.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export class PeopleService {
363363
memberId: string,
364364
organizationId: string,
365365
callerUserId?: string,
366+
options?: { skipOffboarding?: boolean },
366367
): Promise<{
367368
success: boolean;
368369
deletedMember: { id: string; name: string; email: string };
@@ -392,10 +393,11 @@ export class PeopleService {
392393
throw new ForbiddenException('Cannot remove a platform admin');
393394
}
394395

395-
const unassignedItems = await collectAssignedItems({
396-
memberId,
397-
organizationId,
398-
});
396+
const skipOffboarding = options?.skipOffboarding === true;
397+
398+
const unassignedItems = skipOffboarding
399+
? []
400+
: await collectAssignedItems({ memberId, organizationId });
399401

400402
await clearAssignments({ memberId, organizationId });
401403
await removeMemberFromOrgChart({ organizationId, memberId });
@@ -405,7 +407,9 @@ export class PeopleService {
405407
data: {
406408
deactivated: true,
407409
isActive: false,
408-
offboardDate: member.offboardDate ?? new Date(),
410+
...(skipOffboarding
411+
? {}
412+
: { offboardDate: member.offboardDate ?? new Date() }),
409413
},
410414
});
411415

@@ -422,11 +426,13 @@ export class PeopleService {
422426
}
423427
}
424428

425-
await notifyOwnerOfUnassignedItems({
426-
organizationId,
427-
removedMemberName: member.user.name || member.user.email || 'Member',
428-
unassignedItems,
429-
});
429+
if (!skipOffboarding) {
430+
await notifyOwnerOfUnassignedItems({
431+
organizationId,
432+
removedMemberName: member.user.name || member.user.email || 'Member',
433+
unassignedItems,
434+
});
435+
}
430436

431437
this.logger.log(
432438
`Deactivated member: ${member.user.name} (${memberId}) from organization ${organizationId}`,

‎apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import type { BackgroundCheckStatus, MemberWithUser, TaskCompletion } from './Te
4949

5050
interface MemberRowProps {
5151
member: MemberWithUser;
52-
onRemove: (memberId: string) => void;
52+
onRemove: (memberId: string, options: { skipOffboarding: boolean }) => void;
5353
onRemoveDevice: (memberId: string) => void;
5454
onUpdateRole: (memberId: string, roles: string[]) => void;
5555
onReactivate: (memberId: string) => void;
@@ -226,12 +226,12 @@ export function MemberRow({
226226
setIsUpdateRolesOpen(false);
227227
};
228228

229-
const handleRemoveClick = async () => {
229+
const handleRemoveClick = async (options: { skipOffboarding: boolean }) => {
230230
if (!canRemove) return;
231231
setIsRemoveAlertOpen(false);
232232
setIsRemoving(true);
233233
try {
234-
await onRemove(memberId);
234+
await onRemove(memberId, options);
235235
} finally {
236236
setIsRemoving(false);
237237
}

‎apps/app/src/app/(app)/[orgId]/people/all/components/RemoveMemberAlert.tsx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
AlertDialogTitle,
1111
} from '@trycompai/ui/alert-dialog';
1212
import { Button } from '@trycompai/ui/button';
13+
import { Checkbox, Label } from '@trycompai/design-system';
14+
import { useEffect, useState } from 'react';
1315

1416
interface RemoveMemberAlertProps {
1517
open: boolean;
1618
onOpenChange: (open: boolean) => void;
1719
memberName: string;
18-
onRemove: () => void;
20+
onRemove: (options: { skipOffboarding: boolean }) => void;
1921
isRemoving: boolean;
2022
}
2123

@@ -26,6 +28,18 @@ export function RemoveMemberAlert({
2628
onRemove,
2729
isRemoving,
2830
}: RemoveMemberAlertProps) {
31+
const [skipOffboarding, setSkipOffboarding] = useState(false);
32+
33+
useEffect(() => {
34+
if (!open) {
35+
setSkipOffboarding(false);
36+
}
37+
}, [open]);
38+
39+
const handleRemoveClick = () => {
40+
onRemove({ skipOffboarding });
41+
};
42+
2943
return (
3044
<AlertDialog open={open} onOpenChange={onOpenChange}>
3145
<AlertDialogContent>
@@ -36,9 +50,17 @@ export function RemoveMemberAlert({
3650
{'They will no longer have access to this organization.'}
3751
</AlertDialogDescription>
3852
</AlertDialogHeader>
53+
<div className="flex items-center gap-2 py-2">
54+
<Checkbox
55+
id="skip-offboarding"
56+
checked={skipOffboarding}
57+
onCheckedChange={(checked) => setSkipOffboarding(checked === true)}
58+
/>
59+
<Label htmlFor="skip-offboarding">Skip offboarding</Label>
60+
</div>
3961
<AlertDialogFooter>
4062
<AlertDialogCancel>{'Cancel'}</AlertDialogCancel>
41-
<Button variant="destructive" onClick={onRemove} disabled={isRemoving}>
63+
<Button variant="destructive" onClick={handleRemoveClick} disabled={isRemoving}>
4264
{'Remove'}
4365
</Button>
4466
</AlertDialogFooter>

‎apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,12 @@ export function TeamMembersClient({
205205
}
206206
};
207207

208-
const handleRemoveMember = async (memberId: string) => {
208+
const handleRemoveMember = async (
209+
memberId: string,
210+
options: { skipOffboarding: boolean },
211+
) => {
209212
try {
210-
await removeMember(memberId);
213+
await removeMember(memberId, options);
211214
toast.success('Member has been removed from the organization');
212215
router.refresh();
213216
} catch (error) {

‎apps/app/src/hooks/use-people-api.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ export function usePeopleActions() {
4747
);
4848

4949
const removeMember = useCallback(
50-
async (memberId: string) => {
50+
async (memberId: string, options?: { skipOffboarding?: boolean }) => {
51+
const query = options?.skipOffboarding ? '?skipOffboarding=true' : '';
5152
const response = await api.delete<{
5253
success: boolean;
5354
deletedMember: { id: string; name: string; email: string };
54-
}>(`/v1/people/${memberId}`);
55+
}>(`/v1/people/${memberId}${query}`);
5556
if (response.error) {
5657
throw new Error(response.error);
5758
}

‎packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,37 @@ describe('Azure storage checks', () => {
9999
expect(passed).toHaveLength(1);
100100
});
101101

102+
it("public-access: 'Selected networks' (publicNetworkAccess Enabled + defaultAction Deny) passes", async () => {
103+
const { passed, failed } = await run(
104+
storagePublicAccessCheck,
105+
storageList({
106+
allowBlobPublicAccess: false,
107+
publicNetworkAccess: 'Enabled',
108+
networkAcls: {
109+
defaultAction: 'Deny',
110+
bypass: 'AzureServices',
111+
ipRules: [{ value: '203.0.113.0/24', action: 'Allow' }],
112+
},
113+
}),
114+
);
115+
expect(failed).toHaveLength(0);
116+
expect(passed).toHaveLength(1);
117+
});
118+
119+
it('public-access: publicNetworkAccess Enabled with default Allow fails', async () => {
120+
const { passed, failed } = await run(
121+
storagePublicAccessCheck,
122+
storageList({
123+
allowBlobPublicAccess: false,
124+
publicNetworkAccess: 'Enabled',
125+
networkAcls: { defaultAction: 'Allow' },
126+
}),
127+
);
128+
expect(passed).toHaveLength(0);
129+
expect(failed).toHaveLength(1);
130+
expect(failed[0]!.severity).toBe('medium');
131+
});
132+
102133
it('encryption fails when a service is disabled, passes when enabled', async () => {
103134
const bad = await run(
104135
storageEncryptionCheck,

‎packages/integration-platform/src/manifests/azure/checks/storage.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,15 @@ export const storagePublicAccessCheck: IntegrationCheck = {
106106
const p = a.properties ?? {};
107107
const publicBlob = p.allowBlobPublicAccess === true;
108108
// publicNetworkAccess 'Disabled' or 'SecuredByPerimeter' (network security
109-
// perimeter) overrides the firewall default action and is not public.
110-
const networkRestricted =
109+
// perimeter) takes the endpoint off the public internet entirely. When it
110+
// is 'Enabled', networkAcls.defaultAction === 'Deny' still restricts
111+
// traffic to the explicit IP/VNet allowlist (Azure's "Selected networks"
112+
// mode) and is not public.
113+
const networkDisabled =
111114
p.publicNetworkAccess === 'Disabled' ||
112115
p.publicNetworkAccess === 'SecuredByPerimeter';
113-
const publicNetwork =
114-
!networkRestricted &&
115-
(p.publicNetworkAccess === 'Enabled' ||
116-
p.networkAcls?.defaultAction === 'Allow');
116+
const firewallEnforced = p.networkAcls?.defaultAction === 'Deny';
117+
const publicNetwork = !networkDisabled && !firewallEnforced;
117118
if (publicBlob || publicNetwork) {
118119
ctx.fail({
119120
title: `Public access enabled: ${a.name}`,
@@ -122,11 +123,12 @@ export const storagePublicAccessCheck: IntegrationCheck = {
122123
resourceId: a.id,
123124
severity: publicBlob ? 'high' : 'medium',
124125
remediation:
125-
'Disable "Allow Blob public access" and restrict network access to specific VNets/IPs or private endpoints.',
126+
'Disable "Allow Blob public access" and restrict network access to specific VNets/IPs or private endpoints (set networkAcls.defaultAction to "Deny").',
126127
evidence: {
127128
account: a.name,
128129
allowBlobPublicAccess: p.allowBlobPublicAccess,
129130
publicNetworkAccess: p.publicNetworkAccess ?? null,
131+
networkDefaultAction: p.networkAcls?.defaultAction ?? null,
130132
},
131133
});
132134
} else {

0 commit comments

Comments
 (0)