diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx
index 9447d73a20..ec53445c84 100644
--- a/docs/user-guide/providers/gcp/authentication.mdx
+++ b/docs/user-guide/providers/gcp/authentication.mdx
@@ -138,6 +138,10 @@ To keep permissions focused:
4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account.
+
+To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account.
+
+
### Step 3: Generate a JSON Key
1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**.
diff --git a/docs/user-guide/providers/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx
index 6f4f7658e5..6790be5827 100644
--- a/docs/user-guide/providers/gcp/organization.mdx
+++ b/docs/user-guide/providers/gcp/organization.mdx
@@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id
```
-Ensure the credentials used have one of the following roles at the organization level:
-Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`).
+Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy.
+
+```bash
+gcloud organizations add-iam-policy-binding \
+ --member="serviceAccount:" \
+ --role="roles/cloudasset.viewer"
+```
+
+The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials):
+
+```bash
+gcloud services enable cloudasset.googleapis.com --project
+```
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 4ef065cbbe..ef030c5a71 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -35,6 +35,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575)
+- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails; the new `GCPGetOrganizationProjectsError` (3011) is raised instead, naming the required `roles/cloudasset.viewer` binding and Cloud Asset API enablement [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280)
---
diff --git a/prowler/providers/gcp/exceptions/exceptions.py b/prowler/providers/gcp/exceptions/exceptions.py
index 5c5845951c..09cb642cba 100644
--- a/prowler/providers/gcp/exceptions/exceptions.py
+++ b/prowler/providers/gcp/exceptions/exceptions.py
@@ -34,11 +34,17 @@ class GCPBaseException(ProwlerException):
"message": "Error loading Service Account Private Key credentials from dictionary",
"remediation": "Check the dictionary and ensure it contains a Service Account Private Key.",
},
+ (3011, "GCPGetOrganizationProjectsError"): {
+ "message": "Error retrieving projects under the organization via the Cloud Asset API",
+ "remediation": "Ensure the Cloud Asset API is enabled in the credentials' project and that the principal has 'roles/cloudasset.viewer' bound at the organization level. See https://cloud.google.com/asset-inventory/docs/access-control.",
+ },
}
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "GCP"
- error_info = self.GCP_ERROR_CODES.get((code, self.__class__.__name__))
+ # Copy the catalog entry so a custom message does not mutate the
+ # class-level GCP_ERROR_CODES shared across exception instances.
+ error_info = dict(self.GCP_ERROR_CODES.get((code, self.__class__.__name__)))
if message:
error_info["message"] = message
super().__init__(
@@ -104,3 +110,10 @@ class GCPLoadServiceAccountKeyFromDictError(GCPCredentialsError):
super().__init__(
3010, file=file, original_exception=original_exception, message=message
)
+
+
+class GCPGetOrganizationProjectsError(GCPBaseException):
+ def __init__(self, file=None, original_exception=None, message=None):
+ super().__init__(
+ 3011, file=file, original_exception=original_exception, message=message
+ )
diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py
index 5017b84c42..39b3392fdf 100644
--- a/prowler/providers/gcp/gcp_provider.py
+++ b/prowler/providers/gcp/gcp_provider.py
@@ -21,6 +21,8 @@ from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
from prowler.providers.gcp.exceptions.exceptions import (
+ GCPBaseException,
+ GCPGetOrganizationProjectsError,
GCPInvalidProviderIdError,
GCPLoadADCFromDictError,
GCPLoadServiceAccountKeyFromDictError,
@@ -621,10 +623,7 @@ class GcpProvider(Provider):
credentials_file: str
Returns:
- dict[str, GCPProject]
-
- Usage:
- >>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id)
+ dict of project_id and GCPProject object
"""
projects = {}
try:
@@ -632,7 +631,10 @@ class GcpProvider(Provider):
try:
# Initialize Cloud Asset Inventory API for recursive project retrieval
asset_service = discovery.build(
- "cloudasset", "v1", credentials=credentials
+ "cloudasset",
+ "v1",
+ credentials=credentials,
+ num_retries=DEFAULT_RETRY_ATTEMPTS,
)
# Set the scope to the specified organization and filter for projects
scope = f"organizations/{organization_id}"
@@ -643,7 +645,7 @@ class GcpProvider(Provider):
)
while request is not None:
- response = request.execute()
+ response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for asset in response.get("assets", []):
# Extract labels and other project details
@@ -688,13 +690,25 @@ class GcpProvider(Provider):
)
except HttpError as http_error:
if "Cloud Asset API has not been used" in str(http_error):
- logger.error(
- f"Projects cannot be retrieved from the Organization since Cloud Asset API has not been used before or it is disabled [{http_error.__traceback__.tb_lineno}]. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry."
+ message = (
+ "Projects cannot be retrieved from the Organization since the Cloud Asset API "
+ "has not been used before or it is disabled. Enable it by visiting "
+ "https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry."
)
else:
- logger.error(
- f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}"
+ message = (
+ f"Cloud Asset API call failed while listing projects under organization "
+ f"'{organization_id}': {http_error}. Ensure the credentials' principal has "
+ "'roles/cloudasset.viewer' bound at the organization level."
)
+ logger.critical(
+ f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {message}"
+ )
+ raise GCPGetOrganizationProjectsError(
+ file=__file__,
+ original_exception=http_error,
+ message=message,
+ )
else:
try:
# Initialize Cloud Resource Manager API for simple project listing
@@ -781,8 +795,10 @@ class GcpProvider(Provider):
labels={},
lifecycle_state="ACTIVE",
)
- # If no projects were able to be accessed via API, add them manually from the credentials file
- elif credentials_file:
+ # If no projects were able to be accessed via API, add them manually from the credentials file.
+ # Skip this fallback when an organization scan was explicitly requested: silently
+ # downgrading scope to the service account's home project hides permission errors.
+ elif credentials_file and not organization_id:
with open(credentials_file, "r", encoding="utf-8") as file:
project_id = json.load(file)["project_id"]
# Handle empty or null project names
@@ -798,6 +814,8 @@ class GcpProvider(Provider):
labels={},
lifecycle_state="ACTIVE",
)
+ except GCPBaseException as gcp_error:
+ raise gcp_error
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py
index 7d2bea9f88..7b30b828da 100644
--- a/tests/providers/gcp/gcp_provider_test.py
+++ b/tests/providers/gcp/gcp_provider_test.py
@@ -13,6 +13,7 @@ from prowler.config.config import (
)
from prowler.providers.common.models import Connection
from prowler.providers.gcp.exceptions.exceptions import (
+ GCPGetOrganizationProjectsError,
GCPInvalidProviderIdError,
GCPNoAccesibleProjectsError,
GCPTestConnectionError,
@@ -1077,3 +1078,66 @@ class TestGCPProvider:
assert gcp_provider.skip_api_check is True
mocked_is_api_active.assert_not_called()
+
+ def test_get_projects_organization_id_permission_denied_raises(self):
+ """When --organization-id is set and the Cloud Asset API returns a 403,
+ get_projects must raise GCPGetOrganizationProjectsError instead of
+ silently falling back to the service account's home project.
+
+ Regression test for https://github.com/prowler-cloud/prowler/issues/11250.
+ """
+ from googleapiclient.errors import HttpError
+
+ forbidden_response = MagicMock(status=403, reason="Forbidden")
+ http_error = HttpError(
+ resp=forbidden_response,
+ content=b'{"error": {"code": 403, "message": "Permission denied on resource organization"}}',
+ uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets",
+ )
+
+ asset_service = MagicMock()
+ asset_service.assets.return_value.list.return_value.execute.side_effect = (
+ http_error
+ )
+
+ with patch(
+ "prowler.providers.gcp.gcp_provider.discovery.build",
+ return_value=asset_service,
+ ):
+ with pytest.raises(GCPGetOrganizationProjectsError):
+ GcpProvider.get_projects(
+ credentials=MagicMock(),
+ organization_id="test-organization-id",
+ credentials_file="test_credentials_file",
+ )
+
+ def test_get_projects_organization_id_cloud_asset_api_disabled_raises(self):
+ """When --organization-id is set and the Cloud Asset API is disabled,
+ get_projects must raise GCPGetOrganizationProjectsError with the
+ enable-API remediation rather than swallowing the error."""
+ from googleapiclient.errors import HttpError
+
+ disabled_response = MagicMock(status=403, reason="Forbidden")
+ http_error = HttpError(
+ resp=disabled_response,
+ content=b'{"error": {"message": "Cloud Asset API has not been used in project 123 before or it is disabled."}}',
+ uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets",
+ )
+
+ asset_service = MagicMock()
+ asset_service.assets.return_value.list.return_value.execute.side_effect = (
+ http_error
+ )
+
+ with patch(
+ "prowler.providers.gcp.gcp_provider.discovery.build",
+ return_value=asset_service,
+ ):
+ with pytest.raises(GCPGetOrganizationProjectsError) as exc_info:
+ GcpProvider.get_projects(
+ credentials=MagicMock(),
+ organization_id="test-organization-id",
+ credentials_file="test_credentials_file",
+ )
+
+ assert "Cloud Asset API" in str(exc_info.value)