feat(ui): add cross-account compliance view

This commit is contained in:
pedrooot
2026-07-22 12:29:38 +02:00
parent 13a9caa803
commit 8034a96f24
38 changed files with 3349 additions and 120 deletions
@@ -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);
};
+111
View File
@@ -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;
}
+27 -10
View File
@@ -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,
+4 -1
View File
@@ -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