diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx
index 6d5ebc945d..28518fd576 100644
--- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx
+++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx
@@ -104,6 +104,7 @@ const mockProviders: ProviderProps[] = [
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: "123456789012",
alias: "Production AWS",
status: "completed",
@@ -131,6 +132,7 @@ const mockProviders: ProviderProps[] = [
type: "providers",
attributes: {
provider: "gcp",
+ is_dynamic: false,
uid: "prowler-prod-project",
alias: "Production GCP",
status: "completed",
diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts
index 4c712ff081..fff1cc863c 100644
--- a/ui/app/(prowler)/providers/providers-page.utils.test.ts
+++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts
@@ -62,6 +62,7 @@ const providersResponse: ProvidersApiResponse = {
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: "111111111111",
alias: "AWS App Account",
status: "completed",
@@ -107,6 +108,7 @@ const providersResponse: ProvidersApiResponse = {
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: "222222222222",
alias: "Standalone Account",
status: "completed",
diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
index 1583ccc527..e9086cd366 100644
--- a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
+++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx
@@ -31,6 +31,8 @@ import { scanConfigurationFormSchema } from "@/types/formSchemas";
import { ProviderProps } from "@/types/providers";
import { ScanConfigurationData } from "@/types/scan-configurations";
+import { getSelectableProviders } from "./scan-configuration-providers";
+
interface ScanConfigurationEditorProps {
open: boolean;
onClose: (saved: boolean) => void;
@@ -90,21 +92,12 @@ function ScanConfigurationForm({
? validateYaml(configText)
: { isValid: true as const };
- // A provider can only be attached to one config at a time. We exclude
- // providers that are owned by *other* configs from the selector so the user
- // can't double-attach them. (AccountsSelector doesn't expose a per-option
- // disabled state, so filtering out is the cleanest contract here.)
- const ownerByProvider = new Map
();
- for (const c of existingConfigs) {
- if (config && c.id === config.id) continue;
- for (const pid of c.attributes.providers || []) {
- ownerByProvider.set(pid, c.attributes.name);
- }
- }
- const selectableProviders = richProviders.filter(
- (p) => !ownerByProvider.has(p.id),
- );
- const lockedCount = richProviders.length - selectableProviders.length;
+ // Dynamic providers can't use a Scan Configuration, and a provider can only be
+ // attached to one config at a time, so both are excluded from the selector.
+ // (AccountsSelector doesn't expose a per-option disabled state, so filtering
+ // out is the cleanest contract here.)
+ const { selectableProviders, configurableCount, lockedCount } =
+ getSelectableProviders(richProviders, existingConfigs, config?.id ?? null);
const onSubmit = form.handleSubmit(async (values) => {
// Block on a YAML syntax error (the inline message already explains it); the
@@ -252,7 +245,9 @@ function ScanConfigurationForm({
{richProviders.length === 0
? "No providers available in this tenant."
- : "All providers are already attached to other Scan Configurations."}
+ : configurableCount === 0
+ ? "Dynamic providers can't use custom Scan Configurations, so there are none to attach."
+ : "All providers are already attached to other Scan Configurations."}
) : (
+ ({
+ id,
+ type: "providers",
+ attributes: {
+ provider: isDynamic ? "template" : "aws",
+ is_dynamic: isDynamic,
+ },
+ }) as unknown as ProviderProps;
+
+const config = (id: string, providers: string[]): ScanConfigurationData => ({
+ type: "scan-configurations",
+ id,
+ attributes: {
+ inserted_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ name: id,
+ configuration: {},
+ providers,
+ },
+});
+
+describe("getSelectableProviders", () => {
+ it("excludes dynamic providers (they have no config baseline to override)", () => {
+ const result = getSelectableProviders(
+ [provider("aws-1"), provider("dyn-1", true)],
+ [],
+ null,
+ );
+
+ expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
+ expect(result.configurableCount).toBe(1);
+ expect(result.lockedCount).toBe(0);
+ });
+
+ it("excludes providers already attached to another config", () => {
+ const result = getSelectableProviders(
+ [provider("aws-1"), provider("aws-2")],
+ [config("other", ["aws-2"])],
+ null,
+ );
+
+ expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
+ expect(result.lockedCount).toBe(1);
+ });
+
+ it("keeps providers attached to the config being edited selectable", () => {
+ const result = getSelectableProviders(
+ [provider("aws-1"), provider("aws-2")],
+ [config("current", ["aws-1"])],
+ "current",
+ );
+
+ expect(result.selectableProviders.map((p) => p.id)).toEqual([
+ "aws-1",
+ "aws-2",
+ ]);
+ expect(result.lockedCount).toBe(0);
+ });
+
+ it("lockedCount counts only configurable providers attached elsewhere, not dynamic ones", () => {
+ const result = getSelectableProviders(
+ [provider("aws-1"), provider("aws-2"), provider("dyn-1", true)],
+ [config("other", ["aws-2"])],
+ null,
+ );
+
+ expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]);
+ expect(result.configurableCount).toBe(2);
+ expect(result.lockedCount).toBe(1);
+ });
+
+ it("reports zero configurable providers when every provider is dynamic", () => {
+ const result = getSelectableProviders(
+ [provider("dyn-1", true), provider("dyn-2", true)],
+ [],
+ null,
+ );
+
+ expect(result.selectableProviders).toEqual([]);
+ expect(result.configurableCount).toBe(0);
+ expect(result.lockedCount).toBe(0);
+ });
+});
diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts b/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts
new file mode 100644
index 0000000000..4e392e249e
--- /dev/null
+++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts
@@ -0,0 +1,46 @@
+import { ProviderProps } from "@/types/providers";
+import { ScanConfigurationData } from "@/types/scan-configurations";
+
+export interface SelectableProvidersResult {
+ selectableProviders: ProviderProps[];
+ /** Providers eligible for a Scan Configuration at all (i.e. non-dynamic). */
+ configurableCount: number;
+ /** Configurable providers hidden because another config already owns them. */
+ lockedCount: number;
+}
+
+/**
+ * Providers that can be attached to a Scan Configuration.
+ *
+ * Dynamic providers are SDK-defined and have no `config.yaml` baseline to
+ * override, so a Scan Configuration can't apply to them — they are never
+ * selectable. A provider can also belong to only one config at a time, so those
+ * already attached to *another* config are excluded (the one being edited is
+ * kept selectable).
+ */
+export function getSelectableProviders(
+ richProviders: ProviderProps[],
+ existingConfigs: ScanConfigurationData[],
+ currentConfigId: string | null,
+): SelectableProvidersResult {
+ const attachedElsewhere = new Set();
+ for (const c of existingConfigs) {
+ if (currentConfigId && c.id === currentConfigId) continue;
+ for (const pid of c.attributes.providers || []) {
+ attachedElsewhere.add(pid);
+ }
+ }
+
+ const configurableProviders = richProviders.filter(
+ (p) => !p.attributes.is_dynamic,
+ );
+ const selectableProviders = configurableProviders.filter(
+ (p) => !attachedElsewhere.has(p.id),
+ );
+
+ return {
+ selectableProviders,
+ configurableCount: configurableProviders.length,
+ lockedCount: configurableProviders.length - selectableProviders.length,
+ };
+}
diff --git a/ui/changelog.d/dynamic-providers.added.md b/ui/changelog.d/dynamic-providers.added.md
new file mode 100644
index 0000000000..e2d213dca7
--- /dev/null
+++ b/ui/changelog.d/dynamic-providers.added.md
@@ -0,0 +1 @@
+Dynamically registered providers are now listed, filtered, and rendered across the UI, using a generic icon and humanized label when no bespoke assets exist, a "Custom" badge, and read-only handling for non-configurable providers
diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx
index 01868d0e8c..c30397eed4 100644
--- a/ui/components/filters/provider-account-selectors.test.tsx
+++ b/ui/components/filters/provider-account-selectors.test.tsx
@@ -77,6 +77,7 @@ const makeProvider = ({
type: "providers",
attributes: {
provider,
+ is_dynamic: false,
uid,
alias,
status: "completed",
diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx
index 593122d33a..2d7082b120 100644
--- a/ui/components/findings/table/provider-icon-cell.test.tsx
+++ b/ui/components/findings/table/provider-icon-cell.test.tsx
@@ -33,13 +33,15 @@ describe("ProviderIconCell", () => {
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
- it("renders a '?' placeholder for a provider type missing from the map", () => {
- render(
+ it("renders the neutral generic glyph for a provider missing from the map", () => {
+ // Dynamic/unknown providers render the shared generic glyph, not a "?".
+ const { container } = render(
,
);
- expect(screen.getByText("?")).toBeInTheDocument();
+ expect(screen.queryByText("?")).not.toBeInTheDocument();
+ expect(container.querySelector("svg")).toBeInTheDocument();
});
});
diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx
index 350e730e78..5a996415e6 100644
--- a/ui/components/findings/table/provider-icon-cell.tsx
+++ b/ui/components/findings/table/provider-icon-cell.tsx
@@ -1,7 +1,4 @@
-import {
- PROVIDER_TYPE_DATA,
- ProviderTypeIcon,
-} from "@/components/icons/providers-badge/provider-type-icon";
+import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { cn } from "@/lib/utils";
import { ProviderType } from "@/types";
@@ -16,16 +13,6 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
- // Unknown provider types (present in the data but missing from the shared
- // PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
- if (!(provider in PROVIDER_TYPE_DATA)) {
- return (
-
- ?
-
- );
- }
-
return (
= ({
+ size,
+ width,
+ height,
+ ...props
+}) => (
+
+);
diff --git a/ui/components/icons/providers-badge/provider-type-icon.test.tsx b/ui/components/icons/providers-badge/provider-type-icon.test.tsx
index f50a71ae46..c9e6ae3c2a 100644
--- a/ui/components/icons/providers-badge/provider-type-icon.test.tsx
+++ b/ui/components/icons/providers-badge/provider-type-icon.test.tsx
@@ -50,17 +50,17 @@ describe("ProviderTypeIcon", () => {
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
- it("renders a sized placeholder instead of crashing for an unknown type", () => {
- // Regression guard for #9991: an unknown provider type must not throw.
+ it("renders the neutral generic glyph for an unknown type (no crash)", () => {
+ // Regression guard for #9991 + dynamic providers: an unknown provider type
+ // renders the shared generic glyph, not a brand badge or an empty box.
const { container } = render(
,
);
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
- expect(container.querySelector("div")).toHaveStyle({
- width: "24px",
- height: "24px",
- });
+ const glyph = container.querySelector("svg");
+ expect(glyph).toBeInTheDocument();
+ expect(glyph).toHaveAttribute("width", "24");
});
});
@@ -111,9 +111,10 @@ describe("ProviderTypeIconStack", () => {
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
});
- it("renders known icons and skips unknown types without crashing", async () => {
- // Regression guard for #9991: an unknown type in the stack must not throw.
- render(
+ it("renders known icons and the generic glyph for unknown types without crashing", async () => {
+ // Regression guard for #9991: an unknown type in the stack must not throw;
+ // it now renders the neutral generic glyph alongside the known badge.
+ const { container } = render(
{
);
expect(await screen.findByText("AWS")).toBeInTheDocument();
+ // The unknown item renders the generic glyph (an svg), not an empty slot.
+ expect(container.querySelector("svg")).toBeInTheDocument();
});
});
diff --git a/ui/components/icons/providers-badge/provider-type-icon.tsx b/ui/components/icons/providers-badge/provider-type-icon.tsx
index c3915f7d40..d042f61d3d 100644
--- a/ui/components/icons/providers-badge/provider-type-icon.tsx
+++ b/ui/components/icons/providers-badge/provider-type-icon.tsx
@@ -9,7 +9,13 @@ import {
TooltipTrigger,
} from "@/components/shadcn";
import { cn } from "@/lib/utils";
-import type { ProviderType } from "@/types/providers";
+import {
+ isKnownProviderType,
+ type KnownProviderType,
+ type ProviderType,
+} from "@/types/providers";
+
+import { GenericProviderBadge } from "./generic-provider-badge";
type IconProps = { width: number; height: number };
@@ -106,7 +112,7 @@ const OktaProviderBadge = lazy(() =>
* selectors so both stay in sync on labels, icons, and sizing.
*/
export const PROVIDER_TYPE_DATA: Record<
- ProviderType,
+ KnownProviderType,
{ label: string; icon: ComponentType }
> = {
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
@@ -139,21 +145,21 @@ interface ProviderTypeIconProps {
}
/**
- * Renders a single provider-type badge with a sized placeholder fallback.
+ * Renders a single provider-type badge.
*
- * Falls back to the placeholder for provider types missing from
- * `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
- * build does not). The `type` is statically typed as `ProviderType`, so this
- * only guards the runtime case — see #9991, which fixed the same crash class.
+ * Providers outside the known set — a dynamic SDK plug-in — render the neutral
+ * generic glyph instead of a bespoke brand badge. The widened `ProviderType`
+ * forces this fallback branch to be handled at compile time.
*/
export const ProviderTypeIcon = ({
type,
size = 18,
}: ProviderTypeIconProps) => {
- const data = PROVIDER_TYPE_DATA[type];
- if (!data) return ;
+ if (!isKnownProviderType(type)) {
+ return ;
+ }
- const Icon = data.icon;
+ const Icon = PROVIDER_TYPE_DATA[type].icon;
return (
}>
diff --git a/ui/components/providers/providers-accounts-table.test.tsx b/ui/components/providers/providers-accounts-table.test.tsx
index 8c0a6a1c5d..06f242a5e3 100644
--- a/ui/components/providers/providers-accounts-table.test.tsx
+++ b/ui/components/providers/providers-accounts-table.test.tsx
@@ -64,6 +64,7 @@ const createProviderRow = (
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid,
alias,
status: "completed",
diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx
index f888f7f4d8..da2d3c10ef 100644
--- a/ui/components/providers/providers-accounts-view.test.tsx
+++ b/ui/components/providers/providers-accounts-view.test.tsx
@@ -96,6 +96,7 @@ const disconnectedProviders: ProviderProps[] = [
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: "123456789012",
alias: "Production",
status: "completed",
diff --git a/ui/components/providers/table/column-providers.test.tsx b/ui/components/providers/table/column-providers.test.tsx
index c126d386ad..89e7aea791 100644
--- a/ui/components/providers/table/column-providers.test.tsx
+++ b/ui/components/providers/table/column-providers.test.tsx
@@ -63,6 +63,7 @@ const providerRow: ProvidersProviderRow = {
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: "123456789012",
alias: "Production",
status: "completed",
diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx
index 39be1f384f..2590b5669e 100644
--- a/ui/components/providers/table/column-providers.tsx
+++ b/ui/components/providers/table/column-providers.tsx
@@ -198,6 +198,11 @@ export function getColumnProviders(
cloudProvider={provider.attributes.provider}
entityAlias={provider.attributes.alias}
entityId={provider.attributes.uid}
+ nameAction={
+ provider.attributes.is_dynamic ? (
+ Custom
+ ) : undefined
+ }
/>
);
diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx
index 93d78c2447..851b599bc7 100644
--- a/ui/components/providers/table/data-table-row-actions.test.tsx
+++ b/ui/components/providers/table/data-table-row-actions.test.tsx
@@ -141,6 +141,51 @@ const createRow = (hasSecret = false) =>
},
}) as unknown as Row;
+// A dynamic provider outside the hardcoded configurable set (read-only in the UI).
+const createDynamicRow = (hasSecret = true) =>
+ ({
+ original: {
+ id: "provider-dyn-1",
+ rowType: PROVIDERS_ROW_TYPE.PROVIDER,
+ type: "providers",
+ attributes: {
+ provider: "template",
+ is_dynamic: true,
+ uid: "template-account-0001",
+ alias: "Template Plug-in",
+ status: "completed",
+ resources: 0,
+ connection: {
+ connected: true,
+ last_checked_at: "2025-02-13T11:17:00Z",
+ },
+ scanner_args: {
+ only_logs: false,
+ excluded_checks: [],
+ aws_retries_max_attempts: 3,
+ },
+ inserted_at: "2025-02-13T11:17:00Z",
+ updated_at: "2025-02-13T11:17:00Z",
+ created_by: {
+ object: "user",
+ id: "user-1",
+ },
+ },
+ relationships: {
+ secret: {
+ data: hasSecret ? { id: "secret-1", type: "secrets" } : null,
+ },
+ provider_groups: {
+ meta: {
+ count: 0,
+ },
+ data: [],
+ },
+ },
+ groupNames: [],
+ },
+ }) as unknown as Row;
+
const createOrgRow = () =>
({
original: {
@@ -258,6 +303,37 @@ describe("DataTableRowActions", () => {
expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
});
+ it("blocks provider-account actions (alias/credentials/delete) for a dynamic provider but keeps operational actions", async () => {
+ // Given a dynamic provider outside the configurable set, with the advanced
+ // schedule capability enabled (so Edit Scan Schedule can show).
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+
+ // Then — provider-account CRUD is suppressed (can't add/edit/delete it)
+ expect(screen.queryByText("Edit Provider Alias")).not.toBeInTheDocument();
+ expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument();
+ expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument();
+ expect(screen.queryByText("Delete Provider")).not.toBeInTheDocument();
+ // ...but operational actions the provider exists for stay available
+ expect(screen.getByText("Test Connection")).toBeInTheDocument();
+ expect(screen.getByText("View Scan Jobs")).toBeInTheDocument();
+ expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument();
+ });
+
it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => {
// Given
const user = userEvent.setup();
@@ -449,6 +525,37 @@ describe("DataTableRowActions", () => {
expect(item).toHaveAttribute("aria-disabled", "true");
});
+ it("hides Edit Scan Configuration for dynamic providers in Prowler Cloud", async () => {
+ // Given a dynamic provider in a Cloud tenant with scan configs available.
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button"));
+
+ // Then — dynamic providers can't use a Scan Configuration, so the action is
+ // absent entirely, not merely shown as "unavailable".
+ expect(
+ screen.queryByText("Edit Scan Configuration"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Scan Configuration unavailable"),
+ ).not.toBeInTheDocument();
+ });
+
it("renders Update Credentials for provider rows with credentials", async () => {
// Given
const user = userEvent.setup();
diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx
index 5da2f873e6..5c3b00d36b 100644
--- a/ui/components/providers/table/data-table-row-actions.tsx
+++ b/ui/components/providers/table/data-table-row-actions.tsx
@@ -39,6 +39,7 @@ import { getScanScheduleCapability } from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
+import { isConfigurableProvider } from "@/types/providers";
import {
isProvidersOrganizationRow,
PROVIDERS_GROUP_KIND,
@@ -308,14 +309,20 @@ export function DataTableRowActions({
const isOrganizationRow = isProvidersOrganizationRow(rowData);
const provider = isOrganizationRow ? null : rowData;
const providerId = provider?.id ?? "";
- const providerType = provider?.attributes.provider ?? "aws";
+ const providerType = provider?.attributes.provider ?? "";
+ // Only predefined providers can be managed from the UI
+ const canManageProvider = isConfigurableProvider(providerType);
const providerUid = provider?.attributes.uid ?? "";
const providerAlias = provider?.attributes.alias ?? null;
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
const hasSecret = Boolean(provider?.relationships.secret.data);
const isCloudProvider = isCloud() && Boolean(provider);
+ // Dynamic providers have no config.yaml baseline, so a Scan Configuration
+ // can't apply to them — hide the action entirely.
+ const isDynamicProvider = Boolean(provider?.attributes.is_dynamic);
const canManageScanConfig =
isCloudProvider &&
+ !isDynamicProvider &&
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE;
const scheduleProvider: ScanScheduleProvider | undefined = provider
? {
@@ -583,11 +590,13 @@ export function DataTableRowActions({
)}
- }
- label="Edit Provider Alias"
- onSelect={() => setIsEditOpen(true)}
- />
+ {canManageProvider && (
+ }
+ label="Edit Provider Alias"
+ onSelect={() => setIsEditOpen(true)}
+ />
+ )}
}
label="View Scan Jobs"
@@ -616,6 +625,7 @@ export function DataTableRowActions({
/>
)}
{isCloudProvider &&
+ !isDynamicProvider &&
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && (
}
@@ -624,22 +634,24 @@ export function DataTableRowActions({
disabled
/>
)}
- }
- label={hasSecret ? "Update Credentials" : "Add Credentials"}
- onSelect={() =>
- onOpenProviderWizard({
- providerId,
- providerType,
- providerUid,
- providerAlias,
- secretId: providerSecretId,
- mode: providerSecretId
- ? PROVIDER_WIZARD_MODE.UPDATE
- : PROVIDER_WIZARD_MODE.ADD,
- })
- }
- />
+ {canManageProvider && (
+ }
+ label={hasSecret ? "Update Credentials" : "Add Credentials"}
+ onSelect={() =>
+ onOpenProviderWizard({
+ providerId,
+ providerType,
+ providerUid,
+ providerAlias,
+ secretId: providerSecretId,
+ mode: providerSecretId
+ ? PROVIDER_WIZARD_MODE.UPDATE
+ : PROVIDER_WIZARD_MODE.ADD,
+ })
+ }
+ />
+ )}
}
label={loading ? "Testing..." : "Test Connection"}
@@ -649,14 +661,16 @@ export function DataTableRowActions({
}}
disabled={!hasSecret || loading}
/>
-
- }
- label="Delete Provider"
- destructive
- onSelect={() => setIsDeleteOpen(true)}
- />
-
+ {canManageProvider && (
+
+ }
+ label="Delete Provider"
+ destructive
+ onSelect={() => setIsDeleteOpen(true)}
+ />
+
+ )}
>
diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx
index 7621fbe3e1..303a5c918a 100644
--- a/ui/components/providers/workflow/forms/connect-account-form.tsx
+++ b/ui/components/providers/workflow/forms/connect-account-form.tsx
@@ -14,7 +14,12 @@ import { ProviderTitleDocs } from "@/components/providers/workflow/provider-titl
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Form } from "@/components/shadcn/form";
-import { addProviderFormSchema, ApiError, ProviderType } from "@/types";
+import {
+ addProviderFormSchema,
+ ApiError,
+ KnownProviderType,
+ ProviderType,
+} from "@/types";
import { RadioGroupProvider } from "../../radio-group-provider";
@@ -156,7 +161,7 @@ function applyBackStep({
setPrevStep((prev) => prev - 1);
if (prevStep === 2) {
- form.setValue("providerType", undefined as unknown as ProviderType, {
+ form.setValue("providerType", undefined as unknown as KnownProviderType, {
shouldValidate: false,
});
setAwsMethod(null);
diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx
index e8804b39c4..96272e09cd 100644
--- a/ui/components/scans/launch-scan-modal.test.tsx
+++ b/ui/components/scans/launch-scan-modal.test.tsx
@@ -130,15 +130,17 @@ import {
ACTION_ERROR_MESSAGES,
ACTION_ERROR_STATUS,
} from "@/lib/action-errors";
+import { ProviderProps } from "@/types";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { LaunchScanModal } from "./launch-scan-modal";
-const provider = {
+const provider: ProviderProps = {
id: "provider-1",
type: "providers" as const,
attributes: {
provider: "aws" as const,
+ is_dynamic: false,
uid: "123456789012",
alias: "Production",
status: "completed" as const,
diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx
index d8a40407e7..c142c2e3ac 100644
--- a/ui/components/scans/scans-page-shell.test.tsx
+++ b/ui/components/scans/scans-page-shell.test.tsx
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useScansStore } from "@/store";
+import { ProviderProps } from "@/types";
import { ScansPageShell } from "./scans-page-shell";
@@ -102,12 +103,13 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
MutedFindingsConfigButton: () => Configure Mutelist,
}));
-const providers = [
+const providers: ProviderProps[] = [
{
id: "provider-1",
type: "providers" as const,
attributes: {
provider: "aws" as const,
+ is_dynamic: false,
uid: "123456789012",
alias: "Production",
status: "completed" as const,
diff --git a/ui/components/scans/scans.utils.test.ts b/ui/components/scans/scans.utils.test.ts
index b24ea8cbd4..558533f01c 100644
--- a/ui/components/scans/scans.utils.test.ts
+++ b/ui/components/scans/scans.utils.test.ts
@@ -177,6 +177,7 @@ describe("buildPendingScheduleRows", () => {
type: "providers",
attributes: {
provider: "aws",
+ is_dynamic: false,
uid: `uid-${id}`,
alias: `alias-${id}`,
status: "completed",
diff --git a/ui/components/shadcn/entities/get-provider-logo.tsx b/ui/components/shadcn/entities/get-provider-logo.tsx
index 7481d0a8ed..729e17395a 100644
--- a/ui/components/shadcn/entities/get-provider-logo.tsx
+++ b/ui/components/shadcn/entities/get-provider-logo.tsx
@@ -16,7 +16,9 @@ import {
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
+import { GenericProviderBadge } from "@/components/icons/providers-badge/generic-provider-badge";
import { ProviderType } from "@/types";
+import { getProviderDisplayName } from "@/types/providers";
export const getProviderLogo = (provider: ProviderType) => {
switch (provider) {
@@ -53,7 +55,7 @@ export const getProviderLogo = (provider: ProviderType) => {
case "okta":
return ;
default:
- return null;
+ return ;
}
};
@@ -92,6 +94,6 @@ export const getProviderName = (provider: ProviderType): string => {
case "okta":
return "Okta";
default:
- return "Unknown Provider";
+ return getProviderDisplayName(provider);
}
};
diff --git a/ui/lib/provider-credentials/build-credentials.ts b/ui/lib/provider-credentials/build-credentials.ts
index ae8c26e02c..649906f2c6 100644
--- a/ui/lib/provider-credentials/build-credentials.ts
+++ b/ui/lib/provider-credentials/build-credentials.ts
@@ -1,5 +1,9 @@
import { filterEmptyValues, getFormValue } from "@/lib";
import { ProviderType } from "@/types";
+import {
+ isConfigurableProvider,
+ type KnownProviderType,
+} from "@/types/providers";
import { ProviderCredentialFields } from "./provider-credential-fields";
@@ -462,7 +466,10 @@ export const buildSecretConfig = (
const isServiceAccount =
formData.get(ProviderCredentialFields.SERVICE_ACCOUNT_KEY) !== null;
- const secretBuilders = {
+ const secretBuilders: Record<
+ KnownProviderType,
+ () => { secretType: string; secret: unknown }
+ > = {
aws: () => ({
secretType: isRole ? "role" : "static",
secret: buildAWSSecret(formData, isRole),
@@ -533,10 +540,9 @@ export const buildSecretConfig = (
}),
};
- const builder = secretBuilders[providerType];
- if (!builder) {
+ if (!isConfigurableProvider(providerType)) {
throw new Error(`Unsupported provider type: ${providerType}`);
}
- return builder();
+ return secretBuilders[providerType]();
};
diff --git a/ui/lib/provider-filters.test.ts b/ui/lib/provider-filters.test.ts
new file mode 100644
index 0000000000..a2c90e896e
--- /dev/null
+++ b/ui/lib/provider-filters.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ appendSanitizedProviderInFilters,
+ appendSanitizedProviderTypeFilters,
+} from "./provider-filters";
+
+const PROVIDER_TYPE_IN = "filter[provider_type__in]";
+const PROVIDER_IN = "filter[provider__in]";
+
+const makeUrl = () => new URL("https://api.test/v1/providers");
+
+describe("appendSanitizedProviderTypeFilters", () => {
+ it("forwards a known provider type unchanged", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderTypeFilters(url, { [PROVIDER_TYPE_IN]: "aws" });
+ // Then
+ expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("aws");
+ });
+
+ it("forwards a dynamic provider type verbatim (not dropped or replaced)", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderTypeFilters(url, { [PROVIDER_TYPE_IN]: "template" });
+ // Then
+ expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("template");
+ });
+
+ it("injects no provider-type allowlist when nothing is selected", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderTypeFilters(url, {});
+ // Then
+ expect(url.searchParams.has(PROVIDER_TYPE_IN)).toBe(false);
+ });
+
+ it("keeps a mixed known + dynamic selection intact", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderTypeFilters(url, {
+ [PROVIDER_TYPE_IN]: "aws,template",
+ });
+ // Then
+ expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("aws,template");
+ });
+
+ it("excludes the search filter by default", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderTypeFilters(url, {
+ "filter[search]": "prod",
+ [PROVIDER_TYPE_IN]: "template",
+ });
+ // Then
+ expect(url.searchParams.has("filter[search]")).toBe(false);
+ expect(url.searchParams.get(PROVIDER_TYPE_IN)).toBe("template");
+ });
+});
+
+describe("appendSanitizedProviderInFilters", () => {
+ it("forwards a dynamic provider id verbatim", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderInFilters(url, { [PROVIDER_IN]: "provider-uuid" });
+ // Then
+ expect(url.searchParams.get(PROVIDER_IN)).toBe("provider-uuid");
+ });
+
+ it("injects no provider allowlist when nothing is selected", () => {
+ // Given
+ const url = makeUrl();
+ // When
+ appendSanitizedProviderInFilters(url, {});
+ // Then
+ expect(url.searchParams.has(PROVIDER_IN)).toBe(false);
+ });
+});
diff --git a/ui/lib/provider-filters.ts b/ui/lib/provider-filters.ts
index 62edc5bb64..505b151ef6 100644
--- a/ui/lib/provider-filters.ts
+++ b/ui/lib/provider-filters.ts
@@ -1,116 +1,25 @@
-import { PROVIDER_TYPES, type ProviderType } from "@/types/providers";
-
export type ProviderFilterValue = string | string[] | undefined;
export type ProviderFilters = Record;
interface AppendProviderFiltersOptions {
- ensuredInFilterKey?: string;
excludedKeys?: string[];
excludedKeyIncludes?: string[];
}
-type AppendSanitizedProviderTypeFiltersOptions = Omit<
- AppendProviderFiltersOptions,
- "ensuredInFilterKey"
->;
-
-const SUPPORTED_PROVIDER_TYPES_CSV = PROVIDER_TYPES.join(",");
export const PROVIDER_IN_FILTER_KEY = "filter[provider__in]";
export const PROVIDER_TYPE_IN_FILTER_KEY = "filter[provider_type__in]";
-const PROVIDER_TYPE_IN_KEYS = new Set([
- PROVIDER_TYPE_IN_FILTER_KEY,
- "provider_type__in",
-]);
-
-const PROVIDER_SINGLE_KEYS = new Set([
- "filter[provider_type]",
- "provider_type",
-]);
-
-const toCsvString = (value: unknown): string => {
- if (Array.isArray(value)) return value.join(",");
- if (typeof value === "string") return value;
- return "";
-};
-
-const isSupportedProviderType = (value: string): value is ProviderType =>
- (PROVIDER_TYPES as readonly string[]).includes(value);
-
-export const sanitizeProviderTypesCsv = (value?: unknown): string => {
- const rawValue = toCsvString(value);
- if (!rawValue.trim()) return SUPPORTED_PROVIDER_TYPES_CSV;
-
- const supportedProviderTypes = Array.from(
- new Set(
- rawValue
- .split(",")
- .map((providerType) => providerType.trim())
- .filter(isSupportedProviderType),
- ),
- );
-
- return supportedProviderTypes.length > 0
- ? supportedProviderTypes.join(",")
- : SUPPORTED_PROVIDER_TYPES_CSV;
-};
-
-export const sanitizeProviderType = (
- value?: unknown,
-): ProviderType | undefined => {
- const rawValue = toCsvString(value);
- if (!rawValue.trim()) return undefined;
-
- return rawValue
- .split(",")
- .map((providerType) => providerType.trim())
- .find(isSupportedProviderType);
-};
-
-export const sanitizeProviderFilters = (
- filters: ProviderFilters = {},
- ensuredInFilterKey?: string,
-): ProviderFilters => {
- const sanitizedFilters: ProviderFilters = { ...filters };
-
- Object.keys(sanitizedFilters).forEach((key) => {
- if (PROVIDER_TYPE_IN_KEYS.has(key)) {
- sanitizedFilters[key] = sanitizeProviderTypesCsv(sanitizedFilters[key]);
- return;
- }
-
- if (PROVIDER_SINGLE_KEYS.has(key)) {
- const providerType = sanitizeProviderType(sanitizedFilters[key]);
- if (providerType) {
- sanitizedFilters[key] = providerType;
- } else {
- delete sanitizedFilters[key];
- }
- }
- });
-
- if (ensuredInFilterKey) {
- sanitizedFilters[ensuredInFilterKey] = sanitizeProviderTypesCsv(
- sanitizedFilters[ensuredInFilterKey],
- );
- }
-
- return sanitizedFilters;
-};
-
export const appendSanitizedProviderFilters = (
url: URL,
filters: ProviderFilters = {},
{
- ensuredInFilterKey,
excludedKeys = ["filter[search]"],
excludedKeyIncludes = [],
}: AppendProviderFiltersOptions = {},
): void => {
- const sanitizedFilters = sanitizeProviderFilters(filters, ensuredInFilterKey);
const excludedKeysSet = new Set(excludedKeys);
- Object.entries(sanitizedFilters).forEach(([key, value]) => {
+ Object.entries(filters).forEach(([key, value]) => {
if (
value === undefined ||
excludedKeysSet.has(key) ||
@@ -123,22 +32,17 @@ export const appendSanitizedProviderFilters = (
});
};
+// Named aliases so call sites read by intent (provider-type vs provider-id
+// filters). Both forward to appendSanitizedProviderFilters unchanged: the UI no
+// longer forces a provider allowlist, so neither injects a default filter.
export const appendSanitizedProviderTypeFilters = (
url: URL,
filters: ProviderFilters = {},
- options: AppendSanitizedProviderTypeFiltersOptions = {},
-): void =>
- appendSanitizedProviderFilters(url, filters, {
- ...options,
- ensuredInFilterKey: PROVIDER_TYPE_IN_FILTER_KEY,
- });
+ options: AppendProviderFiltersOptions = {},
+): void => appendSanitizedProviderFilters(url, filters, options);
export const appendSanitizedProviderInFilters = (
url: URL,
filters: ProviderFilters = {},
- options: AppendSanitizedProviderTypeFiltersOptions = {},
-): void =>
- appendSanitizedProviderFilters(url, filters, {
- ...options,
- ensuredInFilterKey: PROVIDER_IN_FILTER_KEY,
- });
+ options: AppendProviderFiltersOptions = {},
+): void => appendSanitizedProviderFilters(url, filters, options);
diff --git a/ui/lib/provider-helpers.ts b/ui/lib/provider-helpers.ts
index eedda4f776..08f2bff8f8 100644
--- a/ui/lib/provider-helpers.ts
+++ b/ui/lib/provider-helpers.ts
@@ -46,7 +46,7 @@ export const createProviderDetailsMapping = (
return {
[uid]: {
- provider: provider?.attributes?.provider || "aws",
+ provider: provider?.attributes?.provider ?? "",
uid: uid,
alias: provider?.attributes?.alias ?? null,
},
@@ -65,7 +65,7 @@ export const createProviderDetailsMappingById = (
return {
[id]: {
- provider: provider?.attributes?.provider || "aws",
+ provider: provider?.attributes?.provider ?? "",
uid: provider?.attributes?.uid || "",
alias: provider?.attributes?.alias ?? null,
},
diff --git a/ui/types/providers.test.ts b/ui/types/providers.test.ts
new file mode 100644
index 0000000000..988be74c65
--- /dev/null
+++ b/ui/types/providers.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getProviderDisplayName,
+ humanizeProviderId,
+ isConfigurableProvider,
+ isKnownProviderType,
+} from "./providers";
+
+describe("humanizeProviderId", () => {
+ it("capitalizes a single-word id", () => {
+ expect(humanizeProviderId("template")).toBe("Template");
+ });
+
+ it("splits on hyphens and capitalizes each word", () => {
+ expect(humanizeProviderId("local-template")).toBe("Local Template");
+ });
+
+ it("splits on underscores", () => {
+ expect(humanizeProviderId("foo_bar")).toBe("Foo Bar");
+ });
+
+ it("collapses repeated separators and trims empties", () => {
+ expect(humanizeProviderId("a--b__c")).toBe("A B C");
+ });
+
+ it("returns an empty string for an empty id", () => {
+ expect(humanizeProviderId("")).toBe("");
+ });
+});
+
+describe("getProviderDisplayName", () => {
+ it("returns the configured label for a known provider", () => {
+ expect(getProviderDisplayName("aws")).toBe("AWS");
+ expect(getProviderDisplayName("gcp")).toBe("Google Cloud");
+ });
+
+ it("is case-insensitive for known providers", () => {
+ expect(getProviderDisplayName("AWS")).toBe("AWS");
+ });
+
+ it("humanizes an unknown/dynamic provider id", () => {
+ expect(getProviderDisplayName("template")).toBe("Template");
+ expect(getProviderDisplayName("local-template")).toBe("Local Template");
+ });
+
+ it("normalizes case for an unknown/dynamic provider id", () => {
+ expect(getProviderDisplayName("AWS_THING")).toBe("Aws Thing");
+ expect(getProviderDisplayName("Local-Template")).toBe("Local Template");
+ });
+});
+
+describe("isKnownProviderType / isConfigurableProvider", () => {
+ it("accepts a known provider", () => {
+ expect(isKnownProviderType("aws")).toBe(true);
+ expect(isConfigurableProvider("aws")).toBe(true);
+ });
+
+ it("rejects a dynamic/unknown provider", () => {
+ expect(isKnownProviderType("template")).toBe(false);
+ expect(isConfigurableProvider("template")).toBe(false);
+ });
+
+ it("rejects an empty provider value", () => {
+ expect(isKnownProviderType("")).toBe(false);
+ expect(isConfigurableProvider("")).toBe(false);
+ });
+});
diff --git a/ui/types/providers.ts b/ui/types/providers.ts
index ff5f3742bf..def1f9f004 100644
--- a/ui/types/providers.ts
+++ b/ui/types/providers.ts
@@ -19,9 +19,23 @@ export const PROVIDER_TYPES = [
"okta",
] as const;
-export type ProviderType = (typeof PROVIDER_TYPES)[number];
+/** The closed set of provider types this UI build ships bespoke assets for. */
+export type KnownProviderType = (typeof PROVIDER_TYPES)[number];
-export const PROVIDER_DISPLAY_NAMES: Record = {
+// Autocomplete for predefined + open for dynamic providers
+export type ProviderType = KnownProviderType | (string & {});
+
+/** Type guard: narrows an arbitrary provider value to the known set. */
+export const isKnownProviderType = (
+ provider: string,
+): provider is KnownProviderType =>
+ (PROVIDER_TYPES as readonly string[]).includes(provider);
+
+export const isConfigurableProvider = (
+ provider: string,
+): provider is KnownProviderType => isKnownProviderType(provider);
+
+export const PROVIDER_DISPLAY_NAMES: Record = {
aws: "AWS",
azure: "Azure",
gcp: "Google Cloud",
@@ -40,11 +54,24 @@ export const PROVIDER_DISPLAY_NAMES: Record = {
okta: "Okta",
};
+/**
+ * Turns a raw provider id into a human-readable label: splits on `-`/`_` and
+ * capitalizes each word (e.g. `template` → "Template", `local-template` →
+ * "Local Template"). Used as the display fallback for dynamic providers
+ */
+export function humanizeProviderId(id: string): string {
+ return id
+ .split(/[-_]+/)
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
export function getProviderDisplayName(providerId: string): string {
- return (
- PROVIDER_DISPLAY_NAMES[providerId.toLowerCase() as ProviderType] ||
- providerId
- );
+ const normalized = providerId.toLowerCase();
+ return isKnownProviderType(normalized)
+ ? PROVIDER_DISPLAY_NAMES[normalized]
+ : humanizeProviderId(normalized);
}
export interface ProviderProps {
@@ -52,6 +79,7 @@ export interface ProviderProps {
type: "providers";
attributes: {
provider: ProviderType;
+ is_dynamic: boolean;
uid: string;
alias: string;
status: "completed" | "pending" | "cancelled";