diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx index d8812fa7b3..e5c3527f89 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -17,6 +17,7 @@ Prowler requests the following read-only OAuth 2.0 scopes from the Google Worksp | `https://www.googleapis.com/auth/admin.directory.user.readonly` | Read access to user accounts and their admin status | | `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information | | `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) | +| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments | The delegated user must be a **super administrator** in your Google Workspace organization. Using a non-admin account will result in permission errors when accessing the Admin SDK. @@ -73,7 +74,7 @@ This JSON key grants access to your Google Workspace organization. Never commit 6. In the **OAuth scopes** field, enter the following scopes as a comma-separated list: ``` -https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly +https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly ``` 7. Click **Authorize** @@ -114,7 +115,7 @@ The delegated user must be provided via the `GOOGLEWORKSPACE_DELEGATED_USER` env - **Use environment variables** — Never hardcode credentials in scripts or commands - **Use a dedicated Service Account** — Create one specifically for Prowler, separate from other integrations -- **Use read-only scopes** — Prowler only requires the three read-only scopes listed above +- **Use read-only scopes** — Prowler only requires the read-only scopes listed above - **Restrict key access** — Set file permissions to `600` on the JSON key file - **Rotate keys regularly** — Delete and regenerate the JSON key periodically - **Use a least-privilege super admin** — Consider using a dedicated super admin account for Prowler's delegated user rather than a personal admin account @@ -151,7 +152,7 @@ python3 -c "import json; json.load(open('/path/to/key.json'))" && echo "Valid JS The Service Account cannot impersonate the delegated user. This usually means Domain-Wide Delegation has not been configured, or the OAuth scopes are incorrect. Verify: - The Service Account Client ID is correctly entered in the Admin Console -- All three required OAuth scopes are included +- All required OAuth scopes are included - The delegated user is a super administrator ### Permission Denied on Admin SDK Calls @@ -159,5 +160,5 @@ The Service Account cannot impersonate the delegated user. This usually means Do If Prowler connects but returns empty results or permission errors for specific API calls: - Confirm Domain-Wide Delegation is fully propagated (wait a few minutes after setup) -- Verify all three scopes are authorized in the Admin Console +- Verify all scopes are authorized in the Admin Console - Ensure the delegated user is an active super administrator diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx index 361de533e1..af09ab75c3 100644 --- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx +++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx @@ -78,7 +78,7 @@ The Service Account JSON is the full content of the key file downloaded when cre ![Check Connection](/images/providers/googleworkspace-check-connection.png) -If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors. +If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all required OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors. ### Step 5: Launch the Scan diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 37b00bc5bd..f483ce9d56 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `glue_etl_jobs_no_secrets_in_arguments` check for plaintext secrets in AWS Glue ETL job arguments [(#10368)](https://github.com/prowler-cloud/prowler/pull/10368) - `awslambda_function_no_dead_letter_queue`, `awslambda_function_using_cross_account_layers`, and `awslambda_function_env_vars_not_encrypted_with_cmk` checks for AWS Lambda [(#10381)](https://github.com/prowler-cloud/prowler/pull/10381) - `entra_conditional_access_policy_mdm_compliant_device_required` check for M365 provider [(#10220)](https://github.com/prowler-cloud/prowler/pull/10220) +- `directory_super_admin_only_admin_roles` check for Google Workspace provider [(#10488)](https://github.com/prowler-cloud/prowler/pull/10488) - `ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip` check for AWS provider using `ipaddress.is_global` for accurate public IP detection [(#10335)](https://github.com/prowler-cloud/prowler/pull/10335) - `entra_conditional_access_policy_block_o365_elevated_insider_risk` check for M365 provider [(#10232)](https://github.com/prowler-cloud/prowler/pull/10232) - `--resource-group` and `--list-resource-groups` CLI flags to filter checks by resource group across all providers [(#10479)](https://github.com/prowler-cloud/prowler/pull/10479) diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index fa32397826..7792bba0da 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -54,7 +54,9 @@ { "Id": "1.1.3", "Description": "Ensure super admin accounts are used only for super admin activities", - "Checks": [], + "Checks": [ + "directory_super_admin_only_admin_roles" + ], "Attributes": [ { "Section": "1 Directory", diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 83beee0d3e..549831a2fb 100644 --- a/prowler/providers/googleworkspace/googleworkspace_provider.py +++ b/prowler/providers/googleworkspace/googleworkspace_provider.py @@ -64,6 +64,7 @@ class GoogleworkspaceProvider(Provider): "https://www.googleapis.com/auth/admin.directory.user.readonly", "https://www.googleapis.com/auth/admin.directory.domain.readonly", "https://www.googleapis.com/auth/admin.directory.customer.readonly", + "https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly", ] def __init__( diff --git a/prowler/providers/googleworkspace/services/directory/directory_service.py b/prowler/providers/googleworkspace/services/directory/directory_service.py index ef0b54c18c..6afa8e4521 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_service.py +++ b/prowler/providers/googleworkspace/services/directory/directory_service.py @@ -8,23 +8,21 @@ class Directory(GoogleWorkspaceService): def __init__(self, provider): super().__init__(provider) + self._service = self._build_service("admin", "directory_v1") self.users = self._list_users() + self._roles = self._list_roles() + self._populate_role_assignments() def _list_users(self): logger.info("Directory - Listing Users...") users = {} try: - # Build the Admin SDK Directory service - service = self._build_service("admin", "directory_v1") - - if not service: + if not self._service: logger.error("Failed to build Directory service") return users - # Fetch users using the Directory API - # Reference: https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list - request = service.users().list( + request = self._service.users().list( customer=self.provider.identity.customer_id, maxResults=500, # Max allowed by API orderBy="email", @@ -38,14 +36,11 @@ class Directory(GoogleWorkspaceService): user = User( id=user_data.get("id"), email=user_data.get("primaryEmail"), - is_admin=user_data.get("isAdmin", False), ) users[user.id] = user - logger.debug( - f"Processed user: {user.email} (Admin: {user.is_admin})" - ) + logger.debug(f"Processed user: {user.email}") - request = service.users().list_next(request, response) + request = self._service.users().list_next(request, response) except Exception as error: self._handle_api_error( @@ -62,9 +57,108 @@ class Directory(GoogleWorkspaceService): return users + def _list_roles(self): + logger.info("Directory - Listing Roles...") + roles = {} + + try: + if not self._service: + return roles + + request = self._service.roles().list( + customer=self.provider.identity.customer_id, + ) + + while request is not None: + try: + response = request.execute() + + for role_data in response.get("items", []): + role_id = str(role_data.get("roleId", "")) + role_name = role_data.get("roleName", "") + if role_id and role_name: + roles[role_id] = Role( + id=role_id, + name=role_name, + description=role_data.get("roleDescription", ""), + is_super_admin_role=role_data.get( + "isSuperAdminRole", False + ), + ) + + request = self._service.roles().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "listing roles", + self.provider.identity.customer_id, + ) + break + + logger.info(f"Found {len(roles)} roles in the domain") + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return roles + + def _populate_role_assignments(self): + logger.info("Directory - Fetching Role Assignments...") + + if not self._service: + return + + try: + request = self._service.roleAssignments().list( + customer=self.provider.identity.customer_id, + ) + + while request is not None: + try: + response = request.execute() + + for assignment in response.get("items", []): + user_id = str(assignment.get("assignedTo", "")) + role_id = str(assignment.get("roleId", "")) + user = self.users.get(user_id) + role = self._roles.get(role_id) + if user and role: + user.role_assignments.append(role) + if role.is_super_admin_role: + user.is_admin = True + + request = self._service.roleAssignments().list_next( + request, response + ) + + except Exception as error: + self._handle_api_error( + error, + "listing role assignments", + self.provider.identity.customer_id, + ) + break + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Role(BaseModel): + + id: str + name: str + description: str = "" + is_super_admin_role: bool = False + class User(BaseModel): id: str email: str is_admin: bool = False + role_assignments: list[Role] = [] diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/__init__.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json new file mode 100644 index 0000000000..0264078982 --- /dev/null +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "directory_super_admin_only_admin_roles", + "CheckTitle": "All super admin accounts are used only for super admin activities", + "CheckType": [], + "ServiceName": "directory", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Super admin accounts do not also hold **additional admin roles** such as Groups Admin, User Management Admin, etc. Each super administrator has a separate, non-admin account for daily activities, following the **principle of least privilege**.", + "Risk": "A super admin account that also holds additional admin roles increases the **attack surface** for phishing and credential theft. Compromising a single dual-role account grants full administrative access, bypassing **separation of duties** and enabling unauthorized changes to users, billing, and security settings.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles", + "https://support.google.com/a/answer/9011373" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Directory** > **Users**\n3. Click on the super admin user who also has additional admin roles\n4. Click **Admin roles and privileges**\n5. Remove the additional admin roles from the super admin account\n6. Create a separate account for daily admin tasks", + "Terraform": "" + }, + "Recommendation": { + "Text": "Apply the principle of separation of duties by maintaining dedicated super admin accounts exclusively for privileged tasks. Daily administrative activities should be performed from separate accounts with only the delegated roles required.", + "Url": "https://hub.prowler.com/check/directory_super_admin_only_admin_roles" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "directory_super_admin_count" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py new file mode 100644 index 0000000000..702e3445ce --- /dev/null +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py @@ -0,0 +1,60 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.directory.directory_client import ( + directory_client, +) + + +class directory_super_admin_only_admin_roles(Check): + """Check that super admin accounts are used only for super admin activities + + This check verifies that no super admin user has additional admin roles assigned + beyond the Super Admin role. Super admins should have separate accounts for daily + activities to follow least privilege. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if directory_client.users: + dual_role_admins = {} + for user in directory_client.users.values(): + if user.is_admin: + extra_roles = [ + r.description or r.name + for r in user.role_assignments + if not r.is_super_admin_role + ] + if extra_roles: + dual_role_admins[user.email] = extra_roles + + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=directory_client.provider.identity, + resource_name=directory_client.provider.identity.domain, + resource_id=directory_client.provider.identity.customer_id, + customer_id=directory_client.provider.identity.customer_id, + location="global", + ) + + if dual_role_admins: + details = ", ".join( + f"{email} ({', '.join(roles)})" + for email, roles in dual_role_admins.items() + ) + report.status = "FAIL" + report.status_extended = ( + f"Super admin accounts also holding additional admin roles: {details}. " + f"Super admin accounts should be used only for super admin activities." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"All super admin accounts in domain {directory_client.provider.identity.domain} " + f"are used only for super admin activities." + ) + + findings.append(report) + + return findings diff --git a/tests/providers/googleworkspace/googleworkspace_fixtures.py b/tests/providers/googleworkspace/googleworkspace_fixtures.py index c823c21339..792c4699c3 100644 --- a/tests/providers/googleworkspace/googleworkspace_fixtures.py +++ b/tests/providers/googleworkspace/googleworkspace_fixtures.py @@ -43,6 +43,39 @@ USER_3 = { } +# Role data for Directory API role tests +SUPER_ADMIN_ROLE_ID = "13801188331880449" +SEED_ADMIN_ROLE_ID = "13801188331880451" +GROUPS_ADMIN_ROLE_ID = "13801188331880450" + +ROLE_SUPER_ADMIN = { + "roleId": SUPER_ADMIN_ROLE_ID, + "roleName": "Super Admin", + "roleDescription": "Super Admin", + "isSystemRole": True, + "isSuperAdminRole": True, +} + +# Google automatically assigns _SEED_ADMIN_ROLE to the first account that +# created the domain. It is a super-admin-capable system role with a +# different name, so it must also be excluded when counting "extra" roles. +ROLE_SEED_ADMIN = { + "roleId": SEED_ADMIN_ROLE_ID, + "roleName": "_SEED_ADMIN_ROLE", + "roleDescription": "Super Admin", + "isSystemRole": True, + "isSuperAdminRole": True, +} + +ROLE_GROUPS_ADMIN = { + "roleId": GROUPS_ADMIN_ROLE_ID, + "roleName": "_GROUPS_ADMIN_ROLE", + "roleDescription": "Groups Administrator", + "isSystemRole": True, + "isSuperAdminRole": False, +} + + def set_mocked_googleworkspace_provider( identity: GoogleWorkspaceIdentityInfo = GoogleWorkspaceIdentityInfo( domain=DOMAIN, diff --git a/tests/providers/googleworkspace/services/directory/directory_service_test.py b/tests/providers/googleworkspace/services/directory/directory_service_test.py index 83c7e594c0..50fc8e00bd 100644 --- a/tests/providers/googleworkspace/services/directory/directory_service_test.py +++ b/tests/providers/googleworkspace/services/directory/directory_service_test.py @@ -1,6 +1,12 @@ from unittest.mock import MagicMock, patch from tests.providers.googleworkspace.googleworkspace_fixtures import ( + GROUPS_ADMIN_ROLE_ID, + ROLE_GROUPS_ADMIN, + ROLE_SEED_ADMIN, + ROLE_SUPER_ADMIN, + SEED_ADMIN_ROLE_ID, + SUPER_ADMIN_ROLE_ID, USER_1, USER_2, USER_3, @@ -25,6 +31,24 @@ class TestDirectoryService: mock_service.users().list.return_value = mock_users_list mock_service.users().list_next.return_value = None + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": SUPER_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -67,6 +91,17 @@ class TestDirectoryService: mock_service.users().list.return_value = mock_users_list mock_service.users().list_next.return_value = None + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = {"items": []} + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -97,6 +132,16 @@ class TestDirectoryService: mock_service = MagicMock() mock_service.users().list.side_effect = Exception("API Error") + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = {"items": []} + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -130,3 +175,193 @@ class TestDirectoryService: assert user.id == "test-id" assert user.email == "test@test-company.com" assert user.is_admin is True + assert user.role_assignments == [] + + def test_directory_list_roles(self): + """Test that _list_roles correctly builds a roleId-to-roleName mapping""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + + # Mock empty users + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": []} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + # Mock roles response + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = {"items": []} + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + super_admin_role = directory._roles[SUPER_ADMIN_ROLE_ID] + assert super_admin_role.name == "Super Admin" + assert super_admin_role.description == "Super Admin" + assert super_admin_role.is_super_admin_role is True + + groups_admin_role = directory._roles[GROUPS_ADMIN_ROLE_ID] + assert groups_admin_role.name == "_GROUPS_ADMIN_ROLE" + assert groups_admin_role.description == "Groups Administrator" + assert groups_admin_role.is_super_admin_role is False + + def test_directory_role_assignments_populated(self): + """Test that role assignments are fetched and resolved for super admins""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + + # Mock users - one super admin + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": [USER_1]} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + # Mock roles + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user1-id", "roleId": GROUPS_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + user = directory.users["user1-id"] + role_names = [r.name for r in user.role_assignments] + role_descriptions = [r.description for r in user.role_assignments] + assert "Super Admin" in role_names + assert "_GROUPS_ADMIN_ROLE" in role_names + assert "Groups Administrator" in role_descriptions + assert len(user.role_assignments) == 2 + assert user.is_admin is True + + def test_directory_second_super_admin_detected_via_role_assignments(self): + """Regression: a second super admin whose users.list().isAdmin still + reads False (e.g. API propagation lag, or only holding + _SEED_ADMIN_ROLE) must still be recognised as a super admin through + the Role Assignments API, AND any extra non-super-admin roles they + hold must be surfaced on their User object.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + + stale_user_1 = { + "id": "user1-id", + "primaryEmail": "admin1@test-company.com", + "isAdmin": False, + } + stale_user_2 = { + "id": "user2-id", + "primaryEmail": "admin2@test-company.com", + "isAdmin": False, + } + mock_users_list = MagicMock() + mock_users_list.execute.return_value = {"users": [stale_user_1, stale_user_2]} + mock_service.users().list.return_value = mock_users_list + mock_service.users().list_next.return_value = None + + mock_roles_list = MagicMock() + mock_roles_list.execute.return_value = { + "items": [ROLE_SUPER_ADMIN, ROLE_SEED_ADMIN, ROLE_GROUPS_ADMIN] + } + mock_service.roles().list.return_value = mock_roles_list + mock_service.roles().list_next.return_value = None + + mock_ra = MagicMock() + mock_ra.execute.return_value = { + "items": [ + {"assignedTo": "user1-id", "roleId": SEED_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": SUPER_ADMIN_ROLE_ID}, + {"assignedTo": "user2-id", "roleId": GROUPS_ADMIN_ROLE_ID}, + ] + } + mock_service.roleAssignments().list.return_value = mock_ra + mock_service.roleAssignments().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.directory.directory_service import ( + Directory, + ) + + directory = Directory(mock_provider) + + user1 = directory.users["user1-id"] + user2 = directory.users["user2-id"] + assert user1.is_admin is True + assert user2.is_admin is True + + assert [r.name for r in user1.role_assignments] == ["_SEED_ADMIN_ROLE"] + user2_role_names = {r.name for r in user2.role_assignments} + assert user2_role_names == {"Super Admin", "_GROUPS_ADMIN_ROLE"} diff --git a/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py new file mode 100644 index 0000000000..4025717bf7 --- /dev/null +++ b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py @@ -0,0 +1,446 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.directory.directory_service import ( + Role, + User, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + DOMAIN, + set_mocked_googleworkspace_provider, +) + +SUPER_ADMIN_ROLE = Role( + id="13801188331880449", + name="Super Admin", + description="Super Admin", + is_super_admin_role=True, +) +SEED_ADMIN_ROLE = Role( + id="13801188331880451", + name="_SEED_ADMIN_ROLE", + description="Super Admin", + is_super_admin_role=True, +) +GROUPS_ADMIN_ROLE = Role( + id="13801188331880450", + name="_GROUPS_ADMIN_ROLE", + description="Groups Administrator", + is_super_admin_role=False, +) +USER_MANAGEMENT_ADMIN_ROLE = Role( + id="13801188331880452", + name="_USER_MANAGEMENT_ADMIN_ROLE", + description="User Management Administrator", + is_super_admin_role=False, +) +CUSTOM_ROLE_NO_DESCRIPTION = Role( + id="13801188331880453", + name="custom-helpdesk-role", + description="", + is_super_admin_role=False, +) + + +class TestDirectorySuperAdminOnlyAdminRoles: + def test_pass_super_admins_only_super_admin_role(self): + """Test PASS when super admins have only the Super Admin role""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "admin2-id": User( + id="admin2-id", + email="admin2@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "user1-id": User( + id="user1-id", + email="user@test-company.com", + is_admin=False, + role_assignments=[], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "used only for super admin activities" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_super_admin_with_seed_admin_role(self): + """Test PASS when a super admin only holds _SEED_ADMIN_ROLE. + + _SEED_ADMIN_ROLE is auto-assigned by Google to the original domain + creator and has isSuperAdminRole=True, so it must not count as an + "extra" role. + """ + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SEED_ADMIN_ROLE], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "_SEED_ADMIN_ROLE" not in findings[0].status_extended + + def test_pass_super_admin_with_both_super_admin_and_seed_admin(self): + """Test PASS when admin holds both Super Admin and _SEED_ADMIN_ROLE""" + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, SEED_ADMIN_ROLE], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_fail_super_admin_with_additional_roles(self): + """Test FAIL when a super admin also has additional admin roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + "user1-id": User( + id="user1-id", + email="user@test-company.com", + is_admin=False, + role_assignments=[], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "admin1@test-company.com" in findings[0].status_extended + assert "Groups Administrator" in findings[0].status_extended + assert "_GROUPS_ADMIN_ROLE" not in findings[0].status_extended + assert "used only for super admin activities" in findings[0].status_extended + assert findings[0].resource_name == DOMAIN + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_seed_admin_with_additional_roles(self): + """Test FAIL when a _SEED_ADMIN_ROLE holder also has extra roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="playground@prowler.cloud", + is_admin=True, + role_assignments=[SEED_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "playground@prowler.cloud" in findings[0].status_extended + assert "Groups Administrator" in findings[0].status_extended + assert "_GROUPS_ADMIN_ROLE" not in findings[0].status_extended + assert "_SEED_ADMIN_ROLE" not in findings[0].status_extended + + def test_fail_multiple_super_admins_with_extra_roles(self): + """Test FAIL lists all super admins that have additional roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, GROUPS_ADMIN_ROLE], + ), + "admin2-id": User( + id="admin2-id", + email="admin2@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, USER_MANAGEMENT_ADMIN_ROLE], + ), + "admin3-id": User( + id="admin3-id", + email="admin3@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "admin1@test-company.com" in findings[0].status_extended + assert "admin2@test-company.com" in findings[0].status_extended + assert "admin3@test-company.com" not in findings[0].status_extended + + def test_no_findings_when_no_users(self): + """Test no findings when there are no users""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = {} + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 0 + + def test_non_super_admin_with_roles_not_flagged(self): + """Test that users who are not super admins are ignored even if they have roles""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE], + ), + "delegated1-id": User( + id="delegated1-id", + email="delegated@test-company.com", + is_admin=False, + role_assignments=[GROUPS_ADMIN_ROLE], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "delegated@test-company.com" not in findings[0].status_extended + + def test_pass_super_admin_with_empty_role_assignments(self): + """Test PASS when super admin has no role assignments (edge case)""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_fail_custom_role_without_description_falls_back_to_name(self): + """A custom role with an empty description should be displayed + using its name as a fall-back, so the FAIL message is never blank + for users that genuinely hold extra roles.""" + users = { + "admin1-id": User( + id="admin1-id", + email="admin1@test-company.com", + is_admin=True, + role_assignments=[SUPER_ADMIN_ROLE, CUSTOM_ROLE_NO_DESCRIPTION], + ), + } + + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles.directory_client" + ) as mock_directory_client, + ): + from prowler.providers.googleworkspace.services.directory.directory_super_admin_only_admin_roles.directory_super_admin_only_admin_roles import ( + directory_super_admin_only_admin_roles, + ) + + mock_directory_client.users = users + mock_directory_client.provider = mock_provider + + check = directory_super_admin_only_admin_roles() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "custom-helpdesk-role" in findings[0].status_extended