feat(ui): add organization server actions and scan launching (#10155)

This commit is contained in:
Alejandro Bailo
2026-02-25 12:56:26 +01:00
committed by GitHub
parent fe8d5893af
commit 231bfd6f41
7 changed files with 862 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
APPLY_STATUS,
ApplyStatus,
DiscoveryResult,
} from "@/types/organizations";
import {
buildAccountLookup,
buildOrgTreeData,
getOuIdsForSelectedAccounts,
getSelectableAccountIds,
} from "./organizations.adapter";
const discoveryFixture: DiscoveryResult = {
roots: [
{
id: "r-root",
arn: "arn:aws:organizations::123:root/o-example/r-root",
name: "Root",
policy_types: [],
},
],
organizational_units: [
{
id: "ou-parent",
name: "Parent OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-parent",
parent_id: "r-root",
},
{
id: "ou-child",
name: "Child OU",
arn: "arn:aws:organizations::123:ou/o-example/ou-child",
parent_id: "ou-parent",
},
],
accounts: [
{
id: "111111111111",
arn: "arn:aws:organizations::123:account/o-example/111111111111",
name: "App Account",
email: "app@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-child",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: APPLY_STATUS.READY,
blocked_reasons: [],
},
},
{
id: "222222222222",
arn: "arn:aws:organizations::123:account/o-example/222222222222",
name: "Security Account",
email: "security@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "ou-parent",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "manual_required",
apply_status: APPLY_STATUS.BLOCKED,
blocked_reasons: ["role_missing"],
},
},
{
id: "333333333333",
arn: "arn:aws:organizations::123:account/o-example/333333333333",
name: "Legacy Account",
email: "legacy@example.com",
status: "ACTIVE",
joined_method: "INVITED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
},
],
};
describe("buildOrgTreeData", () => {
it("builds nested tree structure and marks blocked accounts as disabled", () => {
// Given / When
const treeData = buildOrgTreeData(discoveryFixture);
// Then
expect(treeData).toHaveLength(2);
expect(treeData.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-parent", "333333333333"]),
);
const parentOuNode = treeData.find((node) => node.id === "ou-parent");
expect(parentOuNode).toBeDefined();
expect(parentOuNode?.children?.map((node) => node.id)).toEqual(
expect.arrayContaining(["ou-child", "222222222222"]),
);
const blockedAccount = parentOuNode?.children?.find(
(node) => node.id === "222222222222",
);
expect(blockedAccount?.disabled).toBe(true);
});
});
describe("getSelectableAccountIds", () => {
it("returns all accounts except explicitly blocked ones", () => {
const selectableIds = getSelectableAccountIds(discoveryFixture);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
it("excludes accounts with explicit non-ready status values", () => {
const discoveryWithUnexpectedStatus = {
...discoveryFixture,
accounts: [
...discoveryFixture.accounts,
{
id: "444444444444",
arn: "arn:aws:organizations::123:account/o-example/444444444444",
name: "Pending Account",
email: "pending@example.com",
status: "ACTIVE",
joined_method: "CREATED",
joined_timestamp: "2024-01-01T00:00:00Z",
parent_id: "r-root",
registration: {
provider_exists: false,
provider_id: null,
organization_relation: "link_required",
organizational_unit_relation: "link_required",
provider_secret_state: "will_create",
apply_status: "pending" as unknown as ApplyStatus,
blocked_reasons: [],
},
},
],
} satisfies DiscoveryResult;
const selectableIds = getSelectableAccountIds(
discoveryWithUnexpectedStatus,
);
expect(selectableIds).toEqual(["111111111111", "333333333333"]);
});
});
describe("buildAccountLookup", () => {
it("creates a lookup map for all discovered accounts", () => {
const lookup = buildAccountLookup(discoveryFixture);
expect(lookup.get("111111111111")?.name).toBe("App Account");
expect(lookup.get("333333333333")?.name).toBe("Legacy Account");
expect(lookup.size).toBe(3);
});
});
describe("getOuIdsForSelectedAccounts", () => {
it("collects all ancestor OUs for selected accounts without duplicates", () => {
const ouIds = getOuIdsForSelectedAccounts(discoveryFixture, [
"111111111111",
"222222222222",
]);
expect(ouIds).toEqual(expect.arrayContaining(["ou-parent", "ou-child"]));
expect(ouIds.length).toBe(2);
});
});

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
revalidatePathMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import {
applyDiscovery,
getDiscovery,
triggerDiscovery,
updateOrganizationSecret,
} from "./organizations";
describe("organizations actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiErrorMock.mockReturnValue({ error: "Unexpected error" });
});
it("rejects invalid organization secret identifiers", async () => {
// Given
const formData = new FormData();
formData.set("organizationSecretId", "../secret-id");
formData.set("roleArn", "arn:aws:iam::123456789012:role/ProwlerOrgRole");
formData.set("externalId", "o-abc123def4");
// When
const result = await updateOrganizationSecret(formData);
// Then
expect(result).toEqual({ error: "Invalid organization secret ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects invalid discovery identifiers before building the request URL", async () => {
// When
const result = await getDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"discovery/../id",
);
// Then
expect(result).toEqual({ error: "Invalid discovery ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects invalid organization identifiers before triggering discovery", async () => {
// When
const result = await triggerDiscovery("org/id-with-slash");
// Then
expect(result).toEqual({ error: "Invalid organization ID" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("revalidates providers only when apply discovery succeeds", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "apply-1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValueOnce({ error: "Apply failed" });
handleApiResponseMock.mockResolvedValueOnce({ data: { id: "apply-1" } });
// When
const failedResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
);
const successfulResult = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
);
// Then
expect(failedResult).toEqual({ error: "Apply failed" });
expect(successfulResult).toEqual({ data: { id: "apply-1" } });
expect(revalidatePathMock).toHaveBeenCalledTimes(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("revalidates providers when response contains error set to null", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "apply-2" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
handleApiResponseMock.mockResolvedValueOnce({
data: { id: "apply-2" },
error: null,
});
// When
const result = await applyDiscovery(
"123e4567-e89b-12d3-a456-426614174000",
"223e4567-e89b-12d3-a456-426614174111",
[],
[],
);
// Then
expect(result).toEqual({ data: { id: "apply-2" }, error: null });
expect(revalidatePathMock).toHaveBeenCalledTimes(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
});

View File

@@ -0,0 +1,326 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/;
type PathIdentifierValidationResult = { value: string } | { error: string };
function validatePathIdentifier(
value: string | null | undefined,
requiredError: string,
invalidError: string,
): PathIdentifierValidationResult {
const normalizedValue = value?.trim();
if (!normalizedValue) {
return { error: requiredError };
}
if (!PATH_IDENTIFIER_PATTERN.test(normalizedValue)) {
return { error: invalidError };
}
return { value: normalizedValue };
}
function hasActionError(result: unknown): result is { error: unknown } {
return Boolean(
result &&
typeof result === "object" &&
"error" in (result as Record<string, unknown>) &&
(result as Record<string, unknown>).error !== null &&
(result as Record<string, unknown>).error !== undefined,
);
}
/**
* Creates an AWS Organization resource.
* POST /api/v1/organizations
*/
export const createOrganization = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/organizations`);
const name = formData.get("name") as string;
const externalId = formData.get("externalId") as string;
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organizations",
attributes: {
name,
org_type: "aws",
external_id: externalId,
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists AWS Organizations filtered by external ID.
* GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]=aws
*/
export const listOrganizationsByExternalId = async (externalId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organizations`);
url.searchParams.set("filter[external_id]", externalId);
url.searchParams.set("filter[org_type]", "aws");
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Creates an organization secret (role-based credentials).
* POST /api/v1/organization-secrets
*/
export const createOrganizationSecret = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/organization-secrets`);
const organizationId = formData.get("organizationId") as string;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organization-secrets",
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
},
relationships: {
organization: {
data: {
type: "organizations",
id: organizationId,
},
},
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Updates an organization secret (role-based credentials).
* PATCH /api/v1/organization-secrets/{id}
*/
export const updateOrganizationSecret = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationSecretId = formData.get("organizationSecretId") as
| string
| null;
const roleArn = formData.get("roleArn") as string;
const externalId = formData.get("externalId") as string;
const organizationSecretIdValidation = validatePathIdentifier(
organizationSecretId,
"Organization secret ID is required",
"Invalid organization secret ID",
);
if ("error" in organizationSecretIdValidation) {
return organizationSecretIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organization-secrets/${encodeURIComponent(organizationSecretIdValidation.value)}`,
);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "organization-secrets",
id: organizationSecretIdValidation.value,
attributes: {
secret_type: "role",
secret: {
role_arn: roleArn,
external_id: externalId,
},
},
},
}),
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists organization secrets for an organization.
* GET /api/v1/organization-secrets?filter[organization_id]={organizationId}
*/
export const listOrganizationSecretsByOrganizationId = async (
organizationId: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/organization-secrets`);
url.searchParams.set("filter[organization_id]", organizationId);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Triggers an async discovery of the AWS Organization.
* POST /api/v1/organizations/{id}/discover
*/
export const triggerDiscovery = async (organizationId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discover`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Polls the discovery status.
* GET /api/v1/organizations/{orgId}/discoveries/{discoveryId}
*/
export const getDiscovery = async (
organizationId: string,
discoveryId: string,
) => {
const headers = await getAuthHeaders({ contentType: false });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const discoveryIdValidation = validatePathIdentifier(
discoveryId,
"Discovery ID is required",
"Invalid discovery ID",
);
if ("error" in discoveryIdValidation) {
return discoveryIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discoveries/${encodeURIComponent(discoveryIdValidation.value)}`,
);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Applies discovery results — creates providers, links to org/OUs, auto-generates secrets.
* POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply
*/
export const applyDiscovery = async (
organizationId: string,
discoveryId: string,
accounts: Array<{ id: string; alias?: string }>,
organizationalUnits: Array<{ id: string }>,
) => {
const headers = await getAuthHeaders({ contentType: true });
const organizationIdValidation = validatePathIdentifier(
organizationId,
"Organization ID is required",
"Invalid organization ID",
);
if ("error" in organizationIdValidation) {
return organizationIdValidation;
}
const discoveryIdValidation = validatePathIdentifier(
discoveryId,
"Discovery ID is required",
"Invalid discovery ID",
);
if ("error" in discoveryIdValidation) {
return discoveryIdValidation;
}
const url = new URL(
`${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}/discoveries/${encodeURIComponent(discoveryIdValidation.value)}/apply`,
);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "organization-discoveries",
attributes: {
accounts,
organizational_units: organizationalUnits,
},
},
}),
});
const result = await handleApiResponse(response);
if (!hasActionError(result)) {
revalidatePath("/providers");
}
return result;
} catch (error) {
return handleApiError(error);
}
};

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: vi.fn(),
}));
import { launchOrganizationScans } from "./scans";
describe("launchOrganizationScans", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: { id: "scan-id" } });
handleApiErrorMock.mockReturnValue({ error: "Scan launch failed." });
});
it("limits concurrent launch requests to avoid overwhelming the backend", async () => {
// Given
const providerIds = Array.from(
{ length: 12 },
(_, index) => `provider-${index + 1}`,
);
let activeRequests = 0;
let maxActiveRequests = 0;
fetchMock.mockImplementation(async () => {
activeRequests += 1;
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
await new Promise((resolve) => setTimeout(resolve, 5));
activeRequests -= 1;
return new Response(JSON.stringify({ data: { id: "scan-id" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
// When
const result = await launchOrganizationScans(providerIds, "daily");
// Then
expect(maxActiveRequests).toBeLessThanOrEqual(5);
expect(result.successCount).toBe(providerIds.length);
expect(result.failureCount).toBe(0);
});
});

View File

@@ -7,12 +7,15 @@ import {
COMPLIANCE_REPORT_DISPLAY_NAMES,
type ComplianceReportType,
} from "@/lib/compliance/compliance-report-types";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import {
appendSanitizedProviderTypeFilters,
sanitizeProviderTypesCsv,
} from "@/lib/provider-filters";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
export const getScans = async ({
page = 1,
query = "",
@@ -165,6 +168,73 @@ export const scheduleDaily = async (formData: FormData) => {
}
};
export const launchOrganizationScans = async (
providerIds: string[],
scheduleOption: "daily" | "single",
) => {
const validProviderIds = providerIds.filter(Boolean);
if (validProviderIds.length === 0) {
return {
successCount: 0,
failureCount: 0,
totalCount: 0,
};
}
const launchResults = await runWithConcurrencyLimit(
validProviderIds,
ORGANIZATION_SCAN_CONCURRENCY_LIMIT,
async (providerId) => {
try {
const formData = new FormData();
formData.set("providerId", providerId);
const result =
scheduleOption === "daily"
? await scheduleDaily(formData)
: await scanOnDemand(formData);
return {
providerId,
ok: !result?.error,
error: result?.error ? String(result.error) : null,
};
} catch (error) {
return {
providerId,
ok: false,
error:
error instanceof Error ? error.message : "Failed to launch scan.",
};
}
},
);
const summary = launchResults.reduce(
(acc, item) => {
if (item.ok) {
acc.successCount += 1;
return acc;
}
acc.failureCount += 1;
acc.errors.push({
providerId: item.providerId,
error: item.error || "Failed to launch scan.",
});
return acc;
},
{
successCount: 0,
failureCount: 0,
totalCount: validProviderIds.length,
errors: [] as Array<{ providerId: string; error: string }>,
},
);
return summary;
};
export const updateScan = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { runWithConcurrencyLimit } from "./concurrency";
describe("runWithConcurrencyLimit", () => {
it("should process items without exceeding the configured concurrency", async () => {
// Given
const items = Array.from({ length: 12 }, (_, index) => index + 1);
let activeTasks = 0;
let maxActiveTasks = 0;
// When
const results = await runWithConcurrencyLimit(items, 4, async (item) => {
activeTasks += 1;
maxActiveTasks = Math.max(maxActiveTasks, activeTasks);
await new Promise((resolve) => setTimeout(resolve, 5));
activeTasks -= 1;
return item * 2;
});
// Then
expect(maxActiveTasks).toBeLessThanOrEqual(4);
expect(results).toEqual(items.map((item) => item * 2));
});
it("should reject when worker throws an uncaught error", async () => {
// Given
const items = [1, 2, 3];
// When / Then
await expect(
runWithConcurrencyLimit(items, 2, async (item) => {
if (item === 2) {
throw new Error("boom");
}
return item;
}),
).rejects.toThrow("boom");
});
});

38
ui/lib/concurrency.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Runs async work over items with a fixed concurrency limit.
*
* Note: if `worker` throws, this function rejects. Callers should handle
* expected per-item errors inside the worker and return a typed result.
*/
export async function runWithConcurrencyLimit<T, R>(
items: T[],
concurrencyLimit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) {
return [];
}
const normalizedConcurrency = Math.max(1, Math.floor(concurrencyLimit));
const results = new Array<R>(items.length);
let currentIndex = 0;
const runWorker = async () => {
while (currentIndex < items.length) {
const assignedIndex = currentIndex;
currentIndex += 1;
results[assignedIndex] = await worker(
items[assignedIndex],
assignedIndex,
);
}
};
const workers = Array.from(
{ length: Math.min(normalizedConcurrency, items.length) },
() => runWorker(),
);
await Promise.all(workers);
return results;
}