diff --git a/atst/domain/application_roles.py b/atst/domain/application_roles.py index 512887c3..1919b30f 100644 --- a/atst/domain/application_roles.py +++ b/atst/domain/application_roles.py @@ -65,6 +65,15 @@ class ApplicationRoles(object): except NoResultFound: raise NotFoundError("application_role") + @classmethod + def get_many(cls, ids): + return ( + db.session.query(ApplicationRole) + .filter(ApplicationRole.id.in_(ids)) + .filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED) + .all() + ) + @classmethod def update_permission_sets(cls, application_role, new_perm_sets_names): application_role.permission_sets = ApplicationRoles._permission_sets_for_names( diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index 5511bb7d..ec730a3b 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -51,6 +51,8 @@ from .models import ( TenantPrincipalCSPResult, TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPResult, + UserCSPPayload, + UserCSPResult, ) @@ -475,6 +477,11 @@ class MockCloudProvider(CloudProviderInterface): id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" ) + def create_user(self, payload: UserCSPPayload): + self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) + + return UserCSPResult(id=str(uuid4())) + def get_credentials(self, scope="portfolio", tenant_id=None): return self.root_creds() diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 603c29dc..9622658c 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -1,6 +1,7 @@ +from secrets import token_urlsafe from typing import Dict, List, Optional -import re from uuid import uuid4 +import re from pydantic import BaseModel, validator, root_validator @@ -478,13 +479,10 @@ class ProductPurchaseVerificationCSPResult(AliasModel): 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 + password: Optional[str] @property def user_principal_name(self): @@ -494,6 +492,10 @@ class UserCSPPayload(BaseCSPPayload): def mail_nickname(self): return self.display_name.replace(" ", ".").lower() + @validator("password", pre=True, always=True) + def supply_password_default(cls, password): + return password or token_urlsafe(16) + class UserCSPResult(AliasModel): id: str diff --git a/atst/jobs.py b/atst/jobs.py index 14256336..986b2004 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -3,16 +3,16 @@ import pendulum from atst.database import db from atst.queue import celery -from atst.models import EnvironmentRole, JobFailure +from atst.models import JobFailure from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud import CloudProviderInterface from atst.domain.applications import Applications from atst.domain.environments import Environments from atst.domain.portfolios import Portfolios -from atst.domain.environment_roles import EnvironmentRoles -from atst.models.utils import claim_for_update +from atst.domain.application_roles import ApplicationRoles +from atst.models.utils import claim_for_update, claim_many_for_update from atst.utils.localization import translate -from atst.domain.csp.cloud.models import ApplicationCSPPayload +from atst.domain.csp.cloud.models import ApplicationCSPPayload, UserCSPPayload class RecordFailure(celery.Task): @@ -75,6 +75,34 @@ def do_create_application(csp: CloudProviderInterface, application_id=None): db.session.commit() +def do_create_user(csp: CloudProviderInterface, application_role_ids=None): + if not application_role_ids: + return + + app_roles = ApplicationRoles.get_many(application_role_ids) + + with claim_many_for_update(app_roles) as app_roles: + + if any([ar.cloud_id for ar in app_roles]): + return + + csp_details = app_roles[0].application.portfolio.csp_data + user = app_roles[0].user + + payload = UserCSPPayload( + tenant_id=csp_details.get("tenant_id"), + tenant_host_name=csp_details.get("domain_name"), + display_name=user.full_name, + email=user.email, + ) + result = csp.create_user(payload) + for app_role in app_roles: + app_role.cloud_id = result.id + db.session.add(app_role) + + db.session.commit() + + def do_create_environment(csp: CloudProviderInterface, environment_id=None): environment = Environments.get(environment_id) @@ -128,21 +156,6 @@ def render_email(template_path, context): return app.jinja_env.get_template(template_path).render(context) -def do_provision_user(csp: CloudProviderInterface, environment_role_id=None): - environment_role = EnvironmentRoles.get_by_id(environment_role_id) - - with claim_for_update(environment_role) as environment_role: - credentials = environment_role.environment.csp_credentials - - csp_user_id = csp.create_or_update_user( - credentials, environment_role, environment_role.role - ) - environment_role.csp_user_id = csp_user_id - environment_role.status = EnvironmentRole.Status.COMPLETED - db.session.add(environment_role) - db.session.commit() - - def do_work(fn, task, csp, **kwargs): try: fn(csp, **kwargs) @@ -166,6 +179,13 @@ def create_application(self, application_id=None): do_work(do_create_application, self, app.csp.cloud, application_id=application_id) +@celery.task(bind=True, base=RecordFailure) +def create_user(self, application_role_ids=None): + do_work( + do_create_user, self, app.csp.cloud, application_role_ids=application_role_ids + ) + + @celery.task(bind=True, base=RecordFailure) def create_environment(self, environment_id=None): do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id) @@ -178,13 +198,6 @@ def create_atat_admin_user(self, environment_id=None): ) -@celery.task(bind=True) -def provision_user(self, environment_role_id=None): - do_work( - do_provision_user, self, app.csp.cloud, environment_role_id=environment_role_id - ) - - @celery.task(bind=True) def dispatch_provision_portfolio(self): """ @@ -200,6 +213,12 @@ def dispatch_create_application(self): create_application.delay(application_id=application_id) +@celery.task(bind=True) +def dispatch_create_user(self): + for application_role_ids in ApplicationRoles.get_pending_creation(): + create_user.delay(application_role_ids=application_role_ids) + + @celery.task(bind=True) def dispatch_create_environment(self): for environment_id in Environments.get_environments_pending_creation( @@ -214,11 +233,3 @@ def dispatch_create_atat_admin_user(self): pendulum.now() ): create_atat_admin_user.delay(environment_id=environment_id) - - -@celery.task(bind=True) -def dispatch_provision_user(self): - for ( - environment_role_id - ) in EnvironmentRoles.get_environment_roles_pending_creation(): - provision_user.delay(environment_role_id=environment_role_id) diff --git a/atst/queue.py b/atst/queue.py index 70718150..3f97f88c 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -23,8 +23,8 @@ def update_celery(celery, app): "task": "atst.jobs.dispatch_create_atat_admin_user", "schedule": 60, }, - "beat-dispatch_provision_user": { - "task": "atst.jobs.dispatch_provision_user", + "beat-dispatch_create_user": { + "task": "atst.jobs.dispatch_create_user", "schedule": 60, }, } diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py index d9fc963d..d8667891 100644 --- a/tests/domain/cloud/test_models.py +++ b/tests/domain/cloud/test_models.py @@ -7,6 +7,7 @@ from atst.domain.csp.cloud.models import ( KeyVaultCredentials, ManagementGroupCSPPayload, ManagementGroupCSPResponse, + UserCSPPayload, ) @@ -97,3 +98,26 @@ def test_KeyVaultCredentials_enforce_root_creds(): assert KeyVaultCredentials( root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop" ) + + +user_payload = { + "tenant_id": "123", + "display_name": "Han Solo", + "tenant_host_name": "rebelalliance.com", + "email": "han@moseisley.cantina", +} + + +def test_UserCSPPayload_mail_nickname(): + payload = UserCSPPayload(**user_payload) + assert payload.mail_nickname == f"han.solo" + + +def test_UserCSPPayload_user_principal_name(): + payload = UserCSPPayload(**user_payload) + assert payload.user_principal_name == f"han.solo@rebelalliance.com" + + +def test_UserCSPPayload_password(): + payload = UserCSPPayload(**user_payload) + assert payload.password diff --git a/tests/domain/test_application_roles.py b/tests/domain/test_application_roles.py index 9dc42016..ba05fb53 100644 --- a/tests/domain/test_application_roles.py +++ b/tests/domain/test_application_roles.py @@ -153,3 +153,12 @@ def test_get_pending_creation(): expected_ids = [[role_one.id, role_two.id], [role_three.id], [role_four.id]] # Sort them to produce the same order. assert sorted(app_ids) == sorted(expected_ids) + + +def test_get_many(): + ar1 = ApplicationRoleFactory.create() + ar2 = ApplicationRoleFactory.create() + ApplicationRoleFactory.create() + + result = ApplicationRoles.get_many([ar1.id, ar2.id]) + assert result == [ar1, ar2] diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 827aeb67..54f4c5dc 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -5,16 +5,17 @@ from unittest.mock import Mock from atst.domain.csp.cloud import MockCloudProvider from atst.domain.portfolios import Portfolios +from atst.models import ApplicationRoleStatus from atst.jobs import ( RecordFailure, dispatch_create_environment, dispatch_create_application, + dispatch_create_user, dispatch_create_atat_admin_user, dispatch_provision_portfolio, - dispatch_provision_user, create_environment, - do_provision_user, + do_create_user, do_provision_portfolio, do_create_environment, do_create_application, @@ -27,6 +28,7 @@ from tests.factories import ( PortfolioStateMachineFactory, ApplicationFactory, ApplicationRoleFactory, + UserFactory, ) from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure @@ -123,6 +125,30 @@ def test_create_application_job_is_idempotent(csp): csp.create_application.assert_not_called() +def test_create_user_job(session, csp): + portfolio = PortfolioFactory.create( + csp_data={ + "tenant_id": str(uuid4()), + "domain_name": "rebelalliance.onmicrosoft.com", + } + ) + application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321") + user = UserFactory.create( + first_name="Han", last_name="Solo", email="han@example.com" + ) + app_role = ApplicationRoleFactory.create( + application=application, + user=user, + status=ApplicationRoleStatus.ACTIVE, + cloud_id=None, + ) + + do_create_user(csp, [app_role.id]) + session.refresh(app_role) + + assert app_role.cloud_id + + def test_create_atat_admin_user(csp, session): environment = EnvironmentFactory.create(cloud_id="something") do_create_atat_admin_user(csp, environment.id) @@ -178,6 +204,29 @@ def test_dispatch_create_application(monkeypatch): mock.delay.assert_called_once_with(application_id=app.id) +def test_dispatch_create_user(monkeypatch): + application = ApplicationFactory.create(cloud_id="123") + user = UserFactory.create( + first_name="Han", last_name="Solo", email="han@example.com" + ) + app_role = ApplicationRoleFactory.create( + application=application, + user=user, + status=ApplicationRoleStatus.ACTIVE, + cloud_id=None, + ) + + mock = Mock() + monkeypatch.setattr("atst.jobs.create_user", mock) + + # When dispatch_create_user is called + dispatch_create_user.run() + + # It should cause the create_user task to be called once + # with the application id + mock.delay.assert_called_once_with(application_role_ids=[app_role.id]) + + def test_dispatch_create_atat_admin_user(session, monkeypatch): portfolio = PortfolioFactory.create( applications=[ @@ -237,68 +286,6 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker): assert environment.claimed_until == None -def test_dispatch_provision_user(csp, session, celery_app, celery_worker, monkeypatch): - - # Given that I have four environment roles: - # (A) one of which has a completed status - # (B) one of which has an environment that has not been provisioned - # (C) one of which is pending, has a provisioned environment but an inactive application role - # (D) one of which is pending, has a provisioned environment and has an active application role - provisioned_environment = EnvironmentFactory.create( - cloud_id="cloud_id", root_user_info={} - ) - unprovisioned_environment = EnvironmentFactory.create() - _er_a = EnvironmentRoleFactory.create( - environment=provisioned_environment, status=EnvironmentRole.Status.COMPLETED - ) - _er_b = EnvironmentRoleFactory.create( - environment=unprovisioned_environment, status=EnvironmentRole.Status.PENDING - ) - _er_c = EnvironmentRoleFactory.create( - environment=unprovisioned_environment, - status=EnvironmentRole.Status.PENDING, - application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.PENDING), - ) - er_d = EnvironmentRoleFactory.create( - environment=provisioned_environment, - status=EnvironmentRole.Status.PENDING, - application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.ACTIVE), - ) - - mock = Mock() - monkeypatch.setattr("atst.jobs.provision_user", mock) - - # When I dispatch the user provisioning task - dispatch_provision_user.run() - - # I expect it to dispatch only one call, to EnvironmentRole D - mock.delay.assert_called_once_with(environment_role_id=er_d.id) - - -def test_do_provision_user(csp, session): - # Given that I have an EnvironmentRole with a provisioned environment - credentials = MockCloudProvider(())._auth_credentials - provisioned_environment = EnvironmentFactory.create( - cloud_id="cloud_id", root_user_info={"credentials": credentials} - ) - environment_role = EnvironmentRoleFactory.create( - environment=provisioned_environment, - status=EnvironmentRole.Status.PENDING, - role="ADMIN", - ) - - # When I call the user provisoning task - do_provision_user(csp=csp, environment_role_id=environment_role.id) - - session.refresh(environment_role) - # I expect that the CSP create_or_update_user method will be called - csp.create_or_update_user.assert_called_once_with( - credentials, environment_role, CSPRole.ADMIN - ) - # I expect that the EnvironmentRole now has a csp_user_id - assert environment_role.csp_user_id - - def test_dispatch_provision_portfolio( csp, session, portfolio, celery_app, celery_worker, monkeypatch ):