From 099e95c7e3d8b2ac6a47f2400202d3596e17bb81 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 14:13:10 -0500 Subject: [PATCH 1/8] =?UTF-8?q?SPACE=20FORCE=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atst/forms/data.py | 1 + translations.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/atst/forms/data.py b/atst/forms/data.py index bb728686..1aba3829 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -10,6 +10,7 @@ SERVICE_BRANCHES = [ translate("forms.portfolio.defense_component.choices.marine_corps"), ), ("navy", translate("forms.portfolio.defense_component.choices.navy")), + ("space_force", translate("forms.portfolio.defense_component.choices.space_force")), ("other", translate("forms.portfolio.defense_component.choices.other")), ] diff --git a/translations.yaml b/translations.yaml index 6fe84865..24a10b92 100644 --- a/translations.yaml +++ b/translations.yaml @@ -296,6 +296,7 @@ forms: army: Army marine_corps: Marine Corps navy: Navy + space_force: Space Force other: Other title: Select DoD component(s) funding your Portfolio validation_message: You must select at least one defense component. From f8433fb7ef5b1a898a9413505ae59b8a524ff18d Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 15:10:00 -0500 Subject: [PATCH 2/8] Add branches that are not space force --- atst/forms/data.py | 3 +++ translations.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/atst/forms/data.py b/atst/forms/data.py index 1aba3829..ea2c1c8a 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -11,6 +11,9 @@ SERVICE_BRANCHES = [ ), ("navy", translate("forms.portfolio.defense_component.choices.navy")), ("space_force", translate("forms.portfolio.defense_component.choices.space_force")), + ("ccmd_js", translate("forms.portfolio.defense_component.choices.ccmd_js")), + ("dafa", translate("forms.portfolio.defense_component.choices.dafa")), + ("osd_psas", translate("forms.portfolio.defense_component.choices.osd_psas")), ("other", translate("forms.portfolio.defense_component.choices.other")), ] diff --git a/translations.yaml b/translations.yaml index 24a10b92..a147dc13 100644 --- a/translations.yaml +++ b/translations.yaml @@ -297,6 +297,9 @@ forms: marine_corps: Marine Corps navy: Navy space_force: Space Force + ccmd_js: Combatant Command / Joint Staff (CCMD/JS) + dafa: Defense Agency and Field Activity (DAFA) + osd_psas: Office of the Secretary of Defense (OSD) / Principal Staff Assistants (PSAs) other: Other title: Select DoD component(s) funding your Portfolio validation_message: You must select at least one defense component. From 45690660244cd0e4ed9265786222ca3dba8764c0 Mon Sep 17 00:00:00 2001 From: Philip Kalinsky Date: Wed, 12 Feb 2020 09:31:52 -0500 Subject: [PATCH 3/8] state machine last CREATED state transition to COMPLETED --- atst/models/mixins/state_machines.py | 12 ++++++++++++ atst/models/portfolio_state_machine.py | 9 +++++++++ tests/domain/test_portfolio_state_machine.py | 3 ++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/atst/models/mixins/state_machines.py b/atst/models/mixins/state_machines.py index a01771b5..41af66ba 100644 --- a/atst/models/mixins/state_machines.py +++ b/atst/models/mixins/state_machines.py @@ -58,6 +58,18 @@ def _build_transitions(csp_stages): transitions = [] states = [] for stage_i, csp_stage in enumerate(csp_stages): + # the last CREATED stage has a transition to COMPLETED + if stage_i == len(csp_stages) - 1: + transitions.append( + dict( + trigger="complete", + source=compose_state( + list(csp_stages)[stage_i], StageStates.CREATED + ), + dest=FSMStates.COMPLETED, + ) + ) + for state in StageStates: states.append( dict( diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 24a3fefc..595fb8fe 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -125,6 +125,15 @@ class PortfolioStateMachine( self.fail_stage(stage) elif state_obj.is_CREATED: + # if last CREATED state then transition to COMPLETED + if list(AzureStages)[-1].name == state_obj.name.split("_CREATED")[ + 0 + ] and "complete" in self.machine.get_triggers(state_obj.name): + app.logger.info( + "last stage completed. transitioning to COMPLETED state" + ) + self.trigger("complete", **kwargs) + # the create trigger for the next stage should be in the available # triggers for the current state create_trigger = next( diff --git a/tests/domain/test_portfolio_state_machine.py b/tests/domain/test_portfolio_state_machine.py index 46e37f4e..dac3bf2e 100644 --- a/tests/domain/test_portfolio_state_machine.py +++ b/tests/domain/test_portfolio_state_machine.py @@ -116,6 +116,7 @@ def test_fsm_transition_start(mock_cloud_provider, portfolio: Portfolio): FSMStates.TENANT_ADMIN_OWNERSHIP_CREATED, FSMStates.TENANT_PRINCIPAL_OWNERSHIP_CREATED, FSMStates.BILLING_OWNER_CREATED, + FSMStates.COMPLETED, ] if portfolio.csp_data is not None: @@ -139,7 +140,7 @@ def test_fsm_transition_start(mock_cloud_provider, portfolio: Portfolio): "first_name": ppoc.first_name, "last_name": ppoc.last_name, "country_code": "US", - "password_recovery_email_address": "email@example.com", # ppoc.email, + "password_recovery_email_address": ppoc.email, "address": { # TODO: TBD if we're sourcing this from data or config "company_name": "", "address_line_1": "", From 4634477179d3fb523782b2e630c6b674a2648503 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Wed, 12 Feb 2020 13:36:51 -0500 Subject: [PATCH 4/8] Enable reuploading files after an error --- js/components/upload_input.js | 2 +- templates/components/upload_input.html | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/js/components/upload_input.js b/js/components/upload_input.js index 4f9f06fc..8bf806e9 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -72,7 +72,7 @@ export default { const uploader = await this.getUploader() const response = await uploader.upload(file) if (uploadResponseOkay(response)) { - this.attachment = e.target.value + this.attachment = file.name this.objectName = uploader.objectName this.$refs.attachmentFilename.value = file.name this.$refs.attachmentObjectName.value = response.objectName diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html index bd4cd73c..b4292284 100644 --- a/templates/components/upload_input.html +++ b/templates/components/upload_input.html @@ -43,7 +43,6 @@ :id="name" :name="name" aria-label="Task Order Upload" - v-bind:value="attachment" type="file"> From 5130d16064827abc3304dc82ef473d6fbcede0e9 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Wed, 12 Feb 2020 14:07:40 -0500 Subject: [PATCH 5/8] Check the global portfolio to highlight the active portfolio. Change arg name from other_portfolio to portfolio. --- templates/navigation/global_sidenav.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/navigation/global_sidenav.html b/templates/navigation/global_sidenav.html index 3ec44a5d..8dc34b35 100644 --- a/templates/navigation/global_sidenav.html +++ b/templates/navigation/global_sidenav.html @@ -21,10 +21,10 @@
    - {% for other_portfolio in portfolios|sort(attribute='name') %} - {{ SidenavItem(other_portfolio.name, - href=url_for("applications.portfolio_applications", portfolio_id=other_portfolio.id), - active=(other_portfolio.id | string) == request.view_args.get('portfolio_id') + {% for portfolio in portfolios|sort(attribute='name') %} + {{ SidenavItem(portfolio.name, + href=url_for("applications.portfolio_applications", portfolio_id=portfolio.id), + active=portfolio == g.portfolio ) }} {% endfor %}
From c8991a95bf352c06ce1acb9e80df75ec839ab440 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 16:03:56 -0500 Subject: [PATCH 6/8] Add copy for "portfolio is ready" email --- translations.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/translations.yaml b/translations.yaml index a147dc13..4229545a 100644 --- a/translations.yaml +++ b/translations.yaml @@ -83,7 +83,9 @@ errors: email: application_invite: "{inviter_name} has invited you to a JEDI cloud application" portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio" - environment_ready: JEDI cloud environment ready + portfolio_ready: + subject: Portfolio Provisioned + body: "Your portfolio has been provisioned.\nVisit {password_reset_address}, and use your username, {username}, to create a password." task_order_sent: subject: "Task Order {to_number}" body: "Task Order number {to_number} updated." From 8f52443b5d17add51c9a4257855f759462f1405d Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 12 Feb 2020 13:54:24 -0500 Subject: [PATCH 7/8] Send email to PPOC when portfolio is provisioned When a portfolio state machine transitions to the COMPLETED state, an email is sent to the PPOC letting them know it's ready, and provides them with their username needed to create a password. --- .secrets.baseline | 4 ++-- README.md | 1 + atst/jobs.py | 21 +++++++++++++++++ config/base.ini | 1 + tests/test_jobs.py | 59 +++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4e393738..45f10336 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2020-02-10T21:40:38Z", + "generated_at": "2020-02-12T18:51:01Z", "plugins_used": [ { "base64_limit": 4.5, @@ -82,7 +82,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 43, + "line_number": 44, "type": "Secret Keyword" } ], diff --git a/README.md b/README.md index b8530135..accecd38 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ To generate coverage reports for the Javascript tests: - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). - `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_LOGIN_URL`: The URL used to login for an Azure instance. - `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account - `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads - `BLOB_STORAGE_URL`: URL to Azure blob storage container. diff --git a/atst/jobs.py b/atst/jobs.py index 0d462f0f..0094a6dd 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -18,6 +18,7 @@ from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.portfolios import Portfolios from atst.models import CSPRole, JobFailure +from atst.models.mixins.state_machines import FSMStates from atst.domain.task_orders import TaskOrders from atst.models.utils import claim_for_update, claim_many_for_update from atst.queue import celery @@ -177,10 +178,30 @@ def do_work(fn, task, csp, **kwargs): raise task.retry(exc=e) +def send_PPOC_email(portfolio_dict): + ppoc_email = portfolio_dict.get("password_recovery_email_address") + user_id = portfolio_dict.get("user_id") + domain_name = portfolio_dict.get("domain_name") + + send_mail( + recipients=[ppoc_email], + subject=translate("email.portfolio_ready.subject"), + body=translate( + "email.portfolio_ready.body", + { + "password_reset_address": app.config.get("AZURE_LOGIN_URL"), + "username": f"{user_id}@{domain_name}.onmicrosoft.com", + }, + ), + ) + + def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): portfolio = Portfolios.get_for_update(portfolio_id) fsm = Portfolios.get_or_create_state_machine(portfolio) fsm.trigger_next_transition(csp_data=portfolio.to_dictionary()) + if fsm.current_state == FSMStates.COMPLETED: + send_PPOC_email(portfolio.to_dictionary()) @celery.task(bind=True, base=RecordFailure) diff --git a/config/base.ini b/config/base.ini index 727172d8..3aa9ad86 100644 --- a/config/base.ini +++ b/config/base.ini @@ -4,6 +4,7 @@ AZURE_AADP_QTY=5 AZURE_ACCOUNT_NAME AZURE_CLIENT_ID AZURE_GRAPH_RESOURCE="https://graph.microsoft.com/" +AZURE_LOGIN_URL="https://portal.azure.com/" AZURE_POLICY_LOCATION=policies AZURE_POWERSHELL_CLIENT_ID AZURE_ROLE_DEF_ID_BILLING_READER="fa23ad8b-c56e-40d8-ac0c-ce449e1d2c64" diff --git a/tests/test_jobs.py b/tests/test_jobs.py index c14b626c..3edfd769 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -7,8 +7,7 @@ from azure.core.exceptions import AzureError from atst.domain.csp.cloud import MockCloudProvider from atst.domain.csp.cloud.models import UserRoleCSPResult -from atst.domain.portfolios import Portfolios -from atst.models import ApplicationRoleStatus +from atst.models import ApplicationRoleStatus, Portfolio, FSMStates from atst.jobs import ( RecordFailure, @@ -24,6 +23,7 @@ from atst.jobs import ( do_create_environment, do_create_environment_role, do_create_application, + send_PPOC_email, ) from tests.factories import ( ApplicationFactory, @@ -281,10 +281,57 @@ def test_dispatch_provision_portfolio(csp, monkeypatch): mock.delay.assert_called_once_with(portfolio_id=portfolio.id) -def test_do_provision_portfolio(csp, session, portfolio): - do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) - session.refresh(portfolio) - assert portfolio.state_machine +class TestDoProvisionPortfolio: + def test_portfolio_has_state_machine(self, csp, session, portfolio): + do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) + session.refresh(portfolio) + assert portfolio.state_machine + + def test_sends_email_to_PPOC_on_completion( + self, monkeypatch, csp, portfolio: Portfolio + ): + mock = Mock() + monkeypatch.setattr("atst.jobs.send_PPOC_email", mock) + + csp._authorize.return_value = None + csp._maybe_raise.return_value = None + sm: PortfolioStateMachine = PortfolioStateMachineFactory.create( + portfolio=portfolio + ) + # The stage before "COMPLETED" + sm.state = FSMStates.BILLING_OWNER_CREATED + do_provision_portfolio(csp=csp, portfolio_id=portfolio.id) + + # send_PPOC_email was called + assert mock.assert_called_once + + +def test_send_ppoc_email(monkeypatch, app): + mock = Mock() + monkeypatch.setattr("atst.jobs.send_mail", mock) + + ppoc_email = "example@example.com" + user_id = "user_id" + domain_name = "domain" + + send_PPOC_email( + { + "password_recovery_email_address": ppoc_email, + "user_id": user_id, + "domain_name": domain_name, + } + ) + mock.assert_called_once_with( + recipients=[ppoc_email], + subject=translate("email.portfolio_ready.subject"), + body=translate( + "email.portfolio_ready.body", + { + "password_reset_address": app.config.get("AZURE_LOGIN_URL"), + "username": f"{user_id}@{domain_name}.onmicrosoft.com", + }, + ), + ) def test_provision_portfolio_create_tenant( From 9b25a4b907e9029a9dabde7e331fcb9771651c26 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 12 Feb 2020 18:26:42 -0500 Subject: [PATCH 8/8] Move 'onmicrosoft.com' to an app constant --- atst/app.py | 1 + atst/domain/csp/cloud/azure_cloud_provider.py | 4 +--- atst/domain/csp/cloud/models.py | 3 ++- atst/jobs.py | 2 +- tests/domain/cloud/test_models.py | 11 +++++++---- tests/test_jobs.py | 6 +++--- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/atst/app.py b/atst/app.py index 04aed44d..1499671c 100644 --- a/atst/app.py +++ b/atst/app.py @@ -186,6 +186,7 @@ def map_config(config): # with a Beat job once a day) "CELERY_RESULT_EXPIRES": 0, "CELERY_RESULT_EXTENDED": True, + "OFFICE_365_DOMAIN": "onmicrosoft.com", "CONTRACT_START_DATE": datetime.strptime( config.get("default", "CONTRACT_START_DATE"), "%Y-%m-%d" ).date(), diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 7c976b8d..b85bed09 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -327,9 +327,7 @@ class AzureCloudProvider(CloudProviderInterface): if result.status_code == 200: result_dict = result.json() tenant_id = result_dict.get("tenantId") - tenant_admin_username = ( - f"{payload.user_id}@{payload.domain_name}.onmicrosoft.com" - ) + tenant_admin_username = f"{payload.user_id}@{payload.domain_name}.{self.config.get('OFFICE_365_DOMAIN')}" self.update_tenant_creds( tenant_id, KeyVaultCredentials( diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 859158a0..27f2c9c7 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -4,6 +4,7 @@ from typing import Dict, List, Optional from uuid import uuid4 import re +from flask import current_app as app from pydantic import BaseModel, validator, root_validator from atst.utils import snake_to_camel @@ -526,7 +527,7 @@ class UserMixin(BaseModel): @property def user_principal_name(self): - return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com" + return f"{self.mail_nickname}@{self.tenant_host_name}.{app.config.get('OFFICE_365_DOMAIN')}" @property def mail_nickname(self): diff --git a/atst/jobs.py b/atst/jobs.py index 0094a6dd..77a8c2f6 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -190,7 +190,7 @@ def send_PPOC_email(portfolio_dict): "email.portfolio_ready.body", { "password_reset_address": app.config.get("AZURE_LOGIN_URL"), - "username": f"{user_id}@{domain_name}.onmicrosoft.com", + "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}", }, ), ) diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py index e9951c9d..68bcd351 100644 --- a/tests/domain/cloud/test_models.py +++ b/tests/domain/cloud/test_models.py @@ -134,9 +134,12 @@ def test_UserCSPPayload_mail_nickname(): assert payload.mail_nickname == f"han.solo" -def test_UserCSPPayload_user_principal_name(): +def test_UserCSPPayload_user_principal_name(app): payload = UserCSPPayload(**user_payload) - assert payload.user_principal_name == f"han.solo@rebelalliance.onmicrosoft.com" + assert ( + payload.user_principal_name + == f"han.solo@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}" + ) def test_UserCSPPayload_password(): @@ -167,11 +170,11 @@ class TestBillingOwnerCSPPayload: payload = BillingOwnerCSPPayload(**self.user_payload) assert payload.password - def test_user_principal_name(self): + def test_user_principal_name(self, app): payload = BillingOwnerCSPPayload(**self.user_payload) assert ( payload.user_principal_name - == f"billing_admin@rebelalliance.onmicrosoft.com" + == f"billing_admin@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}" ) def test_email(self): diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 3edfd769..9df83264 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -135,11 +135,11 @@ def test_create_application_job_is_idempotent(csp): csp.create_application.assert_not_called() -def test_create_user_job(session, csp): +def test_create_user_job(session, csp, app): portfolio = PortfolioFactory.create( csp_data={ "tenant_id": str(uuid4()), - "domain_name": "rebelalliance.onmicrosoft.com", + "domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}", } ) application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321") @@ -328,7 +328,7 @@ def test_send_ppoc_email(monkeypatch, app): "email.portfolio_ready.body", { "password_reset_address": app.config.get("AZURE_LOGIN_URL"), - "username": f"{user_id}@{domain_name}.onmicrosoft.com", + "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}", }, ), )