diff --git a/.github/workflows/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml index 17b4205620..f0e3372ac9 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -76,7 +76,7 @@ jobs: ### Changes - This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`). + This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`). - Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged - DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged diff --git a/api/changelog.d/oci-home-region-bootstrap.fixed.md b/api/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..0d28492c2b --- /dev/null +++ b/api/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 78a3e14c4f..0d7f042434 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -215,36 +215,34 @@ class TestOracleCloudProviderSecret: assert serializer.is_valid(), serializer.errors assert "region" not in serializer.validated_data - def test_accepts_and_ignores_region_field(self): - secret = self.valid_secret(region="us-phoenix-1") - serializer = OracleCloudProviderSecret(data=secret) - - assert serializer.is_valid(), serializer.errors - - assert "region" not in serializer.validated_data - - @pytest.mark.parametrize( - "legacy_field, legacy_value", - [ - ("region", None), - ("region", ""), - ("region", {"name": "us-ashburn-1"}), - ], - ) - def test_accepts_and_ignores_any_legacy_region_value( - self, legacy_field, legacy_value - ): + def test_keeps_region_as_home_region(self): serializer = OracleCloudProviderSecret( - data=self.valid_secret(**{legacy_field: legacy_value}) + data=self.valid_secret(region=" me-abudhabi-1 ") ) assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["region"] == "me-abudhabi-1" - assert legacy_field not in serializer.validated_data + def test_rejects_unknown_region(self): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region="mars-north-1") + ) + + assert not serializer.is_valid() + assert "region" in serializer.errors + + @pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}]) + def test_drops_blank_or_non_string_region(self, legacy_value): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region=legacy_value) + ) + + assert serializer.is_valid(), serializer.errors + assert "region" not in serializer.validated_data class TestProviderSecretFieldSchema: - def test_oraclecloud_schema_includes_legacy_region_field(self): + def test_oraclecloud_schema_region_is_not_deprecated(self): schema = ProviderSecretField._spectacular_annotation["field"] oraclecloud_schema = next( credential_schema @@ -253,7 +251,7 @@ class TestProviderSecretFieldSchema: == "Oracle Cloud Infrastructure (OCI) API Key Credentials" ) - assert oraclecloud_schema["properties"]["region"]["deprecated"] is True + assert "deprecated" not in oraclecloud_schema["properties"]["region"] class TestKubernetesProviderSecret: diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 4b5e8e1694..a6d2c10f24 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -172,7 +172,7 @@ class TestInitializeProwlerProvider: ) @patch("api.utils.return_prowler_provider") - def test_initialize_oraclecloud_provider_removes_region_string( + def test_initialize_oraclecloud_provider_passes_region_as_home_region( self, mock_return_prowler_provider ): provider = MagicMock() @@ -182,7 +182,7 @@ class TestInitializeProwlerProvider: "fingerprint": "00:11:22:33:44:55:66:77", "key_content": "fake-base64-key-content", "tenancy": "ocid1.tenancy.oc1..fake", - "region": "us-ashburn-1", + "region": "me-abudhabi-1", } mock_return_prowler_provider.return_value = MagicMock() @@ -193,6 +193,7 @@ class TestInitializeProwlerProvider: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..fake", + home_region="me-abudhabi-1", ) @patch("api.utils.return_prowler_provider") @@ -254,11 +255,35 @@ class TestProwlerProviderConnectionTest: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..aaaaaaaexample", - region=getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, - ), + region=OraclecloudProvider._bootstrap_region, + provider_id="ocid1.tenancy.oc1..aaaaaaaexample", + raise_on_exception=False, + ) + + @patch("api.utils.return_prowler_provider") + def test_oraclecloud_connection_test_uses_stored_region_for_identity( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample" + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret.secret = { + "user": "ocid1.user.oc1..aaaaaaaexample", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "fake-base64-key-content", + "tenancy": "ocid1.tenancy.oc1..aaaaaaaexample", + "region": "me-abudhabi-1", + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="00:11:22:33:44:55:66:77", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + region="me-abudhabi-1", provider_id="ocid1.tenancy.oc1..aaaaaaaexample", raise_on_exception=False, ) @@ -434,7 +459,7 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result - def test_get_prowler_provider_kwargs_oraclecloud_removes_region( + def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region( self, ): secret_dict = { @@ -461,6 +486,7 @@ class TestGetProwlerProviderKwargs: "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", "tenancy": "ocid1.tenancy.oc1..fake", "pass_phrase": "fake-passphrase", + "home_region": "us-ashburn-1", } def test_get_prowler_provider_kwargs_with_mutelist(self): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 55ef891386..b5c5d0fcf1 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3363,7 +3363,7 @@ current-context: test-context provider_secret = ProviderSecret.objects.get() assert "region" not in provider_secret.secret - def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_create_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3372,14 +3372,14 @@ current-context: test-context authenticated_client, oraclecloud_provider, self._oraclecloud_secret( - key_content=" test-key-content ", region=" us-ashburn-1 " + key_content=" test-key-content ", region=" me-abudhabi-1 " ), ) assert response.status_code == status.HTTP_201_CREATED provider_secret = ProviderSecret.objects.get() assert provider_secret.secret["key_content"] == "test-key-content" - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" def test_provider_secrets_update_oraclecloud_without_region_stores_no_region( self, @@ -3412,7 +3412,7 @@ current-context: test-context provider_secret.refresh_from_db() assert "region" not in provider_secret.secret - def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_update_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3430,7 +3430,7 @@ current-context: test-context "type": "provider-secrets", "id": str(provider_secret.id), "attributes": { - "secret": self._oraclecloud_secret(region=" us-ashburn-1 ") + "secret": self._oraclecloud_secret(region=" me-abudhabi-1 ") }, } } @@ -3443,7 +3443,7 @@ current-context: test-context assert response.status_code == status.HTTP_200_OK provider_secret.refresh_from_db() - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" @pytest.mark.parametrize( "attributes, error_code, error_pointer", diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8e73b96a39..a48c8c13a4 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -302,17 +302,26 @@ def get_prowler_provider_kwargs( def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into SDK provider kwargs.""" prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) + if home_region: + prowler_provider_kwargs["home_region"] = home_region return prowler_provider_kwargs +def _oraclecloud_home_region(region) -> str | None: + """Return the stored OCI region as a home region, ignoring blank or non-string legacy values.""" + if isinstance(region, str) and region.strip(): + return region.strip() + return None + + def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into test_connection kwargs.""" from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) if ( prowler_provider_kwargs.get("user") @@ -323,11 +332,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: or prowler_provider_kwargs.get("key_file") ) ): - # Connection validation needs one OCI endpoint, but scans remain unfiltered. - prowler_provider_kwargs["region"] = getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, + # Identity calls only succeed in a region the tenancy is subscribed to. + prowler_provider_kwargs["region"] = ( + home_region or OraclecloudProvider._bootstrap_region ) return prowler_provider_kwargs diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 0d80b47c0a..92ce6d7d7b 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -301,8 +301,7 @@ from rest_framework_json_api import serializers }, "region": { "type": "string", - "deprecated": True, - "description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.", + "description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.", }, }, "required": ["user", "fingerprint", "tenancy"], diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 271d3a9b47..5b7e061561 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction from drf_spectacular.utils import extend_schema_field from jwt.exceptions import InvalidKeyError from prowler.lib.mutelist.mutelist import Mutelist +from prowler.providers.oraclecloud.config import OCI_REGIONS from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from rest_framework_json_api import serializers @@ -1917,9 +1918,16 @@ class IacProviderSecret(serializers.Serializer): resource_name = "provider-secrets" -class LegacyOCIRegionField(serializers.Field): +class OCIHomeRegionField(serializers.Field): + """Optional OCI home region; blank or non-string legacy values are dropped.""" + def to_internal_value(self, data): - return data + if not isinstance(data, str) or not data.strip(): + return None + region = data.strip() + if region not in OCI_REGIONS: + raise serializers.ValidationError(f"Invalid OCI region: {region}") + return region def to_representation(self, value): return value @@ -1932,10 +1940,11 @@ class OracleCloudProviderSecret(serializers.Serializer): key_content = serializers.CharField(required=False) tenancy = serializers.CharField() pass_phrase = serializers.CharField(required=False) - region = LegacyOCIRegionField(required=False, allow_null=True) + region = OCIHomeRegionField(required=False, allow_null=True) def validate(self, attrs): - attrs.pop("region", None) + if not attrs.get("region"): + attrs.pop("region", None) if "key_file" not in attrs and "key_content" not in attrs: raise serializers.ValidationError( diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 6a8c8de3e9..950661ff28 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server. 1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID. 2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**. 3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint. -4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). +4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works. ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). @@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide: - **User OCID** for the API key owner - **Fingerprint** of the API key -- **Region** (for example, `us-ashburn-1`) +- **Home Region**: select it from the list (for example, `me-abudhabi-1`) - **Private Key Content** (paste the full PEM value) - **Passphrase (Optional)** if the private key is encrypted Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission. + +The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated. + + +Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region. + ![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png) --- @@ -334,6 +340,11 @@ prowler oci \ #### Region Issues +**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`** +- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`) +- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region +- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region + **Error: "Invalid region"** - Check available regions: `prowler oci --list-regions` - Verify your tenancy is subscribed to the region diff --git a/prowler/changelog.d/oci-home-region-bootstrap.fixed.md b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..de2997773d --- /dev/null +++ b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index a4794f6269..932333ee74 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -89,6 +89,7 @@ class OraclecloudProvider(Provider): key_content: str = None, tenancy: str = None, pass_phrase: str = None, + home_region: str = None, ): """ Initializes the OCI provider. @@ -110,6 +111,7 @@ class OraclecloudProvider(Provider): - key_content: Content of the private key (base64 encoded). - tenancy: The OCID of the tenancy. - pass_phrase: The passphrase for the private key, if encrypted. + - home_region: Region used to bootstrap identity calls with API key credentials; it does not filter the audited regions. Raises: - OCISetUpSessionError: If an error occurs during the setup process. @@ -140,7 +142,7 @@ class OraclecloudProvider(Provider): ) has_direct_credentials = user and fingerprint and tenancy bootstrap_region = single_region or ( - self._bootstrap_region if has_direct_credentials else None + (home_region or self._bootstrap_region) if has_direct_credentials else None ) # Setup OCI Session diff --git a/tests/providers/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index 7deb649d8e..dce02eeb78 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -543,6 +543,58 @@ class TestOraclecloudProviderInit: assert mock_get_regions_to_audit.call_args_list[0].args == (None,) assert provider.regions == all_subscribed_regions + def test_init_with_home_region_bootstraps_there_without_scan_filter(self): + mock_session = OCISession( + config={"region": "me-abudhabi-1"}, signer=None, profile=None + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="me-abudhabi-1", + profile=None, + audited_regions=set(), + audited_compartments=[], + ) + all_subscribed_regions = [ + OCIRegion(key="me-abudhabi-1", name="me-abudhabi-1", is_home_region=True), + OCIRegion(key="me-dubai-1", name="me-dubai-1", is_home_region=False), + ] + + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ) as mock_setup_session, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + return_value=all_subscribed_regions, + ) as mock_get_regions_to_audit, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=["ocid1.compartment.oc1..aaaaaaaexample"], + ), + patch("prowler.providers.common.provider.Provider.set_global_provider"), + ): + provider = OraclecloudProvider( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + home_region="me-abudhabi-1", + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + + assert mock_setup_session.call_args.kwargs["region"] == "me-abudhabi-1" + assert mock_get_regions_to_audit.call_args_list[0].args == (None,) + assert provider.regions == all_subscribed_regions + assert provider.home_region == "me-abudhabi-1" + def test_init_with_config_file_auth_without_region_uses_session_config_region_for_identity( self, ): diff --git a/tests/providers/oraclecloud/ui_regions_sync_test.py b/tests/providers/oraclecloud/ui_regions_sync_test.py new file mode 100644 index 0000000000..3e38914f24 --- /dev/null +++ b/tests/providers/oraclecloud/ui_regions_sync_test.py @@ -0,0 +1,20 @@ +import re +from pathlib import Path + +from prowler.providers.oraclecloud.config import OCI_REGIONS + +UI_REGIONS_FILE = ( + Path(__file__).resolve().parents[3] + / "ui" + / "lib" + / "provider-credentials" + / "oci-regions.ts" +) + + +def test_ui_home_region_list_matches_sdk_regions(): + ui_regions = set( + re.findall(r'"([a-z]{2,3}-[a-z-]+-\d+)"', UI_REGIONS_FILE.read_text()) + ) + + assert ui_regions == set(OCI_REGIONS) diff --git a/ui/changelog.d/oci-home-region-bootstrap.fixed.md b/ui/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..3982d0b7d1 --- /dev/null +++ b/ui/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +Required home region selector in the OCI credentials form, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx index ec989868ea..f25dd49356 100644 --- a/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx @@ -4,6 +4,9 @@ import { WizardInputField, WizardTextareaField, } from "@/components/providers/workflow/forms/fields"; +import { Combobox } from "@/components/shadcn/combobox"; +import { FormControl, FormField, FormMessage } from "@/components/shadcn/form"; +import { OCI_REGION_GROUPS } from "@/lib/provider-credentials/oci-regions"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { OCICredentials } from "@/types"; @@ -48,6 +51,34 @@ export const OracleCloudCredentialsForm = ({ variant="bordered" isRequired /> + ( +
+ + Home Region* + + + + + + Shown in the OCI Console under Tenancy Details. Used only to + validate the credentials: all subscribed regions are scanned. + + +
+ )} + /> ({ + value: region, + label: region, + })), + }, + { heading: "Government", options: OCI_GOVERNMENT_REGIONS }, +]; + +export const OCI_REGION_VALUES = OCI_REGION_GROUPS.flatMap((group) => + group.options.map((option) => option.value), +); diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index ecb8755ccd..9777c1b61b 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -224,6 +224,7 @@ export interface OCIProviderCredential { userId?: string; fingerprint?: string; keyContent?: string; + homeRegion?: string; } // AlibabaCloud credential options @@ -365,6 +366,7 @@ export class ProvidersPage extends BasePage { readonly ociUserIdInput: Locator; readonly ociFingerprintInput: Locator; readonly ociKeyContentInput: Locator; + readonly ociHomeRegionCombobox: Locator; // AlibabaCloud provider form elements readonly alibabacloudAccountIdInput: Locator; @@ -510,6 +512,9 @@ export class ProvidersPage extends BasePage { this.ociKeyContentInput = page.getByRole("textbox", { name: /Private Key Content/i, }); + this.ociHomeRegionCombobox = page.getByRole("combobox", { + name: /Home Region/i, + }); // AlibabaCloud provider form inputs this.alibabacloudAccountIdInput = page.getByRole("textbox", { @@ -1284,6 +1289,12 @@ export class ProvidersPage extends BasePage { if (credentials.keyContent) { await this.ociKeyContentInput.fill(credentials.keyContent); } + if (credentials.homeRegion) { + await this.ociHomeRegionCombobox.click(); + await this.page + .locator(`[role="option"][data-value="${credentials.homeRegion}"]`) + .click(); + } } async verifyOCICredentialsPageLoaded(): Promise { @@ -1294,6 +1305,7 @@ export class ProvidersPage extends BasePage { await expect(this.ociUserIdInput).toBeVisible(); await expect(this.ociFingerprintInput).toBeVisible(); await expect(this.ociKeyContentInput).toBeVisible(); + await expect(this.ociHomeRegionCombobox).toBeVisible(); } async verifyOCIUpdateCredentialsPageLoaded(): Promise { @@ -1304,6 +1316,7 @@ export class ProvidersPage extends BasePage { await expect(this.ociUserIdInput).toBeVisible(); await expect(this.ociFingerprintInput).toBeVisible(); await expect(this.ociKeyContentInput).toBeVisible(); + await expect(this.ociHomeRegionCombobox).toBeVisible(); } async selectAlibabaCloudProvider(): Promise { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 0bc1b9d47d..049c3fcbe3 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -611,7 +611,7 @@ **Preconditions:** - Admin user authentication required (admin.auth.setup setup) -- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT +- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION (optional, defaults to us-ashburn-1) - Remove any existing provider with the same Tenancy ID before starting the test - This test must be run serially and never in parallel with other tests, as it requires the Tenancy ID not to be already registered beforehand. @@ -622,7 +622,7 @@ 3. Select OCI provider type 4. Fill provider details (tenancy ID and alias) 5. Verify OCI credentials page is loaded -6. Fill OCI credentials (user ID, fingerprint, key content) +6. Fill OCI credentials (user ID, fingerprint, key content, home region) 7. Confirm provider connection without launching a scan 8. Verify return to Providers page 9. Verify provider exists in Providers table @@ -640,7 +640,7 @@ - Connect account page displays OCI option - Provider details form accepts tenancy ID and alias - OCI credentials page loads -- Credentials form accepts all required fields (user ID, fingerprint, key content) +- Credentials form accepts all required fields (user ID, fingerprint, key content, home region) - Launch step appears - Successful return to Providers page after closing the launch step - Provider exists in Providers table (verified by tenancy ID) @@ -670,7 +670,7 @@ **Preconditions:** - Admin user authentication required (admin.auth.setup setup) -- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT +- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION (optional, defaults to us-ashburn-1) - An OCI provider with the specified Tenancy ID must already exist (run PROVIDER-E2E-012 first) - This test must be run serially and never in parallel with other tests @@ -682,7 +682,7 @@ 4. Click "Update Credentials" option 5. Verify update credentials page is loaded 6. Verify OCI credentials form fields are visible (confirms providerUid is loaded) -7. Fill OCI credentials (user ID, fingerprint, key content) +7. Fill OCI credentials (user ID, fingerprint, key content, home region) 8. Click Next to submit 9. Verify successful navigation to test connection page diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 4052a1d4c0..4cdd0aa63c 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -954,6 +954,7 @@ test.describe("Add Provider", () => { const userId = process.env.E2E_OCI_USER_ID ?? ""; const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? ""; const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? ""; + const homeRegion = process.env.E2E_OCI_REGION ?? "us-ashburn-1"; // Setup before each test test.beforeEach(async ({ page }) => { @@ -995,6 +996,7 @@ test.describe("Add Provider", () => { userId: userId, fingerprint: fingerprint, keyContent: keyContent, + homeRegion: homeRegion, }; // Navigate to providers page @@ -1439,6 +1441,7 @@ test.describe("Update Provider Credentials", () => { const userId = process.env.E2E_OCI_USER_ID ?? ""; const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? ""; const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? ""; + const homeRegion = process.env.E2E_OCI_REGION ?? "us-ashburn-1"; // Setup before each test test.beforeEach(async ({ page }) => { @@ -1465,6 +1468,7 @@ test.describe("Update Provider Credentials", () => { userId: userId, fingerprint: fingerprint, keyContent: keyContent, + homeRegion: homeRegion, }; // Navigate to providers page diff --git a/ui/types/components.ts b/ui/types/components.ts index 4053984241..340f0c77d9 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -281,6 +281,7 @@ export type OCICredentials = { [ProviderCredentialFields.OCI_FINGERPRINT]: string; [ProviderCredentialFields.OCI_KEY_CONTENT]: string; [ProviderCredentialFields.OCI_TENANCY]: string; + [ProviderCredentialFields.OCI_REGION]: string; [ProviderCredentialFields.OCI_PASS_PHRASE]?: string; [ProviderCredentialFields.PROVIDER_ID]: string; }; diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts index b2532f6b95..2fb81bf73e 100644 --- a/ui/types/env.d.ts +++ b/ui/types/env.d.ts @@ -151,6 +151,7 @@ declare global { E2E_OCI_USER_ID?: string; E2E_OCI_FINGERPRINT?: string; E2E_OCI_KEY_CONTENT?: string; + E2E_OCI_REGION?: string; // E2E Alibaba Cloud E2E_ALIBABACLOUD_ACCOUNT_ID?: string; diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index ddb5f4e301..8206ed3311 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -307,11 +307,33 @@ describe("addCredentialsFormSchema - oraclecloud", () => { [ProviderCredentialFields.OCI_TENANCY]: "ocid1.tenancy.oc1..example", } as const; - it("accepts OCI API key credentials without region", () => { + it("rejects OCI API key credentials without a home region", () => { const schema = addCredentialsFormSchema("oraclecloud"); const result = schema.safeParse(BASE_OCI_VALUES); + expect(result.success).toBe(false); + }); + + it("rejects an unknown OCI home region", () => { + const schema = addCredentialsFormSchema("oraclecloud"); + + const result = schema.safeParse({ + ...BASE_OCI_VALUES, + [ProviderCredentialFields.OCI_REGION]: "mars-north-1", + }); + + expect(result.success).toBe(false); + }); + + it("accepts OCI API key credentials with a home region", () => { + const schema = addCredentialsFormSchema("oraclecloud"); + + const result = schema.safeParse({ + ...BASE_OCI_VALUES, + [ProviderCredentialFields.OCI_REGION]: "me-abudhabi-1", + }); + expect(result.success).toBe(true); }); }); diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index d34a1c84ef..11c8bef03e 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -1,6 +1,7 @@ import yaml from "js-yaml"; import { z } from "zod"; +import { OCI_REGION_VALUES } from "@/lib/provider-credentials/oci-regions"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { validateMutelistYaml, validateYaml } from "@/lib/yaml"; import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml"; @@ -325,6 +326,14 @@ export const addCredentialsFormSchema = ( [ProviderCredentialFields.OCI_TENANCY]: z .string() .min(1, "Tenancy OCID is required"), + [ProviderCredentialFields.OCI_REGION]: z + .string() + .refine( + (region) => OCI_REGION_VALUES.includes(region), + { + error: "Home region is required", + }, + ), [ProviderCredentialFields.OCI_PASS_PHRASE]: z .union([z.string(), z.literal("")]) .optional(), diff --git a/util/update_oci_regions.py b/util/update_oci_regions.py index cb012bec58..acd858a7ec 100644 --- a/util/update_oci_regions.py +++ b/util/update_oci_regions.py @@ -178,6 +178,34 @@ def update_config_file(regions, config_file_path): logging.info(f"Updated OCI_COMMERCIAL_REGIONS with {len(regions)} regions") +def update_ui_regions_file(regions, ui_file_path): + """Rewrite OCI_COMMERCIAL_REGIONS in the UI region list used by the credentials form.""" + logging.info(f"Updating UI regions file: {ui_file_path}") + + with open(ui_file_path, "r") as f: + ui_content = f.read() + + new_regions_array = "const OCI_COMMERCIAL_REGIONS = [\n" + for region_id in regions.keys(): + new_regions_array += f' "{region_id}",\n' + new_regions_array += "];" + + pattern = r"const OCI_COMMERCIAL_REGIONS = \[[^\]]*\];" + if not re.search(pattern, ui_content): + raise Exception( + "Validation failed: OCI_COMMERCIAL_REGIONS not found in the UI regions file." + ) + updated_content = re.sub(pattern, new_regions_array, ui_content) + + if updated_content == ui_content: + logging.warning("No changes detected in UI regions file") + return + + with open(ui_file_path, "w") as f: + f.write(updated_content) + logging.info("Successfully updated UI regions file") + + def main(): """ Main execution function for OCI regions updater. @@ -201,6 +229,16 @@ def main(): update_config_file(commercial_regions, config_file_path) + ui_file_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "..", + "ui", + "lib", + "provider-credentials", + "oci-regions.ts", + ) + update_ui_regions_file(commercial_regions, ui_file_path) + logging.info("OCI regions update completed successfully") return 0