Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2db4efe
chore: merge release v3.77.0 back to main [skip ci]
github-actions[bot] Jun 10, 2026
5d7e2a0
fix(integration-platform): surface real read errors in cloudtrail/kms…
tofikwest Jun 10, 2026
90ea815
Merge pull request #3086 from trycompai/tofik/cs-533-port-toreadfailu…
tofikwest Jun 10, 2026
e9ed93d
feat(trust-portal): display custom frameworks on the trust portal
tofikwest Jun 10, 2026
91135b2
fix(integration-platform): surface real read errors in azure/gcp chec…
tofikwest Jun 10, 2026
1a2c8f7
Merge branch 'main' into tofik/trust-portal-custom-frameworks
tofikwest Jun 10, 2026
ec5ba44
fix(trust-portal): resync custom-framework state + mark response fiel…
tofikwest Jun 10, 2026
71f34ef
Merge pull request #3088 from trycompai/tofik/trust-portal-custom-fra…
tofikwest Jun 10, 2026
9912b9a
feat(integration-platform): scan all enabled Azure subscriptions (CS-…
tofikwest Jun 10, 2026
1493c5c
fix(integration-platform): stamp account attribution on account-level…
tofikwest Jun 10, 2026
db4d09d
Merge branch 'main' into tofik/cs-534-azure-gcp-read-errors
tofikwest Jun 10, 2026
11857e9
Merge pull request #3089 from trycompai/tofik/cs-534-azure-gcp-read-e…
tofikwest Jun 10, 2026
62fd692
fix(integration-platform): isolate per-subscription wildcard scan + g…
tofikwest Jun 10, 2026
ae55a26
Merge branch 'tofik/cs-534-azure-gcp-read-errors' into tofik/cs-534-a…
tofikwest Jun 10, 2026
c7f3b74
Merge pull request #3090 from trycompai/tofik/cs-534-azure-multi-subs…
tofikwest Jun 10, 2026
1cef695
Merge branch 'main' into tofik/cs-534-azure-gcp-read-errors
tofikwest Jun 10, 2026
c1bce47
fix(integration-platform): make multi-subscription scanning strictly …
tofikwest Jun 10, 2026
e6dda8c
fix(integration-platform): surface the subscription scan cap as an ex…
tofikwest Jun 10, 2026
8ff90b0
Merge pull request #3093 from trycompai/tofik/cs-534-azure-gcp-read-e…
tofikwest Jun 10, 2026
ca3090f
Merge branch 'main' into tofik/aws-account-attribution-account-level-…
tofikwest Jun 10, 2026
f1123e5
Merge pull request #3092 from trycompai/tofik/aws-account-attribution…
tofikwest Jun 10, 2026
9f30138
fix: resolve cubic findings from the production deploy review (#3087)
tofikwest Jun 10, 2026
c39011f
fix: guard optimistic state sync and align picker page cap (cubic on …
tofikwest Jun 10, 2026
7a60224
Merge pull request #3095 from trycompai/tofik/deploy-review-fixes-3087
tofikwest Jun 10, 2026
7d51e2c
fix(trust): reset the certificate file input on every selection, not …
tofikwest Jun 10, 2026
29af68b
Merge pull request #3096 from trycompai/tofik/trust-upload-input-reset
tofikwest Jun 10, 2026
3e3ed70
feat(trust-portal): add per-email NDA-bypass allowlist
tofikwest Jun 10, 2026
c2b9122
fix(trust): resolve 4 cubic findings from the production deploy review
tofikwest Jun 10, 2026
e515cd0
Merge pull request #3098 from trycompai/tofik/trust-deploy-cubic-round2
tofikwest Jun 10, 2026
06ed9bd
fix(trust-portal): validate allowed-emails body with a DTO
tofikwest Jun 10, 2026
b956774
Merge branch 'main' into tofik/trust-portal-email-allowlist
tofikwest Jun 10, 2026
e20152c
Merge pull request #3097 from trycompai/tofik/trust-portal-email-allo…
tofikwest Jun 10, 2026
086bf7c
fix(trust): gate certificate drag-and-drop behind the read-only permi…
tofikwest Jun 10, 2026
bb30749
fix(trust): return 400 not 500 on malformed PUT /custom-frameworks body
tofikwest Jun 10, 2026
afc9c8f
Merge branch 'main' into tofik/trust-custom-frameworks-sweep
tofikwest Jun 10, 2026
42da4b7
Merge pull request #3100 from trycompai/tofik/trust-custom-frameworks…
tofikwest Jun 11, 2026
6c35625
fix(integrations): gate the Add-account CTA on integration:create RBAC
tofikwest Jun 11, 2026
b8294af
Merge pull request #3101 from trycompai/tofik/integration-add-account…
tofikwest Jun 11, 2026
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
39 changes: 32 additions & 7 deletions apps/api/src/trust-portal/dto/compliance-resource.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { TrustFramework } from '@db';

export class ComplianceResourceBaseDto {
Expand All @@ -10,13 +10,26 @@ export class ComplianceResourceBaseDto {
@IsString()
organizationId!: string;

@ApiProperty({
description: 'Compliance framework identifier',
// A compliance certificate targets EITHER a native framework OR a custom
// framework. Exactly one of `framework` / `customFrameworkId` must be set;
// the service enforces this (assertExactlyOneFrameworkRef).
@ApiPropertyOptional({
description: 'Native compliance framework identifier',
enum: TrustFramework,
example: TrustFramework.iso_27001,
})
@IsOptional()
@IsEnum(TrustFramework)
framework!: TrustFramework;
framework?: TrustFramework;

@ApiPropertyOptional({
description:
'Org-authored custom framework ID (alternative to `framework`)',
example: 'cfrm_6914cd0e16e4c7dccbb54426',
})
@IsOptional()
@IsString()
customFrameworkId?: string;
}

export class UploadComplianceResourceDto extends ComplianceResourceBaseDto {
Expand Down Expand Up @@ -44,8 +57,20 @@ export class UploadComplianceResourceDto extends ComplianceResourceBaseDto {
export class ComplianceResourceSignedUrlDto extends ComplianceResourceBaseDto {}

export class ComplianceResourceResponseDto {
@ApiProperty({ enum: TrustFramework })
framework!: TrustFramework;
// Always present in the response (one of the two is null), so these are
// required-but-nullable — not optional.
@ApiProperty({
enum: TrustFramework,
description: 'Set for native-framework certificates; null for custom ones',
nullable: true,
})
framework!: TrustFramework | null;

@ApiProperty({
description: 'Set for custom-framework certificates; null for native ones',
nullable: true,
})
customFrameworkId!: string | null;

@ApiProperty()
fileName!: string;
Expand Down
43 changes: 43 additions & 0 deletions apps/api/src/trust-portal/dto/trust-custom-framework.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { z } from 'zod';

/**
* Update the public Trust Portal selection for a single org-authored custom
* framework. Mirrors the enabled + status that native frameworks store as
* columns on `Trust`. At least one of `enabled` / `status` must be provided.
*/
export const UpdateTrustCustomFrameworkSchema = z
.object({
customFrameworkId: z.string().min(1),
enabled: z.boolean().optional(),
status: z.enum(['started', 'in_progress', 'compliant']).optional(),
})
.refine((data) => data.enabled !== undefined || data.status !== undefined, {
message: 'At least one of `enabled` or `status` must be provided',
});

export type UpdateTrustCustomFrameworkDto = z.infer<
typeof UpdateTrustCustomFrameworkSchema
>;

/** A custom framework plus its Trust Portal selection state (admin view). */
export interface TrustCustomFrameworkAdminItem {
customFrameworkId: string;
name: string;
description: string;
/** Whether the framework is shown on the public portal. */
enabled: boolean;
/** Displayed status; defaults to 'started' when never configured. */
status: 'started' | 'in_progress' | 'compliant';
/** Whether a compliance certificate PDF has been uploaded. */
hasCertificate: boolean;
certificateFileName: string | null;
}

/** A custom framework as shown on the public portal. */
export interface TrustCustomFrameworkPublicItem {
id: string;
name: string;
description: string;
status: 'started' | 'in_progress' | 'compliant';
hasCertificate: boolean;
}
14 changes: 14 additions & 0 deletions apps/api/src/trust-portal/dto/update-allowed-emails.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEmail } from 'class-validator';

export class UpdateAllowedEmailsDto {
@ApiProperty({
description:
'Email addresses that bypass NDA signing for trust portal access. Replaces the full list; send an empty array to clear it.',
type: [String],
example: ['person@example.com'],
})
@IsArray()
@IsEmail({}, { each: true })
emails: string[];
}
55 changes: 51 additions & 4 deletions apps/api/src/trust-portal/trust-access.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,10 +456,7 @@ export class TrustAccessController {
@Param('token') token: string,
@Param('policyId') policyId: string,
) {
return this.trustAccessService.downloadPolicyByAccessToken(
token,
policyId,
);
return this.trustAccessService.downloadPolicyByAccessToken(token, policyId);
}

@Get('access/:token/policies/download-all-zip')
Expand Down Expand Up @@ -558,6 +555,37 @@ export class TrustAccessController {
);
}

@Get('access/:token/compliance-resources/custom/:customFrameworkId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'Download a custom-framework compliance certificate by access token',
description:
'Get a signed URL to download a specific custom-framework certificate file',
})
@ApiParam({
name: 'customFrameworkId',
description: 'Org-authored custom framework ID',
example: 'cfrm_abc123',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Signed URL for the custom-framework certificate returned',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Certificate not found',
})
async getCustomComplianceResourceUrlByAccessToken(
@Param('token') token: string,
@Param('customFrameworkId') customFrameworkId: string,
) {
return this.trustAccessService.getCustomComplianceResourceUrlByAccessToken(
token,
customFrameworkId,
);
}

@Get('access/:token/compliance-resources/:framework')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand Down Expand Up @@ -719,4 +747,23 @@ export class TrustAccessController {
async getPublicVendors(@Param('friendlyUrl') friendlyUrl: string) {
return this.trustAccessService.getPublicVendors(friendlyUrl);
}

@Get(':friendlyUrl/custom-frameworks')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get org-authored custom frameworks shown on a trust portal',
description:
'Retrieve the list of custom frameworks the org has chosen to display on its public trust portal.',
})
@ApiParam({
name: 'friendlyUrl',
description: 'Trust Portal friendly URL or Organization ID',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Custom frameworks retrieved successfully',
})
async getPublicCustomFrameworks(@Param('friendlyUrl') friendlyUrl: string) {
return this.trustAccessService.getPublicCustomFrameworks(friendlyUrl);
}
}
138 changes: 138 additions & 0 deletions apps/api/src/trust-portal/trust-access.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ jest.mock('@db', () => ({
trustAccessGrant: {
findUnique: jest.fn(),
},
trustAccessRequest: {
findFirst: jest.fn(),
},
member: {
findFirst: jest.fn(),
},
$transaction: jest.fn(),
},
Prisma: {
PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error {
Expand Down Expand Up @@ -56,6 +63,13 @@ const mockDb = db as unknown as {
trustAccessGrant: {
findUnique: jest.Mock;
};
trustAccessRequest: {
findFirst: jest.Mock;
};
member: {
findFirst: jest.Mock;
};
$transaction: jest.Mock;
};

const mockGetSignedUrl = getSignedUrl as jest.MockedFunction<
Expand All @@ -70,6 +84,7 @@ describe('TrustAccessService favicon branding', () => {
{} as any,
{} as any,
{} as any,
{} as any,
);

beforeEach(() => {
Expand Down Expand Up @@ -172,3 +187,126 @@ describe('TrustAccessService favicon branding', () => {
expect(result.portalUrl).toContain('/acme-security');
});
});

describe('TrustAccessService approveRequest NDA bypass', () => {
const emailService = {
sendAccessGrantedEmail: jest.fn(),
sendNdaSigningEmail: jest.fn(),
};
const service = new TrustAccessService(
{} as any,
emailService as any,
{} as any,
{} as any,
{} as any,
);
const buildPortalAccessUrlSpy = jest.spyOn(
service as any,
'buildPortalAccessUrl',
);

const baseRequest = {
id: 'tar_1',
status: 'under_review',
email: 'chang.liu@client.com',
name: 'Chang Liu',
requestedDurationDays: 30,
organization: { name: 'Acme Security' },
};

let txMock: {
trustAccessRequest: { update: jest.Mock };
trustAccessGrant: { create: jest.Mock };
trustNDAAgreement: { create: jest.Mock };
auditLog: { create: jest.Mock };
};

beforeEach(() => {
jest.clearAllMocks();
txMock = {
trustAccessRequest: {
update: jest
.fn()
.mockResolvedValue({ id: 'tar_1', status: 'approved' }),
},
trustAccessGrant: {
create: jest
.fn()
.mockResolvedValue({ id: 'tag_1', expiresAt: new Date() }),
},
trustNDAAgreement: {
create: jest
.fn()
.mockResolvedValue({ id: 'tna_1', signToken: 'sign-token' }),
},
auditLog: { create: jest.fn().mockResolvedValue({}) },
};
mockDb.trustAccessRequest.findFirst.mockResolvedValue(baseRequest);
mockDb.member.findFirst.mockResolvedValue({ id: 'mem_1', userId: 'usr_1' });
mockDb.$transaction.mockImplementation(
(cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
);
buildPortalAccessUrlSpy.mockResolvedValue(
'https://portal.example.com/access/token',
);
});

it('bypasses NDA when the exact email is allow-listed', async () => {
mockDb.trust.findUnique.mockResolvedValue({
allowedDomains: [],
allowedEmails: ['chang.liu@client.com'],
});

const result = await service.approveRequest('org_1', 'tar_1', {}, 'mem_1');

expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(txMock.trustNDAAgreement.create).not.toHaveBeenCalled();
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendNdaSigningEmail).not.toHaveBeenCalled();
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
data: expect.objectContaining({
ndaBypassed: true,
bypassReason: 'allowed email',
}),
}),
}),
);
expect(result.message).toBe('Access granted');
});

it('bypasses NDA via domain match and records the domain reason', async () => {
mockDb.trust.findUnique.mockResolvedValue({
allowedDomains: ['client.com'],
allowedEmails: [],
});

await service.approveRequest('org_1', 'tar_1', {}, 'mem_1');

expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
data: expect.objectContaining({ bypassReason: 'allowed domain' }),
}),
}),
);
});

it('requires NDA signing when neither email nor domain is allow-listed', async () => {
mockDb.trust.findUnique.mockResolvedValue({
allowedDomains: ['other.com'],
allowedEmails: ['someone@else.com'],
});

const result = await service.approveRequest('org_1', 'tar_1', {}, 'mem_1');

expect(txMock.trustNDAAgreement.create).toHaveBeenCalledTimes(1);
expect(txMock.trustAccessGrant.create).not.toHaveBeenCalled();
expect(emailService.sendNdaSigningEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).not.toHaveBeenCalled();
expect(result.message).toBe('NDA signing email sent');
});
});
Loading
Loading