mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
feat(ui): add cross-account compliance view
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Cross-Provider Compliance'
|
||||
sidebarTitle: 'Cross-Provider Compliance'
|
||||
description: 'Aggregate a single universal compliance framework across every connected provider in Prowler Cloud, review a consolidated roll-up and per-provider breakdown, and download a combined PDF report.'
|
||||
description: 'Aggregate a universal compliance framework across every connected provider type, or a single-provider framework across every provider of one type, review consolidated roll-ups and per-provider breakdowns, and download combined PDF reports.'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
@@ -50,12 +50,12 @@ The catalog grows as new universal frameworks ship in Prowler. Browse the full c
|
||||
<Step title="Open the Compliance section">
|
||||
Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation.
|
||||
</Step>
|
||||
<Step title="Switch to the Cross-provider tab">
|
||||
At the top of the Compliance page, select the **Cross-provider** tab. The **Per Scan** tab (the default) keeps the single-provider compliance experience unchanged.
|
||||
<Step title="Switch to the Multiple Scans tab">
|
||||
At the top of the Compliance page, select the **Multiple Scans** tab. The **Single Scan** tab (the default) keeps the single-provider compliance experience unchanged.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-tab.png" alt="Compliance page showing the Per Scan and Cross-provider tabs, with the Cross-provider tab highlighted" width="900" />
|
||||
<img src="/images/compliance/prowler-app-cross-provider-tab.png" alt="Compliance page showing the Single Scan and Multiple Scans tabs, with the Multiple Scans tab highlighted" width="900" />
|
||||
|
||||
<Note>
|
||||
Cross-Provider Compliance requires at least one completed scan for a compatible provider. If no compatible provider has finished a scan yet, the page shows a notice prompting you to launch or wait for a scan to complete.
|
||||
@@ -63,7 +63,12 @@ Cross-Provider Compliance requires at least one completed scan for a compatible
|
||||
|
||||
## Exploring the Overview
|
||||
|
||||
The overview presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
The Multiple Scans tab is organized into two sections, each labeled with the axis it aggregates across:
|
||||
|
||||
* **Across provider types:** One card per supported universal framework, aggregating every compatible provider type. This is the Cross-Provider Compliance experience described in the rest of this guide.
|
||||
* **Across providers:** One card per single-provider framework (for example, CIS AWS) that can be aggregated across every connected provider of the same type. See [Aggregating a Single-Provider Framework Across Providers](#aggregating-a-single-provider-framework-across-providers).
|
||||
|
||||
The **Across provider types** section presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
|
||||
|
||||
<img src="/images/compliance/prowler-app-cross-provider-overview.png" alt="Cross-Provider Compliance overview showing the provider filters and the framework grid (CSA CCM, CIS Controls, DORA) with per-provider chips, scores, and failed/manual counts" width="900" />
|
||||
|
||||
@@ -201,6 +206,27 @@ A report already generated for a given set of filters does not need to be genera
|
||||
The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting.
|
||||
</Note>
|
||||
|
||||
## Aggregating a Single-Provider Framework Across Providers
|
||||
|
||||
Universal frameworks answer the cross-provider-type question, but most compliance frameworks target a single provider type — CIS AWS, CIS GCP, ENS for Azure. The **Across providers** section of the Multiple Scans tab answers the sibling question for those frameworks: **"How compliant are all of my AWS accounts against CIS AWS, together?"**
|
||||
|
||||
For every provider type with **two or more connected providers**, the section shows a collapsible group headed by the provider type and its counts (frameworks available and providers connected). Expanding a group reveals one card per single-provider framework available for that type, keeping the section compact even when several multi-provider types are connected. Selecting a card opens a detail page with the same layout as the cross-provider detail, with the column axis swapped from provider type to provider:
|
||||
|
||||
* **One column per provider:** Prowler auto-selects the latest completed scan of every provider of that type you are allowed to see, and each requirement shows a status chip per provider (labeled with its alias or account ID).
|
||||
* **Account Coverage:** The coverage card ranks each provider's individual posture so the weakest account is visible at a glance.
|
||||
* **Filters:** Narrow the aggregation to specific providers or provider groups. The provider type is fixed by the framework, so there is no type filter here.
|
||||
* **Findings drill-down:** Expanding a requirement queries the findings of every contributing provider's scan and merges them into a single table.
|
||||
|
||||
The roll-up rules are identical to the cross-provider ones (**FAIL > PASS > MANUAL**, only providers that contributed a result count), applied per provider instead of per provider type. Scan selection, RBAC scoping, and staleness behavior also match: the view always reflects each provider's most recent completed scan, scoped to your role's visibility.
|
||||
|
||||
<Note>
|
||||
Provider types with a single connected provider are not listed — with one provider the aggregation is identical to the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
|
||||
</Note>
|
||||
|
||||
### Cross-Account PDF Report
|
||||
|
||||
The detail page's **Report** button produces a single combined PDF across every contributing provider of the type, with the same asynchronous generation, background notification, and **Download latest** reuse flow as the [cross-provider report](#downloading-the-combined-pdf-report). The report is tied to the exact resolved scan set, so a new scan in any contributing provider makes the previous report stale and Prowler offers to generate an up-to-date one.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
* [Compliance](/user-guide/compliance/tutorials/compliance)
|
||||
|
||||
@@ -42,8 +42,10 @@ import {
|
||||
Framework,
|
||||
RequirementsTotals,
|
||||
} from "@/types/compliance";
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { CrossAccountDetail } from "../_components/cross-account-detail";
|
||||
import { CrossProviderDetail } from "../_components/cross-provider-detail";
|
||||
import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks";
|
||||
import { buildSearchParamsKey } from "../_lib/search-params-key";
|
||||
@@ -113,6 +115,51 @@ export default async function ComplianceDetail({
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
// Cross-account mode: one regular framework aggregated across every
|
||||
// account of one provider type. Cloud-only, like cross-provider.
|
||||
if (mode === "cross-account") {
|
||||
if (!isCloud()) {
|
||||
redirect("/compliance");
|
||||
}
|
||||
|
||||
const providerType = getSingleSearchParam(
|
||||
resolvedSearchParams.providerType,
|
||||
);
|
||||
if (!providerType || !isKnownProviderType(providerType)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const crossAccountTitle = compliancetitle.split("-").join(" ");
|
||||
return (
|
||||
<ContentLayout
|
||||
title={
|
||||
version ? `${crossAccountTitle} - ${version}` : crossAccountTitle
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
key={buildSearchParamsKey(resolvedSearchParams)}
|
||||
fallback={
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
|
||||
<RequirementsStatusCardSkeleton />
|
||||
<TopFailedSectionsCardSkeleton />
|
||||
</div>
|
||||
<SkeletonAccordion />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CrossAccountDetail
|
||||
compliancetitle={compliancetitle}
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
searchParams={resolvedSearchParams}
|
||||
targetSection={section}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const regionFilter = getSingleSearchParam(
|
||||
resolvedSearchParams["filter[region__in]"],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"use server";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { hasActionError } from "@/lib/action-errors";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
import type {
|
||||
CrossAccountApiFilters,
|
||||
CrossAccountOverviewResponse,
|
||||
CrossAccountOverviewResult,
|
||||
LatestCrossProviderPdf,
|
||||
} from "../_types";
|
||||
import {
|
||||
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
|
||||
} from "../_types";
|
||||
|
||||
const CROSS_ACCOUNT_API_PATH = "/cross-account-compliance-overviews";
|
||||
|
||||
/** Error payload shapes the PDF endpoints emit (JSON:API or plain). */
|
||||
interface PdfEndpointErrorBody {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Cross-account sibling of the cross-provider action module's helper: a
|
||||
* user-safe message from a failed PDF endpoint response, with 5xx reported
|
||||
* to Sentry. `operation` must be a STATIC route template. */
|
||||
const getPdfEndpointErrorMessage = async (
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
operation: string,
|
||||
): Promise<string> => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
const errorData: PdfEndpointErrorBody | null = contentType.includes(
|
||||
"text/html",
|
||||
)
|
||||
? null
|
||||
: await response.json().catch(() => null);
|
||||
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureException(
|
||||
new Error(
|
||||
`Cross-account PDF request failed (${response.status}) at ${operation}`,
|
||||
),
|
||||
{
|
||||
tags: {
|
||||
api_error: true,
|
||||
status_code: response.status.toString(),
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
error_source: SentryErrorSource.SERVER_ACTION,
|
||||
},
|
||||
level: "error",
|
||||
contexts: {
|
||||
api_response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
operation,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return GENERIC_SERVER_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
return (
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
fallbackMessage
|
||||
);
|
||||
};
|
||||
|
||||
/** Appends the required identity params + shared filters to a request URL. */
|
||||
const applyCrossAccountParams = (
|
||||
url: URL,
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
filters?: CrossAccountApiFilters,
|
||||
) => {
|
||||
url.searchParams.set("filter[compliance_id]", complianceId);
|
||||
url.searchParams.set("filter[provider_type]", providerType);
|
||||
|
||||
if (filters?.scanIds && filters.scanIds.length > 0) {
|
||||
url.searchParams.set("filter[scan__in]", filters.scanIds.join(","));
|
||||
}
|
||||
const paramMap = {
|
||||
"filter[provider_id__in]": filters?.providerIds,
|
||||
"filter[provider_groups__in]": filters?.providerGroups,
|
||||
};
|
||||
for (const [key, value] of Object.entries(paramMap)) {
|
||||
if (value && value.trim().length > 0) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate a regular (per-provider) compliance framework across the latest
|
||||
* completed scan of every visible account of one provider type (Prowler
|
||||
* Cloud only — the OSS API has no such endpoint).
|
||||
*
|
||||
* When `filters.scanIds` is omitted the API auto-selects the latest COMPLETED
|
||||
* scan per account. Non-2xx responses are returned as structured action
|
||||
* errors so callers can reuse the app-wide 402/403 handlers, mirroring
|
||||
* `getCrossProviderComplianceOverview`.
|
||||
*/
|
||||
export const getCrossAccountComplianceOverview = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
}: {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
}): Promise<CrossAccountOverviewResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const responseData = await handleApiResponse(response);
|
||||
|
||||
if (hasActionError(responseData)) {
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
|
||||
result: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
|
||||
response: responseData as CrossAccountOverviewResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching cross-account compliance overview:", error);
|
||||
return {
|
||||
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR,
|
||||
message: CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger ad-hoc generation of the combined cross-account compliance PDF.
|
||||
* Mirrors `generateCrossProviderPdf`: returns the async task id so the
|
||||
* caller can track it and download via {@link getCrossAccountPdfBinary}.
|
||||
* Pass the exact `scanIds` currently on screen so the report matches the
|
||||
* displayed data instead of racing a scan completing in between.
|
||||
*/
|
||||
export const generateCrossAccountPdf = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
reportName,
|
||||
}: {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
/** Optional download filename; sanitized server-side. */
|
||||
reportName?: string;
|
||||
}): Promise<{ taskId: string } | { error: string }> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
if (reportName) url.searchParams.set("report_name", reportName);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to start PDF generation. Contact support if the issue continues.",
|
||||
`POST ${CROSS_ACCOUNT_API_PATH}/pdf`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) {
|
||||
throw new Error("Unexpected response starting PDF generation.");
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the finished cross-account PDF for a task started by
|
||||
* {@link generateCrossAccountPdf}. Same 202-pending / 2xx-binary / error-JSON
|
||||
* protocol (and shared `ScanBinaryResult` shape) as the cross-provider
|
||||
* download action.
|
||||
*/
|
||||
export const getCrossAccountPdfBinary = async (
|
||||
taskId: string,
|
||||
): Promise<ScanBinaryResult> => {
|
||||
const safeTaskId = taskId.trim();
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(safeTaskId)) {
|
||||
return { error: "Invalid task identifier." };
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/${encodeURIComponent(safeTaskId)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 202) {
|
||||
const json = await response.json();
|
||||
return {
|
||||
pending: true,
|
||||
state: json?.data?.attributes?.state,
|
||||
taskId: json?.data?.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/{taskId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const contentDisposition =
|
||||
response.headers.get("content-disposition") || "";
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename = filenameMatch?.[1] || "cross-account-compliance.pdf";
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
|
||||
return { success: true, data: base64, filename };
|
||||
} catch (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a cross-account PDF already exists for the given filters —
|
||||
* same degrade-to-`null` contract as `getLatestCrossProviderPdf` (404 means
|
||||
* "not generated yet"; the report goes stale the moment any account
|
||||
* completes a new scan).
|
||||
*/
|
||||
export const getLatestCrossAccountPdf = async ({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
}: {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
filters?: CrossAccountApiFilters;
|
||||
}): Promise<LatestCrossProviderPdf | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${CROSS_ACCOUNT_API_PATH}/pdf/latest`);
|
||||
applyCrossAccountParams(url, complianceId, providerType, filters);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await getPdfEndpointErrorMessage(
|
||||
response,
|
||||
"Unable to check for an existing PDF report.",
|
||||
`GET ${CROSS_ACCOUNT_API_PATH}/pdf/latest`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const taskId = json?.data?.id;
|
||||
if (!taskId) return null;
|
||||
|
||||
return {
|
||||
taskId,
|
||||
filename: json?.data?.attributes?.result?.filename,
|
||||
completedAt: json?.data?.attributes?.completed_at,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking for an existing cross-account PDF:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -50,7 +50,7 @@ describe("CompliancePageTabs", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /cross-provider/i }));
|
||||
await user.click(screen.getByRole("tab", { name: /multiple scans/i }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/compliance?tab=cross-provider");
|
||||
|
||||
rerender(
|
||||
@@ -62,7 +62,7 @@ describe("CompliancePageTabs", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /per scan/i }));
|
||||
await user.click(screen.getByRole("tab", { name: /single scan/i }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/compliance");
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("CompliancePageTabs", () => {
|
||||
);
|
||||
|
||||
const crossProviderTab = screen.getByRole("tab", {
|
||||
name: /cross-provider/i,
|
||||
name: /multiple scans/i,
|
||||
});
|
||||
await user.click(crossProviderTab);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const CompliancePageTabs = ({
|
||||
className="flex flex-col gap-[18px]"
|
||||
>
|
||||
<TabsList className="overflow-x-auto">
|
||||
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>Per Scan</TabsTrigger>
|
||||
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>Single Scan</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={COMPLIANCE_TAB.CROSS_PROVIDER}
|
||||
adornment={
|
||||
@@ -73,7 +73,7 @@ export const CompliancePageTabs = ({
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Cross-Provider
|
||||
Multiple Scans
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
interface ComplianceSectionHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Shared heading for the Multiple Scans tab's framework sections
|
||||
* ("Across provider types" / "Across providers"), so both explain their
|
||||
* aggregation axis with one consistent look. */
|
||||
export const ComplianceSectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
}: ComplianceSectionHeaderProps) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-text-neutral-secondary text-xs">{description}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Info } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
RequirementsStatusCard,
|
||||
TopFailedSectionsCard,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import type { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
import {
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} from "@/types/providers";
|
||||
|
||||
import {
|
||||
getCrossAccountComplianceOverview,
|
||||
getLatestCrossAccountPdf,
|
||||
} from "../_actions/cross-account";
|
||||
import { toCrossAccountAccordionItems } from "../_lib/cross-account-accordion";
|
||||
import {
|
||||
buildAccountExtrasMap,
|
||||
computeAccountBreakdown,
|
||||
crossAccountToMapperInput,
|
||||
} from "../_lib/cross-account-adapter";
|
||||
import { parseCrossAccountFilters } from "../_lib/cross-account-frameworks";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
CrossProviderGroupOption,
|
||||
} from "./cross-provider-filters";
|
||||
import { CrossProviderFilters } from "./cross-provider-filters";
|
||||
import { CrossProviderPdfButton } from "./cross-provider-pdf-button";
|
||||
import type { CoverageRow } from "./provider-coverage-card";
|
||||
import { ProviderCoverageCard } from "./provider-coverage-card";
|
||||
|
||||
interface CrossAccountDetailProps {
|
||||
compliancetitle: string;
|
||||
complianceId: string;
|
||||
providerType: KnownProviderType;
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
targetSection?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server island for the cross-account detail (`?mode=cross-account`): the
|
||||
* account-axis sibling of `CrossProviderDetail`. Fetches the roll-up of one
|
||||
* regular framework across every account of one provider type, funnels it
|
||||
* through the real framework mapper via the adapter, and renders the same
|
||||
* summary-charts + accordion layout with per-account augmentations.
|
||||
*/
|
||||
export const CrossAccountDetail = async ({
|
||||
compliancetitle,
|
||||
complianceId,
|
||||
providerType,
|
||||
searchParams,
|
||||
targetSection,
|
||||
}: CrossAccountDetailProps) => {
|
||||
const filters = parseCrossAccountFilters(searchParams);
|
||||
|
||||
const [overviewResponse, providersData, providerGroupsData] =
|
||||
await Promise.all([
|
||||
getCrossAccountComplianceOverview({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
}),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
|
||||
if (
|
||||
overviewResponse.status ===
|
||||
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR
|
||||
) {
|
||||
return <CrossProviderErrorAlert result={overviewResponse.result} />;
|
||||
}
|
||||
|
||||
if (
|
||||
overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR
|
||||
) {
|
||||
return <CrossProviderErrorAlert message={overviewResponse.message} />;
|
||||
}
|
||||
|
||||
const overviewData = overviewResponse.response.data;
|
||||
|
||||
if (!overviewData?.attributes) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertDescription>
|
||||
No cross-account compliance data was returned for this framework. The
|
||||
view aggregates the latest completed scan of every account of this
|
||||
provider type — run a scan to populate it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const attrs = overviewData.attributes;
|
||||
|
||||
// Scoped to the EXACT scans the overview resolved (not the raw filters),
|
||||
// so an offered "Download latest" always matches the data on screen even
|
||||
// if an account finished a new scan between the two calls.
|
||||
const latestPdf = await getLatestCrossAccountPdf({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters: { ...filters, scanIds: attrs.scan_ids },
|
||||
});
|
||||
|
||||
const mapper = getComplianceMapper(attrs.framework);
|
||||
const { attributesData, requirementsData } = crossAccountToMapperInput(attrs);
|
||||
const data = mapper.mapComplianceData(attributesData, requirementsData);
|
||||
const extras = buildAccountExtrasMap(attrs);
|
||||
const coverageRows: CoverageRow[] = computeAccountBreakdown(attrs).map(
|
||||
(entry) => ({
|
||||
key: entry.id,
|
||||
label: entry.label,
|
||||
iconType: providerType,
|
||||
pass: entry.pass,
|
||||
fail: entry.fail,
|
||||
manual: entry.manual,
|
||||
score: entry.score,
|
||||
}),
|
||||
);
|
||||
|
||||
const totals: RequirementsTotals = data.reduce(
|
||||
(acc: RequirementsTotals, framework: Framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const accordionItems = toCrossAccountAccordionItems(
|
||||
data,
|
||||
extras,
|
||||
attrs.framework,
|
||||
attrs.accounts,
|
||||
);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
// Same `${framework.name}-${category.name}` key scheme as the per-scan
|
||||
// detail, so ?section= deep links (e.g. from Top Failed Sections) work.
|
||||
const initialExpandedKeys: string[] = [];
|
||||
if (targetSection) {
|
||||
const candidates = new Set(
|
||||
data.map((framework: Framework) => `${framework.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
if (match) {
|
||||
initialExpandedKeys.push(match.key);
|
||||
}
|
||||
}
|
||||
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
|
||||
const providerAccounts: CrossProviderAccountOption[] = (
|
||||
providersData?.data || []
|
||||
)
|
||||
.filter((provider) => provider.attributes.provider === providerType)
|
||||
.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.attributes.alias
|
||||
? `${provider.attributes.alias} (${provider.attributes.uid})`
|
||||
: provider.attributes.uid,
|
||||
type: provider.attributes.provider,
|
||||
}));
|
||||
|
||||
const providerGroups: CrossProviderGroupOption[] = (
|
||||
providerGroupsData?.data || []
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header card — same structure as the cross-provider detail: identity
|
||||
row (logo + context), filters below. */}
|
||||
<Card variant="base" className="w-full gap-4 p-4 md:p-5">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{logoPath && (
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${compliancetitle} logo`}
|
||||
fill
|
||||
className="rounded-lg border border-gray-300 bg-white object-contain p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{attrs.name || compliancetitle.split("-").join(" ")}
|
||||
</span>
|
||||
<p className="text-text-neutral-tertiary flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={14} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]} ·{" "}
|
||||
{attrs.accounts.length}{" "}
|
||||
{attrs.accounts.length === 1 ? "account" : "accounts"}{" "}
|
||||
aggregated · {attrs.scan_ids.length}{" "}
|
||||
{attrs.scan_ids.length === 1 ? "scan" : "scans"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CrossProviderPdfButton
|
||||
complianceId={complianceId}
|
||||
providerType={providerType}
|
||||
filters={{ ...filters, scanIds: attrs.scan_ids }}
|
||||
latestPdf={latestPdf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No providerTypes: the type select is meaningless here — the
|
||||
provider type is fixed by the framework being viewed. */}
|
||||
<CrossProviderFilters
|
||||
providerAccounts={providerAccounts}
|
||||
providerGroups={providerGroups}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-[minmax(280px,400px)_minmax(280px,360px)_1fr]">
|
||||
<RequirementsStatusCard
|
||||
pass={totals.pass}
|
||||
fail={totals.fail}
|
||||
manual={totals.manual}
|
||||
/>
|
||||
<ProviderCoverageCard
|
||||
rows={coverageRows}
|
||||
title="Account Coverage"
|
||||
emptyMessage="No scanned accounts for this framework yet."
|
||||
/>
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks";
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
/**
|
||||
* Card for a regular per-provider framework in the Cross-Provider tab's
|
||||
* "across accounts" section. Deliberately lightweight — no roll-up numbers:
|
||||
* the section only enumerates which frameworks can be viewed across accounts
|
||||
* (computing every framework's N-account aggregation up front would be one
|
||||
* heavy roll-up call per card). The detail computes the real aggregation.
|
||||
*/
|
||||
export const CrossAccountFrameworkCard = ({
|
||||
complianceId,
|
||||
title,
|
||||
version,
|
||||
providerType,
|
||||
accountCount,
|
||||
}: CrossAccountFrameworkEntry) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
|
||||
|
||||
const navigateToDetail = () => {
|
||||
router.push(
|
||||
buildCrossAccountDetailHref(
|
||||
{ complianceId, title, version, providerType },
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
role="button"
|
||||
aria-label={`${formattedTitle} across ${PROVIDER_DISPLAY_NAMES[providerType]} providers`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
navigateToDetail();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formattedTitle}
|
||||
</h4>
|
||||
<small className="text-text-neutral-secondary truncate text-xs">
|
||||
View across providers
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<ProviderTypeIcon type={providerType} size={16} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs whitespace-nowrap">
|
||||
{accountCount} providers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
|
||||
import { CrossAccountOverviewSection } from "./cross-account-overview-section";
|
||||
|
||||
vi.mock("@/actions/providers", () => ({
|
||||
getAllProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
getScans: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: () => <span aria-hidden="true" />,
|
||||
}));
|
||||
|
||||
vi.mock("./cross-account-framework-card", () => ({
|
||||
CrossAccountFrameworkCard: ({
|
||||
complianceId,
|
||||
providerType,
|
||||
}: {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
}) => (
|
||||
<div data-testid="cross-account-card">
|
||||
{providerType}:{complianceId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Only the fields the section reads; the full ProviderProps shape is not
|
||||
// needed for these tests.
|
||||
type ProvidersResponse = Awaited<ReturnType<typeof getAllProviders>>;
|
||||
const providersResponse = (
|
||||
providers: Array<{ id: string; type: string }>,
|
||||
): ProvidersResponse =>
|
||||
({
|
||||
data: providers.map(({ id, type }) => ({
|
||||
id,
|
||||
attributes: { provider: type, uid: `uid-${id}`, alias: null },
|
||||
})),
|
||||
}) as unknown as ProvidersResponse;
|
||||
|
||||
const scansFor = (scans: Array<{ id: string; providerId: string }>) => ({
|
||||
data: scans.map(({ id, providerId }) => ({
|
||||
id,
|
||||
relationships: { provider: { data: { id: providerId } } },
|
||||
})),
|
||||
included: [
|
||||
{ type: "providers", id: "aws-1", attributes: { provider: "aws" } },
|
||||
{ type: "providers", id: "aws-2", attributes: { provider: "aws" } },
|
||||
{ type: "providers", id: "gcp-1", attributes: { provider: "gcp" } },
|
||||
],
|
||||
});
|
||||
|
||||
const renderSection = async (
|
||||
searchParams: Record<string, string | string[] | undefined> = {},
|
||||
) => render(await CrossAccountOverviewSection({ searchParams }));
|
||||
|
||||
describe("CrossAccountOverviewSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAllProviders).mockReset();
|
||||
vi.mocked(getScans).mockReset();
|
||||
vi.mocked(getCompliancesOverview).mockReset();
|
||||
});
|
||||
|
||||
it("renders nothing when no provider type has two or more accounts", async () => {
|
||||
// Given: one AWS and one GCP account — no multi-account type.
|
||||
vi.mocked(getAllProviders).mockResolvedValue(
|
||||
providersResponse([
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "gcp-1", type: "gcp" },
|
||||
]),
|
||||
);
|
||||
vi.mocked(getScans).mockResolvedValue(scansFor([]));
|
||||
|
||||
// When
|
||||
const { container } = await renderSection();
|
||||
|
||||
// Then: single-account tenants keep the tab unchanged.
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(getCompliancesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the eligible type's frameworks, excluding universal and ThreatScore", async () => {
|
||||
// Given: two AWS accounts (eligible) and one GCP account (not).
|
||||
vi.mocked(getAllProviders).mockResolvedValue(
|
||||
providersResponse([
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
{ id: "gcp-1", type: "gcp" },
|
||||
]),
|
||||
);
|
||||
vi.mocked(getScans).mockResolvedValue(
|
||||
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
|
||||
);
|
||||
vi.mocked(getCompliancesOverview).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "cis_2.0_aws",
|
||||
attributes: { framework: "CIS", version: "2.0" },
|
||||
},
|
||||
// Universal frameworks have their own cross-provider cards above.
|
||||
{
|
||||
id: "csa_ccm_4.0",
|
||||
attributes: { framework: "CSA-CCM", version: "4.0" },
|
||||
},
|
||||
// ThreatScore is excluded, matching the per-scan grid.
|
||||
{
|
||||
id: "prowler_threatscore_aws",
|
||||
attributes: { framework: "ProwlerThreatScore", version: "1.0" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
await renderSection();
|
||||
|
||||
// Then: one collapsed group per eligible type, counts on the header,
|
||||
// cards revealed only on expand.
|
||||
expect(screen.getByText("Across providers")).toBeInTheDocument();
|
||||
expect(screen.getByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 framework · 2 providers")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("cross-account-card")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Item aws" }));
|
||||
|
||||
const cards = screen.getAllByTestId("cross-account-card");
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toHaveTextContent("aws:cis_2.0_aws");
|
||||
expect(getCompliancesOverview).toHaveBeenCalledWith({ scanId: "scan-1" });
|
||||
});
|
||||
|
||||
it("respects the tab's provider type filter", async () => {
|
||||
// Given: AWS is eligible but filtered out.
|
||||
vi.mocked(getAllProviders).mockResolvedValue(
|
||||
providersResponse([
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
]),
|
||||
);
|
||||
vi.mocked(getScans).mockResolvedValue(
|
||||
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
|
||||
);
|
||||
|
||||
// When
|
||||
const { container } = await renderSection({
|
||||
"filter[provider_type__in]": "gcp",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(getCompliancesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Accordion } from "@/components/shadcn/accordion/Accordion";
|
||||
import type { ScanProps, SearchParamsProps } from "@/types";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import {
|
||||
isKnownProviderType,
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} from "@/types/providers";
|
||||
|
||||
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
import { ComplianceSectionHeader } from "./compliance-section-header";
|
||||
import { CrossAccountFrameworkCard } from "./cross-account-framework-card";
|
||||
|
||||
/** Only provider types with at least this many accounts get cross-account
|
||||
* cards — with a single account the view is identical to the per-scan one. */
|
||||
const MIN_ACCOUNTS = 2;
|
||||
|
||||
/**
|
||||
* Server island for the "across accounts" section of the Cross-Provider tab:
|
||||
* for every provider type with 2+ accounts, lists the regular (per-provider)
|
||||
* frameworks that can be viewed aggregated across that type's accounts.
|
||||
*
|
||||
* The framework list per type comes from the latest completed scan of any
|
||||
* account of that type (frameworks are a property of the provider type, not
|
||||
* of the account). Universal frameworks are excluded — they already have
|
||||
* their own cross-provider cards above. Renders nothing when no provider
|
||||
* type qualifies, keeping the tab unchanged for single-account tenants.
|
||||
* Best-effort by design: a type whose scan or framework list fails to load
|
||||
* is dropped from the section rather than failing the tab.
|
||||
*/
|
||||
export const CrossAccountOverviewSection = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const providerTypeFilter =
|
||||
searchParams["filter[provider_type__in]"]
|
||||
?.toString()
|
||||
.split(",")
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const [providersData, scansData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getScans({
|
||||
filters: { "filter[state]": "completed" },
|
||||
pageSize: 50,
|
||||
fields: { scans: "name,completed_at,provider" },
|
||||
include: "provider",
|
||||
}),
|
||||
]);
|
||||
|
||||
const accountCounts = new Map<KnownProviderType, number>();
|
||||
for (const provider of providersData?.data || []) {
|
||||
const type = provider.attributes.provider;
|
||||
if (!isKnownProviderType(type)) continue;
|
||||
accountCounts.set(type, (accountCounts.get(type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const eligibleTypes = Array.from(accountCounts.entries())
|
||||
.filter(([type, count]) => {
|
||||
if (count < MIN_ACCOUNTS) return false;
|
||||
return (
|
||||
providerTypeFilter.length === 0 || providerTypeFilter.includes(type)
|
||||
);
|
||||
})
|
||||
.map(([type]) => type)
|
||||
.sort();
|
||||
|
||||
if (eligibleTypes.length === 0) return null;
|
||||
|
||||
// Latest completed scan per eligible type (the API returns scans newest
|
||||
// first). One representative scan per type is enough to enumerate the
|
||||
// type's frameworks.
|
||||
const latestScanByType = new Map<KnownProviderType, string>();
|
||||
for (const scan of (scansData?.data || []) as ScanProps[]) {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
if (!providerId) continue;
|
||||
const providerData = scansData.included?.find(
|
||||
(item: { type: string; id: string }) =>
|
||||
item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
const type = providerData?.attributes?.provider;
|
||||
if (!isKnownProviderType(type)) continue;
|
||||
if (!eligibleTypes.includes(type) || latestScanByType.has(type)) continue;
|
||||
latestScanByType.set(type, scan.id);
|
||||
}
|
||||
|
||||
const universalIds = new Set(
|
||||
CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId),
|
||||
);
|
||||
|
||||
const entriesByType = await Promise.all(
|
||||
Array.from(latestScanByType.entries()).map(async ([type, scanId]) => {
|
||||
const compliancesData = await getCompliancesOverview({ scanId });
|
||||
const frameworks: ComplianceOverviewData[] = Array.isArray(
|
||||
compliancesData?.data,
|
||||
)
|
||||
? compliancesData.data
|
||||
: [];
|
||||
|
||||
return frameworks
|
||||
.filter(
|
||||
(compliance) =>
|
||||
compliance.attributes.framework !== "ProwlerThreatScore" &&
|
||||
!universalIds.has(compliance.id),
|
||||
)
|
||||
.map(
|
||||
(compliance): CrossAccountFrameworkEntry => ({
|
||||
complianceId: compliance.id,
|
||||
title: compliance.attributes.framework,
|
||||
version: compliance.attributes.version,
|
||||
providerType: type,
|
||||
accountCount: accountCounts.get(type) ?? 0,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}),
|
||||
);
|
||||
|
||||
const groups = entriesByType
|
||||
.filter((entries) => entries.length > 0)
|
||||
.sort((a, b) => a[0].providerType.localeCompare(b[0].providerType));
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
// One collapsed group per provider type instead of a flat grid: with
|
||||
// several multi-account types connected, the flat grid piles up dozens of
|
||||
// cards (each type ships 20-40 frameworks) and buries the universal
|
||||
// section's hierarchy. Collapsed-by-default keeps the catalog scannable —
|
||||
// the header carries the counts, expanding reveals that type's cards.
|
||||
const accordionItems: AccordionItemProps[] = groups.map((entries) => {
|
||||
const { providerType, accountCount } = entries[0];
|
||||
return {
|
||||
key: providerType,
|
||||
title: (
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<ProviderTypeIcon type={providerType} size={18} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]}
|
||||
</span>
|
||||
),
|
||||
subtitle: `${entries.length} ${entries.length === 1 ? "framework" : "frameworks"} · ${accountCount} providers`,
|
||||
content: (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{entries.map((entry) => (
|
||||
<CrossAccountFrameworkCard
|
||||
key={`${entry.providerType}-${entry.complianceId}`}
|
||||
{...entry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<ComplianceSectionHeader
|
||||
title="Across providers"
|
||||
description="Single-provider frameworks aggregated across every provider of the same type, using each provider's latest completed scan. Expand a provider type to browse its frameworks."
|
||||
/>
|
||||
<Accordion items={accordionItems} selectionMode="multiple" />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
import type { Requirement } from "@/types/compliance";
|
||||
|
||||
import type {
|
||||
CrossAccountAccountRef,
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../_types";
|
||||
|
||||
interface CrossAccountRequirementContentProps {
|
||||
/** The requirement as produced by the framework mapper (roll-up level). */
|
||||
requirement: Requirement;
|
||||
extras: CrossAccountRequirementExtras;
|
||||
accountMeta: CrossAccountAccountRef[];
|
||||
framework: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined findings view for a cross-account requirement: the requirement
|
||||
* detail rendered once and a single findings table querying every
|
||||
* contributing account's scan at once. Unlike the cross-provider variant
|
||||
* there is no per-provider check labeling — every account shares one check
|
||||
* set. Mounts lazily: the accordion unmounts collapsed content, so the
|
||||
* combined fetch only fires on expand.
|
||||
*/
|
||||
export const CrossAccountRequirementContent = ({
|
||||
requirement,
|
||||
extras,
|
||||
accountMeta,
|
||||
framework,
|
||||
}: CrossAccountRequirementContentProps) => {
|
||||
const contributingAccounts = accountMeta.filter(
|
||||
(account) => extras.accounts[account.id],
|
||||
);
|
||||
|
||||
if (contributingAccounts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm">
|
||||
No account scan contributed to this requirement with the current
|
||||
filters.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const scanIds = Array.from(
|
||||
new Set(
|
||||
contributingAccounts.flatMap(
|
||||
(account) => extras.scanIdsByAccount[account.id] ?? [],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanIds={scanIds}
|
||||
framework={framework}
|
||||
disableFindings={requirement.check_ids.length === 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -31,8 +31,10 @@ export interface CrossProviderGroupOption {
|
||||
}
|
||||
|
||||
interface CrossProviderFiltersProps {
|
||||
/** Provider types offered by the visible universal frameworks. */
|
||||
providerTypes: readonly KnownProviderType[];
|
||||
/** Provider types offered by the visible universal frameworks. Omit (or
|
||||
* pass empty) to hide the type select — the cross-account detail does,
|
||||
* since its provider type is fixed by the framework being viewed. */
|
||||
providerTypes?: readonly KnownProviderType[];
|
||||
providerAccounts: CrossProviderAccountOption[];
|
||||
providerGroups: CrossProviderGroupOption[];
|
||||
}
|
||||
@@ -89,14 +91,16 @@ export const CrossProviderFilters = ({
|
||||
}: CrossProviderFiltersProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<UrlMultiSelect
|
||||
filterKey="provider_type__in"
|
||||
placeholder="Provider type"
|
||||
options={providerTypes.map((type) => ({
|
||||
value: type,
|
||||
label: PROVIDER_DISPLAY_NAMES[type],
|
||||
}))}
|
||||
/>
|
||||
{providerTypes && providerTypes.length > 0 && (
|
||||
<UrlMultiSelect
|
||||
filterKey="provider_type__in"
|
||||
placeholder="Provider type"
|
||||
options={providerTypes.map((type) => ({
|
||||
value: type,
|
||||
label: PROVIDER_DISPLAY_NAMES[type],
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<UrlMultiSelect
|
||||
filterKey="provider_id__in"
|
||||
placeholder="Providers"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
import { ComplianceSectionHeader } from "./compliance-section-header";
|
||||
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
|
||||
import type {
|
||||
CrossProviderAccountOption,
|
||||
@@ -179,11 +180,20 @@ export const CrossProviderOverview = async ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{summaries.map((summary) => (
|
||||
<CrossProviderFrameworkCard key={summary.complianceId} {...summary} />
|
||||
))}
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<ComplianceSectionHeader
|
||||
title="Across provider types"
|
||||
description="Universal frameworks aggregated across every compatible provider type, using the latest completed scan of each provider."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{summaries.map((summary) => (
|
||||
<CrossProviderFrameworkCard
|
||||
key={summary.complianceId}
|
||||
{...summary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,12 +27,14 @@ beforeAll(() => {
|
||||
|
||||
const {
|
||||
generatePdfMock,
|
||||
generateAccountPdfMock,
|
||||
trackAndPollMock,
|
||||
downloadPdfMock,
|
||||
toastMock,
|
||||
storeState,
|
||||
} = vi.hoisted(() => ({
|
||||
generatePdfMock: vi.fn(),
|
||||
generateAccountPdfMock: vi.fn(),
|
||||
trackAndPollMock: vi.fn(),
|
||||
downloadPdfMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
@@ -54,6 +56,10 @@ vi.mock("../_actions/cross-provider", () => ({
|
||||
generateCrossProviderPdf: generatePdfMock,
|
||||
}));
|
||||
|
||||
vi.mock("../_actions/cross-account", () => ({
|
||||
generateCrossAccountPdf: generateAccountPdfMock,
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/cross-provider-pdf", () => ({
|
||||
CROSS_PROVIDER_PDF_TASK_KIND: "cross-provider-pdf",
|
||||
buildCrossProviderPdfTaskScope: vi.fn(() => "scope-1"),
|
||||
@@ -61,6 +67,13 @@ vi.mock("../_lib/cross-provider-pdf", () => ({
|
||||
crossProviderPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../_lib/cross-account-pdf", () => ({
|
||||
CROSS_ACCOUNT_PDF_TASK_KIND: "cross-account-pdf",
|
||||
buildCrossAccountPdfTaskScope: vi.fn(() => "account-scope-1"),
|
||||
downloadCrossAccountPdf: vi.fn(),
|
||||
crossAccountPdfHandler: { onReady: vi.fn(), onError: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/store/task-watcher/store", () => ({
|
||||
TASK_WATCHER_STATUS: { PENDING: "pending", READY: "ready", ERROR: "error" },
|
||||
trackAndPollTask: trackAndPollMock,
|
||||
@@ -205,6 +218,35 @@ describe("CrossProviderPdfButton", () => {
|
||||
await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-8"));
|
||||
});
|
||||
|
||||
it("switches to the cross-account plumbing when providerType is set", async () => {
|
||||
// Given
|
||||
generateAccountPdfMock.mockResolvedValue({ taskId: "task-acc-1" });
|
||||
const user = userEvent.setup();
|
||||
render(<CrossProviderPdfButton {...props} providerType="aws" />);
|
||||
|
||||
// When
|
||||
await openGenerateModal(user);
|
||||
await user.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
// Then — the cross-account action and task kind are used, not the
|
||||
// cross-provider ones.
|
||||
await waitFor(() =>
|
||||
expect(generateAccountPdfMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(generateAccountPdfMock).toHaveBeenCalledWith({
|
||||
complianceId: "csa_ccm_4.0",
|
||||
providerType: "aws",
|
||||
filters: props.filters,
|
||||
reportName: undefined,
|
||||
});
|
||||
expect(generatePdfMock).not.toHaveBeenCalled();
|
||||
expect(trackAndPollMock).toHaveBeenCalledWith({
|
||||
taskId: "task-acc-1",
|
||||
kind: "cross-account-pdf",
|
||||
meta: expect.objectContaining({ scopeKey: "account-scope-1" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a completed report from a different filter scope", async () => {
|
||||
// Given
|
||||
storeState.tasks = {
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
useTaskWatcherStore,
|
||||
} from "@/store/task-watcher/store";
|
||||
|
||||
import { generateCrossAccountPdf } from "../_actions/cross-account";
|
||||
import { generateCrossProviderPdf } from "../_actions/cross-provider";
|
||||
import {
|
||||
buildCrossAccountPdfTaskScope,
|
||||
CROSS_ACCOUNT_PDF_TASK_KIND,
|
||||
downloadCrossAccountPdf,
|
||||
} from "../_lib/cross-account-pdf";
|
||||
import {
|
||||
buildCrossProviderPdfTaskScope,
|
||||
CROSS_PROVIDER_PDF_TASK_KIND,
|
||||
@@ -31,6 +37,10 @@ import type {
|
||||
|
||||
interface CrossProviderPdfButtonProps {
|
||||
complianceId: string;
|
||||
/** Set to switch the button to cross-account mode: same UI, but the
|
||||
* generate/download/latest plumbing targets the cross-account endpoints
|
||||
* and task kind for this provider type's accounts. */
|
||||
providerType?: string;
|
||||
/** The filters (and exact scan ids) of the view currently on screen, so
|
||||
* the generated PDF matches what the user is looking at. */
|
||||
filters: CrossProviderApiFilters;
|
||||
@@ -41,18 +51,28 @@ interface CrossProviderPdfButtonProps {
|
||||
|
||||
export const CrossProviderPdfButton = ({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
latestPdf,
|
||||
}: CrossProviderPdfButtonProps) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [reportName, setReportName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const taskScope = buildCrossProviderPdfTaskScope(complianceId, filters);
|
||||
const isCrossAccount = providerType !== undefined;
|
||||
const taskKind = isCrossAccount
|
||||
? CROSS_ACCOUNT_PDF_TASK_KIND
|
||||
: CROSS_PROVIDER_PDF_TASK_KIND;
|
||||
const downloadPdf = isCrossAccount
|
||||
? downloadCrossAccountPdf
|
||||
: downloadCrossProviderPdf;
|
||||
const taskScope = isCrossAccount
|
||||
? buildCrossAccountPdfTaskScope(complianceId, providerType, filters)
|
||||
: buildCrossProviderPdfTaskScope(complianceId, filters);
|
||||
|
||||
const isGenerating = useTaskWatcherStore((state) =>
|
||||
Object.values(state.tasks).some(
|
||||
(task) =>
|
||||
task.kind === CROSS_PROVIDER_PDF_TASK_KIND &&
|
||||
task.kind === taskKind &&
|
||||
task.status === TASK_WATCHER_STATUS.PENDING &&
|
||||
task.meta.scopeKey === taskScope,
|
||||
),
|
||||
@@ -61,7 +81,7 @@ export const CrossProviderPdfButton = ({
|
||||
Object.values(state.tasks).reduce<(typeof state.tasks)[string] | undefined>(
|
||||
(latest, task) => {
|
||||
if (
|
||||
task.kind !== CROSS_PROVIDER_PDF_TASK_KIND ||
|
||||
task.kind !== taskKind ||
|
||||
task.status !== TASK_WATCHER_STATUS.READY ||
|
||||
task.meta.scopeKey !== taskScope
|
||||
) {
|
||||
@@ -83,11 +103,18 @@ export const CrossProviderPdfButton = ({
|
||||
const handleGenerate = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await generateCrossProviderPdf({
|
||||
complianceId,
|
||||
filters,
|
||||
reportName: reportName.trim() || undefined,
|
||||
});
|
||||
const result = isCrossAccount
|
||||
? await generateCrossAccountPdf({
|
||||
complianceId,
|
||||
providerType,
|
||||
filters,
|
||||
reportName: reportName.trim() || undefined,
|
||||
})
|
||||
: await generateCrossProviderPdf({
|
||||
complianceId,
|
||||
filters,
|
||||
reportName: reportName.trim() || undefined,
|
||||
});
|
||||
|
||||
if ("error" in result) {
|
||||
toast({
|
||||
@@ -106,7 +133,7 @@ export const CrossProviderPdfButton = ({
|
||||
});
|
||||
await trackAndPollTask({
|
||||
taskId: result.taskId,
|
||||
kind: CROSS_PROVIDER_PDF_TASK_KIND,
|
||||
kind: taskKind,
|
||||
meta: {
|
||||
complianceId,
|
||||
scopeKey: taskScope,
|
||||
@@ -158,7 +185,7 @@ export const CrossProviderPdfButton = ({
|
||||
icon={<DownloadIcon />}
|
||||
label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`}
|
||||
description={availablePdf.filename}
|
||||
onSelect={() => downloadCrossProviderPdf(availablePdf.taskId)}
|
||||
onSelect={() => downloadPdf(availablePdf.taskId)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
@@ -172,8 +199,16 @@ export const CrossProviderPdfButton = ({
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="Generate Cross-Provider Report"
|
||||
description="The report covers the providers, accounts and filters currently applied to this view."
|
||||
title={
|
||||
isCrossAccount
|
||||
? "Generate Cross-Account Report"
|
||||
: "Generate Cross-Provider Report"
|
||||
}
|
||||
description={
|
||||
isCrossAccount
|
||||
? "The report covers the accounts and filters currently applied to this view."
|
||||
: "The report covers the providers, accounts and filters currently applied to this view."
|
||||
}
|
||||
size="xl"
|
||||
>
|
||||
<form
|
||||
|
||||
@@ -7,45 +7,72 @@ import {
|
||||
getScoreColor,
|
||||
getScoreIndicatorClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import type { KnownProviderType } from "@/types/providers";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import type { ProviderBreakdownEntry } from "../_types";
|
||||
|
||||
interface ProviderCoverageCardProps {
|
||||
breakdown: ProviderBreakdownEntry[];
|
||||
/** Pre-labeled coverage row — the cross-account detail feeds one per
|
||||
* account, with the fixed provider type as the icon. */
|
||||
export interface CoverageRow {
|
||||
key: string;
|
||||
label: string;
|
||||
iconType: KnownProviderType;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Per-provider pass score for the cross-provider detail: one row per
|
||||
* provider with a completed scan. */
|
||||
interface ProviderCoverageCardProps {
|
||||
/** Cross-provider breakdown (one row per scanned provider type). */
|
||||
breakdown?: ProviderBreakdownEntry[];
|
||||
/** Pre-labeled rows (cross-account: one per account). Wins over
|
||||
* `breakdown` when both are given. */
|
||||
rows?: CoverageRow[];
|
||||
title?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/** Per-column pass score for the cross-provider/cross-account details: one
|
||||
* row per provider type (or account) with a completed scan. */
|
||||
export const ProviderCoverageCard = ({
|
||||
breakdown,
|
||||
rows,
|
||||
title = "Provider Coverage",
|
||||
emptyMessage = "No scanned providers for this framework yet.",
|
||||
}: ProviderCoverageCardProps) => {
|
||||
const scannedProviders = breakdown.filter((entry) => !entry.unscanned);
|
||||
const resolvedRows: CoverageRow[] =
|
||||
rows ??
|
||||
(breakdown ?? [])
|
||||
.filter((entry) => !entry.unscanned)
|
||||
.map((entry) => ({
|
||||
key: entry.provider,
|
||||
label: PROVIDER_DISPLAY_NAMES[entry.provider],
|
||||
iconType: entry.provider,
|
||||
pass: entry.pass,
|
||||
fail: entry.fail,
|
||||
manual: entry.manual,
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full min-h-[372px] flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Provider Coverage</CardTitle>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
{/* Capped + scrollable so a long provider list never stretches the
|
||||
sibling chart cards in the same grid row. */}
|
||||
{/* Capped + scrollable so a long list never stretches the sibling
|
||||
chart cards in the same grid row. */}
|
||||
<CardContent className="minimal-scrollbar flex max-h-[300px] flex-col gap-4 overflow-y-auto">
|
||||
{scannedProviders.length === 0 && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No scanned providers for this framework yet.
|
||||
</p>
|
||||
{resolvedRows.length === 0 && (
|
||||
<p className="text-text-neutral-secondary text-sm">{emptyMessage}</p>
|
||||
)}
|
||||
{scannedProviders.map((entry) => (
|
||||
<div
|
||||
key={entry.provider}
|
||||
data-testid={`coverage-row-${entry.provider}`}
|
||||
>
|
||||
{resolvedRows.map((entry) => (
|
||||
<div key={entry.key} data-testid={`coverage-row-${entry.key}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIcon type={entry.provider} size={18} />
|
||||
<span className="truncate">
|
||||
{PROVIDER_DISPLAY_NAMES[entry.provider]}
|
||||
</span>
|
||||
<ProviderTypeIcon type={entry.iconType} size={18} />
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{entry.score}%
|
||||
@@ -53,7 +80,7 @@ export const ProviderCoverageCard = ({
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<Progress
|
||||
aria-label={`${PROVIDER_DISPLAY_NAMES[entry.provider]} passing score`}
|
||||
aria-label={`${entry.label} passing score`}
|
||||
value={entry.score}
|
||||
className="border-border-neutral-secondary h-2 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CrossAccountAccountRef } from "../_types";
|
||||
import { RequirementAccountChips } from "./requirement-account-chips";
|
||||
|
||||
const account = (n: number, alias: string | null): CrossAccountAccountRef => ({
|
||||
id: `00000000-0000-4000-8000-00000000000${n}`,
|
||||
uid: `10000000000${n}`,
|
||||
alias,
|
||||
});
|
||||
|
||||
describe("RequirementAccountChips", () => {
|
||||
it("shows inline labeled chips for up to two accounts", () => {
|
||||
const meta = [account(1, "prod"), account(2, null)];
|
||||
render(
|
||||
<RequirementAccountChips
|
||||
accounts={{ [meta[0].id]: "FAIL", [meta[1].id]: "PASS" }}
|
||||
accountMeta={meta}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("100000000002")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("requirement-status-summary"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses to per-status counts beyond two accounts", () => {
|
||||
const meta = [
|
||||
account(1, "prod"),
|
||||
account(2, "staging"),
|
||||
account(3, "dev"),
|
||||
account(4, null),
|
||||
];
|
||||
render(
|
||||
<RequirementAccountChips
|
||||
accounts={{
|
||||
[meta[0].id]: "FAIL",
|
||||
[meta[1].id]: "FAIL",
|
||||
[meta[2].id]: "PASS",
|
||||
[meta[3].id]: "MANUAL",
|
||||
}}
|
||||
accountMeta={meta}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Constant-footprint summary: one count per status present, no
|
||||
// per-account labels inline.
|
||||
const summary = screen.getByTestId("requirement-status-summary");
|
||||
expect(summary).toHaveTextContent("Fail×2");
|
||||
expect(summary).toHaveTextContent("Manual×1");
|
||||
expect(summary).toHaveTextContent("Pass×1");
|
||||
expect(screen.queryByText("prod")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only counts accounts that contributed a status", () => {
|
||||
const meta = [account(1, "prod"), account(2, "staging"), account(3, "dev")];
|
||||
render(
|
||||
<RequirementAccountChips
|
||||
accounts={{ [meta[0].id]: "PASS", [meta[1].id]: "PASS" }}
|
||||
accountMeta={meta}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Two contributing accounts → still inline, the silent third is ignored.
|
||||
expect(screen.getByText("prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("staging")).toBeInTheDocument();
|
||||
expect(screen.queryByText("dev")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
import { accountDisplayLabel } from "../_lib/cross-account-adapter";
|
||||
import type { AccountStatusMap, CrossAccountAccountRef } from "../_types";
|
||||
import { RequirementStatusSummary } from "./requirement-status-summary";
|
||||
|
||||
interface RequirementAccountChipsProps {
|
||||
accounts: AccountStatusMap;
|
||||
/** Ordered account metadata (server-sorted by alias) so chips are stable
|
||||
* across requirements. */
|
||||
accountMeta: CrossAccountAccountRef[];
|
||||
}
|
||||
|
||||
/** Text labels are wide (aliases, 12-digit uids): beyond two accounts the
|
||||
* inline chips would out-crowd the requirement title, so the row switches
|
||||
* to the aggregated per-status summary. */
|
||||
const MAX_INLINE_ACCOUNT_CHIPS = 2;
|
||||
|
||||
/** Per-account status chips shown next to a cross-account requirement:
|
||||
* each contributing account's short label paired with its own
|
||||
* PASS/FAIL/MANUAL — the account-axis sibling of RequirementProviderChips.
|
||||
* With many accounts, collapses to per-status counts + hover breakdown. */
|
||||
export const RequirementAccountChips = ({
|
||||
accounts,
|
||||
accountMeta,
|
||||
}: RequirementAccountChipsProps) => {
|
||||
const entries = accountMeta.filter((account) => accounts[account.id]);
|
||||
|
||||
if (entries.length > MAX_INLINE_ACCOUNT_CHIPS) {
|
||||
return (
|
||||
<RequirementStatusSummary
|
||||
entries={entries.map((account) => ({
|
||||
key: account.id,
|
||||
label: accountDisplayLabel(account),
|
||||
status: accounts[account.id]!,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// shrink-0: the chips keep their one-line intrinsic width and the row
|
||||
// TITLE truncates instead — compressed chips used to stack into two
|
||||
// lines or get clipped at the trigger's edge on long titles.
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
{entries.map((account) => (
|
||||
<Tooltip key={account.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`requirement-chip-${account.id}`}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="text-text-neutral-secondary max-w-24 truncate text-xs">
|
||||
{account.alias || account.uid}
|
||||
</span>
|
||||
<StatusFindingBadge
|
||||
status={accounts[account.id] as FindingStatus}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{accountDisplayLabel(account)}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,21 +13,44 @@ import {
|
||||
import { PROVIDER_DISPLAY_NAMES, PROVIDER_TYPES } from "@/types/providers";
|
||||
|
||||
import type { ProviderStatusMap } from "../_types";
|
||||
import { RequirementStatusSummary } from "./requirement-status-summary";
|
||||
|
||||
interface RequirementProviderChipsProps {
|
||||
providers: ProviderStatusMap;
|
||||
}
|
||||
|
||||
/** Icon chips are compact, so five fit comfortably (CSA's full provider
|
||||
* set); frameworks like CIS Controls declare up to 14 provider types,
|
||||
* where the row must collapse to the aggregated summary instead. */
|
||||
const MAX_INLINE_PROVIDER_CHIPS = 5;
|
||||
|
||||
/** Per-provider status chips shown next to a cross-provider requirement:
|
||||
* each contributing provider's icon paired with its own PASS/FAIL/MANUAL. */
|
||||
* each contributing provider's icon paired with its own PASS/FAIL/MANUAL.
|
||||
* With many provider types, collapses to per-status counts + hover
|
||||
* breakdown. */
|
||||
export const RequirementProviderChips = ({
|
||||
providers,
|
||||
}: RequirementProviderChipsProps) => {
|
||||
// Iterate the canonical order so chips are stable across requirements.
|
||||
const entries = PROVIDER_TYPES.filter((type) => providers[type]);
|
||||
|
||||
if (entries.length > MAX_INLINE_PROVIDER_CHIPS) {
|
||||
return (
|
||||
<RequirementStatusSummary
|
||||
entries={entries.map((type) => ({
|
||||
key: type,
|
||||
label: PROVIDER_DISPLAY_NAMES[type],
|
||||
icon: <ProviderTypeIcon type={type} size={14} />,
|
||||
status: providers[type]!,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
// shrink-0: same rationale as RequirementAccountChips — keep the chips
|
||||
// on one line and let the row title truncate instead.
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
{entries.map((type) => (
|
||||
<Tooltip key={type}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
import type { CrossProviderStatus } from "../_types";
|
||||
|
||||
export interface RequirementStatusEntry {
|
||||
key: string;
|
||||
/** Short label shown in the hover breakdown list. */
|
||||
label: string;
|
||||
/** Optional icon rendered before the label in the breakdown list. */
|
||||
icon?: ReactNode;
|
||||
status: CrossProviderStatus;
|
||||
}
|
||||
|
||||
/** Statuses in triage order — failures first, evidence last. */
|
||||
const STATUS_ORDER: readonly CrossProviderStatus[] = ["FAIL", "MANUAL", "PASS"];
|
||||
|
||||
/** Hover breakdown stays glanceable; beyond this it points at the row's
|
||||
* drill-down instead of becoming a scrolling list inside a tooltip. */
|
||||
const MAX_BREAKDOWN_ROWS = 12;
|
||||
|
||||
/**
|
||||
* Aggregated per-status counts for a requirement row whose column axis has
|
||||
* too many members to chip inline (many accounts of one provider type, or
|
||||
* many provider types). Constant footprint regardless of N: one count badge
|
||||
* per status present, with the full per-member breakdown on hover.
|
||||
*/
|
||||
export const RequirementStatusSummary = ({
|
||||
entries,
|
||||
}: {
|
||||
entries: RequirementStatusEntry[];
|
||||
}) => {
|
||||
const counts = STATUS_ORDER.map((status) => ({
|
||||
status,
|
||||
count: entries.filter((entry) => entry.status === status).length,
|
||||
})).filter(({ count }) => count > 0);
|
||||
|
||||
const breakdown = entries.slice(0, MAX_BREAKDOWN_ROWS);
|
||||
const hidden = entries.length - breakdown.length;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="requirement-status-summary"
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
{counts.map(({ status, count }) => (
|
||||
<span key={status} className="inline-flex items-center gap-1">
|
||||
<StatusFindingBadge status={status as FindingStatus} size="sm" />
|
||||
<span className="text-text-neutral-secondary text-xs tabular-nums">
|
||||
×{count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{breakdown.map((entry) => (
|
||||
<span
|
||||
key={entry.key}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.icon}
|
||||
<span className="max-w-48 truncate text-xs">{entry.label}</span>
|
||||
</span>
|
||||
<StatusFindingBadge
|
||||
status={entry.status as FindingStatus}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
+{hidden} more — expand the requirement for the full breakdown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Framework } from "@/types/compliance";
|
||||
|
||||
// Same stubs as the cross-provider accordion test: the requirement content
|
||||
// drags the findings/server-action chain into jsdom, and the section header
|
||||
// reaches next-auth through the shadcn/table barrel. Assembly structure is
|
||||
// what's under test here.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({ ClientAccordionContent: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({ ComplianceAccordionTitle: () => null }),
|
||||
);
|
||||
|
||||
import type {
|
||||
CrossAccountAccountRef,
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../../_types";
|
||||
import { toCrossAccountAccordionItems } from "../cross-account-accordion";
|
||||
|
||||
const ACC1 = "11111111-1111-4111-8111-111111111111";
|
||||
const ACC2 = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const accountMeta: CrossAccountAccountRef[] = [
|
||||
{ id: ACC1, uid: "123456789012", alias: "prod" },
|
||||
{ id: ACC2, uid: "210987654321", alias: null },
|
||||
];
|
||||
|
||||
const data: Framework[] = [
|
||||
{
|
||||
name: "CIS",
|
||||
pass: 1,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
categories: [
|
||||
{
|
||||
name: "1. IAM",
|
||||
pass: 1,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
controls: [
|
||||
{
|
||||
label: "1. IAM",
|
||||
pass: 1,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
requirements: [
|
||||
{
|
||||
name: "1.1 - Maintain contact details",
|
||||
description: "desc",
|
||||
status: "FAIL",
|
||||
pass: 0,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
check_ids: ["check_a"],
|
||||
},
|
||||
{
|
||||
name: "1.2 - Security contact",
|
||||
description: "desc",
|
||||
status: "PASS",
|
||||
pass: 1,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
check_ids: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const extras = new Map<string, CrossAccountRequirementExtras>([
|
||||
[
|
||||
"1.1 - Maintain contact details",
|
||||
{
|
||||
requirementId: "1.1",
|
||||
accounts: { [ACC1]: "FAIL", [ACC2]: "PASS" },
|
||||
checkIds: ["check_a"],
|
||||
scanIdsByAccount: { [ACC1]: ["scan-1"], [ACC2]: ["scan-2"] },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
describe("toCrossAccountAccordionItems", () => {
|
||||
const items = toCrossAccountAccordionItems(data, extras, "CIS", accountMeta);
|
||||
|
||||
it("keeps the per-scan accordion key scheme so ?section= deep links work", () => {
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].key).toBe("CIS-1. IAM");
|
||||
expect(items[0].items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("generates unique requirement keys across controls of one category", () => {
|
||||
// Two controls whose requirement lists both start at index 0 — keying
|
||||
// on the requirement index alone would collide (React duplicate-key
|
||||
// warning seen with CIS categories holding several controls).
|
||||
const twoControls: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
data[0].categories[0].controls[0],
|
||||
{
|
||||
...data[0].categories[0].controls[0],
|
||||
label: "another control",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const keys = toCrossAccountAccordionItems(
|
||||
twoControls,
|
||||
extras,
|
||||
"CIS",
|
||||
accountMeta,
|
||||
)[0].items!.map((item) => item.key);
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("shows one labeled chip per contributing account", () => {
|
||||
const { unmount } = render(<>{items[0].items?.[0].title}</>);
|
||||
|
||||
expect(
|
||||
screen.getByText("1.1 - Maintain contact details"),
|
||||
).toBeInTheDocument();
|
||||
// Both accounts contribute: alias when set, uid otherwise.
|
||||
expect(screen.getByText("prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("210987654321")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^fail$/i)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/^pass$/i)).toHaveLength(1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("falls back to a single roll-up badge when a requirement has no per-account breakdown", () => {
|
||||
render(<>{items[0].items?.[1].title}</>);
|
||||
|
||||
expect(screen.getByText("1.2 - Security contact")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^pass$/i)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the control label as the row title for CIS-style 1:1 controls", () => {
|
||||
// The CIS mapper names requirements with the bare id and keeps the rich
|
||||
// "id - description" on its one-requirement control — the row must show
|
||||
// the rich label, matching the Single Scan view.
|
||||
const cisStyle: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
{
|
||||
label: "2.1.1 - Ensure centralized root access",
|
||||
pass: 0,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
requirements: [
|
||||
{
|
||||
...data[0].categories[0].controls[0].requirements[0],
|
||||
name: "2.1.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const cisItems = toCrossAccountAccordionItems(
|
||||
cisStyle,
|
||||
new Map(),
|
||||
"CIS",
|
||||
accountMeta,
|
||||
);
|
||||
const { unmount } = render(<>{cisItems[0].items?.[0].title}</>);
|
||||
|
||||
expect(
|
||||
screen.getByText("2.1.1 - Ensure centralized root access"),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("keeps labeled multi-requirement controls as a nested level (ENS style)", () => {
|
||||
const ensStyle: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
{
|
||||
...data[0].categories[0].controls[0],
|
||||
label: "op.acc - Access control group",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ensItems = toCrossAccountAccordionItems(
|
||||
ensStyle,
|
||||
extras,
|
||||
"ENS",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
// One nested control item wrapping its requirement rows.
|
||||
expect(ensItems[0].items).toHaveLength(1);
|
||||
expect(ensItems[0].items?.[0].items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps multi-framework data (ENS marcos) as the top accordion level", () => {
|
||||
const marcos: Framework[] = [
|
||||
{ ...data[0], name: "Operacional" },
|
||||
{ ...data[0], name: "Organizativo" },
|
||||
];
|
||||
|
||||
const marcoItems = toCrossAccountAccordionItems(
|
||||
marcos,
|
||||
extras,
|
||||
"ENS",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
expect(marcoItems.map((item) => item.key)).toEqual([
|
||||
"Operacional",
|
||||
"Organizativo",
|
||||
]);
|
||||
// Categories nest under their marco, keeping the per-scan key scheme.
|
||||
expect(marcoItems[0].items?.[0].key).toBe("Operacional-1. IAM");
|
||||
});
|
||||
|
||||
it("shows the requirement type chip (requisito/recomendación) like per-scan", () => {
|
||||
const typed: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
{
|
||||
...data[0].categories[0].controls[0],
|
||||
requirements: [
|
||||
{
|
||||
...data[0].categories[0].controls[0].requirements[0],
|
||||
type: "requisito",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const typedItems = toCrossAccountAccordionItems(
|
||||
typed,
|
||||
extras,
|
||||
"ENS",
|
||||
accountMeta,
|
||||
);
|
||||
const { unmount } = render(<>{typedItems[0].items?.[0].title}</>);
|
||||
|
||||
expect(screen.getByText("requisito")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CrossAccountOverviewAttributes } from "../../_types";
|
||||
import {
|
||||
accountDisplayLabel,
|
||||
buildAccountExtrasMap,
|
||||
computeAccountBreakdown,
|
||||
crossAccountToMapperInput,
|
||||
} from "../cross-account-adapter";
|
||||
|
||||
const ACC1 = "11111111-1111-4111-8111-111111111111";
|
||||
const ACC2 = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const buildAttrs = (): CrossAccountOverviewAttributes => ({
|
||||
compliance_id: "cis_2.0_aws",
|
||||
provider_type: "aws",
|
||||
framework: "CIS",
|
||||
name: "CIS Amazon Web Services Foundations Benchmark",
|
||||
version: "2.0",
|
||||
description: "CIS AWS 2.0",
|
||||
accounts: [
|
||||
{ id: ACC1, uid: "123456789012", alias: "prod" },
|
||||
{ id: ACC2, uid: "210987654321", alias: null },
|
||||
],
|
||||
scan_ids: ["scan-1", "scan-2"],
|
||||
scan_ids_by_account: { [ACC1]: ["scan-1"], [ACC2]: ["scan-2"] },
|
||||
requirements_passed: 1,
|
||||
requirements_failed: 1,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 2,
|
||||
requirements: [
|
||||
{
|
||||
id: "1.1",
|
||||
name: "Maintain current contact details",
|
||||
description: "desc-1",
|
||||
attributes: [{ Section: "1. IAM" }],
|
||||
status: "FAIL",
|
||||
accounts: { [ACC1]: "FAIL", [ACC2]: "PASS" },
|
||||
check_ids: ["account_maintain_current_contact_details"],
|
||||
},
|
||||
{
|
||||
id: "1.2",
|
||||
name: "",
|
||||
description: "desc-2",
|
||||
attributes: [],
|
||||
status: "PASS",
|
||||
accounts: { [ACC1]: "PASS" },
|
||||
check_ids: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("crossAccountToMapperInput", () => {
|
||||
it("produces the mapper pair with flat check_ids and passthrough metadata", () => {
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(buildAttrs());
|
||||
|
||||
expect(attributesData.data).toHaveLength(2);
|
||||
expect(requirementsData.data).toHaveLength(2);
|
||||
|
||||
const first = attributesData.data[0];
|
||||
expect(first.id).toBe("1.1");
|
||||
expect(first.attributes.framework).toBe("CIS");
|
||||
// The per-provider template already ships metadata as a list — it must
|
||||
// feed attributes.metadata directly, not get re-wrapped.
|
||||
expect(first.attributes.attributes.metadata).toEqual([
|
||||
{ Section: "1. IAM" },
|
||||
]);
|
||||
expect(first.attributes.attributes.check_ids).toEqual([
|
||||
"account_maintain_current_contact_details",
|
||||
]);
|
||||
|
||||
expect(requirementsData.data[0].attributes.status).toBe("FAIL");
|
||||
expect(requirementsData.data[1].attributes.status).toBe("PASS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAccountExtrasMap", () => {
|
||||
it("registers every candidate name a framework mapper may compose", () => {
|
||||
const extras = buildAccountExtrasMap(buildAttrs());
|
||||
|
||||
// CSA/CIS-Controls/DORA-style mappers compose "id - name"; CIS/CCC/PCI
|
||||
// use the bare id; the generic mapper uses the bare name. All three
|
||||
// must resolve to the same entry so the accordion join works for every
|
||||
// framework the cross-account view serves.
|
||||
const composed = extras.get("1.1 - Maintain current contact details");
|
||||
expect(composed).toBeDefined();
|
||||
expect(extras.get("1.1")).toBe(composed);
|
||||
expect(extras.get("Maintain current contact details")).toBe(composed);
|
||||
expect(composed?.accounts).toEqual({ [ACC1]: "FAIL", [ACC2]: "PASS" });
|
||||
expect(composed?.checkIds).toEqual([
|
||||
"account_maintain_current_contact_details",
|
||||
]);
|
||||
expect(composed?.scanIdsByAccount[ACC1]).toEqual(["scan-1"]);
|
||||
|
||||
// Nameless requirements register just the id.
|
||||
expect(extras.get("1.2")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAccountBreakdown", () => {
|
||||
it("scores each account over its contributed non-manual requirements", () => {
|
||||
const breakdown = computeAccountBreakdown(buildAttrs());
|
||||
|
||||
expect(breakdown).toHaveLength(2);
|
||||
// Server account order (sorted by alias) is preserved.
|
||||
expect(breakdown[0].id).toBe(ACC1);
|
||||
expect(breakdown[0].label).toBe("prod (123456789012)");
|
||||
expect(breakdown[0].pass).toBe(1);
|
||||
expect(breakdown[0].fail).toBe(1);
|
||||
expect(breakdown[0].score).toBe(50);
|
||||
|
||||
// Account 2 contributed only one PASS row; the requirement it skipped
|
||||
// must not drag its score.
|
||||
expect(breakdown[1].label).toBe("210987654321");
|
||||
expect(breakdown[1].pass).toBe(1);
|
||||
expect(breakdown[1].fail).toBe(0);
|
||||
expect(breakdown[1].score).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountDisplayLabel", () => {
|
||||
it("prefers alias with uid in parentheses, falls back to uid", () => {
|
||||
expect(accountDisplayLabel({ id: ACC1, uid: "123", alias: "prod" })).toBe(
|
||||
"prod (123)",
|
||||
);
|
||||
expect(accountDisplayLabel({ id: ACC2, uid: "456", alias: null })).toBe(
|
||||
"456",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCrossAccountDetailHref,
|
||||
parseCrossAccountFilters,
|
||||
} from "../cross-account-frameworks";
|
||||
|
||||
describe("buildCrossAccountDetailHref", () => {
|
||||
it("builds the detail path with mode, identity and provider type", () => {
|
||||
const href = buildCrossAccountDetailHref({
|
||||
complianceId: "cis_2.0_aws",
|
||||
title: "CIS",
|
||||
version: "2.0",
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
const url = new URL(href, "https://example.test");
|
||||
expect(url.pathname).toBe("/compliance/CIS");
|
||||
expect(url.searchParams.get("mode")).toBe("cross-account");
|
||||
expect(url.searchParams.get("complianceId")).toBe("cis_2.0_aws");
|
||||
expect(url.searchParams.get("version")).toBe("2.0");
|
||||
expect(url.searchParams.get("providerType")).toBe("aws");
|
||||
});
|
||||
|
||||
it("forwards only the cross-account filter params", () => {
|
||||
const href = buildCrossAccountDetailHref(
|
||||
{
|
||||
complianceId: "cis_2.0_aws",
|
||||
title: "CIS",
|
||||
version: "2.0",
|
||||
providerType: "aws",
|
||||
},
|
||||
{
|
||||
"filter[provider_id__in]": "acc-1,acc-2",
|
||||
"filter[provider_groups__in]": "group-1",
|
||||
// Not part of the cross-account contract: must not leak into the link.
|
||||
"filter[provider_type__in]": "aws,azure",
|
||||
unrelated: "x",
|
||||
},
|
||||
);
|
||||
|
||||
const url = new URL(href, "https://example.test");
|
||||
expect(url.searchParams.get("filter[provider_id__in]")).toBe("acc-1,acc-2");
|
||||
expect(url.searchParams.get("filter[provider_groups__in]")).toBe("group-1");
|
||||
expect(url.searchParams.has("filter[provider_type__in]")).toBe(false);
|
||||
expect(url.searchParams.has("unrelated")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCrossAccountFilters", () => {
|
||||
it("extracts the endpoint filters and drops empties", () => {
|
||||
expect(
|
||||
parseCrossAccountFilters({
|
||||
"filter[provider_id__in]": "acc-1",
|
||||
"filter[provider_groups__in]": "",
|
||||
}),
|
||||
).toEqual({ providerIds: "acc-1", providerGroups: undefined });
|
||||
|
||||
expect(parseCrossAccountFilters({})).toEqual({
|
||||
providerIds: undefined,
|
||||
providerGroups: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Same stubs as the sibling accordion tests: the mappers' own
|
||||
// toAccordionItems (unused here) drag the findings/server-action chain into
|
||||
// jsdom through these imports.
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/client-accordion-content",
|
||||
() => ({ ClientAccordionContent: () => null }),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-title",
|
||||
() => ({
|
||||
ComplianceAccordionTitle: ({ label }: { label: string }) => (
|
||||
<span>{label}</span>
|
||||
),
|
||||
}),
|
||||
);
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
|
||||
() => ({
|
||||
// Render the row name so parity assertions can compare the per-scan
|
||||
// accordion's visible titles against the cross-account builder's.
|
||||
ComplianceAccordionRequirementTitle: ({ name }: { name: string }) => (
|
||||
<span>{name}</span>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
import { mapComplianceData as mapCis } from "@/lib/compliance/cis";
|
||||
import { mapComplianceData as mapEns } from "@/lib/compliance/ens";
|
||||
import {
|
||||
mapComplianceData as mapGeneric,
|
||||
toAccordionItems as perScanGenericItems,
|
||||
} from "@/lib/compliance/generic";
|
||||
|
||||
import type { CrossAccountOverviewAttributes } from "../../_types";
|
||||
import { toCrossAccountAccordionItems } from "../cross-account-accordion";
|
||||
import { crossAccountToMapperInput } from "../cross-account-adapter";
|
||||
|
||||
const ACC = "11111111-1111-4111-8111-111111111111";
|
||||
const accountMeta = [{ id: ACC, uid: "123456789012", alias: "prod" }];
|
||||
|
||||
/** End-to-end parity harness: API payload → adapter → REAL mapper → builder.
|
||||
* Verifies the cross-account accordion renders the same titles/hierarchy
|
||||
* the mapper's own per-scan view derives from the same data. */
|
||||
const buildAttrs = (
|
||||
requirements: CrossAccountOverviewAttributes["requirements"],
|
||||
framework: string,
|
||||
): CrossAccountOverviewAttributes => ({
|
||||
compliance_id: "test",
|
||||
provider_type: "aws",
|
||||
framework,
|
||||
name: framework,
|
||||
version: "1.0",
|
||||
description: "framework description",
|
||||
accounts: accountMeta,
|
||||
scan_ids: ["scan-1"],
|
||||
scan_ids_by_account: { [ACC]: ["scan-1"] },
|
||||
requirements_passed: 0,
|
||||
requirements_failed: 0,
|
||||
requirements_manual: 0,
|
||||
total_requirements: requirements.length,
|
||||
requirements,
|
||||
});
|
||||
|
||||
describe("cross-account pipeline parity with real mappers", () => {
|
||||
it("CIS: rows show the mapper's rich 'id - description' title", () => {
|
||||
// Shaped like the backend template for a CIS framework: bare id as
|
||||
// name, the title in description, CIS metadata fields in attributes.
|
||||
const attrs = buildAttrs(
|
||||
[
|
||||
{
|
||||
id: "2.1.1",
|
||||
name: "2.1.1",
|
||||
description: "Ensure centralized root access in AWS Organizations",
|
||||
attributes: [
|
||||
{
|
||||
Section: "2 Identity and Access Management",
|
||||
Profile: "Level 1",
|
||||
Description:
|
||||
"Ensure centralized root access in AWS Organizations",
|
||||
AssessmentStatus: "Automated",
|
||||
},
|
||||
],
|
||||
status: "PASS",
|
||||
accounts: { [ACC]: "PASS" },
|
||||
check_ids: ["check_a"],
|
||||
},
|
||||
],
|
||||
"CIS",
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(attrs);
|
||||
const data = mapCis(attributesData, requirementsData);
|
||||
const items = toCrossAccountAccordionItems(
|
||||
data,
|
||||
new Map(),
|
||||
"CIS",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
// Per-scan CIS: category "2. Identity and Access Management", row title
|
||||
// = control label "2.1.1 - Ensure…".
|
||||
expect(items[0].key).toContain("2. Identity and Access Management");
|
||||
const { unmount } = render(<>{items[0].items?.[0].title}</>);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"2.1.1 - Ensure centralized root access in AWS Organizations",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("ENS: marcos on top, labeled control groups nested, type chip on rows", () => {
|
||||
const ensRequirement = (
|
||||
id: string,
|
||||
marco: string,
|
||||
grupo: string,
|
||||
tipo: string,
|
||||
) => ({
|
||||
id,
|
||||
name: id,
|
||||
description: "Proveedor de identidad centralizado",
|
||||
attributes: [
|
||||
{
|
||||
Marco: marco,
|
||||
Categoria: "Control de acceso",
|
||||
IdGrupoControl: grupo,
|
||||
Tipo: tipo,
|
||||
Nivel: "alto",
|
||||
Dimensiones: ["trazabilidad"],
|
||||
ModoEjecucion: "automático",
|
||||
DescripcionControl: "Descripción del control",
|
||||
},
|
||||
],
|
||||
status: "PASS" as const,
|
||||
accounts: { [ACC]: "PASS" as const },
|
||||
check_ids: ["check_a"],
|
||||
});
|
||||
|
||||
const attrs = buildAttrs(
|
||||
[
|
||||
ensRequirement(
|
||||
"op.acc.1.aws.iam.2",
|
||||
"operacional",
|
||||
"op.acc.1",
|
||||
"requisito",
|
||||
),
|
||||
ensRequirement(
|
||||
"op.acc.1.aws.iam.3",
|
||||
"operacional",
|
||||
"op.acc.1",
|
||||
"recomendacion",
|
||||
),
|
||||
ensRequirement("org.1.aws.iam.1", "organizativo", "org.1", "requisito"),
|
||||
],
|
||||
"ENS",
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(attrs);
|
||||
const data = mapEns(attributesData, requirementsData);
|
||||
const items = toCrossAccountAccordionItems(
|
||||
data,
|
||||
new Map(),
|
||||
"ENS",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
// Per-scan ENS: marcos (frameworks) at the top…
|
||||
expect(items.map((item) => item.key)).toEqual([
|
||||
"operacional",
|
||||
"organizativo",
|
||||
]);
|
||||
// …categories under the marco, control group as its own nested level.
|
||||
const category = items[0].items?.[0];
|
||||
expect(category?.key).toBe("operacional-Control de acceso");
|
||||
expect(category?.items).toHaveLength(1);
|
||||
const group = category?.items?.[0];
|
||||
expect(group?.items).toHaveLength(2);
|
||||
|
||||
// Requirement rows carry the ENS type chip like per-scan.
|
||||
const { unmount } = render(<>{group?.items?.[0].title}</>);
|
||||
expect(screen.getByText("requisito")).toBeInTheDocument();
|
||||
expect(screen.getByText("op.acc.1.aws.iam.2")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("generic flat structure (GDPR-style): requirements render as top-level rows", () => {
|
||||
// GDPR metadata has no distinct Section, so the generic mapper stores
|
||||
// requirements directly on the framework with EMPTY categories — the
|
||||
// accordion must not come out empty (regression: blank GDPR accordion).
|
||||
const attrs = buildAttrs(
|
||||
[
|
||||
{
|
||||
id: "article_25",
|
||||
name: "Article 25: Data protection by design and by default",
|
||||
description: "Data protection by design and by default",
|
||||
attributes: [
|
||||
{
|
||||
Section: "Article 25: Data protection by design and by default",
|
||||
},
|
||||
],
|
||||
status: "FAIL",
|
||||
accounts: { [ACC]: "FAIL" },
|
||||
check_ids: ["check_a"],
|
||||
},
|
||||
{
|
||||
id: "article_32",
|
||||
name: "Article 32: Security of processing",
|
||||
description: "Security of processing",
|
||||
attributes: [{ Section: "Article 32: Security of processing" }],
|
||||
status: "FAIL",
|
||||
accounts: { [ACC]: "FAIL" },
|
||||
check_ids: ["check_b"],
|
||||
},
|
||||
],
|
||||
"GDPR",
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(attrs);
|
||||
const data = mapGeneric(attributesData, requirementsData);
|
||||
const items = toCrossAccountAccordionItems(
|
||||
data,
|
||||
new Map(),
|
||||
"GDPR",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
const { unmount } = render(<>{items[0].title}</>);
|
||||
expect(
|
||||
screen.getByText("Article 25: Data protection by design and by default"),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("PCI 3-level shape: same leaf rows as the per-scan generic accordion", () => {
|
||||
// Real pci_3.2.1_aws metadata shape: every item has SubSection; the
|
||||
// top-level requirements (2.1, 2.2) carry SubSection === Section, the
|
||||
// children carry their parent's titled SubSection. The mapper hard-codes
|
||||
// bare-id names for PCI, so bare rows are per-scan behavior, not a
|
||||
// cross-account regression.
|
||||
const pciItem = (id: string, subSection: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
description: `Description for ${id}`,
|
||||
attributes: [
|
||||
{
|
||||
Section: "Requirement 2: Do not use vendor-supplied defaults",
|
||||
SubSection: subSection,
|
||||
},
|
||||
],
|
||||
status: "FAIL" as const,
|
||||
accounts: { [ACC]: "FAIL" as const },
|
||||
check_ids: ["check_a"],
|
||||
});
|
||||
const SECTION = "Requirement 2: Do not use vendor-supplied defaults";
|
||||
|
||||
const attrs = buildAttrs(
|
||||
[
|
||||
pciItem("2.1", SECTION),
|
||||
pciItem("2.2", SECTION),
|
||||
pciItem("2.1.a", "2.1 Always change vendor-supplied defaults"),
|
||||
pciItem("2.2.1", "2.2 Develop configuration standards"),
|
||||
pciItem("2.2.2", "2.2 Develop configuration standards"),
|
||||
],
|
||||
"PCI",
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(attrs);
|
||||
const data = mapGeneric(attributesData, requirementsData);
|
||||
|
||||
const collectLeafTitles = (items: ReturnType<typeof perScanGenericItems>) =>
|
||||
items.flatMap(function walk(item): string[] {
|
||||
const children = item.items ?? [];
|
||||
if (children.length > 0) {
|
||||
return children.flatMap(walk);
|
||||
}
|
||||
const { unmount, container } = render(<>{item.title}</>);
|
||||
const text = container.textContent ?? "";
|
||||
unmount();
|
||||
return [text];
|
||||
});
|
||||
|
||||
const crossAccountLeaves = collectLeafTitles(
|
||||
toCrossAccountAccordionItems(data, new Map(), "PCI", accountMeta),
|
||||
);
|
||||
const perScanLeaves = collectLeafTitles(perScanGenericItems(data, "scan"));
|
||||
|
||||
// Same leaf rows, same visible titles (chips/badges aside). The only
|
||||
// structural difference is deliberate: per-scan wraps the top-level
|
||||
// requirements in a group whose label repeats the category header;
|
||||
// cross-account flattens that redundant wrapper.
|
||||
expect(
|
||||
crossAccountLeaves.map((t) => t.replace(/Fail$/, "")).sort(),
|
||||
).toEqual(perScanLeaves.sort());
|
||||
});
|
||||
|
||||
it("generic (onboarding-style): rows show the requirement name", () => {
|
||||
const attrs = buildAttrs(
|
||||
[
|
||||
{
|
||||
id: "Predefine IAM Roles",
|
||||
name: "Predefine IAM Roles",
|
||||
description: "Predefine IAM roles for the account",
|
||||
attributes: [
|
||||
{ Section: "Deploy account from predefined IaC template" },
|
||||
],
|
||||
status: "FAIL",
|
||||
accounts: { [ACC]: "FAIL" },
|
||||
check_ids: ["check_a"],
|
||||
},
|
||||
],
|
||||
"AWS-Account-Security-Onboarding",
|
||||
);
|
||||
|
||||
const { attributesData, requirementsData } =
|
||||
crossAccountToMapperInput(attrs);
|
||||
const data = mapGeneric(attributesData, requirementsData);
|
||||
const items = toCrossAccountAccordionItems(
|
||||
data,
|
||||
new Map(),
|
||||
"AWS-Account-Security-Onboarding",
|
||||
accountMeta,
|
||||
);
|
||||
|
||||
const { unmount } = render(<>{items[0].items?.[0].title}</>);
|
||||
expect(screen.getByText("Predefine IAM Roles")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../_actions/cross-account", () => ({
|
||||
getCrossAccountPdfBinary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast", () => ({
|
||||
toast: vi.fn(),
|
||||
ToastAction: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
downloadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildCrossAccountPdfTaskScope } from "../cross-account-pdf";
|
||||
|
||||
describe("buildCrossAccountPdfTaskScope", () => {
|
||||
it("normalizes set-like filter ordering into one task scope", () => {
|
||||
const first = buildCrossAccountPdfTaskScope("cis_2.0_aws", "aws", {
|
||||
scanIds: ["scan-2", "scan-1"],
|
||||
providerIds: "provider-2,provider-1",
|
||||
});
|
||||
const second = buildCrossAccountPdfTaskScope("cis_2.0_aws", "aws", {
|
||||
scanIds: ["scan-1", "scan-2"],
|
||||
providerIds: "provider-1,provider-2",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("keeps reports from different provider types in separate scopes", () => {
|
||||
const awsScope = buildCrossAccountPdfTaskScope("cis_2.0_aws", "aws", {
|
||||
scanIds: ["scan-1"],
|
||||
});
|
||||
const gcpScope = buildCrossAccountPdfTaskScope("cis_2.0_aws", "gcp", {
|
||||
scanIds: ["scan-1"],
|
||||
});
|
||||
|
||||
expect(awsScope).not.toBe(gcpScope);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,72 @@ describe("toCrossProviderAccordionItems", () => {
|
||||
expect(items[0].items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses the control label as the row title for CIS-style 1:1 controls", () => {
|
||||
const cisStyle: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
{
|
||||
label: "2.1.1 - Ensure centralized root access",
|
||||
pass: 0,
|
||||
fail: 1,
|
||||
manual: 0,
|
||||
requirements: [
|
||||
{
|
||||
...data[0].categories[0].controls[0].requirements[0],
|
||||
name: "2.1.1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const cisItems = toCrossProviderAccordionItems(cisStyle, new Map(), "CIS");
|
||||
const { unmount } = render(<>{cisItems[0].items?.[0].title}</>);
|
||||
|
||||
expect(
|
||||
screen.getByText("2.1.1 - Ensure centralized root access"),
|
||||
).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("generates unique requirement keys across controls of one category", () => {
|
||||
// Two controls whose requirement lists both start at index 0 — keying
|
||||
// on the requirement index alone would collide (React duplicate-key
|
||||
// warning seen on the cross-account sibling with CIS categories).
|
||||
const twoControls: Framework[] = [
|
||||
{
|
||||
...data[0],
|
||||
categories: [
|
||||
{
|
||||
...data[0].categories[0],
|
||||
controls: [
|
||||
data[0].categories[0].controls[0],
|
||||
{
|
||||
...data[0].categories[0].controls[0],
|
||||
label: "another control",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const keys = toCrossProviderAccordionItems(
|
||||
twoControls,
|
||||
extras,
|
||||
"CSA-CCM",
|
||||
)[0].items!.map((item) => item.key);
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("shows the status only once via the provider chips (no duplicate roll-up badge)", () => {
|
||||
// A&A-01 has a single provider (aws FAIL): its status must appear once,
|
||||
// in the chip — not also as a separate roll-up badge.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import type { Control, Framework, Requirement } from "@/types/compliance";
|
||||
|
||||
import { CrossAccountRequirementContent } from "../_components/cross-account-requirement-content";
|
||||
import { RequirementAccountChips } from "../_components/requirement-account-chips";
|
||||
import type {
|
||||
CrossAccountAccountRef,
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../_types";
|
||||
|
||||
/**
|
||||
* Accordion assembly for the cross-account detail — the account-axis sibling
|
||||
* of `toCrossProviderAccordionItems` (same section key scheme, so `?section=`
|
||||
* deep links behave identically). Each requirement's status is shown once,
|
||||
* on the title row: via the per-account chips when a breakdown exists, or a
|
||||
* single roll-up badge as a fallback. `extras` is the map produced by
|
||||
* `buildAccountExtrasMap`, keyed by the mapper-composed requirement name.
|
||||
*
|
||||
* Row titles and hierarchy mirror what each mapper's OWN per-scan
|
||||
* `toAccordionItems` renders, so a framework looks the same here as in the
|
||||
* Single Scan view:
|
||||
* - CSA/CIS-Controls/DORA-style mappers put every requirement (already
|
||||
* richly named `id - title`) under one control labeled like its category
|
||||
* → flatten to requirement rows.
|
||||
* - The CIS mapper creates one control PER requirement whose label carries
|
||||
* the full `id - description` while `requirement.name` is the bare id
|
||||
* → use the control label as the row title.
|
||||
* - ENS-style mappers group frameworks (marcos) above categories and
|
||||
* several requirements under a labeled control → keep both extra levels,
|
||||
* like per-scan does, and show each requirement's type chip
|
||||
* (requisito/recomendación/refuerzo).
|
||||
*/
|
||||
export const toCrossAccountAccordionItems = (
|
||||
data: Framework[],
|
||||
extras: Map<string, CrossAccountRequirementExtras>,
|
||||
framework: string,
|
||||
accountMeta: CrossAccountAccountRef[],
|
||||
): AccordionItemProps[] => {
|
||||
const requirementItem = (
|
||||
requirement: Requirement,
|
||||
itemKey: string,
|
||||
rowTitle: string,
|
||||
): AccordionItemProps => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
const requirementType =
|
||||
typeof requirement.type === "string" ? requirement.type : "";
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{/* Same left-side composition as the per-scan
|
||||
ComplianceAccordionRequirementTitle (type chip + name +
|
||||
invalid-config note); only the right side differs (per-account
|
||||
chips instead of one status badge). */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{requirementType && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{requirementType}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{rowTitle}</span>
|
||||
{requirement.invalid_config && (
|
||||
<InfoTooltip content={INVALID_CONFIG_NOTE} />
|
||||
)}
|
||||
</div>
|
||||
{requirementExtras ? (
|
||||
<RequirementAccountChips
|
||||
accounts={requirementExtras.accounts}
|
||||
accountMeta={accountMeta}
|
||||
/>
|
||||
) : (
|
||||
<StatusFindingBadge status={requirement.status as FindingStatus} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
// Explicit key on the content element, matching the per-scan mappers
|
||||
// (csa.tsx et al.): these elements travel to the client inside the
|
||||
// serialized `items` array, where React's Flight layer warns about
|
||||
// un-keyed elements ("Each child in a list…").
|
||||
content: requirementExtras ? (
|
||||
<CrossAccountRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
accountMeta={accountMeta}
|
||||
framework={framework}
|
||||
/>
|
||||
) : (
|
||||
<p key={`content-${itemKey}`} className="text-sm">
|
||||
No per-account breakdown is available for this requirement.
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const controlItems = (
|
||||
control: Control,
|
||||
categoryName: string,
|
||||
baseKey: string,
|
||||
): AccordionItemProps[] => {
|
||||
// A label that just repeats the category (the flat-mapper convention)
|
||||
// carries no information; a distinct one is the mapper's richer title.
|
||||
const groupLabel =
|
||||
control.label && control.label !== categoryName
|
||||
? control.label
|
||||
: undefined;
|
||||
|
||||
if (groupLabel && control.requirements.length > 1) {
|
||||
// ENS-style group: keep the control as its own accordion level, the
|
||||
// way that mapper's per-scan toAccordionItems renders it.
|
||||
return [
|
||||
{
|
||||
key: baseKey,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={groupLabel}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
// Single-requirement controls (CIS style) carry the full
|
||||
// `id - description` in the control label while requirement.name is
|
||||
// the bare id — prefer the richer title, like the per-scan view.
|
||||
(groupLabel ?? requirement.name) as string,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
|
||||
frameworkData.categories.map((category) => ({
|
||||
key: `${frameworkData.name}-${category.name}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={data.length === 1}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
// The control index participates in the key: a category can hold
|
||||
// several controls whose requirement lists all start at index 0, so
|
||||
// keying on the requirement index alone collides across controls
|
||||
// (React "two children with the same key").
|
||||
items: category.controls.flatMap((control, controlIndex) =>
|
||||
controlItems(
|
||||
control,
|
||||
category.name,
|
||||
`${frameworkData.name}-${category.name}-c${controlIndex}`,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
|
||||
// Flat generic-mapper structure (e.g. GDPR): requirements hang directly
|
||||
// off the framework with no categories — that mapper's per-scan
|
||||
// toAccordionItems renders them as top-level rows, so mirror it.
|
||||
const directRequirements =
|
||||
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
return directRequirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${frameworkData.name}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
);
|
||||
}
|
||||
return categoryItems(frameworkData);
|
||||
};
|
||||
|
||||
// Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the
|
||||
// framework as the top accordion level, exactly like that mapper's own
|
||||
// per-scan toAccordionItems; single-framework data starts at categories.
|
||||
if (data.length > 1) {
|
||||
return data.map((frameworkData) => ({
|
||||
key: frameworkData.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={frameworkData.name}
|
||||
pass={frameworkData.pass}
|
||||
fail={frameworkData.fail}
|
||||
manual={frameworkData.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: frameworkItems(frameworkData),
|
||||
}));
|
||||
}
|
||||
|
||||
return data.flatMap(frameworkItems);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
AttributesData,
|
||||
AttributesItemData,
|
||||
RequirementItemData,
|
||||
RequirementsData,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import type {
|
||||
AccountBreakdownEntry,
|
||||
CrossAccountAccountRef,
|
||||
CrossAccountOverviewAttributes,
|
||||
CrossAccountRequirementExtras,
|
||||
} from "../_types";
|
||||
|
||||
/** Candidate display names a framework mapper may give this requirement.
|
||||
*
|
||||
* Unlike the cross-provider view (whose three universal frameworks all
|
||||
* compose `id - name`), cross-account serves EVERY framework, and mappers
|
||||
* disagree on the composed name: CSA/CIS-Controls/DORA use `id - name`,
|
||||
* CIS/CCC/PCI use the bare `id`, the generic mapper uses the bare `name`.
|
||||
* The extras map registers every candidate so the join works regardless of
|
||||
* which mapper renders the framework. */
|
||||
const candidateRequirementNames = (id: string, name: string): string[] => {
|
||||
const candidates = [id];
|
||||
if (name) {
|
||||
candidates.push(name, `${id} - ${name}`);
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/** Display label for an account column: alias when set, uid otherwise. */
|
||||
export const accountDisplayLabel = (account: CrossAccountAccountRef): string =>
|
||||
account.alias ? `${account.alias} (${account.uid})` : account.uid;
|
||||
|
||||
/**
|
||||
* Convert a cross-account overview into the `{AttributesData,
|
||||
* RequirementsData}` pair the per-scan compliance mappers consume — the
|
||||
* cross-account sibling of `crossProviderToMapperInput`. The per-provider
|
||||
* template already ships each requirement's metadata as a list, so it feeds
|
||||
* `attributes.metadata` directly, and `check_ids` is the single flat list
|
||||
* every account shares.
|
||||
*/
|
||||
export const crossAccountToMapperInput = (
|
||||
attrs: CrossAccountOverviewAttributes,
|
||||
): { attributesData: AttributesData; requirementsData: RequirementsData } => {
|
||||
const attributeItems: AttributesItemData[] = [];
|
||||
const requirementItems: RequirementItemData[] = [];
|
||||
|
||||
for (const requirement of attrs.requirements) {
|
||||
attributeItems.push({
|
||||
type: "compliance-requirements-attributes",
|
||||
id: requirement.id,
|
||||
attributes: {
|
||||
framework_description: attrs.description || "",
|
||||
name: requirement.name,
|
||||
framework: attrs.framework,
|
||||
version: attrs.version || "",
|
||||
description: requirement.description || "",
|
||||
attributes: {
|
||||
metadata: (requirement.attributes ??
|
||||
[]) as AttributesItemData["attributes"]["attributes"]["metadata"],
|
||||
check_ids: requirement.check_ids ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
requirementItems.push({
|
||||
type: "compliance-requirements-details",
|
||||
id: requirement.id,
|
||||
attributes: {
|
||||
framework: attrs.framework,
|
||||
version: attrs.version || "",
|
||||
description: requirement.description || "",
|
||||
status: requirement.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
attributesData: { data: attributeItems },
|
||||
requirementsData: { data: requirementItems },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross-account context for each requirement, keyed by the exact composed
|
||||
* name the mappers produce, so renderers can join per-account statuses and
|
||||
* scan ids onto mapped requirements without touching the mappers.
|
||||
*/
|
||||
export const buildAccountExtrasMap = (
|
||||
attrs: CrossAccountOverviewAttributes,
|
||||
): Map<string, CrossAccountRequirementExtras> => {
|
||||
const extras = new Map<string, CrossAccountRequirementExtras>();
|
||||
|
||||
for (const requirement of attrs.requirements) {
|
||||
const entry: CrossAccountRequirementExtras = {
|
||||
requirementId: requirement.id,
|
||||
accounts: requirement.accounts,
|
||||
checkIds: requirement.check_ids ?? [],
|
||||
scanIdsByAccount: attrs.scan_ids_by_account,
|
||||
};
|
||||
for (const name of candidateRequirementNames(
|
||||
requirement.id,
|
||||
requirement.name,
|
||||
)) {
|
||||
// First registration wins on the (unlikely) cross-requirement
|
||||
// collision, so a bare-name key never hijacks another entry's id key.
|
||||
if (!extras.has(name)) {
|
||||
extras.set(name, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-account score summary for the coverage panel. Keeps the server's
|
||||
* account order (sorted by alias). Score is the pass percentage over
|
||||
* non-manual requirements the account contributed.
|
||||
*/
|
||||
export const computeAccountBreakdown = (
|
||||
attrs: CrossAccountOverviewAttributes,
|
||||
): AccountBreakdownEntry[] =>
|
||||
attrs.accounts.map((account) => {
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
let manual = 0;
|
||||
|
||||
for (const requirement of attrs.requirements) {
|
||||
const status = requirement.accounts[account.id];
|
||||
if (status === "PASS") pass += 1;
|
||||
else if (status === "FAIL") fail += 1;
|
||||
else if (status === "MANUAL") manual += 1;
|
||||
}
|
||||
|
||||
const scored = pass + fail;
|
||||
return {
|
||||
id: account.id,
|
||||
label: accountDisplayLabel(account),
|
||||
pass,
|
||||
fail,
|
||||
manual,
|
||||
total: pass + fail + manual,
|
||||
score: scored > 0 ? Math.round((pass / scored) * 100) : 0,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
CrossAccountApiFilters,
|
||||
CrossAccountFrameworkEntry,
|
||||
} from "../_types";
|
||||
|
||||
/** Cross-account filter params forwarded from the overview into detail
|
||||
* links (and consumed back by the detail page). The provider type is fixed
|
||||
* per view, so unlike cross-provider there is no provider_type__in here. */
|
||||
const CROSS_ACCOUNT_FILTER_PARAMS = [
|
||||
"filter[provider_id__in]",
|
||||
"filter[provider_groups__in]",
|
||||
] as const;
|
||||
|
||||
/** Parses the URL filter params the cross-account endpoint accepts. Kept
|
||||
* next to CROSS_ACCOUNT_FILTER_PARAMS so the overview section and the
|
||||
* detail island build identical, typed filter objects. */
|
||||
export const parseCrossAccountFilters = (
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
): CrossAccountApiFilters => ({
|
||||
providerIds: searchParams["filter[provider_id__in]"]?.toString() || undefined,
|
||||
providerGroups:
|
||||
searchParams["filter[provider_groups__in]"]?.toString() || undefined,
|
||||
});
|
||||
|
||||
export const buildCrossAccountDetailHref = (
|
||||
entry: Pick<
|
||||
CrossAccountFrameworkEntry,
|
||||
"complianceId" | "title" | "version" | "providerType"
|
||||
>,
|
||||
searchParams?: Record<string, string | string[] | undefined>,
|
||||
): string => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("mode", "cross-account");
|
||||
params.set("complianceId", entry.complianceId);
|
||||
params.set("version", entry.version);
|
||||
params.set("providerType", entry.providerType);
|
||||
|
||||
for (const key of CROSS_ACCOUNT_FILTER_PARAMS) {
|
||||
const value = searchParams?.[key]?.toString();
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
|
||||
return `/compliance/${encodeURIComponent(entry.title)}?${params.toString()}`;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { toast, ToastAction } from "@/components/shadcn/toast";
|
||||
import { downloadFile } from "@/lib/helper";
|
||||
import type { TaskKindHandler } from "@/store/task-watcher/store";
|
||||
|
||||
import { getCrossAccountPdfBinary } from "../_actions/cross-account";
|
||||
import type { CrossAccountApiFilters } from "../_types";
|
||||
|
||||
export const CROSS_ACCOUNT_PDF_TASK_KIND = "cross-account-pdf";
|
||||
|
||||
const normalizeCommaSeparatedFilter = (value?: string): string =>
|
||||
value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",") ?? "";
|
||||
|
||||
/** Stable identity for the exact cross-account view a PDF represents. */
|
||||
export const buildCrossAccountPdfTaskScope = (
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
filters: CrossAccountApiFilters,
|
||||
): string =>
|
||||
JSON.stringify({
|
||||
complianceId,
|
||||
providerType,
|
||||
scanIds: [...(filters.scanIds ?? [])].sort(),
|
||||
providerIds: normalizeCommaSeparatedFilter(filters.providerIds),
|
||||
providerGroups: normalizeCommaSeparatedFilter(filters.providerGroups),
|
||||
});
|
||||
|
||||
/** Fetches the finished cross-account PDF and hands it to the browser —
|
||||
* same never-rejects contract as `downloadCrossProviderPdf`. */
|
||||
export const downloadCrossAccountPdf = async (
|
||||
taskId: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const result = await getCrossAccountPdfBinary(taskId);
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
"The cross-account compliance PDF has been downloaded successfully.",
|
||||
toast,
|
||||
);
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download failed",
|
||||
description: "Could not fetch the report. Please try again later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Completion handler for cross-account PDF generation tasks, fired by the
|
||||
* generic task watcher — survives navigation and hard reloads like its
|
||||
* cross-provider sibling. */
|
||||
export const crossAccountPdfHandler: TaskKindHandler = {
|
||||
onReady: (task) => {
|
||||
toast({
|
||||
title: "Compliance report ready",
|
||||
description: task.meta.reportLabel
|
||||
? `The ${task.meta.reportLabel} cross-account PDF has been generated.`
|
||||
: "The cross-account compliance PDF has been generated.",
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Download report"
|
||||
onClick={() => downloadCrossAccountPdf(task.taskId)}
|
||||
>
|
||||
Download
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: (task) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Report generation failed",
|
||||
description:
|
||||
task.error ||
|
||||
"The cross-account PDF could not be generated. Try again later.",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/shadcn/table/status-finding-badge";
|
||||
import type { Framework } from "@/types/compliance";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import type { Control, Framework, Requirement } from "@/types/compliance";
|
||||
|
||||
import { CrossProviderRequirementContent } from "../_components/cross-provider-requirement-content";
|
||||
import { RequirementProviderChips } from "../_components/requirement-provider-chips";
|
||||
@@ -19,13 +21,119 @@ import type { CrossProviderRequirementExtras } from "../_types";
|
||||
* breakdown exists, or a single roll-up badge as a fallback. `extras` is the
|
||||
* map produced by `buildRequirementExtrasMap`, keyed by the mapper-composed
|
||||
* requirement name.
|
||||
*
|
||||
* Row titles and hierarchy mirror what each mapper's OWN per-scan
|
||||
* `toAccordionItems` renders (see the cross-account sibling for the full
|
||||
* rationale): flat mappers keep requirement rows, single-requirement
|
||||
* controls with a distinct label (CIS style) use that richer label as the
|
||||
* row title, labeled multi-requirement controls (ENS style) keep the
|
||||
* control as a nested accordion level, and multi-framework data keeps the
|
||||
* framework as the top level.
|
||||
*/
|
||||
export const toCrossProviderAccordionItems = (
|
||||
data: Framework[],
|
||||
extras: Map<string, CrossProviderRequirementExtras>,
|
||||
framework: string,
|
||||
): AccordionItemProps[] =>
|
||||
data.flatMap((frameworkData) =>
|
||||
): AccordionItemProps[] => {
|
||||
const requirementItem = (
|
||||
requirement: Requirement,
|
||||
itemKey: string,
|
||||
rowTitle: string,
|
||||
): AccordionItemProps => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
const requirementType =
|
||||
typeof requirement.type === "string" ? requirement.type : "";
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{/* Same left-side composition as the per-scan
|
||||
ComplianceAccordionRequirementTitle (type chip + name +
|
||||
invalid-config note); only the right side differs (per-provider
|
||||
chips instead of one status badge). */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{requirementType && (
|
||||
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
{requirementType}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{rowTitle}</span>
|
||||
{requirement.invalid_config && (
|
||||
<InfoTooltip content={INVALID_CONFIG_NOTE} />
|
||||
)}
|
||||
</div>
|
||||
{requirementExtras ? (
|
||||
<RequirementProviderChips providers={requirementExtras.providers} />
|
||||
) : (
|
||||
<StatusFindingBadge status={requirement.status as FindingStatus} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
// Explicit key on the content element, matching the per-scan mappers
|
||||
// (csa.tsx et al.): these elements travel to the client inside the
|
||||
// serialized `items` array, where React's Flight layer warns about
|
||||
// un-keyed elements ("Each child in a list…").
|
||||
content: requirementExtras ? (
|
||||
<CrossProviderRequirementContent
|
||||
key={`content-${itemKey}`}
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
framework={framework}
|
||||
/>
|
||||
) : (
|
||||
<p key={`content-${itemKey}`} className="text-sm">
|
||||
No per-provider breakdown is available for this requirement.
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const controlItems = (
|
||||
control: Control,
|
||||
categoryName: string,
|
||||
baseKey: string,
|
||||
): AccordionItemProps[] => {
|
||||
const groupLabel =
|
||||
control.label && control.label !== categoryName
|
||||
? control.label
|
||||
: undefined;
|
||||
|
||||
if (groupLabel && control.requirements.length > 1) {
|
||||
return [
|
||||
{
|
||||
key: baseKey,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={groupLabel}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return control.requirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${baseKey}-req-${reqIndex}`,
|
||||
(groupLabel ?? requirement.name) as string,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const categoryItems = (frameworkData: Framework): AccordionItemProps[] =>
|
||||
frameworkData.categories.map((category) => ({
|
||||
key: `${frameworkData.name}-${category.name}`,
|
||||
title: (
|
||||
@@ -34,49 +142,60 @@ export const toCrossProviderAccordionItems = (
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={data.length === 1}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
// The control index participates in the key: a category can hold
|
||||
// several controls whose requirement lists all start at index 0, so
|
||||
// keying on the requirement index alone collides across controls
|
||||
// (React "two children with the same key").
|
||||
items: category.controls.flatMap((control, controlIndex) =>
|
||||
controlItems(
|
||||
control,
|
||||
category.name,
|
||||
`${frameworkData.name}-${category.name}-c${controlIndex}`,
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const frameworkItems = (frameworkData: Framework): AccordionItemProps[] => {
|
||||
// Flat generic-mapper structure (e.g. GDPR): requirements hang directly
|
||||
// off the framework with no categories — that mapper's per-scan
|
||||
// toAccordionItems renders them as top-level rows, so mirror it.
|
||||
const directRequirements =
|
||||
(frameworkData as { requirements?: Requirement[] }).requirements ?? [];
|
||||
if (directRequirements.length > 0) {
|
||||
return directRequirements.map((requirement, reqIndex) =>
|
||||
requirementItem(
|
||||
requirement,
|
||||
`${frameworkData.name}-req-${reqIndex}`,
|
||||
requirement.name as string,
|
||||
),
|
||||
);
|
||||
}
|
||||
return categoryItems(frameworkData);
|
||||
};
|
||||
|
||||
// Multi-framework data (ENS marcos: Operacional, Organizativo…) keeps the
|
||||
// framework as the top accordion level, exactly like that mapper's own
|
||||
// per-scan toAccordionItems; single-framework data starts at categories.
|
||||
if (data.length > 1) {
|
||||
return data.map((frameworkData) => ({
|
||||
key: frameworkData.name,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={frameworkData.name}
|
||||
pass={frameworkData.pass}
|
||||
fail={frameworkData.fail}
|
||||
manual={frameworkData.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.flatMap((control) =>
|
||||
control.requirements.map((requirement, reqIndex) => {
|
||||
const requirementExtras = extras.get(requirement.name as string);
|
||||
items: frameworkItems(frameworkData),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${frameworkData.name}-${category.name}-req-${reqIndex}`,
|
||||
title: (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate">
|
||||
{requirement.name as string}
|
||||
</span>
|
||||
{/* Status shown once: the per-provider chips carry each
|
||||
provider's status; only fall back to a roll-up badge when
|
||||
no per-provider breakdown exists. */}
|
||||
{requirementExtras ? (
|
||||
<RequirementProviderChips
|
||||
providers={requirementExtras.providers}
|
||||
/>
|
||||
) : (
|
||||
<StatusFindingBadge
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
content: requirementExtras ? (
|
||||
<CrossProviderRequirementContent
|
||||
requirement={requirement}
|
||||
extras={requirementExtras}
|
||||
framework={framework}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
No per-provider breakdown is available for this requirement.
|
||||
</p>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
}),
|
||||
),
|
||||
})),
|
||||
);
|
||||
return data.flatMap(frameworkItems);
|
||||
};
|
||||
|
||||
@@ -161,3 +161,114 @@ export interface LatestCrossProviderPdf {
|
||||
filename?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// Types for the Cloud-only cross-account compliance roll-up, backed by
|
||||
// GET /cross-account-compliance-overviews (one regular per-provider framework
|
||||
// aggregated across the latest scan of every account of a single provider
|
||||
// type; same server-side roll-up rules as the cross-provider endpoint, with
|
||||
// the column axis being the account instead of the provider type).
|
||||
|
||||
export const CROSS_ACCOUNT_OVERVIEW_TYPE =
|
||||
"cross-account-compliance-overviews" as const;
|
||||
|
||||
/** Contributing account (Provider) metadata, sorted by alias server-side. */
|
||||
export interface CrossAccountAccountRef {
|
||||
id: string;
|
||||
uid: string;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
/** Requirement status per account, keyed by Provider UUID. */
|
||||
export type AccountStatusMap = Record<string, CrossProviderStatus>;
|
||||
|
||||
export interface CrossAccountRequirementData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Framework-specific metadata list from the per-provider template
|
||||
* (already unwrapped — same shape the per-scan mappers consume). */
|
||||
attributes: unknown[];
|
||||
status: CrossProviderStatus;
|
||||
accounts: AccountStatusMap;
|
||||
/** Single flat list — every account shares the provider type. */
|
||||
check_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CrossAccountOverviewAttributes {
|
||||
compliance_id: string;
|
||||
provider_type: string;
|
||||
framework: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
accounts: CrossAccountAccountRef[];
|
||||
scan_ids: string[];
|
||||
/** Provider UUID → scan UUIDs aggregated for that account. */
|
||||
scan_ids_by_account: Record<string, string[]>;
|
||||
requirements_passed: number;
|
||||
requirements_failed: number;
|
||||
requirements_manual: number;
|
||||
total_requirements: number;
|
||||
requirements: CrossAccountRequirementData[];
|
||||
}
|
||||
|
||||
export interface CrossAccountOverviewData {
|
||||
type: typeof CROSS_ACCOUNT_OVERVIEW_TYPE;
|
||||
id: string;
|
||||
attributes: CrossAccountOverviewAttributes;
|
||||
}
|
||||
|
||||
export interface CrossAccountOverviewResponse {
|
||||
data: CrossAccountOverviewData;
|
||||
}
|
||||
|
||||
/** Result variants reuse the cross-provider status constants so the shared
|
||||
* error components (CrossProviderErrorAlert) work unchanged. */
|
||||
export type CrossAccountOverviewResult =
|
||||
| {
|
||||
status: typeof CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS;
|
||||
response: CrossAccountOverviewResponse;
|
||||
}
|
||||
| CrossProviderOverviewActionErrorResult
|
||||
| CrossProviderOverviewLoadErrorResult;
|
||||
|
||||
/** Filters accepted by the cross-account endpoint (comma-joined). */
|
||||
export interface CrossAccountApiFilters {
|
||||
scanIds?: string[];
|
||||
providerIds?: string;
|
||||
providerGroups?: string;
|
||||
}
|
||||
|
||||
/** Cross-account context joined onto a mapped requirement, keyed by the
|
||||
* composed requirement name the per-scan mappers produce. */
|
||||
export interface CrossAccountRequirementExtras {
|
||||
requirementId: string;
|
||||
accounts: AccountStatusMap;
|
||||
checkIds: string[];
|
||||
scanIdsByAccount: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Card data for the per-provider frameworks section of the Cross-Provider
|
||||
* tab: one regular framework of one provider type, aggregatable across
|
||||
* that type's accounts. */
|
||||
export interface CrossAccountFrameworkEntry {
|
||||
/** Regular framework id used as filter[compliance_id] (e.g. cis_2.0_aws). */
|
||||
complianceId: string;
|
||||
/** Framework display name; also the [compliancetitle] path segment and the
|
||||
* key getComplianceIcon resolves the framework icon from. */
|
||||
title: string;
|
||||
version: string;
|
||||
providerType: KnownProviderType;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
export interface AccountBreakdownEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
pass: number;
|
||||
fail: number;
|
||||
manual: number;
|
||||
total: number;
|
||||
/** 0-100 pass percentage over non-manual requirements. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
|
||||
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
|
||||
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
|
||||
import { CrossProviderOverview } from "./_components/cross-provider-overview";
|
||||
import { COMPLIANCE_TAB } from "./_types";
|
||||
|
||||
@@ -62,16 +63,32 @@ export default async function Compliance({
|
||||
crossProviderEnabled={crossProviderEnabled}
|
||||
perScanContent={null}
|
||||
crossProviderContent={
|
||||
<Suspense
|
||||
key={`cross-provider-${searchParamsKey}`}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<CrossProviderOverview searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
// gap-6 = the app-wide 24px below a filter row (Findings and the
|
||||
// Single Scan tab both use mb-6), so filters→"Across provider
|
||||
// types" and cards→"Across providers" read as one rhythm.
|
||||
<div className="flex flex-col gap-6">
|
||||
<Suspense
|
||||
key={`cross-provider-${searchParamsKey}`}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<CrossProviderOverview searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
{/* Regular per-provider frameworks viewable across accounts.
|
||||
Renders nothing for single-account tenants; no fallback so
|
||||
the universal grid above never waits on it. */}
|
||||
<Suspense
|
||||
key={`cross-account-${searchParamsKey}`}
|
||||
fallback={null}
|
||||
>
|
||||
<CrossAccountOverviewSection
|
||||
searchParams={resolvedSearchParams}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Compliance tab naming: "Per Scan" is now "Single Scan" and "Cross-Provider" is now "Multiple Scans", with matching "Across provider types" and "Across providers" section headers explaining each aggregation axis
|
||||
@@ -0,0 +1 @@
|
||||
Cross-account compliance view in the Multiple Scans tab: an "Across providers" section listing single-provider frameworks aggregatable across every account of the same provider type, with a per-account detail, findings drill-down and combined PDF report (Prowler Cloud only)
|
||||
@@ -192,12 +192,37 @@ export function getStandaloneFindingColumns({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const rawAlias = getProviderData(row, "alias");
|
||||
const rawUid = getProviderData(row, "uid");
|
||||
// getProviderData's union includes the provider's connection object;
|
||||
// only string attribute values are renderable here.
|
||||
const alias = typeof rawAlias === "string" ? rawAlias : "-";
|
||||
const uid = typeof rawUid === "string" ? rawUid : "-";
|
||||
// The icon alone cannot tell accounts of the same type apart — the
|
||||
// cross-provider/cross-account drill-downs merge findings from
|
||||
// several accounts into this one table, so each row carries its
|
||||
// account label (alias when set, uid otherwise).
|
||||
const label = alias !== "-" ? alias : uid;
|
||||
|
||||
return (
|
||||
<ProviderIconCell
|
||||
provider={provider as ProviderType}
|
||||
className="size-8"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconCell
|
||||
provider={provider as ProviderType}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
{label !== "-" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-secondary max-w-[110px] truncate text-xs">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{alias !== "-" && uid !== "-" ? `${alias} (${uid})` : label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
|
||||
@@ -138,7 +138,10 @@ export const Accordion = ({
|
||||
isCompact ? "py-2" : "py-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
{/* min-w-0 lets flex children of the title shrink/truncate;
|
||||
without it a long requirement title forces the row past
|
||||
the trigger's rounded border (flex min-width:auto). */}
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-sm">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="text-text-neutral-tertiary text-xs">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CROSS_ACCOUNT_PDF_TASK_KIND,
|
||||
crossAccountPdfHandler,
|
||||
} from "@/app/(prowler)/compliance/_lib/cross-account-pdf";
|
||||
import {
|
||||
CROSS_PROVIDER_PDF_TASK_KIND,
|
||||
crossProviderPdfHandler,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
// this tab. Adding a new watched task kind (integration tests, scan exports,
|
||||
// …) is one line here plus a handler next to the feature that owns it.
|
||||
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
|
||||
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
|
||||
|
||||
/**
|
||||
* Mounted once in the app layout (next to `Toaster`): resumes polling any
|
||||
|
||||
Reference in New Issue
Block a user