diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 13b474e6..2f14597a 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -6,7 +6,7 @@ from uuid import uuid4 from atst.utils import sha256_hex from .cloud_provider_interface import CloudProviderInterface -from .exceptions import AuthenticationException +from .exceptions import AuthenticationException, UserProvisioningException from .models import ( SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, @@ -48,6 +48,8 @@ from .models import ( TenantPrincipalCSPResult, TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPResult, + UserCSPPayload, + UserCSPResult, ) from .policy import AzurePolicyManager @@ -191,6 +193,7 @@ class AzureCloudProvider(CloudProviderInterface): def create_application(self, payload: ApplicationCSPPayload): creds = self._source_creds(payload.tenant_id) + # TODO: these should be tenant_sp_client_id, etc credentials = self._get_credential_obj( { "client_id": creds.root_sp_client_id, @@ -310,7 +313,9 @@ class AzureCloudProvider(CloudProviderInterface): tenant_admin_password=payload.password, ), ) - return self._ok(TenantCSPResult(**result_dict)) + return self._ok( + TenantCSPResult(domain_name=payload.domain_name, **result_dict) + ) else: return self._error(result.json()) @@ -850,6 +855,80 @@ class AzureCloudProvider(CloudProviderInterface): return service_principal + def create_user(self, payload: UserCSPPayload) -> UserCSPResult: + """Create a user in an Azure Active Directory instance. + Unlike most of the methods on this interface, this requires + two API calls: one POST to create the user and one PATCH to + set the alternate email address. The email address cannot + be set on the first API call. The email address is + necessary so that users can do Self-Service Password + Recovery. + + Arguments: + payload {UserCSPPayload} -- a payload object with the + data necessary for both calls + + Returns: + UserCSPResult -- a result object containing the AAD ID. + """ + graph_token = self._get_tenant_principal_token( + payload.tenant_id, resource=self.graph_resource + ) + if graph_token is None: + raise AuthenticationException( + "Could not resolve graph token for tenant admin" + ) + + result = self._create_active_directory_user(graph_token, payload) + self._update_active_directory_user_email(graph_token, result.id, payload) + + return result + + def _create_active_directory_user(self, graph_token, payload: UserCSPPayload): + request_body = { + "accountEnabled": True, + "displayName": payload.display_name, + "mailNickname": payload.mail_nickname, + "userPrincipalName": payload.user_principal_name, + "passwordProfile": { + "forceChangePasswordNextSignIn": True, + "password": payload.password, + }, + } + + auth_header = { + "Authorization": f"Bearer {graph_token}", + } + + url = f"{self.graph_resource}v1.0/users" + + response = self.sdk.requests.post(url, headers=auth_header, json=request_body) + + if response.ok: + return UserCSPResult(**response.json()) + else: + raise UserProvisioningException(f"Failed to create user: {response.json()}") + + def _update_active_directory_user_email( + self, graph_token, user_id, payload: UserCSPPayload + ): + request_body = {"otherMails": [payload.email]} + + auth_header = { + "Authorization": f"Bearer {graph_token}", + } + + url = f"{self.graph_resource}v1.0/users/{user_id}" + + response = self.sdk.requests.patch(url, headers=auth_header, json=request_body) + + if response.ok: + return True + else: + raise UserProvisioningException( + f"Failed update user email: {response.json()}" + ) + def _extract_subscription_id(self, subscription_url): sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url) @@ -871,14 +950,15 @@ class AzureCloudProvider(CloudProviderInterface): creds.root_tenant_id, creds.root_sp_client_id, creds.root_sp_key ) - def _get_sp_token(self, tenant_id, client_id, secret_key): + def _get_sp_token(self, tenant_id, client_id, secret_key, resource=None): context = self.sdk.adal.AuthenticationContext( f"{self.sdk.cloud.endpoints.active_directory}/{tenant_id}" ) + resource = resource or self.sdk.cloud.endpoints.resource_manager # TODO: handle failure states here token_response = context.acquire_token_with_client_credentials( - self.sdk.cloud.endpoints.resource_manager, client_id, secret_key + resource, client_id, secret_key ) return token_response.get("accessToken", None) @@ -939,10 +1019,13 @@ class AzureCloudProvider(CloudProviderInterface): "tenant_id": self.tenant_id, } - def _get_tenant_principal_token(self, tenant_id): + def _get_tenant_principal_token(self, tenant_id, resource=None): creds = self._source_creds(tenant_id) return self._get_sp_token( - creds.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key + creds.tenant_id, + creds.tenant_sp_client_id, + creds.tenant_sp_key, + resource=resource, ) def _get_elevated_management_token(self, tenant_id): diff --git a/atst/domain/csp/cloud/exceptions.py b/atst/domain/csp/cloud/exceptions.py index 6ed47dff..49b05fb4 100644 --- a/atst/domain/csp/cloud/exceptions.py +++ b/atst/domain/csp/cloud/exceptions.py @@ -88,17 +88,6 @@ class UserProvisioningException(GeneralCSPException): """Failed to provision a user """ - def __init__(self, env_identifier, user_identifier, reason): - self.env_identifier = env_identifier - self.user_identifier = user_identifier - self.reason = reason - - @property - def message(self): - return "Failed to create user {} for environment {}: {}".format( - self.user_identifier, self.env_identifier, self.reason - ) - class UserRemovalException(GeneralCSPException): """Failed to remove a user diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index dce01f4e..5511bb7d 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -175,6 +175,7 @@ class MockCloudProvider(CloudProviderInterface): "tenant_id": "", "user_id": "", "user_object_id": "", + "domain_name": "", "tenant_admin_username": "test", "tenant_admin_password": "test", } diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index efd4b8cf..603c29dc 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -39,6 +39,7 @@ class TenantCSPResult(AliasModel): user_id: str tenant_id: str user_object_id: str + domain_name: str tenant_admin_username: Optional[str] tenant_admin_password: Optional[str] @@ -474,3 +475,25 @@ class ProductPurchaseVerificationCSPPayload(BaseCSPPayload): class ProductPurchaseVerificationCSPResult(AliasModel): premium_purchase_date: str + + +class UserCSPPayload(BaseCSPPayload): + # userPrincipalName must be username + tenant + # display name should be full name + # mail nickname should be... email address? + display_name: str + tenant_host_name: str + email: str + password: str + + @property + def user_principal_name(self): + return f"{self.mail_nickname}@{self.tenant_host_name}" + + @property + def mail_nickname(self): + return self.display_name.replace(" ", ".").lower() + + +class UserCSPResult(AliasModel): + id: str