Repository: google/cloud-forensics-utils

Issue:
Add functionality to list project hierarchy
<!-- Edit the body of your new issue then click the ✓ "Create Issue" button in the top right of the editor. The first line will be the issue title. Assignees and Labels follow after a blank line. Leave an empty line before beginning the body of the issue. -->

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/374?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) Report
> :exclamation: No coverage uploaded for pull request base (`main@ed42f93`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/google/cloud-forensics-utils/pull/374/graphs/tree.svg?width=650&height=150&src=pr&token=zZkPno3ah0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)](https://codecov.io/gh/google/cloud-forensics-utils/pull/374?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)

```diff
@@           Coverage Diff           @@
##             main     #374   +/-   ##
=======================================
  Coverage        ?   59.33%           
=======================================
  Files           ?       36           
  Lines           ?     2978           
  Branches        ?        0           
=======================================
  Hits            ?     1767           
  Misses          ?     1211           
  Partials        ?        0           
```

| Flag | Coverage Δ | |
|---|---|---|
| nosetests | `59.33% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/374?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/374?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Last update [ed42f93...0c36c14](https://codecov.io/gh/google/cloud-forensics-utils/pull/374?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).


Patch:
diff --git a/libcloudforensics/providers/gcp/internal/cloudresourcemanager.py b/libcloudforensics/providers/gcp/internal/cloudresourcemanager.py
new file mode 100644
index 0000000..175089f
--- /dev/null
+++ b/libcloudforensics/providers/gcp/internal/cloudresourcemanager.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Google Cloud Resource Manager functionality."""
+
+from typing import TYPE_CHECKING, Dict, List, Any
+from libcloudforensics.providers.gcp.internal import common
+
+if TYPE_CHECKING:
+  import googleapiclient
+
+
+class GoogleCloudResourceManager:
+  """Class to call the Google Cloud Resource Manager API."""
+
+  RESOURCE_MANAGER_API_VERSION = 'v3'
+  RESOURCE_TYPES = ['projects', 'folders', 'organizations']
+
+  def GrmApi(self) -> 'googleapiclient.discovery.Resource':
+    """Get a Resource Manager service object.
+
+    Returns:
+      googleapiclient.discovery.Resource: A Resource Manager service
+          object.
+    """
+
+    return common.CreateService(
+        'cloudresourcemanager', self.RESOURCE_MANAGER_API_VERSION)
+
+  def ProjectAncestry(self, project_number: str) -> List[Any]:
+    """List ancestor resources for a project.
+
+    Args:
+      project_number (str): The project number to list ancestry for.
+
+    Returns:
+      List[Any]: the list of ancestor resources.
+    """
+
+    ancestry = []
+    current_resource = self.GetResource('projects/' + project_number)
+    ancestry.append(current_resource)
+
+    while 'parent' in current_resource:
+      parent_name = current_resource['parent']
+      current_resource = self.GetResource(parent_name)
+      ancestry.append(current_resource)
+
+    return ancestry
+
+  def GetResource(self, name: str) -> Dict[str, Any]:
+    """Get a Cloud Resource Manager resource.
+
+    Args:
+      name (str): a resource identifier in the format
+        resource_type/resource_number e.g. projects/123456789012 where
+        project_type is one of projects, folders or organizations.
+
+    Returns:
+      Dict[str, Any]: The resource details.
+
+    Raises:
+      TypeError: if an invalid resource type is provided.
+    """
+    resource_type = name.split('/')[0]
+    if resource_type not in self.RESOURCE_TYPES:
+      raise TypeError('Invalid resource type "{0:s}", resource must be one of '
+          '"projects", "folders" or "organizations" provided in the format '
+          '"resource_type/resource_number".'.format(name))
+
+    service = self.GrmApi()
+    resource_client = getattr(service, resource_type)()
+    request = {'name': name}
+    # Safe to unpack
+    response = common.ExecuteRequest(resource_client, 'get', request)[0]
+    return response
diff --git a/libcloudforensics/providers/gcp/internal/serviceusage.py b/libcloudforensics/providers/gcp/internal/serviceusage.py
new file mode 100644
index 0000000..7de6f92
--- /dev/null
+++ b/libcloudforensics/providers/gcp/internal/serviceusage.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Google Service Usage functionality."""
+
+from typing import TYPE_CHECKING, List, Any
+from libcloudforensics.providers.gcp.internal import common
+
+if TYPE_CHECKING:
+  import googleapiclient
+
+
+class GoogleServiceUsage:
+  """Class to call the Google Cloud Service Usage API."""
+
+  SERVICE_USAGE_API_VERSION = 'v1'
+
+  def GsuApi(self) -> 'googleapiclient.discovery.Resource':
+    """Get a Service Usage service object.
+
+    Returns:
+      googleapiclient.discovery.Resource: A Service Usage service object.
+    """
+
+    return common.CreateService(
+        'serviceusage', self.SERVICE_USAGE_API_VERSION)
+
+  def GetEnabled(self, project_number: str) -> List[Any]:
+    """Get enabled services/APIs for a project.
+
+    Args:
+      project_number (str): The project_number to fetch enabled APIs for.
+
+    Returns:
+      List[Any]: A list of enabled services/APIs.
+    """
+
+    services_client = self.GsuApi().services() # pylint: disable=no-member
+    parent = 'projects/' + project_number
+    request = {'parent': parent, 'filter': 'state:ENABLED'}
+    responses = common.ExecuteRequest(services_client, 'list', request)
+
+    services = []
+    for response in responses:
+      for service in response.get('services', []):
+        services.append(service['config']['name'])
+
+    return services
+
+  def EnableService(self, project_number: str, service_name: str) -> None:
+    """Enable a service/API for a project.
+
+    Args:
+      project_number (str): The project_number to enable the service for.
+      service_name (str): The service to enable.
+    """
+
+    services_client = self.GsuApi().services() # pylint: disable=no-member
+    name = 'projects/' + project_number + '/services/' + service_name
+    request = {'name': name}
+    common.ExecuteRequest(services_client, 'enable', request)
+
+  def DisableService(self, project_number: str, service_name: str) -> None:
+    """Disable a service/API for a project.
+
+    Args:
+      project_number (str): The project_number to disable the service for.
+      service_name (str): The service to disable.
+    """
+
+    services_client = self.GsuApi().services() # pylint: disable=no-member
+    name = 'projects/' + project_number + '/services/' + service_name
+    request = {'name': name}
+    common.ExecuteRequest(services_client, 'disable', request)


Test patch:
diff --git a/tests/providers/gcp/gcp_mocks.py b/tests/providers/gcp/gcp_mocks.py
index 4657cdb..ea5c65c 100644
--- a/tests/providers/gcp/gcp_mocks.py
+++ b/tests/providers/gcp/gcp_mocks.py
@@ -25,6 +25,8 @@ from libcloudforensics.providers.gcp.internal import monitoring as gcp_monitorin
 from libcloudforensics.providers.gcp.internal import storage as gcp_storage
 from libcloudforensics.providers.gcp.internal import storagetransfer as gcp_storagetransfer
 from libcloudforensics.providers.gcp.internal import cloudsql as gcp_cloudsql
+from libcloudforensics.providers.gcp.internal import cloudresourcemanager as gcp_resourcmanager
+from libcloudforensics.providers.gcp.internal import serviceusage as gcp_serviceusage
 # pylint: enable=line-too-long
 
 FAKE_ANALYSIS_PROJECT = gcp_project.GoogleCloudProject(
@@ -83,6 +85,8 @@ FAKE_GCST = gcp_storagetransfer.GoogleCloudStorageTransfer('fake-target-project'
 FAKE_GCB = gcp_build.GoogleCloudBuild('fake-target-project')
 FAKE_MONITORING = gcp_monitoring.GoogleCloudMonitoring('fake-target-project')
 FAKE_CLOUDSQLINSTANCE = gcp_cloudsql.GoogleCloudSQL('fake-target-project')
+FAKE_CLOUD_RESOURCE_MANAGER = gcp_resourcmanager.GoogleCloudResourceManager()
+FAKE_SERVICE_USAGE = gcp_serviceusage.GoogleServiceUsage()
 # pylint: enable=line-too-long
 
 # Mock struct to mimic GCP's API responses
@@ -743,3 +747,66 @@ MOCK_EFFECTIVE_FIREWALLS = {
     ]
 }
 # pylint: enable=line-too-long
+
+MOCK_CLOUD_RESOURCE_PROJECT = {
+    "createTime": "2020-01-01T00:00:00.000Z",
+    "displayName": "fake-project",
+    "etag": "Tm90IGFuIGV0YWd4eHh4eA==",
+    "name": "projects/000000000000",
+    "parent": "folders/111111111111",
+    "projectId": "fake-project",
+    "state": "ACTIVE",
+    "updateTime": "2020-01-01T00:00:00.000Z"
+  }
+
+MOCK_CLOUD_RESOURCE_FOLDER = {
+    "createTime": "2020-01-01T00:00:00.000Z",
+    "displayName": "fake-folder",
+    "etag": "Tm90IGFuIGV0YWd4eHh4eA==",
+    "name": "folders/111111111111",
+    "parent": "organizations/222222222222",
+    "state": "ACTIVE",
+    "updateTime": "2020-01-01T00:00:00.000Z"
+}
+
+MOCK_CLOUD_RESOURCE_ORGANIZATION = {
+    "createTime": "2020-01-01T00:00:00.000Z",
+    "directoryCustomerId": "bm9jdXN0",
+    "displayName": "fake-organization.com",
+    "etag": "Tm90IGFuIGV0YWd4eHh4eA==",
+    "name": "organizations/222222222222",
+    "state": "ACTIVE",
+    "updateTime": "2020-01-01T00:00:00.000Z"
+}
+
+# pylint: disable=line-too-long
+MOCK_ENABLED_SERVICES = [
+    {
+        "services": [
+            {
+                "config": {
+                    "name": "bigquery.googleapis.com",
+                    "title": "BigQuery API"
+                },
+                "name": "projects/000000000000/services/bigquery.googleapis.com",
+                "state": "ENABLED"
+            },
+            {
+                "config": {
+                    "name": "cloudapis.googleapis.com",
+                    "title": "Google Cloud APIs"
+                },
+                "name": "projects/000000000000/services/cloudapis.googleapis.com",
+                "state": "ENABLED"
+            },
+            {
+                "config": {
+                    "name": "compute.googleapis.com",
+                    "title": "Compute Engine API"
+                },
+                "name": "projects/000000000000/services/compute.googleapis.com",
+                "state": "ENABLED"
+            }
+        ]
+    }
+]
diff --git a/tests/providers/gcp/internal/test_cloudresourcemanager.py b/tests/providers/gcp/internal/test_cloudresourcemanager.py
new file mode 100644
index 0000000..40d3a1a
--- /dev/null
+++ b/tests/providers/gcp/internal/test_cloudresourcemanager.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for the gcp module - cloudresourcemanager.py"""
+import typing
+import unittest
+
+import mock
+
+from tests.providers.gcp import gcp_mocks
+
+
+class GoogleCloudResourceManagerTest(unittest.TestCase):
+  """Test Google Cloud Resource Manager class."""
+  # pylint: disable=line-too-long
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.cloudresourcemanager.GoogleCloudResourceManager.GrmApi')
+  def testGetResource(self, mock_grm_api, mock_execute_request):
+    """Validates the GetResource function"""
+    mock_execute_request.return_value = [gcp_mocks.MOCK_CLOUD_RESOURCE_PROJECT]
+    mock_resource_client = mock_grm_api.return_value.projects.return_value
+    response = gcp_mocks.FAKE_CLOUD_RESOURCE_MANAGER.GetResource(
+        'projects/000000000000')
+    mock_execute_request.assert_called_with(mock_resource_client,
+        'get', {'name': 'projects/000000000000'})
+    self.assertEqual(response,
+        {
+          "createTime": "2020-01-01T00:00:00.000Z",
+          "displayName": "fake-project",
+          "etag": "Tm90IGFuIGV0YWd4eHh4eA==",
+          "name": "projects/000000000000",
+          "parent": "folders/111111111111",
+          "projectId": "fake-project",
+          "state": "ACTIVE",
+          "updateTime": "2020-01-01T00:00:00.000Z"
+        })
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.cloudresourcemanager.GoogleCloudResourceManager.GrmApi')
+  def testGetResourceInvalidName(self, _, __):
+    """Validates the GetResource function raises an exception for an invalid
+    resource name."""
+    with self.assertRaises(TypeError):
+      gcp_mocks.FAKE_CLOUD_RESOURCE_MANAGER.GetResource(
+          'badtype/000000000000')
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.cloudresourcemanager.GoogleCloudResourceManager.GrmApi')
+  def testGetProjectAncestry(self, _, mock_execute_request):
+    """Validates the GetProjectAncestry function"""
+    mock_execute_request.side_effect = [
+        [gcp_mocks.MOCK_CLOUD_RESOURCE_PROJECT],
+        [gcp_mocks.MOCK_CLOUD_RESOURCE_FOLDER],
+        [gcp_mocks.MOCK_CLOUD_RESOURCE_ORGANIZATION]
+    ]
+    response = gcp_mocks.FAKE_CLOUD_RESOURCE_MANAGER.ProjectAncestry(
+        '000000000000')
+    self.assertListEqual(response,
+        [
+          {
+            'createTime': '2020-01-01T00:00:00.000Z',
+            'displayName': 'fake-project',
+            'etag': 'Tm90IGFuIGV0YWd4eHh4eA==',
+            'name': 'projects/000000000000',
+            'parent': 'folders/111111111111',
+            'projectId': 'fake-project',
+            'state': 'ACTIVE', 'updateTime': '2020-01-01T00:00:00.000Z'
+          },
+          {
+            'createTime': '2020-01-01T00:00:00.000Z',
+            'displayName': 'fake-folder',
+            'etag': 'Tm90IGFuIGV0YWd4eHh4eA==',
+            'name': 'folders/111111111111',
+            'parent': 'organizations/222222222222',
+            'state': 'ACTIVE',
+            'updateTime': '2020-01-01T00:00:00.000Z'
+          },
+          {
+            'createTime': '2020-01-01T00:00:00.000Z',
+            'directoryCustomerId': 'bm9jdXN0',
+            'displayName': 'fake-organization.com',
+            'etag': 'Tm90IGFuIGV0YWd4eHh4eA==',
+            'name': 'organizations/222222222222',
+            'state': 'ACTIVE',
+            'updateTime': '2020-01-01T00:00:00.000Z'
+          }
+        ])
diff --git a/tests/providers/gcp/internal/test_serviceusage.py b/tests/providers/gcp/internal/test_serviceusage.py
new file mode 100644
index 0000000..bbf1e37
--- /dev/null
+++ b/tests/providers/gcp/internal/test_serviceusage.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for the gcp module - serviceusage.py"""
+import typing
+import unittest
+
+import mock
+
+from tests.providers.gcp import gcp_mocks
+
+
+class GoogleServiceUsageTest(unittest.TestCase):
+  """Test Google Service Usage class."""
+  # pylint: disable=line-too-long
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.serviceusage.GoogleServiceUsage.GsuApi')
+  def testGetEnabled(self, mock_gsu_api, mock_execute_request):
+    """Validates the GetEnabled function"""
+    mock_execute_request.return_value = gcp_mocks.MOCK_ENABLED_SERVICES
+    mock_service_usage = mock_gsu_api.return_value.services.return_value
+    response = gcp_mocks.FAKE_SERVICE_USAGE.GetEnabled('000000000000')
+
+    mock_execute_request.assert_called_with(mock_service_usage,
+        'list', {'parent': 'projects/000000000000', 'filter': 'state:ENABLED'})
+
+    self.assertListEqual(response, [
+        'bigquery.googleapis.com',
+        'cloudapis.googleapis.com',
+        'compute.googleapis.com'
+      ])
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.serviceusage.GoogleServiceUsage.GsuApi')
+  def testEnableService(self, mock_gsu_api, mock_execute_request):
+    """Validates that EnableService calls ExecuteRequest with the correct
+    arguments."""
+    mock_service_usage = mock_gsu_api.return_value.services.return_value
+    gcp_mocks.FAKE_SERVICE_USAGE.EnableService(
+        '000000000000','container.googleapis.com')
+
+    mock_execute_request.assert_called_with(mock_service_usage, 'enable',
+        {'name': 'projects/000000000000/services/container.googleapis.com'})
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.common.ExecuteRequest')
+  @mock.patch('libcloudforensics.providers.gcp.internal.serviceusage.GoogleServiceUsage.GsuApi')
+  def testDisableService(self, mock_gsu_api, mock_execute_request):
+    """Validates that DisableService calls ExecuteRequest with the correct
+    arguments."""
+    mock_service_usage = mock_gsu_api.return_value.services.return_value
+    gcp_mocks.FAKE_SERVICE_USAGE.DisableService(
+        '000000000000','container.googleapis.com')
+
+    mock_execute_request.assert_called_with(mock_service_usage, 'disable',
+        {'name': 'projects/000000000000/services/container.googleapis.com'})