diff --git a/cdpctl/templates/config_azure.yml.j2 b/cdpctl/templates/config_azure.yml.j2 index 3a93b7f..0ea3ea0 100644 --- a/cdpctl/templates/config_azure.yml.j2 +++ b/cdpctl/templates/config_azure.yml.j2 @@ -38,6 +38,10 @@ env: ## The Ranger Audit Identity to be used for the environment # example: azure-cdp-ranger_audit-identity ranger_audit: {{ info['env:azure:role:name:ranger_audit']|default("", true) }} + + ## The Cloudera Management Console Operator Identity to be used for the environment + # example: azure-cdp-cross_account-identity + cross_account: {{ info['env:azure:role:name:cross_account']|default("", true) }} storage: ## The Storage Account used for the environment name: {{ info['env:azure:storage:name']|default("", true) }} diff --git a/cdpctl/validation/infra/conftest.py b/cdpctl/validation/infra/conftest.py index dbaac38..b02417f 100644 --- a/cdpctl/validation/infra/conftest.py +++ b/cdpctl/validation/infra/conftest.py @@ -440,3 +440,89 @@ def azure_assumer_required_resource_group_actions() -> List[str]: "Microsoft.Support/operationsstatus/read", "Microsoft.Support/operations/read", ] + + +@pytest.fixture +def azure_cross_account_required_resource_group_actions() -> List[str]: + """Get the Azure actions needed for the cross account identity.""" + return [ + "Microsoft.Storage/storageAccounts/read", + "Microsoft.Storage/storageAccounts/write", + "Microsoft.Storage/storageAccounts/blobServices/write", + "Microsoft.Storage/storageAccounts/blobServices/containers/delete", + "Microsoft.Storage/storageAccounts/blobServices/containers/read", + "Microsoft.Storage/storageAccounts/blobServices/containers/write", + "Microsoft.Storage/storageAccounts/fileServices/write", + "Microsoft.Storage/storageAccounts/listkeys/action", + "Microsoft.Storage/storageAccounts/regeneratekey/action", + "Microsoft.Storage/storageAccounts/delete", + "Microsoft.Storage/locations/deleteVirtualNetworkOrSubnets/action", + "Microsoft.Network/virtualNetworks/read", + "Microsoft.Network/virtualNetworks/write", + "Microsoft.Network/virtualNetworks/delete", + "Microsoft.Network/virtualNetworks/subnets/read", + "Microsoft.Network/virtualNetworks/subnets/write", + "Microsoft.Network/virtualNetworks/subnets/delete", + "Microsoft.Network/virtualNetworks/subnets/join/action", + "Microsoft.Network/publicIPAddresses/read", + "Microsoft.Network/publicIPAddresses/write", + "Microsoft.Network/publicIPAddresses/delete", + "Microsoft.Network/publicIPAddresses/join/action", + "Microsoft.Network/networkInterfaces/read", + "Microsoft.Network/networkInterfaces/write", + "Microsoft.Network/networkInterfaces/delete", + "Microsoft.Network/networkInterfaces/join/action", + "Microsoft.Network/networkInterfaces/ipconfigurations/read", + "Microsoft.Network/networkSecurityGroups/read", + "Microsoft.Network/networkSecurityGroups/write", + "Microsoft.Network/networkSecurityGroups/delete", + "Microsoft.Network/networkSecurityGroups/join/action", + "Microsoft.Network/virtualNetworks/subnets/joinViaServiceEndpoint/action", + "Microsoft.Network/loadBalancers/delete", + "Microsoft.Network/loadBalancers/read", + "Microsoft.Network/loadBalancers/write", + "Microsoft.Network/loadBalancers/backendAddressPools/join/action", + "Microsoft.Compute/availabilitySets/read", + "Microsoft.Compute/availabilitySets/write", + "Microsoft.Compute/availabilitySets/delete", + "Microsoft.Compute/disks/read", + "Microsoft.Compute/disks/write", + "Microsoft.Compute/disks/delete", + "Microsoft.Compute/images/read", + "Microsoft.Compute/images/write", + "Microsoft.Compute/images/delete", + "Microsoft.Compute/virtualMachines/read", + "Microsoft.Compute/virtualMachines/write", + "Microsoft.Compute/virtualMachines/delete", + "Microsoft.Compute/virtualMachines/start/action", + "Microsoft.Compute/virtualMachines/restart/action", + "Microsoft.Compute/virtualMachines/deallocate/action", + "Microsoft.Compute/virtualMachines/powerOff/action", + "Microsoft.Compute/virtualMachines/vmSizes/read", + "Microsoft.Authorization/roleAssignments/read", + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Resources/deployments/read", + "Microsoft.Resources/deployments/write", + "Microsoft.Resources/deployments/delete", + "Microsoft.Resources/deployments/operations/read", + "Microsoft.Resources/deployments/operationstatuses/read", + "Microsoft.Resources/deployments/exportTemplate/action", + "Microsoft.Resources/subscriptions/read", + "Microsoft.ManagedIdentity/userAssignedIdentities/read", + "Microsoft.ManagedIdentity/userAssignedIdentities/assign/action", + "Microsoft.DBforPostgreSQL/servers/write", + "Microsoft.DBforPostgreSQL/servers/delete", + "Microsoft.DBforPostgreSQL/servers/virtualNetworkRules/write", + "Microsoft.Resources/deployments/cancel/action", + ] + + +@pytest.fixture +def azure_cross_account_required_resource_group_data_actions() -> List[str]: + """Get the Azure data actions needed for the cross account identity.""" + return [ + "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read", + "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write", + "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/delete", + "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/add/action", + ] diff --git a/cdpctl/validation/infra/validate_azure_cross_account_identity.py b/cdpctl/validation/infra/validate_azure_cross_account_identity.py new file mode 100644 index 0000000..97525e2 --- /dev/null +++ b/cdpctl/validation/infra/validate_azure_cross_account_identity.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +### +# CLOUDERA CDP Control (cdpctl) +# +# (C) Cloudera, Inc. 2021-2021 +# All rights reserved. +# +# Applicable Open Source License: GNU AFFERO GENERAL PUBLIC LICENSE +# +# NOTE: Cloudera open source products are modular software products +# made up of hundreds of individual components, each of which was +# individually copyrighted. Each Cloudera open source product is a +# collective work under U.S. Copyright Law. Your license to use the +# collective work is as provided in your written agreement with +# Cloudera. Used apart from the collective work, this file is +# licensed for your use pursuant to the open source license +# identified above. +# +# This code is provided to you pursuant a written agreement with +# (i) Cloudera, Inc. or (ii) a third-party authorized to distribute +# this code. If you do not have a written agreement with Cloudera nor +# with an authorized and properly licensed third party, you do not +# have any rights to access nor to use this code. +# +# Absent a written agreement with Cloudera, Inc. (“Cloudera”) to the +# contrary, A) CLOUDERA PROVIDES THIS CODE TO YOU WITHOUT WARRANTIES OF ANY +# KIND; (B) CLOUDERA DISCLAIMS ANY AND ALL EXPRESS AND IMPLIED +# WARRANTIES WITH RESPECT TO THIS CODE, INCLUDING BUT NOT LIMITED TO +# IMPLIED WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY AND +# FITNESS FOR A PARTICULAR PURPOSE; (C) CLOUDERA IS NOT LIABLE TO YOU, +# AND WILL NOT DEFEND, INDEMNIFY, NOR HOLD YOU HARMLESS FOR ANY CLAIMS +# ARISING FROM OR RELATED TO THE CODE; AND (D)WITH RESPECT TO YOUR EXERCISE +# OF ANY RIGHTS GRANTED TO YOU FOR THE CODE, CLOUDERA IS NOT LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE OR +# CONSEQUENTIAL DAMAGES INCLUDING, BUT NOT LIMITED TO, DAMAGES +# RELATED TO LOST REVENUE, LOST PROFITS, LOSS OF INCOME, LOSS OF +# BUSINESS ADVANTAGE OR UNAVAILABILITY, OR LOSS OR CORRUPTION OF +# DATA. +# +# Source File Name: validate_azure_cross_account_identity.py +### +"""Validation of Azure Cross Account Identity.""" +# "Cross Account Identity" is equivalent to "Cloudera Management Console Operator Identity" # noqa: D401,E501 +# Customer facing text changed to "Cloudera Management Console Operator Identity" to +# match the documentation + +from typing import Any, Dict, List + +import pytest +from azure.mgmt.authorization import AuthorizationManagementClient +from azure.mgmt.resource import ResourceManagementClient + +from cdpctl.validation import fail, get_config_value +from cdpctl.validation.azure_utils import ( + check_for_actions, + get_client, + get_resource_group_scope, + get_role_assignments, +) +from cdpctl.validation.infra.issues import ( + AZURE_IDENTITY_MISSING_ACTIONS_FOR_LOCATION, + AZURE_IDENTITY_MISSING_DATA_ACTIONS_FOR_LOCATION, +) + +_cross_account_info = {} + + +@pytest.fixture(name="cross_account_info") +def cross_account_info_fixture(): + """Get the info for the Cross Account Identity.""" + return _cross_account_info + + +@pytest.fixture(autouse=True, name="resource_client") +def resource_client_fixture(config: Dict[str, Any]) -> ResourceManagementClient: + """Return an Azure Resource Client.""" + return get_client("resource", config) + + +@pytest.fixture(autouse=True, name="auth_client") +def auth_client_fixture(config: Dict[str, Any]) -> AuthorizationManagementClient: + """Return an Azure Auth Client.""" + return get_client("auth", config) + + +@pytest.mark.azure +@pytest.mark.infra +def azure_cross_account_identity_exists_validation( + config: Dict[str, Any], + auth_client: AuthorizationManagementClient, + resource_client: ResourceManagementClient, +) -> None: # pragma: no cover + """Azure Cloudera Management Console Operator Identity exists.""" + + _cross_account_info["sub_id"] = get_config_value( + config=config, key="infra:azure:subscription_id" + ) + _cross_account_info["rg_name"] = get_config_value( + config=config, key="infra:azure:metagroup:name" + ) + _cross_account_info["name"] = get_config_value( + config=config, key="env:azure:role:name:cross_account" + ) + + _cross_account_info["assignments"] = get_role_assignments( + auth_client=auth_client, + resource_client=resource_client, + identity_name=_cross_account_info["name"], + subscription_id=_cross_account_info["sub_id"], + resource_group=_cross_account_info["rg_name"], + ) + + +@pytest.mark.azure +@pytest.mark.infra +@pytest.mark.dependency(depends=["azure_cross_account_identity_exists_validation"]) +def azure_cross_account_rg_actions_validation( + auth_client: AuthorizationManagementClient, + azure_cross_account_required_resource_group_actions: List[str], + cross_account_info, +) -> None: # pragma: no cover + """Cloudera Management Console Operator Identity has the necessary actions on the resource group.""" # noqa: D401,E501 + + proper_scope = get_resource_group_scope( + subscription_id=cross_account_info["sub_id"], + resource_group=cross_account_info["rg_name"], + ) + + missing_actions, _ = check_for_actions( + auth_client=auth_client, + role_assigments=cross_account_info["assignments"], + proper_scope=proper_scope, + required_actions=azure_cross_account_required_resource_group_actions, + required_data_actions=[], + ) + + if missing_actions: + fail( + AZURE_IDENTITY_MISSING_ACTIONS_FOR_LOCATION, + subjects=[ + cross_account_info["name"], + proper_scope, # noqa: E501 + ], + resources=missing_actions, + ) + + +@pytest.mark.azure +@pytest.mark.infra +@pytest.mark.dependency(depends=["azure_cross_account_identity_exists_validation"]) +def azure_cross_account_rg_data_actions_validation( + auth_client: AuthorizationManagementClient, + azure_cross_account_required_resource_group_data_actions: List[str], + cross_account_info, +) -> None: # pragma: no cover + """Cloudera Management Console Operator Identity has the necessary data actions on the resource group.""" # noqa: D401,E501 + + proper_scope = get_resource_group_scope( + subscription_id=cross_account_info["sub_id"], + resource_group=cross_account_info["rg_name"], + ) + + _, missing_data_actions = check_for_actions( + auth_client=auth_client, + role_assigments=cross_account_info["assignments"], + proper_scope=proper_scope, + required_actions=[], + required_data_actions=azure_cross_account_required_resource_group_data_actions, + ) + if missing_data_actions: + fail( + AZURE_IDENTITY_MISSING_DATA_ACTIONS_FOR_LOCATION, + subjects=[ + cross_account_info["name"], + proper_scope, # noqa: E501 + ], + resources=missing_data_actions, + ) diff --git a/tests/validation/infra/conftest.py b/tests/validation/infra/conftest.py index f567b33..59c8a83 100644 --- a/tests/validation/infra/conftest.py +++ b/tests/validation/infra/conftest.py @@ -44,6 +44,8 @@ """Import validation fixtures.""" from cdpctl.validation.infra.conftest import ( autoscaling_resources_needed_actions, + azure_cross_account_required_resource_group_actions, + azure_cross_account_required_resource_group_data_actions, azure_data_required_actions, azure_data_required_data_actions, cdp_cidrs, diff --git a/tests/validation/infra/test_validate_azure_cross_account_identity.py b/tests/validation/infra/test_validate_azure_cross_account_identity.py new file mode 100644 index 0000000..6127041 --- /dev/null +++ b/tests/validation/infra/test_validate_azure_cross_account_identity.py @@ -0,0 +1,298 @@ +### +# CLOUDERA CDP Control (cdpctl) +# +# (C) Cloudera, Inc. 2021-2021 +# All rights reserved. +# +# Applicable Open Source License: GNU AFFERO GENERAL PUBLIC LICENSE +# +# NOTE: Cloudera open source products are modular software products +# made up of hundreds of individual components, each of which was +# individually copyrighted. Each Cloudera open source product is a +# collective work under U.S. Copyright Law. Your license to use the +# collective work is as provided in your written agreement with +# Cloudera. Used apart from the collective work, this file is +# licensed for your use pursuant to the open source license +# identified above. +# +# This code is provided to you pursuant a written agreement with +# (i) Cloudera, Inc. or (ii) a third-party authorized to distribute +# this code. If you do not have a written agreement with Cloudera nor +# with an authorized and properly licensed third party, you do not +# have any rights to access nor to use this code. +# +# Absent a written agreement with Cloudera, Inc. (“Cloudera”) to the +# contrary, A) CLOUDERA PROVIDES THIS CODE TO YOU WITHOUT WARRANTIES OF ANY +# KIND; (B) CLOUDERA DISCLAIMS ANY AND ALL EXPRESS AND IMPLIED +# WARRANTIES WITH RESPECT TO THIS CODE, INCLUDING BUT NOT LIMITED TO +# IMPLIED WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY AND +# FITNESS FOR A PARTICULAR PURPOSE; (C) CLOUDERA IS NOT LIABLE TO YOU, +# AND WILL NOT DEFEND, INDEMNIFY, NOR HOLD YOU HARMLESS FOR ANY CLAIMS +# ARISING FROM OR RELATED TO THE CODE; AND (D)WITH RESPECT TO YOUR EXERCISE +# OF ANY RIGHTS GRANTED TO YOU FOR THE CODE, CLOUDERA IS NOT LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE OR +# CONSEQUENTIAL DAMAGES INCLUDING, BUT NOT LIMITED TO, DAMAGES +# RELATED TO LOST REVENUE, LOST PROFITS, LOSS OF INCOME, LOSS OF +# BUSINESS ADVANTAGE OR UNAVAILABILITY, OR LOSS OR CORRUPTION OF +# DATA. +# +# Source File Name: test_validate_azure_cross_account_identity.py +### +"""Azure Validate Cross Account Identity Tests.""" +import dataclasses +from typing import Dict, List +from unittest.mock import Mock + +from azure.core.exceptions import ResourceNotFoundError +from azure.mgmt.authorization import AuthorizationManagementClient +from azure.mgmt.resource import ResourceManagementClient + +from cdpctl.validation.azure_utils import get_resource_group_scope +from cdpctl.validation.infra.validate_azure_cross_account_identity import ( + azure_cross_account_identity_exists_validation, + azure_cross_account_rg_actions_validation, + azure_cross_account_rg_data_actions_validation, +) +from tests.validation import expect_validation_failure, expect_validation_success + + +def get_config(role_name): + return { + "infra": { + "azure": {"subscription_id": "test_id", "metagroup": {"name": "rg_name"}} + }, + "env": { + "azure": { + "role": {"name": {"cross_account": f"{role_name}"}}, + "storage": { + "name": "storage_name", + "path": { + "logs": "abfs://logs@storage_name.dfs.core.windows.net", + }, + }, + } + }, + } + + +def setup_mocks( + resource_client: ResourceManagementClient, + auth_client: AuthorizationManagementClient, + identity_name: str, + azure_role: str, + scope: str, + cross_account_info: Dict, + actions: List, + data_actions: List, +): + ResourceGetbyidResponse = dataclasses.make_dataclass( + "ResourceGetbyidResponse", [("properties", Dict)] + ) + if identity_name == "notassumer": + resource_client.resources.get_by_id.side_effect = ResourceNotFoundError() + else: + resource_client.resources.get_by_id.return_value = ResourceGetbyidResponse( + {"principalId": identity_name} + ) + + AuthListResponseProperties = dataclasses.make_dataclass( + "AuthListResponseProperties", [("role_definition_id", str), ("scope", str)] + ) + auth_client.role_assignments.list.return_value = [ + AuthListResponseProperties(identity_name, scope) + ] + + Permission = dataclasses.make_dataclass( + "Permission", + [ + "actions", + "not_actions", + "data_actions", + "not_data_actions", + ], + ) + + RoleDefinition = dataclasses.make_dataclass( + "RoleDefinition", ["role_name", "permissions"] + ) + + auth_client.role_definitions.get_by_id.return_value = RoleDefinition( + azure_role, + [ + Permission( + actions=actions, + not_actions=[], + data_actions=data_actions, + not_data_actions=[], + ) + ], + ) + + cross_account_info["assignments"] = [ + AuthListResponseProperties(identity_name, scope) + ] + cross_account_info["name"] = identity_name + cross_account_info["sub_id"] = "test_id" + cross_account_info["rg_name"] = "rg_name" + + +def test_azure_cross_account_identity_exists_validation_success(): + scope = get_resource_group_scope("test_id", "rg_name") + + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name="cross_account", + azure_role="Virtual Machine Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=[], + data_actions=[], + ) + + func = expect_validation_success(azure_cross_account_identity_exists_validation) + func(get_config("cross_account"), auth_client, resource_client) + + +def test_azure_cross_account_identity_exists_validation_fail(): + scope = get_resource_group_scope("test_id", "rg_name") + + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name="notassumer", + azure_role="Virtual Machine Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=[], + data_actions=[], + ) + + func = expect_validation_failure(azure_cross_account_identity_exists_validation) + func(get_config("cross_account"), auth_client, resource_client) + + +def test_azure_cross_account_rg_actions_validation_success( + azure_cross_account_required_resource_group_actions: List[str], +): + identity_name = "cross_account" + scope = get_resource_group_scope("test_id", "rg_name") + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name=identity_name, + azure_role="Storage Blob Data Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=azure_cross_account_required_resource_group_actions, + data_actions=[], + ) + + func = expect_validation_success(azure_cross_account_rg_actions_validation) + func( + auth_client, + azure_cross_account_required_resource_group_actions, + cross_account_info, + ) + + +def test_azure_cross_account_rg_actions_validation_fail( + azure_cross_account_required_resource_group_actions: List[str], +): + identity_name = "cross_account" + scope = get_resource_group_scope("test_id", "rg_name") + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name=identity_name, + azure_role="Storage Blob Data Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=[], + data_actions=[], + ) + + func = expect_validation_failure(azure_cross_account_rg_actions_validation) + func( + auth_client, + azure_cross_account_required_resource_group_actions, + cross_account_info, + ) + + +def test_azure_cross_account_rg_data_actions_validation_success( + azure_cross_account_required_resource_group_data_actions: List[str], +): + identity_name = "cross_account" + scope = get_resource_group_scope("test_id", "rg_name") + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name=identity_name, + azure_role="Storage Blob Data Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=[], + data_actions=azure_cross_account_required_resource_group_data_actions, + ) + + func = expect_validation_success(azure_cross_account_rg_data_actions_validation) + func( + auth_client, + azure_cross_account_required_resource_group_data_actions, + cross_account_info, + ) + + +def test_azure_cross_account_rg_data_actions_validation_fail( + azure_cross_account_required_resource_group_data_actions: List[str], +): + identity_name = "cross_account" + scope = get_resource_group_scope("test_id", "rg_name") + resource_client = Mock(spec=ResourceManagementClient) + auth_client = Mock(spec=AuthorizationManagementClient) + + cross_account_info = {} + + setup_mocks( + resource_client=resource_client, + auth_client=auth_client, + identity_name=identity_name, + azure_role="Storage Blob Data Contributor", + scope=scope, + cross_account_info=cross_account_info, + actions=[], + data_actions=[], + ) + + func = expect_validation_failure(azure_cross_account_rg_data_actions_validation) + func( + auth_client, + azure_cross_account_required_resource_group_data_actions, + cross_account_info, + )