diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index d124555be7..dc451861c5 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,10 +2,11 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.21.0] (Prowler UNRELEASED) +## [1.21.0] (Prowler v5.21.0 UNRELEASED) ### 🚀 Added +- Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333) - Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) - Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317) - Events tab in Findings and Resource detail cards showing an AWS CloudTrail timeline with expandable event rows, actor info, request/response JSON payloads, and error details [(#10320)](https://github.com/prowler-cloud/prowler/pull/10320) diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 0be0e1dd20..68ffec2706 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -10,6 +10,7 @@ import { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, ImageProviderBadge, KS8ProviderBadge, @@ -35,6 +36,7 @@ const PROVIDER_ICON: Record = { kubernetes: , m365: , github: , + googleworkspace: , iac: , image: , oraclecloud: , diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index bf7adf87cd..6c62a34667 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -78,6 +78,11 @@ const OpenStackProviderBadge = lazy(() => default: m.OpenStackProviderBadge, })), ); +const GoogleWorkspaceProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GoogleWorkspaceProviderBadge, + })), +); type IconProps = { width: number; height: number }; @@ -113,6 +118,10 @@ const PROVIDER_DATA: Record< label: "GitHub", icon: GitHubProviderBadge, }, + googleworkspace: { + label: "Google Workspace", + icon: GoogleWorkspaceProviderBadge, + }, iac: { label: "Infrastructure as Code", icon: IacProviderBadge, diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index f368b776cd..d56d82b5c9 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -5,6 +5,7 @@ import { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, ImageProviderBadge, KS8ProviderBadge, @@ -22,6 +23,7 @@ export const PROVIDER_ICONS = { kubernetes: KS8ProviderBadge, m365: M365ProviderBadge, github: GitHubProviderBadge, + googleworkspace: GoogleWorkspaceProviderBadge, iac: IacProviderBadge, image: ImageProviderBadge, oraclecloud: OracleCloudProviderBadge, diff --git a/ui/components/icons/providers-badge/googleworkspace-provider-badge.tsx b/ui/components/icons/providers-badge/googleworkspace-provider-badge.tsx new file mode 100644 index 0000000000..dc853282ce --- /dev/null +++ b/ui/components/icons/providers-badge/googleworkspace-provider-badge.tsx @@ -0,0 +1,44 @@ +import { type FC } from "react"; + +import { IconSvgProps } from "@/types"; + +export const GoogleWorkspaceProviderBadge: FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts index 5627352ab0..be035c9e7d 100644 --- a/ui/components/icons/providers-badge/index.ts +++ b/ui/components/icons/providers-badge/index.ts @@ -8,6 +8,7 @@ import { AzureProviderBadge } from "./azure-provider-badge"; import { CloudflareProviderBadge } from "./cloudflare-provider-badge"; import { GCPProviderBadge } from "./gcp-provider-badge"; import { GitHubProviderBadge } from "./github-provider-badge"; +import { GoogleWorkspaceProviderBadge } from "./googleworkspace-provider-badge"; import { IacProviderBadge } from "./iac-provider-badge"; import { ImageProviderBadge } from "./image-provider-badge"; import { KS8ProviderBadge } from "./ks8-provider-badge"; @@ -23,6 +24,7 @@ export { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, ImageProviderBadge, KS8ProviderBadge, @@ -40,6 +42,7 @@ export const PROVIDER_BADGE_BY_NAME: Record> = { Kubernetes: KS8ProviderBadge, "Microsoft 365": M365ProviderBadge, GitHub: GitHubProviderBadge, + "Google Workspace": GoogleWorkspaceProviderBadge, "Infrastructure as Code": IacProviderBadge, "Container Registry": ImageProviderBadge, "Oracle Cloud Infrastructure": OracleCloudProviderBadge, diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index a179772e20..02466189a1 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -15,6 +15,7 @@ import { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, ImageProviderBadge, KS8ProviderBadge, @@ -61,6 +62,11 @@ const PROVIDERS = [ label: "GitHub", badge: GitHubProviderBadge, }, + { + value: "googleworkspace", + label: "Google Workspace", + badge: GoogleWorkspaceProviderBadge, + }, { value: "iac", label: "Infrastructure as Code", diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx index aa61d07479..02a4628e5d 100644 --- a/ui/components/providers/workflow/forms/base-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -22,6 +22,7 @@ import { CloudflareTokenCredentials, GCPDefaultCredentials, GCPServiceAccountKey, + GoogleWorkspaceCredentials, IacCredentials, ImageCredentials, KubernetesCredentials, @@ -52,6 +53,7 @@ import { } from "./select-credentials-type/m365"; import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form"; +import { GoogleWorkspaceCredentialsForm } from "./via-credentials/googleworkspace-credentials-form"; import { IacCredentialsForm } from "./via-credentials/iac-credentials-form"; import { ImageCredentialsForm } from "./via-credentials/image-credentials-form"; import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; @@ -263,6 +265,13 @@ export const BaseCredentialsForm = ({ control={form.control as unknown as Control} /> )} + {providerType === "googleworkspace" && ( + + } + /> + )} {!hideActions && (
diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index 0c0687081e..0b08aec1c3 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -111,6 +111,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => { label: "Project ID", placeholder: "e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890", }; + case "googleworkspace": + return { + label: "Customer ID", + placeholder: "e.g. C01234abc", + }; default: return { label: "Provider UID", diff --git a/ui/components/providers/workflow/forms/via-credentials/googleworkspace-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/googleworkspace-credentials-form.tsx new file mode 100644 index 0000000000..de692aa1fe --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/googleworkspace-credentials-form.tsx @@ -0,0 +1,58 @@ +import { Control, Controller } from "react-hook-form"; + +import { + WizardInputField, + WizardTextareaField, +} from "@/components/providers/workflow/forms/fields"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { GoogleWorkspaceCredentials } from "@/types"; + +export const GoogleWorkspaceCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Service Account +
+
+ Provide your Service Account JSON and the admin email to impersonate. +
+
+ {/* Hidden input for customer_id - auto-populated from provider UID */} + } + /> + + +
+ Credentials never leave your browser unencrypted and are stored as + secrets in the backend. You can revoke the Service Account from the + Google Cloud Console anytime if needed. +
+ + ); +}; diff --git a/ui/components/ui/entities/get-provider-logo.tsx b/ui/components/ui/entities/get-provider-logo.tsx index 1fb8a5ae41..6e5381aa66 100644 --- a/ui/components/ui/entities/get-provider-logo.tsx +++ b/ui/components/ui/entities/get-provider-logo.tsx @@ -5,6 +5,7 @@ import { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, ImageProviderBadge, KS8ProviderBadge, @@ -29,6 +30,8 @@ export const getProviderLogo = (provider: ProviderType) => { return ; case "github": return ; + case "googleworkspace": + return ; case "iac": return ; case "image": @@ -62,6 +65,8 @@ export const getProviderName = (provider: ProviderType): string => { return "Microsoft 365"; case "github": return "GitHub"; + case "googleworkspace": + return "Google Workspace"; case "iac": return "Infrastructure as Code"; case "image": diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts index 020dc7a6b2..68a34183f7 100644 --- a/ui/hooks/use-credentials-form.ts +++ b/ui/hooks/use-credentials-form.ts @@ -226,6 +226,14 @@ export const useCredentialsForm = ({ [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: "", [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: "", }; + case "googleworkspace": + return { + ...baseDefaults, + [ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID]: + providerUid || "", + [ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: "", + [ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER]: "", + }; case "image": return { ...baseDefaults, diff --git a/ui/lib/error-mappings.ts b/ui/lib/error-mappings.ts index 7024801e30..ff15c790c6 100644 --- a/ui/lib/error-mappings.ts +++ b/ui/lib/error-mappings.ts @@ -40,4 +40,10 @@ export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record = { ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT, [ErrorPointers.OPENSTACK_CLOUDS_YAML_CLOUD]: ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD, + [ErrorPointers.GOOGLEWORKSPACE_CUSTOMER_ID]: + ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID, + [ErrorPointers.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: + ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT, + [ErrorPointers.GOOGLEWORKSPACE_DELEGATED_USER]: + ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER, }; diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 023b90df1e..7b4cdf4c7e 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -87,6 +87,11 @@ export const getProviderHelpText = (provider: string) => { text: "Need help connecting your OpenStack cloud?", link: "https://goto.prowler.com/provider-openstack", }; + case "googleworkspace": + return { + text: "Need help connecting your Google Workspace account?", + link: "https://goto.prowler.com/provider-googleworkspace", + }; default: return { text: "How to setup a provider?", diff --git a/ui/lib/provider-credentials/build-crendentials.ts b/ui/lib/provider-credentials/build-crendentials.ts index 9238a9d9a6..27f0babba1 100644 --- a/ui/lib/provider-credentials/build-crendentials.ts +++ b/ui/lib/provider-credentials/build-crendentials.ts @@ -264,6 +264,21 @@ export const buildOpenStackSecret = (formData: FormData) => { return filterEmptyValues(secret); }; +export const buildGoogleWorkspaceSecret = (formData: FormData) => { + const secret = { + [ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: + getFormValue( + formData, + ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT, + ), + [ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER]: getFormValue( + formData, + ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER, + ), + }; + return filterEmptyValues(secret); +}; + export const buildIacSecret = (formData: FormData) => { const secret = { [ProviderCredentialFields.REPOSITORY_URL]: getFormValue( @@ -480,6 +495,10 @@ export const buildSecretConfig = ( secretType: "static", secret: buildOpenStackSecret(formData), }), + googleworkspace: () => ({ + secretType: "static", + secret: buildGoogleWorkspaceSecret(formData), + }), }; const builder = secretBuilders[providerType]; diff --git a/ui/lib/provider-credentials/provider-credential-fields.ts b/ui/lib/provider-credentials/provider-credential-fields.ts index c5773233e1..f4f2c60c3a 100644 --- a/ui/lib/provider-credentials/provider-credential-fields.ts +++ b/ui/lib/provider-credentials/provider-credential-fields.ts @@ -83,6 +83,11 @@ export const ProviderCredentialFields = { // OpenStack fields OPENSTACK_CLOUDS_YAML_CONTENT: "clouds_yaml_content", OPENSTACK_CLOUDS_YAML_CLOUD: "clouds_yaml_cloud", + + // Google Workspace fields + GOOGLEWORKSPACE_CUSTOMER_ID: "customer_id", + GOOGLEWORKSPACE_CREDENTIALS_CONTENT: "credentials_content", + GOOGLEWORKSPACE_DELEGATED_USER: "delegated_user", } as const; // Type for credential field values @@ -137,6 +142,10 @@ export const ErrorPointers = { CLOUDFLARE_API_EMAIL: "/data/attributes/secret/api_email", OPENSTACK_CLOUDS_YAML_CONTENT: "/data/attributes/secret/clouds_yaml_content", OPENSTACK_CLOUDS_YAML_CLOUD: "/data/attributes/secret/clouds_yaml_cloud", + GOOGLEWORKSPACE_CUSTOMER_ID: "/data/attributes/secret/customer_id", + GOOGLEWORKSPACE_CREDENTIALS_CONTENT: + "/data/attributes/secret/credentials_content", + GOOGLEWORKSPACE_DELEGATED_USER: "/data/attributes/secret/delegated_user", } as const; export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers]; diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 55a0ed21ab..b8a5d28e91 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -59,6 +59,28 @@ export interface AlibabaCloudProviderData { alias?: string; } +// Google Workspace provider data +export interface GoogleWorkspaceProviderData { + customerId: string; + alias?: string; +} + +// Google Workspace credential options +export const GOOGLEWORKSPACE_CREDENTIAL_OPTIONS = { + GOOGLEWORKSPACE_SERVICE_ACCOUNT: "service_account", +} as const; + +// Google Workspace credential type +type GoogleWorkspaceCredentialType = + (typeof GOOGLEWORKSPACE_CREDENTIAL_OPTIONS)[keyof typeof GOOGLEWORKSPACE_CREDENTIAL_OPTIONS]; + +// Google Workspace provider credential +export interface GoogleWorkspaceProviderCredential { + type: GoogleWorkspaceCredentialType; + serviceAccountJson: string; + delegatedUser: string; +} + // AWS credential options export const AWS_CREDENTIAL_OPTIONS = { AWS_ROLE_ARN: "role", @@ -223,6 +245,12 @@ export class ProvidersPage extends BasePage { readonly githubProviderRadio: Locator; readonly ociProviderRadio: Locator; readonly alibabacloudProviderRadio: Locator; + readonly googleworkspaceProviderRadio: Locator; + + // Google Workspace provider form elements + readonly googleworkspaceCustomerIdInput: Locator; + readonly googleworkspaceServiceAccountJsonInput: Locator; + readonly googleworkspaceDelegatedUserInput: Locator; // AWS provider form elements readonly accountIdInput: Locator; @@ -449,6 +477,20 @@ export class ProvidersPage extends BasePage { name: /Connect assuming RAM Role/i, }); + // Google Workspace + this.googleworkspaceProviderRadio = page.getByRole("option", { + name: /Google Workspace/i, + }); + this.googleworkspaceCustomerIdInput = page.getByRole("textbox", { + name: "Customer ID", + }); + this.googleworkspaceServiceAccountJsonInput = page.getByRole("textbox", { + name: /Service Account JSON/i, + }); + this.googleworkspaceDelegatedUserInput = page.getByRole("textbox", { + name: /Delegated User Email/i, + }); + // Alias input this.aliasInput = page.getByRole("textbox", { name: "Provider alias (optional)", @@ -1208,6 +1250,41 @@ export class ProvidersPage extends BasePage { await expect(this.alibabacloudAccessKeySecretInput).toBeVisible(); } + async selectGoogleWorkspaceProvider(): Promise { + await this.selectProviderRadio(this.googleworkspaceProviderRadio); + } + + async fillGoogleWorkspaceProviderDetails( + data: GoogleWorkspaceProviderData, + ): Promise { + await this.googleworkspaceCustomerIdInput.fill(data.customerId); + + if (data.alias) { + await this.aliasInput.fill(data.alias); + } + } + + async fillGoogleWorkspaceCredentials( + credentials: GoogleWorkspaceProviderCredential, + ): Promise { + if (credentials.serviceAccountJson) { + await this.googleworkspaceServiceAccountJsonInput.fill( + credentials.serviceAccountJson, + ); + } + if (credentials.delegatedUser) { + await this.googleworkspaceDelegatedUserInput.fill( + credentials.delegatedUser, + ); + } + } + + async verifyGoogleWorkspaceCredentialsPageLoaded(): Promise { + await this.verifyPageHasProwlerTitle(); + await expect(this.googleworkspaceServiceAccountJsonInput).toBeVisible(); + await expect(this.googleworkspaceDelegatedUserInput).toBeVisible(); + } + async verifyPageLoaded(): Promise { // Verify the providers page is loaded @@ -1228,6 +1305,7 @@ export class ProvidersPage extends BasePage { await expect(this.kubernetesProviderRadio).toBeVisible(); await expect(this.githubProviderRadio).toBeVisible(); await expect(this.alibabacloudProviderRadio).toBeVisible(); + await expect(this.googleworkspaceProviderRadio).toBeVisible(); } async verifyCredentialsPageLoaded(): Promise { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 2f2bc410ca..1e0873d5a1 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -948,3 +948,67 @@ - Organization ID must follow AWS format (e.g., o-abc123def4) - Role ARN must belong to the StackSet deployment for Organizations flow - Provider cleanup is executed before test run to avoid unique constraint conflicts + +--- + +## Test Case: `PROVIDER-E2E-017` - Add Google Workspace Provider with Service Account Credentials + +**Priority:** `critical` + +**Tags:** + +- type → @e2e, @serial +- feature → @providers +- provider → @googleworkspace + +**Description/Objective:** Validates the complete flow of adding a new Google Workspace provider using Service Account authentication with Customer ID, Service Account JSON, and delegated user email. + +**Preconditions:** + +- Admin user authentication required (admin.auth.setup setup) +- Environment variables configured: E2E_GOOGLEWORKSPACE_CUSTOMER_ID, E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON, E2E_GOOGLEWORKSPACE_DELEGATED_USER +- Remove any existing provider with the same Customer ID before starting the test +- This test must be run serially and never in parallel with other tests, as it requires the Customer ID not to be already registered beforehand. + +### Flow Steps: + +1. Navigate to providers page +2. Click "Add Provider" button +3. Select Google Workspace provider type +4. Fill provider details (customer ID and alias) +5. Verify Google Workspace credentials page is loaded +6. Fill Google Workspace credentials (customer ID, service account JSON, delegated user email) +7. Launch initial scan +8. Verify redirect to Scans page +9. Verify scheduled scan status in Scans table (provider exists and scan name is "scheduled scan") + +### Expected Result: + +- Google Workspace provider successfully added with Service Account credentials +- Initial scan launched successfully +- User redirected to Scans page +- Scheduled scan appears in Scans table with correct provider and scan name + +### Key verification points: + +- Provider page loads correctly +- Connect account page displays Google Workspace option +- Provider details form accepts customer ID (format: C[0-9a-zA-Z]+) and alias +- Credentials page loads with customer ID, service account JSON textarea, and delegated user email fields +- Customer ID help text is visible with instructions on finding the Customer ID +- Service account JSON field accepts multi-line formatted JSON +- Delegated user email field validates email format +- Launch scan page appears +- Successful redirect to Scans page after scan launch +- Provider exists in Scans table (verified by customer ID) +- Scan name field contains "scheduled scan" + +### Notes: + +- Test uses environment variables for Google Workspace credentials +- Service Account JSON is provided as multi-line JSON string (not base64 encoded) +- Customer ID must start with 'C' followed by alphanumeric characters (e.g., C01234abc) +- Delegated user email must be a super admin in the Google Workspace domain +- Provider cleanup performed before each test to ensure clean state +- Requires valid Google Workspace account with Service Account having domain-wide delegation enabled +- Service Account must have appropriate Google Workspace API scopes for security scanning diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index fc9f802830..7d2a5dd2a0 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -27,6 +27,9 @@ import { AlibabaCloudProviderData, AlibabaCloudProviderCredential, ALIBABACLOUD_CREDENTIAL_OPTIONS, + GoogleWorkspaceProviderData, + GoogleWorkspaceProviderCredential, + GOOGLEWORKSPACE_CREDENTIAL_OPTIONS, } from "./providers-page"; import { ScansPage } from "../scans/scans-page"; import fs from "fs"; @@ -1348,6 +1351,90 @@ test.describe("Add Provider", () => { }, ); }); + + test.describe.serial("Add Google Workspace Provider", () => { + let providersPage: ProvidersPage; + let scansPage: ScansPage; + + const customerId = process.env.E2E_GOOGLEWORKSPACE_CUSTOMER_ID ?? ""; + const serviceAccountJson = + process.env.E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON ?? ""; + const delegatedUser = process.env.E2E_GOOGLEWORKSPACE_DELEGATED_USER ?? ""; + + test.beforeEach(async ({ page }) => { + test.skip( + !customerId || !serviceAccountJson || !delegatedUser, + "Google Workspace E2E env vars are not set", + ); + providersPage = new ProvidersPage(page); + await deleteProviderIfExists(providersPage, customerId!); + }); + + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test( + "should add a new Google Workspace provider with service account credentials", + { + tag: [ + "@critical", + "@e2e", + "@providers", + "@googleworkspace", + "@serial", + "@PROVIDER-E2E-017", + ], + }, + async ({ page }) => { + const googleWorkspaceProviderData: GoogleWorkspaceProviderData = { + customerId: customerId, + alias: "Test E2E Google Workspace - Service Account", + }; + + const googleWorkspaceCredentials: GoogleWorkspaceProviderCredential = { + type: GOOGLEWORKSPACE_CREDENTIAL_OPTIONS.GOOGLEWORKSPACE_SERVICE_ACCOUNT, + serviceAccountJson: serviceAccountJson, + delegatedUser: delegatedUser, + }; + + // Navigate to providers page + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + // Start adding new provider + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + + // Select Google Workspace provider + await providersPage.selectGoogleWorkspaceProvider(); + + // Fill provider details (customer ID and alias) + await providersPage.fillGoogleWorkspaceProviderDetails( + googleWorkspaceProviderData, + ); + await providersPage.clickNext(); + + // Verify credentials page is loaded + await providersPage.verifyGoogleWorkspaceCredentialsPageLoaded(); + + // Fill credentials + await providersPage.fillGoogleWorkspaceCredentials( + googleWorkspaceCredentials, + ); + await providersPage.clickNext(); + + // Launch scan + await providersPage.verifyLaunchScanPageLoaded(); + await providersPage.clickNext(); + + // Wait for redirect to scan page + scansPage = new ScansPage(page); + await scansPage.verifyPageLoaded(); + + // Verify scan status is "Scheduled scan" + await scansPage.verifyScheduledScanStatus(customerId); + }, + ); + }); }); test.describe("Update Provider Credentials", () => { diff --git a/ui/types/components.ts b/ui/types/components.ts index 96fbce5083..787f58c51c 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -364,6 +364,13 @@ export type OpenStackCredentials = { [ProviderCredentialFields.PROVIDER_ID]: string; }; +export type GoogleWorkspaceCredentials = { + [ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID]: string; + [ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: string; + [ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER]: string; + [ProviderCredentialFields.PROVIDER_ID]: string; +}; + export type CredentialsFormSchema = | AWSCredentials | AWSCredentialsRole @@ -379,7 +386,8 @@ export type CredentialsFormSchema = | AlibabaCloudCredentials | AlibabaCloudCredentialsRole | CloudflareCredentials - | OpenStackCredentials; + | OpenStackCredentials + | GoogleWorkspaceCredentials; export interface SearchParamsProps { [key: string]: string | string[] | undefined; diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index c7044c782c..7598757266 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -145,6 +145,17 @@ export const addProviderFormSchema = z [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), }), + z.object({ + providerType: z.literal("googleworkspace"), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), + providerUid: z + .string() + .trim() + .regex( + /^C[0-9a-zA-Z]+$/, + "Customer ID must start with 'C' followed by alphanumeric characters (e.g., C01234abc)", + ), + }), ]), ); @@ -316,21 +327,56 @@ export const addCredentialsFormSchema = ( }) .optional(), } - : providerType === "openstack" + : providerType === "googleworkspace" ? { - [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: + [ProviderCredentialFields.GOOGLEWORKSPACE_CUSTOMER_ID]: z .string() - .min( - 1, - "Clouds YAML content is required", + .trim() + .regex( + /^C[0-9a-zA-Z]+$/, + "Customer ID must start with 'C' followed by alphanumeric characters (e.g., C01234abc)", ), - [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: - z - .string() - .min(1, "Cloud name is required"), + [ProviderCredentialFields.GOOGLEWORKSPACE_CREDENTIALS_CONTENT]: + z.string().refine( + (val) => { + try { + const parsed = JSON.parse(val); + return ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ); + } catch { + return false; + } + }, + { + message: + "Invalid JSON format. Please provide a valid Service Account JSON.", + }, + ), + [ProviderCredentialFields.GOOGLEWORKSPACE_DELEGATED_USER]: + z.email({ + error: + "Please enter a valid email address", + }), } - : {}), + : providerType === "openstack" + ? { + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: + z + .string() + .min( + 1, + "Clouds YAML content is required", + ), + [ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: + z + .string() + .min(1, "Cloud name is required"), + } + : {}), }) .superRefine((data: Record, ctx) => { if (providerType === "m365") { diff --git a/ui/types/providers.ts b/ui/types/providers.ts index 066bea0414..61a18722a2 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -6,6 +6,7 @@ export const PROVIDER_TYPES = [ "m365", "mongodbatlas", "github", + "googleworkspace", "iac", "image", "oraclecloud", @@ -24,6 +25,7 @@ export const PROVIDER_DISPLAY_NAMES: Record = { m365: "Microsoft 365", mongodbatlas: "MongoDB Atlas", github: "GitHub", + googleworkspace: "Google Workspace", iac: "Infrastructure as Code", image: "Container Registry", oraclecloud: "Oracle Cloud Infrastructure",