diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 2f571b59..13b474e6 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -1,5 +1,4 @@ import json -import re from secrets import token_urlsafe from typing import Any, Dict from uuid import uuid4 @@ -9,6 +8,10 @@ from atst.utils import sha256_hex from .cloud_provider_interface import CloudProviderInterface from .exceptions import AuthenticationException from .models import ( + SubscriptionCreationCSPPayload, + SubscriptionCreationCSPResult, + SubscriptionVerificationCSPPayload, + SuscriptionVerificationCSPResult, AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPResult, ApplicationCSPPayload, @@ -48,10 +51,6 @@ from .models import ( ) from .policy import AzurePolicyManager -SUBSCRIPTION_ID_REGEX = re.compile( - "subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})", - re.I, -) # This needs to be a fully pathed role definition identifier, not just a UUID # TODO: Extract these from sdk msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD @@ -236,49 +235,6 @@ class AzureCloudProvider(CloudProviderInterface): # instead? return create_request.result() - def _create_subscription( - self, - credentials, - display_name, - billing_profile_id, - sku_id, - management_group_id, - billing_account_name, - invoice_section_name, - ): - sub_client = self.sdk.subscription.SubscriptionClient(credentials) - - billing_profile_id = "?" # where do we source this? - sku_id = AZURE_SKU_ID - # These 2 seem like something that might be worthwhile to allow tiebacks to - # TOs filed for the environment - billing_account_name = "?" # from TO? - invoice_section_name = "?" # from TO? - - body = self.sdk.subscription.models.ModernSubscriptionCreationParameters( - display_name=display_name, - billing_profile_id=billing_profile_id, - sku_id=sku_id, - management_group_id=management_group_id, - ) - - # We may also want to create billing sections in the enrollment account - sub_creation_operation = sub_client.subscription_factory.create_subscription( - billing_account_name, invoice_section_name, body - ) - - # the resulting object from this process is a link to the new subscription - # not a subscription model, so we'll have to unpack the ID - new_sub = sub_creation_operation.result() - - subscription_id = self._extract_subscription_id(new_sub.subscription_link) - if subscription_id: - return subscription_id - else: - # troublesome error, subscription should exist at this point - # but we just don't have a valid ID - pass - def _create_policy_definition( self, credentials, subscription_id, management_group_id, properties, ): @@ -522,6 +478,59 @@ class AzureCloudProvider(CloudProviderInterface): else: return self._error(result.json()) + def create_subscription(self, payload: SubscriptionCreationCSPPayload): + sp_token = self._get_tenant_principal_token(payload.tenant_id) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for subscription creation" + ) + + request_body = { + "displayName": payload.display_name, + "skuId": AZURE_SKU_ID, + "managementGroupId": payload.parent_group_id, + } + + url = f"{self.sdk.cloud.endpoints.resource_manager}/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/invoiceSections/{payload.invoice_section_name}/providers/Microsoft.Subscription/createSubscription?api-version=2019-10-01-preview" + + auth_header = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.put(url, headers=auth_header, json=request_body) + + if result.status_code in [200, 202]: + # 202 has location/retry after headers + return SubscriptionCreationCSPResult(**result.headers, **result.json()) + else: + return self._error(result.json()) + + def create_subscription_creation(self, payload: SubscriptionCreationCSPPayload): + return self.create_subscription(payload) + + def create_subscription_verification( + self, payload: SubscriptionVerificationCSPPayload + ): + sp_token = self._get_tenant_principal_token(payload.tenant_id) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for subscription verification" + ) + + auth_header = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.get( + payload.subscription_verify_url, headers=auth_header + ) + + if result.ok: + # 202 has location/retry after headers + return SuscriptionVerificationCSPResult(**result.json()) + else: + return self._error(result.json()) + def create_product_purchase(self, payload: ProductPurchaseCSPPayload): sp_token = self._get_root_provisioning_token() if sp_token is None: @@ -930,6 +939,12 @@ class AzureCloudProvider(CloudProviderInterface): "tenant_id": self.tenant_id, } + def _get_tenant_principal_token(self, tenant_id): + creds = self._source_creds(tenant_id) + return self._get_sp_token( + creds.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key + ) + def _get_elevated_management_token(self, tenant_id): mgmt_token = self._get_tenant_admin_token( tenant_id, self.sdk.cloud.endpoints.resource_manager diff --git a/atst/domain/csp/cloud/cloud_provider_interface.py b/atst/domain/csp/cloud/cloud_provider_interface.py index 5f4b9ab5..d173396a 100644 --- a/atst/domain/csp/cloud/cloud_provider_interface.py +++ b/atst/domain/csp/cloud/cloud_provider_interface.py @@ -112,9 +112,3 @@ class CloudProviderInterface: This may move to be a computed property on the Environment domain object """ raise NotImplementedError() - - def create_subscription(self, environment): - """Returns True if a new subscription has been created or raises an - exception if an error occurs while creating a subscription. - """ - raise NotImplementedError() diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index 7a41cb31..dce01f4e 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -31,6 +31,10 @@ from .models import ( ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + SubscriptionCreationCSPPayload, + SubscriptionCreationCSPResult, + SubscriptionVerificationCSPPayload, + SuscriptionVerificationCSPResult, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -113,6 +117,29 @@ class MockCloudProvider(CloudProviderInterface): return csp_environment_id + def create_subscription(self, payload: SubscriptionCreationCSPPayload): + return self.create_subscription_creation(payload) + + def create_subscription_creation(self, payload: SubscriptionCreationCSPPayload): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return SubscriptionCreationCSPResult( + subscription_verify_url="https://zombo.com", subscription_retry_after=10 + ) + + def create_subscription_verification( + self, payload: SubscriptionVerificationCSPPayload + ): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return SuscriptionVerificationCSPResult( + subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230" + ) + def create_atat_admin_user(self, auth_credentials, csp_environment_id): self._authorize(auth_credentials) @@ -408,11 +435,6 @@ class MockCloudProvider(CloudProviderInterface): return self._maybe(12) - def create_subscription(self, environment): - self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) - - return True - def get_calculator_url(self): return "https://www.rackspace.com/en-us/calculator" diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 4435093c..efd4b8cf 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -408,6 +408,50 @@ class KeyVaultCredentials(BaseModel): return values +class SubscriptionCreationCSPPayload(BaseCSPPayload): + display_name: str + parent_group_id: str + billing_account_name: str + billing_profile_name: str + invoice_section_name: str + + +class SubscriptionCreationCSPResult(AliasModel): + subscription_verify_url: str + subscription_retry_after: int + + class Config: + fields = { + "subscription_verify_url": "Location", + "subscription_retry_after": "Retry-After", + } + + +class SubscriptionVerificationCSPPayload(BaseCSPPayload): + subscription_verify_url: str + + +SUBSCRIPTION_ID_REGEX = re.compile( + "\/?subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})", + re.I, +) + + +class SuscriptionVerificationCSPResult(AliasModel): + subscription_id: str + + @validator("subscription_id", pre=True, always=True) + def enforce_display_name_length(cls, sub_id): + sub_id_match = SUBSCRIPTION_ID_REGEX.match(sub_id) + if sub_id_match: + return sub_id_match.group(1) + + return False + + class Config: + fields = {"subscription_id": "subscriptionLink"} + + class ProductPurchaseCSPPayload(BaseCSPPayload): billing_account_name: str billing_profile_name: str diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 4b14a087..14e9c01d 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -140,7 +140,6 @@ class PortfolioStateMachine( # Accumulate payload w/ creds payload = event.kwargs.get("csp_data") - payload["creds"] = event.kwargs.get("creds") payload_data_cls = get_stage_csp_class(stage, "payload") if not payload_data_cls: diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 443989db..8b744b04 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -14,6 +14,8 @@ from atst.domain.applications import Applications from atst.domain.application_roles import ApplicationRoles from atst.domain.audit_log import AuditLog from atst.domain.csp.cloud.exceptions import GeneralCSPException + +from atst.domain.csp.cloud.models import SubscriptionCreationCSPPayload from atst.domain.common import Paginator from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations @@ -525,6 +527,25 @@ def resend_invite(application_id, application_role_id): ) +def build_subscription_payload(environment) -> SubscriptionCreationCSPPayload: + csp_data = environment.application.portfolio.csp_data + parent_group_id = environment.cloud_id + invoice_section_name = csp_data["billing_profile_properties"]["invoice_sections"][ + 0 + ]["invoice_section_name"] + + display_name = f"{environment.application.name}-{environment.name}" + + return SubscriptionCreationCSPPayload( + tenant_id=csp_data.get("tenant_id"), + display_name=display_name, + parent_group_id=parent_group_id, + billing_account_name=csp_data.get("billing_account_name"), + billing_profile_name=csp_data.get("billing_profile_name"), + invoice_section_name=invoice_section_name, + ) + + @applications_bp.route( "/environments//add_subscription", methods=["POST"] ) @@ -533,7 +554,8 @@ def create_subscription(environment_id): environment = Environments.get(environment_id) try: - app.csp.cloud.create_subscription(environment) + payload = build_subscription_payload(environment) + app.csp.cloud.create_subscription(payload) flash("environment_subscription_success", name=environment.displayname) except GeneralCSPException: diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index 242f1fb3..eef5620e 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -12,7 +12,6 @@ from atst.domain.csp.cloud.models import ( AdminRoleDefinitionCSPResult, ApplicationCSPPayload, ApplicationCSPResult, - BaseCSPPayload, BillingInstructionCSPPayload, BillingInstructionCSPResult, BillingProfileCreationCSPPayload, @@ -25,6 +24,10 @@ from atst.domain.csp.cloud.models import ( ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, + SubscriptionCreationCSPPayload, + SubscriptionCreationCSPResult, + SubscriptionVerificationCSPPayload, + SuscriptionVerificationCSPResult, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -46,40 +49,6 @@ from atst.domain.csp.cloud.models import ( BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31" -def test_create_subscription_succeeds(mock_azure: AzureCloudProvider): - environment = EnvironmentFactory.create() - - subscription_id = str(uuid4()) - - credentials = mock_azure._get_credential_obj(AUTH_CREDENTIALS) - display_name = "Test Subscription" - billing_profile_id = str(uuid4()) - sku_id = str(uuid4()) - management_group_id = ( - environment.cloud_id # environment.csp_details.management_group_id? - ) - billing_account_name = ( - "?" # environment.application.portfilio.csp_details.billing_account.name? - ) - invoice_section_name = "?" # environment.name? or something specific to billing? - - mock_azure.sdk.subscription.SubscriptionClient.return_value.subscription_factory.create_subscription.return_value.result.return_value.subscription_link = ( - f"subscriptions/{subscription_id}" - ) - - result = mock_azure._create_subscription( - credentials, - display_name, - billing_profile_id, - sku_id, - management_group_id, - billing_account_name, - invoice_section_name, - ) - - assert result == subscription_id - - def mock_management_group_create(mock_azure, spec_dict): mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = ( spec_dict @@ -123,7 +92,7 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider): tenant_id="1234", display_name=application.name, parent_id=str(uuid4()) ) - result = mock_azure.create_application(payload) + result: ApplicationCSPResult = mock_azure.create_application(payload) assert result.id == "Test Id" @@ -686,3 +655,66 @@ def test_create_tenant_principal_ownership(mock_azure: AzureCloudProvider): ) assert result.principal_owner_assignment_id == "id" + + +def test_create_subscription_creation(mock_azure: AzureCloudProvider): + with patch.object( + AzureCloudProvider, + "_get_tenant_principal_token", + wraps=mock_azure._get_tenant_principal_token, + ) as _get_tenant_principal_token: + _get_tenant_principal_token.return_value = "my fake token" + + mock_result = Mock() + mock_result.status_code = 202 + mock_result.headers = { + "Location": "https://verify.me", + "Retry-After": 10, + } + mock_result.json.return_value = {} + mock_azure.sdk.requests.put.return_value = mock_result + management_group_id = str(uuid4()) + payload = SubscriptionCreationCSPPayload( + **dict( + tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435", + display_name="application_env_sub1", + parent_group_id=management_group_id, + billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31", + billing_profile_name="KQWI-W2SU-BG7-TGB", + invoice_section_name="6HMZ-2HLO-PJA-TGB", + ) + ) + + result: SubscriptionCreationCSPResult = mock_azure.create_subscription_creation( + payload + ) + + assert result.subscription_verify_url == "https://verify.me" + + +def test_create_subscription_verification(mock_azure: AzureCloudProvider): + with patch.object( + AzureCloudProvider, + "_get_tenant_principal_token", + wraps=mock_azure._get_tenant_principal_token, + ) as _get_tenant_principal_token: + _get_tenant_principal_token.return_value = "my fake token" + + mock_result = Mock() + mock_result.ok = True + mock_result.json.return_value = { + "subscriptionLink": "/subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230" + } + mock_azure.sdk.requests.get.return_value = mock_result + + payload = SubscriptionVerificationCSPPayload( + **dict( + tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435", + subscription_verify_url="https://verify.me", + ) + ) + + result: SuscriptionVerificationCSPResult = mock_azure.create_subscription_verification( + payload + ) + assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230" diff --git a/tests/routes/applications/test_settings.py b/tests/routes/applications/test_settings.py index 8f2595f2..04d59f03 100644 --- a/tests/routes/applications/test_settings.py +++ b/tests/routes/applications/test_settings.py @@ -1,35 +1,38 @@ -import uuid -from flask import url_for, get_flashed_messages -from unittest.mock import Mock import datetime -from werkzeug.datastructures import ImmutableMultiDict +import uuid +from unittest.mock import Mock, patch + import pytest - +from flask import get_flashed_messages, url_for from tests.factories import * +from tests.mock_azure import mock_azure +from tests.utils import captured_templates +from werkzeug.datastructures import ImmutableMultiDict -from atst.domain.applications import Applications +from atst.database import db from atst.domain.application_roles import ApplicationRoles +from atst.domain.applications import Applications +from atst.domain.common import Paginator +from atst.domain.csp.cloud.azure_cloud_provider import AzureCloudProvider +from atst.domain.csp.cloud.exceptions import GeneralCSPException +from atst.domain.csp.cloud.models import SubscriptionCreationCSPResult from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations -from atst.domain.common import Paginator -from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.permission_sets import PermissionSets -from atst.models.application_role import Status as ApplicationRoleStatus -from atst.models.environment_role import CSPRole, EnvironmentRole -from atst.models.permissions import Permissions from atst.forms.application import EditEnvironmentForm from atst.forms.application_member import UpdateMemberForm from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS +from atst.models.application_role import Status as ApplicationRoleStatus +from atst.models.environment_role import CSPRole, EnvironmentRole +from atst.models.permissions import Permissions from atst.routes.applications.settings import ( - filter_env_roles_form_data, filter_env_roles_data, + filter_env_roles_form_data, get_environments_obj_for_app, handle_create_member, handle_update_member, ) -from tests.utils import captured_templates - def test_updating_application_environments_success(client, user_session): portfolio = PortfolioFactory.create() @@ -779,22 +782,41 @@ def test_handle_update_member_with_error(set_g, monkeypatch, mock_logger): assert mock_logger.messages[-1] == exception -def test_create_subscription_success(client, user_session): +def test_create_subscription_success( + client, user_session, mock_azure: AzureCloudProvider +): environment = EnvironmentFactory.create() user_session(environment.portfolio.owner) - response = client.post( - url_for("applications.create_subscription", environment_id=environment.id), - ) + environment.cloud_id = "management/group/id" + environment.application.portfolio.csp_data = { + "billing_account_name": "xxxx-xxxx-xxx-xxx", + "billing_profile_name": "xxxxxxxxxxx:xxxxxxxxxxxxx_xxxxxx", + "tenant_id": "xxxxxxxxxxx-xxxxxxxxxx-xxxxxxx-xxxxx", + "billing_profile_properties": { + "invoice_sections": [{"invoice_section_name": "xxxx-xxxx-xxx-xxx"}] + }, + } - assert response.status_code == 302 - assert response.location == url_for( - "applications.settings", - application_id=environment.application.id, - _external=True, - fragment="application-environments", - _anchor="application-environments", - ) + with patch.object( + AzureCloudProvider, "create_subscription", wraps=mock_azure.create_subscription, + ) as create_subscription: + create_subscription.return_value = SubscriptionCreationCSPResult( + subscription_verify_url="https://zombo.com", subscription_retry_after=10 + ) + + response = client.post( + url_for("applications.create_subscription", environment_id=environment.id), + ) + + assert response.status_code == 302 + assert response.location == url_for( + "applications.settings", + application_id=environment.application.id, + _external=True, + fragment="application-environments", + _anchor="application-environments", + ) def test_create_subscription_failure(client, user_session, monkeypatch): @@ -809,6 +831,16 @@ def test_create_subscription_failure(client, user_session, monkeypatch): ) user_session(environment.portfolio.owner) + environment.cloud_id = "management/group/id" + environment.application.portfolio.csp_data = { + "billing_account_name": "xxxx-xxxx-xxx-xxx", + "billing_profile_name": "xxxxxxxxxxx:xxxxxxxxxxxxx_xxxxxx", + "tenant_id": "xxxxxxxxxxx-xxxxxxxxxx-xxxxxxx-xxxxx", + "billing_profile_properties": { + "invoice_sections": [{"invoice_section_name": "xxxx-xxxx-xxx-xxx"}] + }, + } + response = client.post( url_for("applications.create_subscription", environment_id=environment.id), )