Skip to content

Commit b1307d2

Browse files
github-actions[bot]chasprowebdevtofikwestclaude
authored
[comp] Production Deploy (#3354)
* fix(api): add isActive filter to member query * fix(app): sort assignee list alphabetically in assignee dropdown * fix(risk-treatment): ensure live tasks filter before ranking in draft plan ## Problem When drafting a treatment plan for a risk, the AI tool returns "0 tasks and 0 controls" even when relevant tasks exist in the account. This blocks 20+ risks from getting mitigation suggestions and leaves risk scores stuck at elevated levels. ## Root cause The suggestion pipeline ranks candidates by embedding similarity first, then filters to in-scope tasks. Orphaned task embeddings (deleted or moved out of scope) aren't cleaned up and crowd out live tasks in the top-30 ranking slice. By the time we filter to tasks actually in the current scope, we often have zero candidates left. ## Fix Reorder the pipeline to filter live tasks first (checking taskById scope), then rank and slice by similarity. Added a small over-fetch buffer to account for filtering shrinkage. Changes are isolated to run-linkage.ts and index.ts in the app-library, no core model or §4 changes. ## Explicitly NOT touched - Embedding cleanup logic in lib/vector/sync (out of scope for this fix) - suggestionsOnly limit of 15 (separate concern) - Org+type cosine filter (correct as-is) ## Verification ✅ Drafting plan on affected risks now returns live tasks and controls ✅ Rerank scores still respected after filtering ✅ No regressions on risks with abundant candidates ✅ Spot checked 5 risks in test org that previously returned 0 results * fix(cloud-security): exclude per-task runs from latest scan selection ## Problem Automated daily AWS Cloud Security scans show minimal results while manual scans on the same day return full results. Customers see an incomplete security posture from automated runs but get correct data when triggering manually. ## Root cause The `getLatestRunsByConnection` and `getNewPlatformFindings` queries select the newest `IntegrationCheckRun` per connection using `distinct(['connectionId'])` without filtering by check type. The full AWS security scan (checkId 'aws-security-scan') runs at 05:00 and produces complete results. Later at 06:00 per-task evidence checks write their own runs to the same connectionId with status updates. Since the query picks the absolute newest run per connection, it shadows the full scan result with a tiny per-task run. Manual scans bypass this by writing a fresh full run that temporarily becomes the latest. ## Fix Scope the latest-run selection to only the provider's 'security-scan' checkId, excluding per-task evidence check runs from the latest run calculation. This ensures the dashboard always displays the full scan result as intended. ## Explicitly NOT touched Per-task evidence check runs continue to execute and store normally. No changes to scan scheduling or task execution logic. Historical data remains intact. ## Verification ✅ Automated daily scan now returns full result set matching manual scan ✅ Dashboard latest run consistently shows complete AWS security findings ✅ Per-task check runs still execute and don't interfere with primary results * fix: address follow-up Follow-up changes. * fix(api): tie trust portal access link expiry to the grant duration * feat(trust): add setting to show/hide the Security Questionnaire on the public trust portal Org owners can now toggle whether the AI-assisted Security Questionnaire is offered on their public trust center, from a new "Questionnaire" tab in Trust Portal settings. Defaults to on, so existing portals are unchanged. - Trust.securityQuestionnaireEnabled column (default true) + migration - PUT /v1/trust-portal/settings/security-questionnaire (trust:update) and the flag surfaced in GET /v1/trust-portal/settings - Public read carriers for the portal: GET /v1/trust-access/:friendlyUrl/ security-questionnaire and the flag on the access-grant response - Admin UI: TrustPortalQuestionnaire toggle + tab - Tests for the service, public getter, and the toggle component Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg * fix(trust): address cubic review on the questionnaire toggle - getPublicSecurityQuestionnaireEnabled: resolve friendlyUrl then fall back to organizationId with two findUnique calls (deterministic precedence, matches getPublicFavicon) instead of an unordered findFirst OR. - TrustPortalQuestionnaire: resync local state when initialEnabled changes so the toggle can't show stale visibility after a parent refetch. - TrustPortalQuestionnaire: add aria-pressed to the Visible/Hidden buttons so the active state is exposed to assistive tech. - Tests updated for the two-step lookup, aria-pressed, and resync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg * fix(risk-treatment): harden orphan task-vector prune (CS-681 review) Resolve the cubic review findings on the CS-681 orphan-vector sweep: - Recover the raw source id from the `task_${org}_` embedding-id prefix when a vector has no `metadata.sourceId`, instead of treating the prefixed id as a raw id. The old fallback would fail the live-scope check and delete a LIVE task's vector, and push a bogus id whose hash-clear matched no row. Applied the same fallback to findSimilarTasks, which would otherwise silently drop such tasks from suggestions. - Enumerate the org's task vectors with cursor-paginated `range` over their id prefix rather than a single top-1000 `query`, removing the silent cap that let large orgs retain unscanned orphans. Drops the fake probe vector; adds a page-count backstop that warns rather than looping forever. - Delete per batch with continue-on-failure and return only the sourceIds whose vector was actually deleted. A transient Upstash error no longer aborts the remaining batches or blocks hash-clearing for earlier successful batches, so a task can never end up with a cached hash and no vector (which would skip re-embedding forever). Adds unit tests for the prefix fallback, cursor pagination across pages, and resilient per-batch deletes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PVoGSrLhLrehdzKFPojSbj * refactor(trust): share the public trust lookup + clarify a test name - Extract resolveTrustByFriendlyUrl<S>() and use it in both getPublicFavicon and getPublicSecurityQuestionnaireEnabled so the friendlyUrl → organizationId fallback lives in one typed helper (addresses cubic drift concern). - Rename the toggle test to "saves enabled=false when an admin clicks Hidden" to match the updateSecurityQuestionnaireEnabled(false) behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg --------- Co-authored-by: chasprowebdev <chasgarciaprowebdev@gmail.com> Co-authored-by: Tofik Hasanov <annexcies@gmail.com> Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 073a391 commit b1307d2

25 files changed

Lines changed: 1441 additions & 44 deletions
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Regression tests for CS-702: the automated daily cloud scan appeared to
2+
// return far fewer results than a manual scan. Root cause was in the
3+
// latest-run lookups here — they picked the newest IntegrationCheckRun per
4+
// connection with NO checkId scope, so the ~06:00 per-task evidence runs (a
5+
// handful of results each) shadowed the ~05:00 full cloud-security scan
6+
// (hundreds of results). These tests reproduce that exact ordering via a
7+
// Prisma mock that emulates where + orderBy + distinct.
8+
9+
interface RunRow {
10+
id: string;
11+
connectionId: string;
12+
checkId: string;
13+
taskId: string | null;
14+
status: string;
15+
completedAt: Date;
16+
durationMs: number | null;
17+
totalChecked: number | null;
18+
passedCount: number | null;
19+
failedCount: number | null;
20+
}
21+
22+
interface ResultRow {
23+
id: string;
24+
title: string;
25+
description: string | null;
26+
remediation: string | null;
27+
severity: string | null;
28+
collectedAt: Date;
29+
checkRunId: string;
30+
passed: boolean;
31+
evidence: Record<string, unknown> | null;
32+
resourceId: string | null;
33+
resourceType: string | null;
34+
}
35+
36+
// In-memory fixtures the mocked Prisma layer reads from. Set per-test.
37+
let runRows: RunRow[] = [];
38+
let resultRows: ResultRow[] = [];
39+
40+
// Emulate the slice of Prisma semantics these queries rely on: filter by the
41+
// provided `where`, order by completedAt desc, then distinct-by-connectionId
42+
// (Postgres DISTINCT ON keeps the first row after ordering).
43+
function checkRunFindMany(args: {
44+
where: {
45+
connectionId?: { in: string[] };
46+
checkId?: { in: string[] };
47+
status?: { in: string[] };
48+
};
49+
distinct?: string[];
50+
}): Promise<RunRow[]> {
51+
const { where } = args;
52+
let rows = runRows.filter((r) => {
53+
if (where.connectionId && !where.connectionId.in.includes(r.connectionId)) {
54+
return false;
55+
}
56+
if (where.checkId && !where.checkId.in.includes(r.checkId)) return false;
57+
if (where.status && !where.status.in.includes(r.status)) return false;
58+
return true;
59+
});
60+
rows = [...rows].sort(
61+
(a, b) => b.completedAt.getTime() - a.completedAt.getTime(),
62+
);
63+
if (args.distinct?.includes('connectionId')) {
64+
const seen = new Set<string>();
65+
rows = rows.filter((r) => {
66+
if (seen.has(r.connectionId)) return false;
67+
seen.add(r.connectionId);
68+
return true;
69+
});
70+
}
71+
return Promise.resolve(rows);
72+
}
73+
74+
function checkResultFindMany(args: {
75+
where: { checkRunId: { in: string[] } };
76+
}): Promise<ResultRow[]> {
77+
const ids = args.where.checkRunId.in;
78+
return Promise.resolve(resultRows.filter((r) => ids.includes(r.checkRunId)));
79+
}
80+
81+
const dbMock = {
82+
integrationConnection: { findMany: jest.fn() },
83+
integration: { findMany: jest.fn() },
84+
integrationCheckRun: { findMany: jest.fn(checkRunFindMany) },
85+
integrationCheckResult: { findMany: jest.fn(checkResultFindMany) },
86+
};
87+
88+
jest.mock('@db', () => ({ db: dbMock }));
89+
jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn() }));
90+
jest.mock('./evidence-sanitizer', () => ({
91+
sanitizeEvidence: (v: unknown) => v,
92+
}));
93+
jest.mock('./check-definition.utils', () => ({
94+
resolveCheckKey: jest.fn(() => 'check-key'),
95+
}));
96+
jest.mock('./cloud-security-query.legacy', () => ({
97+
getLegacyFindings: jest.fn().mockResolvedValue([]),
98+
}));
99+
jest.mock('./finding-exceptions', () => ({
100+
loadActiveExceptionSet: jest.fn().mockResolvedValue({ size: 0 }),
101+
}));
102+
103+
import { getManifest } from '@trycompai/integration-platform';
104+
import { CloudSecurityQueryService } from './cloud-security-query.service';
105+
106+
const ORG_ID = 'org_test';
107+
const CONNECTION_ID = 'icn_aws';
108+
const SCAN_AT = new Date('2026-07-02T05:00:00Z'); // full scan
109+
const TASK_AT = new Date('2026-07-02T06:00:00Z'); // per-task run — NEWER
110+
111+
const awsConnection = {
112+
id: CONNECTION_ID,
113+
organizationId: ORG_ID,
114+
provider: { slug: 'aws', name: 'AWS' },
115+
metadata: {},
116+
variables: {},
117+
status: 'active',
118+
lastSyncAt: TASK_AT,
119+
createdAt: SCAN_AT,
120+
updatedAt: TASK_AT,
121+
};
122+
123+
// The 05:00 full scan: many results, taskId null, checkId 'aws-security-scan'.
124+
const scanRun: RunRow = {
125+
id: 'run_scan',
126+
connectionId: CONNECTION_ID,
127+
checkId: 'aws-security-scan',
128+
taskId: null,
129+
status: 'success',
130+
completedAt: SCAN_AT,
131+
durationMs: 5000,
132+
totalChecked: 300,
133+
passedCount: 250,
134+
failedCount: 50,
135+
};
136+
137+
// The 06:00 per-task evidence run: a handful of results, taskId set, checkId
138+
// is a manifest check id. This is the run that used to shadow the scan.
139+
const taskRun: RunRow = {
140+
id: 'run_task',
141+
connectionId: CONNECTION_ID,
142+
checkId: 'aws-iam-account-security',
143+
taskId: 'tsk_iam',
144+
status: 'failed',
145+
completedAt: TASK_AT,
146+
durationMs: 120,
147+
totalChecked: 3,
148+
passedCount: 1,
149+
failedCount: 2,
150+
};
151+
152+
function scanResult(id: string): ResultRow {
153+
return {
154+
id,
155+
title: `scan-${id}`,
156+
description: null,
157+
remediation: null,
158+
severity: 'medium',
159+
collectedAt: SCAN_AT,
160+
checkRunId: 'run_scan',
161+
passed: false,
162+
evidence: {},
163+
resourceId: `res-${id}`,
164+
resourceType: 'AwsResource',
165+
};
166+
}
167+
168+
const taskResult: ResultRow = {
169+
id: 'task_only',
170+
title: 'task-only',
171+
description: null,
172+
remediation: null,
173+
severity: 'high',
174+
collectedAt: TASK_AT,
175+
checkRunId: 'run_task',
176+
passed: false,
177+
evidence: {},
178+
resourceId: 'res-task',
179+
resourceType: 'AwsResource',
180+
};
181+
182+
describe('CloudSecurityQueryService — latest-run scoping (CS-702)', () => {
183+
let service: CloudSecurityQueryService;
184+
185+
beforeEach(() => {
186+
jest.clearAllMocks();
187+
service = new CloudSecurityQueryService();
188+
189+
// Both runs exist for the same connection; the per-task run is newer.
190+
runRows = [scanRun, taskRun];
191+
resultRows = [
192+
scanResult('a'),
193+
scanResult('b'),
194+
scanResult('c'),
195+
taskResult,
196+
];
197+
198+
dbMock.integrationConnection.findMany.mockResolvedValue([awsConnection]);
199+
dbMock.integration.findMany.mockResolvedValue([]);
200+
dbMock.integrationCheckRun.findMany.mockImplementation(checkRunFindMany);
201+
dbMock.integrationCheckResult.findMany.mockImplementation(
202+
checkResultFindMany,
203+
);
204+
(getManifest as jest.Mock).mockReturnValue({
205+
supportsMultipleConnections: true,
206+
variables: [],
207+
checks: [],
208+
});
209+
});
210+
211+
it('getFindings returns the full-scan results, not the newer per-task run', async () => {
212+
const findings = await service.getFindings(ORG_ID);
213+
214+
// The three scan findings — and none from the per-task run.
215+
expect(findings).toHaveLength(3);
216+
expect(findings.map((f) => f.id).sort()).toEqual(['a', 'b', 'c']);
217+
expect(findings.every((f) => f.checkId === 'aws-security-scan')).toBe(true);
218+
expect(findings.some((f) => f.id === 'task_only')).toBe(false);
219+
});
220+
221+
it('getProviders reports the full-scan summary, not the per-task run summary', async () => {
222+
const providers = await service.getProviders(ORG_ID);
223+
224+
const aws = providers.find((p) => p.id === CONNECTION_ID);
225+
expect(aws?.latestRun).toEqual(
226+
expect.objectContaining({
227+
totalChecked: 300,
228+
passedCount: 250,
229+
failedCount: 50,
230+
}),
231+
);
232+
});
233+
234+
it('scopes the latest-run lookup to cloud-security scan checkIds', async () => {
235+
await service.getFindings(ORG_ID);
236+
237+
expect(dbMock.integrationCheckRun.findMany).toHaveBeenCalledWith(
238+
expect.objectContaining({
239+
where: expect.objectContaining({
240+
checkId: {
241+
in: ['aws-security-scan', 'gcp-security-scan', 'azure-security-scan'],
242+
},
243+
}),
244+
}),
245+
);
246+
});
247+
});

‎apps/api/src/cloud-security/cloud-security-query.service.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ export type { CloudFinding, CloudProvider, CloudProviderLatestRun };
1717

1818
const CLOUD_PROVIDER_SLUGS = ['aws', 'gcp', 'azure'] as const;
1919

20+
// The cloud-security scan persists exactly one run per connection under this
21+
// coarse, run-level checkId (`storeFindings` in cloud-security.service.ts). The
22+
// SAME connection also accumulates OTHER IntegrationCheckRun rows on different
23+
// schedules — per-task evidence checks (checkId = manifest check id, taskId set,
24+
// written ~06:00 UTC) and the on-connect "All Checks (Auto)" run (checkId
25+
// 'all'), each holding only a handful of results. The latest-run lookups below
26+
// MUST scope to these scan runs; otherwise a later per-task run shadows the
27+
// full daily scan and the Cloud Tests dashboard shows a fraction of the
28+
// findings (CS-702).
29+
const CLOUD_SCAN_CHECK_IDS = CLOUD_PROVIDER_SLUGS.map(
30+
(slug) => `${slug}-security-scan`,
31+
);
32+
2033
/** Extract project ID from a GCP resource path like //iam.googleapis.com/projects/my-proj/... */
2134
function extractProjectIdFromResource(
2235
resourceId: string | null,
@@ -222,6 +235,9 @@ export class CloudSecurityQueryService {
222235
const runs = await db.integrationCheckRun.findMany({
223236
where: {
224237
connectionId: { in: connectionIds },
238+
// Only the full cloud-security scan run — not later per-task / 'all'
239+
// runs on the same connection (see CLOUD_SCAN_CHECK_IDS).
240+
checkId: { in: CLOUD_SCAN_CHECK_IDS },
225241
status: { in: ['success', 'failed'] },
226242
},
227243
orderBy: { completedAt: 'desc' },
@@ -285,6 +301,9 @@ export class CloudSecurityQueryService {
285301
const latestRuns = await db.integrationCheckRun.findMany({
286302
where: {
287303
connectionId: { in: connectionIds },
304+
// Only the full cloud-security scan run — not later per-task / 'all'
305+
// runs on the same connection (see CLOUD_SCAN_CHECK_IDS).
306+
checkId: { in: CLOUD_SCAN_CHECK_IDS },
288307
status: { in: ['success', 'failed'] },
289308
},
290309
orderBy: { completedAt: 'desc' },

‎apps/api/src/email/templates/access-reclaim.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const AccessReclaimEmail = ({
8888
<br />
8989
<Section>
9090
<Text className="text-[12px] leading-[24px] text-[#666666]">
91-
This link will expire in 24 hours. Your grant expires on:{' '}
91+
This link will remain valid until your access expires on:{' '}
9292
<strong>
9393
{expiresAt.toLocaleDateString('en-US', {
9494
year: 'numeric',

‎apps/api/src/people/utils/member-queries.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class MemberQueries {
6666
return db.member.findMany({
6767
where: {
6868
organizationId,
69-
...(includeDeactivated ? {} : { deactivated: false }),
69+
...(includeDeactivated ? {} : { deactivated: false, isActive: true }),
7070
...(filters?.onboardAfter || filters?.onboardBefore
7171
? {
7272
onboardDate: {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { z } from 'zod';
2+
3+
export const UpdateSecurityQuestionnaireSchema = z.object({
4+
enabled: z.boolean(),
5+
});
6+
7+
export type UpdateSecurityQuestionnaireDto = z.infer<
8+
typeof UpdateSecurityQuestionnaireSchema
9+
>;

‎apps/api/src/trust-portal/trust-access.controller.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,37 @@ export class TrustAccessController {
680680
return this.trustAccessService.getPublicOverview(friendlyUrl);
681681
}
682682

683+
@Get(':friendlyUrl/security-questionnaire')
684+
@HttpCode(HttpStatus.OK)
685+
@ApiOperation({
686+
summary: 'Get Security Questionnaire visibility for a trust portal',
687+
description:
688+
"Whether the org offers the AI-assisted Security Questionnaire on its public trust portal. Defaults to enabled when the portal can't be resolved.",
689+
})
690+
@ApiParam({
691+
name: 'friendlyUrl',
692+
description: 'Trust Portal friendly URL or Organization ID',
693+
})
694+
@ApiResponse({
695+
status: HttpStatus.OK,
696+
description: 'Security Questionnaire visibility retrieved successfully',
697+
schema: {
698+
type: 'object',
699+
properties: {
700+
enabled: { type: 'boolean' },
701+
},
702+
},
703+
})
704+
async getPublicSecurityQuestionnaire(
705+
@Param('friendlyUrl') friendlyUrl: string,
706+
) {
707+
const enabled =
708+
await this.trustAccessService.getPublicSecurityQuestionnaireEnabled(
709+
friendlyUrl,
710+
);
711+
return { enabled };
712+
}
713+
683714
@Get(':friendlyUrl/custom-links')
684715
@HttpCode(HttpStatus.OK)
685716
@ApiOperation({

0 commit comments

Comments
 (0)