diff --git a/Pipfile b/Pipfile index 4030b90d..810da811 100644 --- a/Pipfile +++ b/Pipfile @@ -21,7 +21,7 @@ flask-wtf = "*" pyopenssl = "*" requests = "*" lockfile = "*" -werkzeug = "*" +werkzeug = "==0.16.1" PyYAML = "*" azure-storage = "*" azure-storage-common = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8b3379bb..391f766d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "faa5dab7bc6d13d39c0ef80f015f34a7fce2d66bec273ff38b7bd9ba232a3502" + "sha256": "44296f145fcb42cff5fadf14a706ec9598f4436ccbdf05e1d69fcd8316c89e8d" }, "pipfile-spec": 6, "requires": { @@ -164,10 +164,10 @@ }, "billiard": { "hashes": [ - "sha256:01afcb4e7c4fd6480940cfbd4d9edc19d7a7509d6ada533984d0d0f49901ec82", - "sha256:b8809c74f648dfe69b973c8e660bcec00603758c9db8ba89d7719f88d5f01f26" + "sha256:26fd494dc3251f8ce1f5559744f18aeed427fdaf29a75d7baae26752a5d3816f", + "sha256:f4e09366653aa3cb3ae8ed16423f9ba1665ff426f087bcdbbed86bf3664fe02c" ], - "version": "==3.6.1.0" + "version": "==3.6.2.0" }, "celery": { "hashes": [ @@ -582,11 +582,11 @@ }, "redis": { "hashes": [ - "sha256:7595976eb0b4e1fc3ad5478f1fd44215a814ee184a7820de92726f559bdff9cd", - "sha256:e933bdb504c69cbd5bdf4e2bb819a99644a36731cef4c59aa637cebfd5ddd4f9" + "sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f", + "sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833" ], "index": "pypi", - "version": "==3.4.0" + "version": "==3.4.1" }, "requests": { "hashes": [ @@ -908,11 +908,11 @@ }, "ipython": { "hashes": [ - "sha256:0f4bcf18293fb666df8511feec0403bdb7e061a5842ea6e88a3177b0ceb34ead", - "sha256:387686dd7fc9caf29d2fddcf3116c4b07a11d9025701d220c589a430b0171d8a" + "sha256:d9459e7237e2e5858738ff9c3e26504b79899b58a6d49e574d352493d80684c6", + "sha256:f6689108b1734501d3b59c84427259fd5ac5141afe2e846cfa8598eb811886c9" ], "index": "pypi", - "version": "==7.11.1" + "version": "==7.12.0" }, "ipython-genutils": { "hashes": [ diff --git a/alembic/versions/17da2a475429_add_application_role_cloud_id.py b/alembic/versions/17da2a475429_add_application_role_cloud_id.py new file mode 100644 index 00000000..fffaaad8 --- /dev/null +++ b/alembic/versions/17da2a475429_add_application_role_cloud_id.py @@ -0,0 +1,29 @@ +"""add application_role.cloud_id + +Revision ID: 17da2a475429 +Revises: 50979d8ef680 +Create Date: 2020-02-01 10:43:03.073539 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '17da2a475429' # pragma: allowlist secret +down_revision = '50979d8ef680' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('application_roles', sa.Column('cloud_id', sa.String(), nullable=True)) + op.add_column('application_roles', sa.Column('claimed_until', sa.TIMESTAMP(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('application_roles', 'cloud_id') + op.drop_column('application_roles', 'claimed_until') + # ### end Alembic commands ### diff --git a/atst/domain/application_roles.py b/atst/domain/application_roles.py index 826f7f6c..1919b30f 100644 --- a/atst/domain/application_roles.py +++ b/atst/domain/application_roles.py @@ -1,8 +1,12 @@ +from itertools import groupby +from typing import List +from uuid import UUID + from sqlalchemy.orm.exc import NoResultFound from atst.database import db from atst.domain.environment_roles import EnvironmentRoles -from atst.models import ApplicationRole, ApplicationRoleStatus +from atst.models import Application, ApplicationRole, ApplicationRoleStatus, Portfolio from .permission_sets import PermissionSets from .exceptions import NotFoundError @@ -61,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( @@ -92,3 +105,29 @@ class ApplicationRoles(object): db.session.add(application_role) db.session.commit() + + @classmethod + def get_pending_creation(cls) -> List[List[UUID]]: + """ + Returns a list of lists of ApplicationRole IDs. The IDs + should be grouped by user and portfolio. + """ + results = ( + db.session.query(ApplicationRole.id, ApplicationRole.user_id, Portfolio.id) + .join(Application, Application.id == ApplicationRole.application_id) + .join(Portfolio, Portfolio.id == Application.portfolio_id) + .filter(Application.cloud_id.isnot(None)) + .filter(ApplicationRole.deleted == False) + .filter(ApplicationRole.cloud_id.is_(None)) + .filter(ApplicationRole.user_id.isnot(None)) + .filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE) + ).all() + + groups = [] + keyfunc = lambda pair: (pair[1], pair[2]) + sorted_results = sorted(results, key=keyfunc) + for _, g in groupby(sorted_results, keyfunc): + group = [pair[0] for pair in list(g)] + groups.append(group) + + return groups diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 0daa804c..5f97e161 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, @@ -24,6 +24,7 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, KeyVaultCredentials, ManagementGroupCSPPayload, ManagementGroupCSPResponse, @@ -35,6 +36,7 @@ from .models import ( ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + ReportingCSPPayload, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -51,6 +53,8 @@ from .models import ( TenantPrincipalCSPResult, TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPResult, + UserCSPPayload, + UserCSPResult, ) from .policy import AzurePolicyManager @@ -196,9 +200,9 @@ class AzureCloudProvider(CloudProviderInterface): creds = self._source_creds(payload.tenant_id) credentials = self._get_credential_obj( { - "client_id": creds.root_sp_client_id, - "secret_key": creds.root_sp_key, - "tenant_id": creds.root_tenant_id, + "client_id": creds.tenant_sp_client_id, + "secret_key": creds.tenant_sp_key, + "tenant_id": creds.tenant_id, }, resource=self.sdk.cloud.endpoints.resource_manager, ) @@ -352,7 +356,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()) @@ -892,6 +898,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) @@ -913,14 +993,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) @@ -981,10 +1062,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): @@ -1030,3 +1114,41 @@ class AzureCloudProvider(CloudProviderInterface): hashed = sha256_hex(tenant_id) raw_creds = self.get_secret(hashed) return KeyVaultCredentials(**json.loads(raw_creds)) + + def get_reporting_data(self, payload: ReportingCSPPayload): + """ + Queries the Cost Management API for an invoice section's raw reporting data + + We query at the invoiceSection scope. The full scope path is passed in + with the payload at the `invoice_section_id` key. + """ + creds = self._source_tenant_creds(payload.tenant_id) + token = self._get_sp_token( + payload.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key + ) + + if not token: + raise AuthenticationException("Could not retrieve tenant access token") + + headers = {"Authorization": f"Bearer {token}"} + + request_body = { + "type": "Usage", + "timeframe": "Custom", + "timePeriod": {"from": payload.from_date, "to": payload.to_date,}, + "dataset": { + "granularity": "Daily", + "aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}}, + "grouping": [{"type": "Dimension", "name": "InvoiceId"}], + }, + } + cost_mgmt_url = ( + f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01" + ) + result = self.sdk.requests.post( + f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}", + json=request_body, + headers=headers, + ) + if result.ok: + return CostManagementQueryCSPResult(**result.json()) 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 0a573937..98e474bf 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -29,12 +29,15 @@ from .models import ( ManagementGroupCSPResponse, ManagementGroupGetCSPPayload, ManagementGroupGetCSPResponse, + CostManagementQueryCSPResult, + CostManagementQueryProperties, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + ReportingCSPPayload, SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, SubscriptionVerificationCSPPayload, @@ -55,6 +58,8 @@ from .models import ( TenantPrincipalCSPResult, TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPResult, + UserCSPPayload, + UserCSPResult, ) @@ -179,6 +184,7 @@ class MockCloudProvider(CloudProviderInterface): "tenant_id": "", "user_id": "", "user_object_id": "", + "domain_name": "", "tenant_admin_username": "test", "tenant_admin_password": "test", } @@ -501,8 +507,35 @@ 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() def update_tenant_creds(self, tenant_id, secret): return secret + + def get_reporting_data(self, payload: ReportingCSPPayload): + 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) + object_id = str(uuid4()) + + properties = CostManagementQueryProperties( + **dict( + columns=[ + {"name": "PreTaxCost", "type": "Number"}, + {"name": "UsageDate", "type": "Number"}, + {"name": "InvoiceId", "type": "String"}, + {"name": "Currency", "type": "String"}, + ], + rows=[], + ) + ) + + return CostManagementQueryCSPResult( + **dict(name=object_id, properties=properties,) + ) diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index bbf72815..ad095bf7 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 @@ -39,6 +40,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] @@ -484,3 +486,57 @@ class ProductPurchaseVerificationCSPPayload(BaseCSPPayload): class ProductPurchaseVerificationCSPResult(AliasModel): premium_purchase_date: str + + +class UserCSPPayload(BaseCSPPayload): + display_name: str + tenant_host_name: str + email: str + password: Optional[str] + + @property + def user_principal_name(self): + return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com" + + @property + 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 + + +class QueryColumn(AliasModel): + name: str + type: str + + +class CostManagementQueryProperties(AliasModel): + columns: List[QueryColumn] + rows: List[Optional[list]] + + +class CostManagementQueryCSPResult(AliasModel): + name: str + properties: CostManagementQueryProperties + + +class ReportingCSPPayload(BaseCSPPayload): + invoice_section_id: str + from_date: str + to_date: str + + @root_validator(pre=True) + def extract_invoice_section(cls, values): + try: + values["invoice_section_id"] = values["billing_profile_properties"][ + "invoice_sections" + ][0]["invoice_section_id"] + return values + except (KeyError, IndexError): + raise ValueError("Invoice section ID not present in payload") diff --git a/atst/domain/csp/files.py b/atst/domain/csp/files.py index 837cecbb..aade1775 100644 --- a/atst/domain/csp/files.py +++ b/atst/domain/csp/files.py @@ -79,7 +79,7 @@ class AzureFileService(FileService): sas_token = bbs.generate_blob_shared_access_signature( self.container_name, object_name, - permission=self.BlobPermissions.READ, + permission=self.BlobSasPermissions(read=True), expiry=datetime.utcnow() + self.timeout, content_disposition=f"attachment; filename={filename}", protocol="https", diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index 1254ac71..b8663730 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -15,6 +15,8 @@ from atst.models import ( Permissions, PortfolioRole, PortfolioRoleStatus, + TaskOrder, + CLIN, ) from .query import PortfoliosQuery, PortfolioStateMachinesQuery @@ -144,7 +146,7 @@ class Portfolios(object): return db.session.query(Portfolio.id) @classmethod - def get_portfolios_pending_provisioning(cls) -> List[UUID]: + def get_portfolios_pending_provisioning(cls, now) -> List[UUID]: """ Any portfolio with a corresponding State Machine that is either: not started yet, @@ -153,22 +155,18 @@ class Portfolios(object): """ results = ( - cls.base_provision_query() + db.session.query(Portfolio.id) .join(PortfolioStateMachine) + .join(TaskOrder) + .join(CLIN) + .filter(Portfolio.deleted == False) + .filter(CLIN.start_date <= now) + .filter(CLIN.end_date > now) .filter( or_( PortfolioStateMachine.state == FSMStates.UNSTARTED, - PortfolioStateMachine.state == FSMStates.FAILED, - PortfolioStateMachine.state == FSMStates.TENANT_FAILED, + PortfolioStateMachine.state.like("%CREATED"), ) ) ) return [id_ for id_, in results] - - # db.session.query(PortfolioStateMachine).\ - # filter( - # or_( - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # ) - # ).all() diff --git a/atst/jobs.py b/atst/jobs.py index 14256336..ee820066 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,19 +198,12 @@ 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): """ Iterate over portfolios with a corresponding State Machine that have not completed. """ - for portfolio_id in Portfolios.get_portfolios_pending_provisioning(): + for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()): provision_portfolio.delay(portfolio_id=portfolio_id) @@ -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/models/application.py b/atst/models/application.py index 1af9e39f..c8e8caf0 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint, TIMESTAMP +from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import relationship, synonym from atst.models.base import Base @@ -9,7 +9,11 @@ from atst.models.types import Id class Application( - Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin + Base, + mixins.TimestampsMixin, + mixins.AuditableMixin, + mixins.DeletableMixin, + mixins.ClaimableMixin, ): __tablename__ = "applications" @@ -41,7 +45,6 @@ class Application( ) cloud_id = Column(String) - claimed_until = Column(TIMESTAMP(timezone=True)) @property def users(self): diff --git a/atst/models/application_role.py b/atst/models/application_role.py index d65ceac7..68ed8512 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -1,5 +1,5 @@ from enum import Enum -from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table +from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.event import listen @@ -33,6 +33,7 @@ class ApplicationRole( mixins.AuditableMixin, mixins.PermissionsMixin, mixins.DeletableMixin, + mixins.ClaimableMixin, ): __tablename__ = "application_roles" @@ -59,6 +60,8 @@ class ApplicationRole( primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)", ) + cloud_id = Column(String) + @property def latest_invitation(self): if self.invitations: diff --git a/atst/models/environment.py b/atst/models/environment.py index a0713c63..b9d16fe0 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, String, TIMESTAMP, UniqueConstraint +from sqlalchemy import Column, ForeignKey, String, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import JSONB from enum import Enum @@ -9,7 +9,11 @@ import atst.models.types as types class Environment( - Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin + Base, + mixins.TimestampsMixin, + mixins.AuditableMixin, + mixins.DeletableMixin, + mixins.ClaimableMixin, ): __tablename__ = "environments" @@ -28,8 +32,6 @@ class Environment( cloud_id = Column(String) root_user_info = Column(JSONB(none_as_null=True)) - claimed_until = Column(TIMESTAMP(timezone=True)) - roles = relationship( "EnvironmentRole", back_populates="environment", diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 24aaeb7e..56fe78d4 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -1,5 +1,5 @@ from enum import Enum -from sqlalchemy import Index, ForeignKey, Column, String, TIMESTAMP, Enum as SQLAEnum +from sqlalchemy import Index, ForeignKey, Column, String, Enum as SQLAEnum from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -15,7 +15,11 @@ class CSPRole(Enum): class EnvironmentRole( - Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin + Base, + mixins.TimestampsMixin, + mixins.AuditableMixin, + mixins.DeletableMixin, + mixins.ClaimableMixin, ): __tablename__ = "environment_roles" @@ -33,7 +37,6 @@ class EnvironmentRole( application_role = relationship("ApplicationRole") csp_user_id = Column(String()) - claimed_until = Column(TIMESTAMP(timezone=True)) class Status(Enum): PENDING = "pending" diff --git a/atst/models/mixins/__init__.py b/atst/models/mixins/__init__.py index e95b2516..36a5c43a 100644 --- a/atst/models/mixins/__init__.py +++ b/atst/models/mixins/__init__.py @@ -4,3 +4,4 @@ from .permissions import PermissionsMixin from .deletable import DeletableMixin from .invites import InvitesMixin from .state_machines import FSMMixin +from .claimable import ClaimableMixin diff --git a/atst/models/mixins/claimable.py b/atst/models/mixins/claimable.py new file mode 100644 index 00000000..afa33a59 --- /dev/null +++ b/atst/models/mixins/claimable.py @@ -0,0 +1,5 @@ +from sqlalchemy import Column, TIMESTAMP + + +class ClaimableMixin(object): + claimed_until = Column(TIMESTAMP(timezone=True)) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 14e9c01d..f5c1a461 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -175,11 +175,14 @@ class PortfolioStateMachine( app.logger.info(exc.json()) print(exc.json()) app.logger.info(payload_data) + # TODO: Ensure that failing the stage does not preclude a Celery retry self.fail_stage(stage) + # TODO: catch and handle general CSP exception here except (ConnectionException, UnknownServerException) as exc: app.logger.error( f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1, ) + # TODO: Ensure that failing the stage does not preclude a Celery retry self.fail_stage(stage) self.finish_stage(stage) diff --git a/atst/models/utils.py b/atst/models/utils.py index 6059d33e..7fba3206 100644 --- a/atst/models/utils.py +++ b/atst/models/utils.py @@ -1,3 +1,5 @@ +from typing import List + from sqlalchemy import func, sql, Interval, and_, or_ from contextlib import contextmanager @@ -28,7 +30,7 @@ def claim_for_update(resource, minutes=30): .filter( and_( Model.id == resource.id, - or_(Model.claimed_until == None, Model.claimed_until <= func.now()), + or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()), ) ) .update({"claimed_until": claim_until}, synchronize_session="fetch") @@ -48,3 +50,51 @@ def claim_for_update(resource, minutes=30): Model.claimed_until != None ).update({"claimed_until": None}, synchronize_session="fetch") db.session.commit() + + +@contextmanager +def claim_many_for_update(resources: List, minutes=30): + """ + Claim a mutually exclusive expiring hold on a group of resources. + Uses the database as a central source of time in case the server clocks have drifted. + + Args: + resources: A list of SQLAlchemy model instances with a `claimed_until` attribute. + minutes: The maximum amount of time, in minutes, to hold the claim. + """ + Model = resources[0].__class__ + + claim_until = func.now() + func.cast( + sql.functions.concat(minutes, " MINUTES"), Interval + ) + + ids = tuple(r.id for r in resources) + + # Optimistically query for and update the resources in question. If they're + # already claimed, `rows_updated` will be 0 and we can give up. + rows_updated = ( + db.session.query(Model) + .filter( + and_( + Model.id.in_(ids), + or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()), + ) + ) + .update({"claimed_until": claim_until}, synchronize_session="fetch") + ) + if rows_updated < 1: + # TODO: Generalize this exception class so it can take multiple resources + raise ClaimFailedException(resources[0]) + + # Fetch the claimed resources + claimed = db.session.query(Model).filter(Model.id.in_(ids)).all() + + try: + # Give the resource to the caller. + yield claimed + finally: + # Release the claim. + db.session.query(Model).filter(Model.id.in_(ids)).filter( + Model.claimed_until != None + ).update({"claimed_until": None}, synchronize_session="fetch") + db.session.commit() 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/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 8b744b04..8807c7f1 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -467,9 +467,10 @@ def revoke_invite(application_id, application_role_id): if invite.is_pending: ApplicationInvitations.revoke(invite.token) flash( - "application_invite_revoked", + "invite_revoked", + resource="Application", user_name=app_role.user_name, - application_name=g.application.name, + resource_name=g.application.name, ) else: flash( diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index 9ec56aa3..80375e75 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -37,8 +37,14 @@ def accept_invitation(portfolio_token): ) @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation") def revoke_invitation(portfolio_id, portfolio_token): - PortfolioInvitations.revoke(portfolio_token) + invite = PortfolioInvitations.revoke(portfolio_token) + flash( + "invite_revoked", + resource="Portfolio", + user_name=invite.user_name, + resource_name=g.portfolio.name, + ) return redirect( url_for( "portfolios.admin", diff --git a/atst/utils/flash.py b/atst/utils/flash.py index ea85f1ef..b7ca0cb9 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -33,11 +33,6 @@ MESSAGES = { "message": "flash.application_invite.resent.message", "category": "success", }, - "application_invite_revoked": { - "title": "flash.application_invite.revoked.title", - "message": "flash.application_invite.revoked.message", - "category": "success", - }, "application_member_removed": { "title": "flash.application_member.removed.title", "message": "flash.application_member.removed.message", @@ -103,6 +98,11 @@ MESSAGES = { "message": None, "category": "warning", }, + "invite_revoked": { + "title": "flash.invite_revoked.title", + "message": "flash.invite_revoked.message", + "category": "success", + }, "logged_out": { "title": "flash.logged_out.title", "message": "flash.logged_out.message", diff --git a/js/components/sidenav_toggler.js b/js/components/sidenav_toggler.js index faba4c3b..11717849 100644 --- a/js/components/sidenav_toggler.js +++ b/js/components/sidenav_toggler.js @@ -1,30 +1,21 @@ +import ExpandSidenavMixin from '../mixins/expand_sidenav' import ToggleMixin from '../mixins/toggle' -const cookieName = 'expandSidenav' - export default { name: 'sidenav-toggler', - mixins: [ToggleMixin], + mixins: [ExpandSidenavMixin, ToggleMixin], - props: { - defaultVisible: { - type: Boolean, - default: function() { - if (document.cookie.match(cookieName)) { - return !!document.cookie.match(cookieName + ' *= *true') - } else { - return true - } - }, - }, + mounted: function() { + this.$parent.$emit('sidenavToggle', this.isVisible) }, methods: { toggle: function(e) { e.preventDefault() this.isVisible = !this.isVisible - document.cookie = cookieName + '=' + this.isVisible + '; path=/' + document.cookie = this.cookieName + '=' + this.isVisible + '; path=/' + this.$parent.$emit('sidenavToggle', this.isVisible) }, }, } diff --git a/js/components/upload_input.js b/js/components/upload_input.js index ae155c48..9856405b 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -2,6 +2,13 @@ import { buildUploader } from '../lib/upload' import { emitFieldChange } from '../lib/emitters' import inputValidations from '../lib/input_validations' +function uploadResponseOkay(response) { + // check BlobUploadCommonResponse: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest + // The upload operation is a PUT that should return a 201 + // https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob#status-code + return response._response.status === 201 +} + export default { name: 'uploadinput', @@ -21,7 +28,7 @@ export default { type: String, }, sizeLimit: { - type: String, + type: Number, }, }, @@ -34,7 +41,7 @@ export default { sizeError: false, filenameError: false, downloadLink: '', - fileSizeLimit: parseInt(this.sizeLimit), + fileSizeLimit: this.sizeLimit, } }, @@ -63,7 +70,7 @@ export default { const uploader = await this.getUploader() const response = await uploader.upload(file) - if (response.ok) { + if (uploadResponseOkay(response)) { this.attachment = e.target.value this.$refs.attachmentFilename.value = file.name this.$refs.attachmentObjectName.value = response.objectName @@ -73,7 +80,7 @@ export default { this.downloadLink = await this.getDownloadLink( file.name, - response.objectName + uploader.objectName ) } else { emitFieldChange(this) diff --git a/js/index.js b/js/index.js index fb5cdd6e..6495268b 100644 --- a/js/index.js +++ b/js/index.js @@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form' import ClinFields from './components/clin_fields' import PopDateRange from './components/pop_date_range' import ToggleMenu from './components/toggle_menu' +import ExpandSidenav from './mixins/expand_sidenav' Vue.config.productionTip = false Vue.use(VTooltip) Vue.mixin(Modal) +Vue.mixin(ExpandSidenav) const app = new Vue({ el: '#app-root', @@ -67,6 +69,12 @@ const app = new Vue({ ToggleMenu, }, + data: function() { + return { + sidenavExpanded: this.defaultVisible, + } + }, + mounted: function() { this.$on('modalOpen', data => { if (data['isOpen']) { @@ -105,6 +113,10 @@ const app = new Vue({ } }) }) + + this.$on('sidenavToggle', data => { + this.sidenavExpanded = data + }) }, delimiters: ['!{', '}'], diff --git a/js/lib/upload.js b/js/lib/upload.js index ccbf9011..3f3df246 100644 --- a/js/lib/upload.js +++ b/js/lib/upload.js @@ -1,4 +1,4 @@ -import Azure from 'azure-storage' +import { BlobServiceClient } from '@azure/storage-blob' import 'whatwg-fetch' class AzureUploader { @@ -10,46 +10,22 @@ class AzureUploader { } async upload(file) { - const blobService = Azure.createBlobServiceWithSas( - `https://${this.accountName}.blob.core.windows.net`, - this.sasToken + const blobServiceClient = new BlobServiceClient( + `https://${this.accountName}.blob.core.windows.net?${this.sasToken}` ) - const fileReader = new FileReader() + const containerClient = blobServiceClient.getContainerClient( + this.containerName + ) + const blobClient = containerClient.getBlockBlobClient(this.objectName) const options = { - contentSettings: { - contentType: 'application/pdf', + blobHTTPHeaders: { + blobContentType: 'application/pdf', }, metadata: { filename: file.name, }, } - - return new Promise((resolve, reject) => { - fileReader.addEventListener('load', f => { - blobService.createBlockBlobFromText( - this.containerName, - `${this.objectName}`, - f.target.result, - options, - (err, result) => { - if (err) { - resolve({ ok: false }) - } else { - resolve({ ok: true, objectName: this.objectName }) - } - } - ) - }) - fileReader.readAsText(file) - }) - } - - downloadUrl(objectName) { - const blobService = Azure.createBlobServiceWithSas( - `https://${this.accountName}.blob.core.windows.net`, - this.sasToken - ) - return blobService.getUrl(this.containerName, objectName, this.sasToken) + return blobClient.uploadBrowserData(file, options) } } @@ -60,7 +36,8 @@ export class MockUploader { } async upload(file, objectName) { - return Promise.resolve({ ok: true, objectName: this.objectName }) + // mock BlobUploadCommonResponse structure: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest + return Promise.resolve({ _response: { status: 201 } }) } } diff --git a/js/mixins/expand_sidenav.js b/js/mixins/expand_sidenav.js new file mode 100644 index 00000000..7553b7d4 --- /dev/null +++ b/js/mixins/expand_sidenav.js @@ -0,0 +1,15 @@ +export default { + props: { + cookieName: 'expandSidenav', + defaultVisible: { + type: Boolean, + default: function() { + if (document.cookie.match(this.cookieName)) { + return !!document.cookie.match(this.cookieName + ' *= *true') + } else { + return true + } + }, + }, + }, +} diff --git a/package.json b/package.json index 8521623c..f7aee1d1 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "author": "", "license": "MIT", "dependencies": { + "@azure/storage-blob": "^12.0.2", "ally.js": "^1.4.1", "autoprefixer": "^9.1.3", - "azure-storage": "^2.10.3", "babel-polyfill": "^6.26.0", "date-fns": "^1.29.0", "ramda": "^0.25.0", diff --git a/styles/atat.scss b/styles/atat.scss index 72c7af40..8eb73473 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -47,3 +47,4 @@ @import "sections/application_edit"; @import "sections/reports"; @import "sections/task_order"; +@import "sections/ccpo"; diff --git a/styles/components/_alerts.scss b/styles/components/_alerts.scss index be326807..eb62a756 100644 --- a/styles/components/_alerts.scss +++ b/styles/components/_alerts.scss @@ -11,6 +11,7 @@ .usa-alert { padding-bottom: 2.4rem; + max-width: $max-panel-width; } @mixin alert { @@ -97,38 +98,3 @@ } } } - -.alert { - @include alert; - @include alert-level("info"); - - &.alert--success { - @include alert-level("success"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-green, $color-white); - } - } - } - - &.alert--warning { - @include alert-level("warning"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-gold-dark, $color-white); - } - } - } - - &.alert--error { - @include alert-level("error"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-red, $color-white); - } - } - } -} diff --git a/styles/components/_error_page.scss b/styles/components/_error_page.scss index 19ae7531..683b527f 100644 --- a/styles/components/_error_page.scss +++ b/styles/components/_error_page.scss @@ -1,8 +1,13 @@ .error-page { - max-width: 475px; - margin: auto; + max-width: $max-page-width; .panel { + box-shadow: none; + background-color: unset; + border: none; + max-width: 475px; + margin: auto; + &__heading { text-align: center; padding: $gap 0; @@ -15,17 +20,6 @@ margin-bottom: $gap; } } - - &__body { - padding: $gap * 2; - margin: 0; - - hr { - width: 80%; - margin: auto; - margin-bottom: $gap * 3; - } - } } .icon { diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 6d84f426..feca57b6 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,7 +12,7 @@ flex-direction: row; align-items: stretch; justify-content: space-between; - max-width: 1190px; + max-width: $max-page-width; a { color: $color-white; diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 12657ca4..372fa868 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -19,6 +19,7 @@ $sidenav-collapsed-width: 10rem; $max-panel-width: 90rem; $home-pg-icon-width: 6rem; $large-spacing: 4rem; +$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing; /* * USWDS Variables diff --git a/styles/elements/_action_group.scss b/styles/elements/_action_group.scss index fe375f67..c2d11049 100644 --- a/styles/elements/_action_group.scss +++ b/styles/elements/_action_group.scss @@ -32,22 +32,35 @@ } .action-group-footer { - @extend .action-group; - - &:last-child { - margin-bottom: 0; - } - margin-top: 0; - margin-bottom: 0; padding-top: $gap; padding-bottom: $gap; - + padding-right: $gap * 4; position: fixed; bottom: $footer-height; + left: 0; background: white; - right: 0; - padding-right: $gap * 4; border-top: 1px solid $color-gray-lighter; - width: 100%; z-index: 1; + width: 100%; + + &.action-group-footer--expand-offset { + padding-left: $sidenav-expanded-width; + } + + &.action-group-footer--collapse-offset { + padding-left: $sidenav-collapsed-width; + } + + .action-group-footer--container { + @extend .action-group; + + margin-top: 0; + margin-bottom: 0; + margin-left: $large-spacing; + max-width: $max-panel-width; + + &:last-child { + margin-bottom: 0; + } + } } diff --git a/styles/sections/_ccpo.scss b/styles/sections/_ccpo.scss new file mode 100644 index 00000000..4033ac2e --- /dev/null +++ b/styles/sections/_ccpo.scss @@ -0,0 +1,3 @@ +.ccpo-panel-container { + max-width: $max-panel-width; +} diff --git a/templates/applications/new/step_1.html b/templates/applications/new/step_1.html index d06ddee0..3841bf96 100644 --- a/templates/applications/new/step_1.html +++ b/templates/applications/new/step_1.html @@ -39,14 +39,18 @@ - - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }} - {% endblock %} - - Cancel - - + diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index 462c0f46..2cd5cf98 100644 --- a/templates/applications/new/step_2.html +++ b/templates/applications/new/step_2.html @@ -58,20 +58,24 @@ {{ Icon("plus") }} + + + + - - - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }} - {% endblock %} - - Previous - - - Cancel - - diff --git a/templates/applications/new/step_3.html b/templates/applications/new/step_3.html index d88a704c..35af10fa 100644 --- a/templates/applications/new/step_3.html +++ b/templates/applications/new/step_3.html @@ -25,16 +25,20 @@ action_update="applications.update_new_application_step_3") }} - - - {{ "portfolios.applications.new.step_3_button_text" | translate }} - - - {{ "common.previous" | translate }} - - - {{ "common.cancel" | translate }} - - + {% endblock %} diff --git a/templates/ccpo/add_user.html b/templates/ccpo/add_user.html index e8544a2b..eccd27a9 100644 --- a/templates/ccpo/add_user.html +++ b/templates/ccpo/add_user.html @@ -4,21 +4,23 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - -
- {{ form.csrf_token }} -

{{ "ccpo.form.add_user_title" | translate }}

-
-
- {{ TextInput(form.dod_id, validation='dodId', optional=False) }} -
-
-
- {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} - {{ "common.cancel" | translate }} +
+ + + {{ form.csrf_token }} +

{{ "ccpo.form.add_user_title" | translate }}

+
+
+ {{ TextInput(form.dod_id, validation='dodId', optional=False) }} +
+
+
+ {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} + {{ "common.cancel" | translate }} +
-
- - + + +
{% endblock %} diff --git a/templates/ccpo/confirm_user.html b/templates/ccpo/confirm_user.html index dfe30bca..a45abd3c 100644 --- a/templates/ccpo/confirm_user.html +++ b/templates/ccpo/confirm_user.html @@ -3,31 +3,33 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - {% if new_user %} -

{{ 'ccpo.form.confirm_user_title' | translate }}

-
- {{ form.csrf_token }} - -
-

- {{ "ccpo.form.confirm_user_text" | translate }} -

-

- {{ new_user.full_name }} -

-

- {{ new_user.email }} -

-
- -
- {% endif %} +
+ {% if new_user %} +

{{ 'ccpo.form.confirm_user_title' | translate }}

+
+ {{ form.csrf_token }} + +
+

+ {{ "ccpo.form.confirm_user_text" | translate }} +

+

+ {{ new_user.full_name }} +

+

+ {{ new_user.email }} +

+
+ +
+ {% endif %} +
{% endblock %} diff --git a/templates/ccpo/users.html b/templates/ccpo/users.html index c5c8cc3b..15e5d9fe 100644 --- a/templates/ccpo/users.html +++ b/templates/ccpo/users.html @@ -6,78 +6,80 @@ {% from "components/modal.html" import Modal %} {% block content %} -
-
- {{ "ccpo.users_title" | translate }} -
+
+
+
+ {{ "ccpo.users_title" | translate }} +
- {% include "fragments/flash.html" %} - - - - - - - - {% if user_can(permissions.DELETE_CCPO_USER) %} - - {% endif %} - - - - {% for user, form in users_info %} - {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} - {% set disable_button_class = 'button-danger-outline' %} - {% if user == g.current_user %} - {% set disable_button_class = "usa-button-disabled" %} - {% endif %} + {% include "fragments/flash.html" %} +
{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }}
+ - - - + + + {% if user_can(permissions.DELETE_CCPO_USER) %} - + {% endif %} - {% endfor %} - -
{{ user.full_name }}{{ user.email }}{{ user.dod_id }}{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }} - - {{ "common.disable" | translate }} - -
+ + + {% for user, form in users_info %} + {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} + {% set disable_button_class = 'button-danger-outline' %} + {% if user == g.current_user %} + {% set disable_button_class = "usa-button-disabled" %} + {% endif %} + + + {{ user.full_name }} + {{ user.email }} + {{ user.dod_id }} + {% if user_can(permissions.DELETE_CCPO_USER) %} + + + {{ "common.disable" | translate }} + + + {% endif %} + + {% endfor %} + + +
+ + {% if user_can(permissions.CREATE_CCPO_USER) %} + + {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} + + {% endif %} + + {% if user_can(permissions.DELETE_CCPO_USER) %} + {% for user, form in users_info %} + {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} + {% call Modal(name=modal_id) %} +

Disable CCPO User

+
+ {{ + Alert( + title=("components.modal.destructive_title" | translate), + message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})), + level="warning" + ) + }} + {{ + DeleteConfirmation( + modal_id=modal_id, + delete_text='Remove Access', + delete_action=(url_for('ccpo.remove_access', user_id=user.id)), + form=form, + confirmation_text='remove' + ) + }} + {% endcall %} + {% endfor %} + {% endif %}
- - {% if user_can(permissions.CREATE_CCPO_USER) %} - - {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} - - {% endif %} - - {% if user_can(permissions.DELETE_CCPO_USER) %} - {% for user, form in users_info %} - {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} - {% call Modal(name=modal_id) %} -

Disable CCPO User

-
- {{ - Alert( - title=("components.modal.destructive_title" | translate), - message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})), - level="warning" - ) - }} - {{ - DeleteConfirmation( - modal_id=modal_id, - delete_text='Remove Access', - delete_action=(url_for('ccpo.remove_access', user_id=user.id)), - form=form, - confirmation_text='remove' - ) - }} - {% endcall %} - {% endfor %} - {% endif %} {% endblock %} diff --git a/templates/error.html b/templates/error.html index 449c9a88..45ff12a3 100644 --- a/templates/error.html +++ b/templates/error.html @@ -5,6 +5,7 @@ {% block content %}
+
{{ Icon('cloud', classes="icon--red icon--large")}}
@@ -17,6 +18,7 @@ {%- endif %}

+
{% endblock %} diff --git a/templates/error_base.html b/templates/error_base.html index 92be8e60..b5751e81 100644 --- a/templates/error_base.html +++ b/templates/error_base.html @@ -10,29 +10,30 @@ +
+ {% block template_vars %}{% endblock %} - {% block template_vars %}{% endblock %} + {% include 'components/usa_header.html' %} - {% include 'components/usa_header.html' %} + {% include 'navigation/topbar.html' %} - {% include 'navigation/topbar.html' %} +
-
+
+ {% block sidenav %}{% endblock %} -
- {% block sidenav %}{% endblock %} - - {% block content %} - these are not the droids you are looking for - {% endblock %} + {% block content %} + these are not the droids you are looking for + {% endblock %} +
+ + {% include 'footer.html' %} + + {% block modal %}{% endblock %} + {% assets "js_all" %} + + {% endassets %}
- - {% include 'footer.html' %} - - {% block modal %}{% endblock %} - {% assets "js_all" %} - - {% endassets %} diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index 3305d924..940becee 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -15,6 +15,7 @@

{{ "portfolios.header" | translate }}

{{ 'portfolios.new.title' | translate }}

+
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
@@ -38,13 +39,18 @@ {{ "forms.portfolio.defense_component.help_text" | translate | safe }}
- diff --git a/templates/task_orders/builder_base.html b/templates/task_orders/builder_base.html index 9ee8dd0c..66e84d53 100644 --- a/templates/task_orders/builder_base.html +++ b/templates/task_orders/builder_base.html @@ -31,7 +31,10 @@
{% block to_builder_form_field %}{% endblock %}
- + {% endblock %} diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index fd98fddf..fea9ab43 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -5,6 +5,8 @@ from uuid import uuid4 import pytest from tests.factories import ApplicationFactory, EnvironmentFactory from tests.mock_azure import AUTH_CREDENTIALS, mock_azure +import pendulum +import pydantic from atst.domain.csp.cloud import AzureCloudProvider from atst.domain.csp.cloud.models import ( @@ -24,10 +26,12 @@ from atst.domain.csp.cloud.models import ( ManagementGroupCSPResponse, ManagementGroupGetCSPPayload, ManagementGroupGetCSPResponse, + CostManagementQueryCSPResult, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, + ReportingCSPPayload, SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, SubscriptionVerificationCSPPayload, @@ -765,3 +769,77 @@ def test_create_subscription_verification(mock_azure: AzureCloudProvider): payload ) assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230" + + +def test_get_reporting_data(mock_azure: AzureCloudProvider): + mock_result = Mock() + mock_result.json.return_value = { + "eTag": None, + "id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB/providers/Microsoft.CostManagement/query/e82d0cda-2ffb-4476-a98a-425c83c216f9", + "location": None, + "name": "e82d0cda-2ffb-4476-a98a-425c83c216f9", + "properties": { + "columns": [ + {"name": "PreTaxCost", "type": "Number"}, + {"name": "UsageDate", "type": "Number"}, + {"name": "InvoiceId", "type": "String"}, + {"name": "Currency", "type": "String"}, + ], + "nextLink": None, + "rows": [], + }, + "sku": None, + "type": "Microsoft.CostManagement/query", + } + mock_result.ok = True + mock_azure.sdk.requests.post.return_value = mock_result + mock_azure = mock_get_secret(mock_azure) + + # Subset of a profile's CSP data that we care about for reporting + csp_data = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": { + "invoice_sections": [ + { + "invoice_section_id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB", + } + ], + }, + } + + data: CostManagementQueryCSPResult = mock_azure.get_reporting_data( + ReportingCSPPayload( + from_date=pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD"), + to_date=pendulum.now().format("YYYY-MM-DD"), + **csp_data, + ) + ) + + assert isinstance(data, CostManagementQueryCSPResult) + assert data.name == "e82d0cda-2ffb-4476-a98a-425c83c216f9" + assert len(data.properties.columns) == 4 + + +def test_get_reporting_data_malformed_payload(mock_azure: AzureCloudProvider): + mock_result = Mock() + mock_result.ok = True + mock_azure.sdk.requests.post.return_value = mock_result + mock_azure = mock_get_secret(mock_azure) + + # Malformed csp_data payloads that should throw pydantic validation errors + index_error = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": {"invoice_sections": [],}, + } + key_error = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": {"invoice_sections": [{}],}, + } + + for malformed_payload in [key_error, index_error]: + with pytest.raises(pydantic.ValidationError): + assert mock_azure.get_reporting_data( + ReportingCSPPayload( + from_date="foo", to_date="bar", **malformed_payload, + ) + ) diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py index d9fc963d..10c81293 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", + "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.onmicrosoft.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 ca5c6fc1..ba05fb53 100644 --- a/tests/domain/test_application_roles.py +++ b/tests/domain/test_application_roles.py @@ -86,3 +86,79 @@ def test_disable(session): session.refresh(environment_role) assert member_role.status == ApplicationRoleStatus.DISABLED assert environment_role.deleted + + +def test_get_pending_creation(): + + # ready Applications belonging to the same Portfolio + portfolio_one = PortfolioFactory.create() + ready_app = ApplicationFactory.create(cloud_id="123", portfolio=portfolio_one) + ready_app2 = ApplicationFactory.create(cloud_id="321", portfolio=portfolio_one) + + # ready Application belonging to a new Portfolio + ready_app3 = ApplicationFactory.create(cloud_id="567") + unready_app = ApplicationFactory.create() + + # two distinct Users + user_one = UserFactory.create() + user_two = UserFactory.create() + + # Two ApplicationRoles belonging to the same User and + # different Applications. These should sort together because + # they are all under the same portfolio (portfolio_one). + role_one = ApplicationRoleFactory.create( + user=user_one, application=ready_app, status=ApplicationRoleStatus.ACTIVE + ) + role_two = ApplicationRoleFactory.create( + user=user_one, application=ready_app2, status=ApplicationRoleStatus.ACTIVE + ) + + # An ApplicationRole belonging to a different User. This will + # be included but sort separately because it belongs to a + # different user. + role_three = ApplicationRoleFactory.create( + user=user_two, application=ready_app, status=ApplicationRoleStatus.ACTIVE + ) + + # An ApplicationRole belonging to one of the existing users + # but under a different portfolio. It will sort separately. + role_four = ApplicationRoleFactory.create( + user=user_one, application=ready_app3, status=ApplicationRoleStatus.ACTIVE + ) + + # This ApplicationRole will not be in the results because its + # application is not ready (implicitly, its cloud_id is not + # set.) + ApplicationRoleFactory.create( + user=UserFactory.create(), + application=unready_app, + status=ApplicationRoleStatus.ACTIVE, + ) + + # This ApplicationRole will not be in the results because it + # does not have a user associated. + ApplicationRoleFactory.create( + user=None, application=ready_app, status=ApplicationRoleStatus.ACTIVE, + ) + + # This ApplicationRole will not be in the results because its + # status is not ACTIVE. + ApplicationRoleFactory.create( + user=UserFactory.create(), + application=unready_app, + status=ApplicationRoleStatus.DISABLED, + ) + + app_ids = ApplicationRoles.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/domain/test_environments.py b/tests/domain/test_environments.py index ff4b8605..9144c68a 100644 --- a/tests/domain/test_environments.py +++ b/tests/domain/test_environments.py @@ -1,5 +1,4 @@ import pytest -import pendulum from uuid import uuid4 from atst.domain.environments import Environments @@ -14,6 +13,7 @@ from tests.factories import ( EnvironmentRoleFactory, ApplicationRoleFactory, ) +from tests.utils import EnvQueryTest def test_create_environments(): @@ -119,40 +119,6 @@ def test_update_does_not_duplicate_names_within_application(): Environments.update(dupe_env, name) -class EnvQueryTest: - @property - def NOW(self): - return pendulum.now() - - @property - def YESTERDAY(self): - return self.NOW.subtract(days=1) - - @property - def TOMORROW(self): - return self.NOW.add(days=1) - - def create_portfolio_with_clins(self, start_and_end_dates, env_data=None): - env_data = env_data or {} - return PortfolioFactory.create( - applications=[ - { - "name": "Mos Eisley", - "description": "Where Han shot first", - "environments": [{"name": "thebar", **env_data}], - } - ], - task_orders=[ - { - "create_clins": [ - {"start_date": start_date, "end_date": end_date} - for (start_date, end_date) in start_and_end_dates - ] - } - ], - ) - - class TestGetEnvironmentsPendingCreate(EnvQueryTest): def test_with_expired_clins(self, session): self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) diff --git a/tests/domain/test_portfolios.py b/tests/domain/test_portfolios.py index ff8ccacb..1093253b 100644 --- a/tests/domain/test_portfolios.py +++ b/tests/domain/test_portfolios.py @@ -26,6 +26,7 @@ from tests.factories import ( PortfolioStateMachineFactory, get_all_portfolio_permission_sets, ) +from tests.utils import EnvQueryTest @pytest.fixture(scope="function") @@ -263,10 +264,44 @@ def test_create_state_machine(portfolio): assert fsm -def test_get_portfolios_pending_provisioning(session): - for x in range(5): - portfolio = PortfolioFactory.create() - sm = PortfolioStateMachineFactory.create(portfolio=portfolio) - if x == 2: - sm.state = FSMStates.COMPLETED - assert len(Portfolios.get_portfolios_pending_provisioning()) == 4 +class TestGetPortfoliosPendingCreate(EnvQueryTest): + def test_finds_unstarted(self): + for x in range(5): + if x == 2: + state = "COMPLETED" + else: + state = "UNSTARTED" + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status=state + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 4 + + def test_finds_created(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_CREATED" + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 1 + + def test_does_not_find_failed(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_FAILED" + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_expired_clins(self): + self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_active_clins(self): + portfolio = self.create_portfolio_with_clins([(self.YESTERDAY, self.TOMORROW)]) + Portfolios.get_portfolios_pending_provisioning(self.NOW) == [portfolio.id] + + def test_with_future_clins(self): + self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)]) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_already_provisioned_env(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex} + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 diff --git a/tests/models/test_utils.py b/tests/models/test_utils.py new file mode 100644 index 00000000..d73de13a --- /dev/null +++ b/tests/models/test_utils.py @@ -0,0 +1,104 @@ +from threading import Thread + +from atst.domain.exceptions import ClaimFailedException +from atst.models.utils import claim_for_update, claim_many_for_update + +from tests.factories import EnvironmentFactory + + +def test_claim_for_update(session): + environment = EnvironmentFactory.create() + + satisfied_claims = [] + exceptions = [] + + # Two threads race to do work on environment and check out the lock + class FirstThread(Thread): + def run(self): + try: + with claim_for_update(environment) as env: + assert env.claimed_until + satisfied_claims.append("FirstThread") + except ClaimFailedException: + exceptions.append("FirstThread") + + class SecondThread(Thread): + def run(self): + try: + with claim_for_update(environment) as env: + assert env.claimed_until + satisfied_claims.append("SecondThread") + except ClaimFailedException: + exceptions.append("SecondThread") + + t1 = FirstThread() + t2 = SecondThread() + t1.start() + t2.start() + t1.join() + t2.join() + + session.refresh(environment) + + assert len(satisfied_claims) == 1 + assert len(exceptions) == 1 + + if satisfied_claims == ["FirstThread"]: + assert exceptions == ["SecondThread"] + else: + assert satisfied_claims == ["SecondThread"] + assert exceptions == ["FirstThread"] + + # The claim is released + assert environment.claimed_until is None + + +def test_claim_many_for_update(session): + environments = [ + EnvironmentFactory.create(), + EnvironmentFactory.create(), + ] + + satisfied_claims = [] + exceptions = [] + + # Two threads race to do work on environment and check out the lock + class FirstThread(Thread): + def run(self): + try: + with claim_many_for_update(environments) as envs: + assert all([e.claimed_until for e in envs]) + satisfied_claims.append("FirstThread") + except ClaimFailedException: + exceptions.append("FirstThread") + + class SecondThread(Thread): + def run(self): + try: + with claim_many_for_update(environments) as envs: + assert all([e.claimed_until for e in envs]) + satisfied_claims.append("SecondThread") + except ClaimFailedException: + exceptions.append("SecondThread") + + t1 = FirstThread() + t2 = SecondThread() + t1.start() + t2.start() + t1.join() + t2.join() + + for env in environments: + session.refresh(env) + + assert len(satisfied_claims) == 1 + assert len(exceptions) == 1 + + if satisfied_claims == ["FirstThread"]: + assert exceptions == ["SecondThread"] + else: + assert satisfied_claims == ["SecondThread"] + assert exceptions == ["FirstThread"] + + # The claim is released + # assert environment.claimed_until is None diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 2ac5f408..f15e9da0 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -2,27 +2,25 @@ import pendulum import pytest from uuid import uuid4 from unittest.mock import Mock -from threading import Thread 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, do_create_atat_admin_user, ) -from atst.models.utils import claim_for_update -from atst.domain.exceptions import ClaimFailedException from tests.factories import ( EnvironmentFactory, EnvironmentRoleFactory, @@ -30,6 +28,7 @@ from tests.factories import ( PortfolioStateMachineFactory, ApplicationFactory, ApplicationRoleFactory, + UserFactory, ) from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure @@ -126,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) @@ -181,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=[ @@ -240,11 +286,8 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker): assert environment.claimed_until == None -def test_claim_for_update(session): +def test_dispatch_provision_portfolio(csp, monkeypatch): portfolio = PortfolioFactory.create( - applications=[ - {"environments": [{"cloud_id": uuid4().hex, "root_user_info": {}}]} - ], task_orders=[ { "create_clins": [ @@ -256,115 +299,6 @@ def test_claim_for_update(session): } ], ) - environment = portfolio.applications[0].environments[0] - - satisfied_claims = [] - exceptions = [] - - # Two threads race to do work on environment and check out the lock - class FirstThread(Thread): - def run(self): - try: - with claim_for_update(environment): - satisfied_claims.append("FirstThread") - except ClaimFailedException: - exceptions.append("FirstThread") - - class SecondThread(Thread): - def run(self): - try: - with claim_for_update(environment): - satisfied_claims.append("SecondThread") - except ClaimFailedException: - exceptions.append("SecondThread") - - t1 = FirstThread() - t2 = SecondThread() - t1.start() - t2.start() - t1.join() - t2.join() - - session.refresh(environment) - - assert len(satisfied_claims) == 1 - assert len(exceptions) == 1 - - if satisfied_claims == ["FirstThread"]: - assert exceptions == ["SecondThread"] - else: - assert satisfied_claims == ["SecondThread"] - assert exceptions == ["FirstThread"] - - # The claim is released - assert environment.claimed_until is 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 -): sm = PortfolioStateMachineFactory.create(portfolio=portfolio) mock = Mock() monkeypatch.setattr("atst.jobs.provision_portfolio", mock) diff --git a/tests/utils.py b/tests/utils.py index 66bf2b18..665910af 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,9 +5,12 @@ from unittest.mock import Mock from OpenSSL import crypto from cryptography.hazmat.backends import default_backend from flask import template_rendered +import pendulum from atst.utils.notification_sender import NotificationSender +import tests.factories as factories + @contextmanager def captured_templates(app): @@ -62,3 +65,40 @@ def make_crl_list(x509_obj, x509_path): issuer = x509_obj.issuer.public_bytes(default_backend()) filename = os.path.basename(x509_path) return [(filename, issuer.hex())] + + +class EnvQueryTest: + @property + def NOW(self): + return pendulum.now() + + @property + def YESTERDAY(self): + return self.NOW.subtract(days=1) + + @property + def TOMORROW(self): + return self.NOW.add(days=1) + + def create_portfolio_with_clins( + self, start_and_end_dates, env_data=None, state_machine_status=None + ): + env_data = env_data or {} + return factories.PortfolioFactory.create( + state=state_machine_status, + applications=[ + { + "name": "Mos Eisley", + "description": "Where Han shot first", + "environments": [{"name": "thebar", **env_data}], + } + ], + task_orders=[ + { + "create_clins": [ + {"start_date": start_date, "end_date": end_date} + for (start_date, end_date) in start_and_end_dates + ] + } + ], + ) diff --git a/translations.yaml b/translations.yaml index 44dd2a92..c16f89c6 100644 --- a/translations.yaml +++ b/translations.yaml @@ -128,9 +128,6 @@ flash: message: There was an error processing the invitation for {user_name} from {application_name} resent: message: "{email} has been sent an invitation to access this Application" - revoked: - title: Application invitation revoked - message: You have successfully revoked the invite for {user_name} from {application_name} application_member: removed: title: Team member removed from application @@ -166,6 +163,9 @@ flash: errors: title: There were some errors message: Please see below. + invite_revoked: + title: "{resource} invitation revoked" + message: "You have successfully revoked the invite for {user_name} from {resource_name}" login_required_message: After you log in, you will be redirected to your destination page. login_required_title: Log in required logged_out: diff --git a/yarn.lock b/yarn.lock index 9ee18250..3dc5f26f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,97 @@ # yarn lockfile v1 +"@azure/abort-controller@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.0.1.tgz#8510935b25ac051e58920300e9d7b511ca6e656a" + integrity sha512-wP2Jw6uPp8DEDy0n4KNidvwzDjyVV2xnycEIq7nPzj1rHyb/r+t3OPeNT1INZePP2wy5ZqlwyuyOMTi0ePyY1A== + dependencies: + tslib "^1.9.3" + +"@azure/core-asynciterator-polyfill@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7" + integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg== + +"@azure/core-auth@^1.0.0": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.0.2.tgz#7377c0cacf0e3c988ce321295bf5d2c174e0e288" + integrity sha512-zhPJObdrhz2ymIqGL1x8i3meEuaLz0UPjH9mOq9RGOlJB2Pb6K6xPtkHbRsfElgoO9USR4hH2XU5pLa4/JHHIw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-tracing" "1.0.0-preview.7" + "@opentelemetry/types" "^0.2.0" + tslib "^1.9.3" + +"@azure/core-http@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@azure/core-http/-/core-http-1.0.3.tgz#faea8da9187278b7109c2af74d6f96e0c806e636" + integrity sha512-hmsalo2i1noF5LMwNBNymJnf210ha7Rh6x+BQBBcb+wUZI5hVGRbaRgHzqpJiH8FmfJrDuKZI+S7i2rILUBJTg== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.0.0" + "@azure/core-tracing" "1.0.0-preview.7" + "@azure/logger" "^1.0.0" + "@opentelemetry/types" "^0.2.0" + "@types/node-fetch" "^2.5.0" + "@types/tunnel" "^0.0.1" + form-data "^3.0.0" + node-fetch "^2.6.0" + process "^0.11.10" + tough-cookie "^3.0.1" + tslib "^1.10.0" + tunnel "^0.0.6" + uuid "^3.3.2" + xml2js "^0.4.19" + +"@azure/core-lro@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-1.0.0.tgz#9837398e03aa04b5b0b09158f4338861348dcce4" + integrity sha512-l4abIb8S9qmlv3bJkonLvgGSVQcSXq5jByA7Z28GRGJaQN/mSFal9YQOuLvVag+JXQJsoftuxJFrZiggF2TwOg== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.0.0" + events "^3.0.0" + tslib "^1.9.3" + +"@azure/core-paging@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.0.0.tgz#3aa3855582154260326feea97f9f8322cbfe56d9" + integrity sha512-CzaT7LwxU97PZ+/Pn7uAbNGXY2mJ/3b56kmLsZzbR9stfrNfzlILxR94WHG/D1jZEQOk4lUNiaqJ2zP7nSGJhA== + dependencies: + "@azure/core-asynciterator-polyfill" "^1.0.0" + +"@azure/core-tracing@1.0.0-preview.7": + version "1.0.0-preview.7" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.0-preview.7.tgz#e9ee9c88f0dcf50d8e5b468fc827203165ecbc3f" + integrity sha512-pkFCw6OiJrpR+aH1VQe6DYm3fK2KWCC5Jf3m/Pv1RxF08M1Xm08RCyQ5Qe0YyW5L16yYT2nnV48krVhYZ6SGFA== + dependencies: + "@opencensus/web-types" "0.0.7" + "@opentelemetry/types" "^0.2.0" + tslib "^1.9.3" + +"@azure/logger@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.0.tgz#48b371dfb34288c8797e5c104f6c4fb45bf1772c" + integrity sha512-g2qLDgvmhyIxR3JVS8N67CyIOeFRKQlX/llxYJQr1OSGQqM3HTpVP8MjmjcEKbL/OIt2N9C9UFaNQuKOw1laOA== + dependencies: + tslib "^1.9.3" + +"@azure/storage-blob@^12.0.2": + version "12.0.2" + resolved "https://registry.yarnpkg.com/@azure/storage-blob/-/storage-blob-12.0.2.tgz#6bdaf1171007972051024f3ca852e06a82d7f1a0" + integrity sha512-URELuzMzSDKUVRLt+bXIh/PZe54qWiLxDQ443rcvfpRAPP3QBLnDFw6hFacSXeU/0bOIRcbzAeKtKOMhoE7lSw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.0.0" + "@azure/core-lro" "^1.0.0" + "@azure/core-paging" "^1.0.0" + "@azure/core-tracing" "1.0.0-preview.7" + "@azure/logger" "^1.0.0" + "@opentelemetry/types" "^0.2.0" + events "^3.0.0" + tslib "^1.10.0" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" @@ -720,6 +811,16 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== +"@opencensus/web-types@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a" + integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g== + +"@opentelemetry/types@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/types/-/types-0.2.0.tgz#2a0afd40fa7026e39ea56a454642bda72b172f80" + integrity sha512-GtwNB6BNDdsIPAYEdpp3JnOGO/3AJxjPvny53s3HERBdXSJTGQw8IRhiaTEX0b3w9P8+FwFZde4k+qkjn67aVw== + "@parcel/fs@^1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@parcel/fs/-/fs-1.11.0.tgz#fb8a2be038c454ad46a50dc0554c1805f13535cd" @@ -761,6 +862,18 @@ "@parcel/utils" "^1.11.0" physical-cpu-count "^2.0.0" +"@types/node-fetch@^2.5.0": + version "2.5.4" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.4.tgz#5245b6d8841fc3a6208b82291119bc11c4e0ce44" + integrity sha512-Oz6id++2qAOFuOlE1j0ouk1dzl3mmI1+qINPNBhi9nt/gVOz0G+13Ao6qjhdF0Ys+eOkhu6JnFmt38bR3H0POQ== + dependencies: + "@types/node" "*" + +"@types/node@*": + version "13.7.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.0.tgz#b417deda18cf8400f278733499ad5547ed1abec4" + integrity sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ== + "@types/node@^10.11.7": version "10.14.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.8.tgz#fe444203ecef1162348cd6deb76c62477b2cc6e9" @@ -780,6 +893,13 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== +"@types/tunnel@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c" + integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A== + dependencies: + "@types/node" "*" + "@vue/test-utils@^1.0.0-beta.25": version "1.0.0-beta.25" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.25.tgz#4703076de3076bac42cdd242cd53e6fb8752ed8c" @@ -1112,23 +1232,6 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c" integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A== -azure-storage@^2.10.3: - version "2.10.3" - resolved "https://registry.yarnpkg.com/azure-storage/-/azure-storage-2.10.3.tgz#c5966bf929d87587d78f6847040ea9a4b1d4a50a" - integrity sha512-IGLs5Xj6kO8Ii90KerQrrwuJKexLgSwYC4oLWmc11mzKe7Jt2E5IVg+ZQ8K53YWZACtVTMBNO3iGuA+4ipjJxQ== - dependencies: - browserify-mime "~1.2.9" - extend "^3.0.2" - json-edm-parser "0.1.2" - md5.js "1.3.4" - readable-stream "~2.0.0" - request "^2.86.0" - underscore "~1.8.3" - uuid "^3.0.0" - validator "~9.4.1" - xml2js "0.2.8" - xmlbuilder "^9.0.7" - babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -1814,11 +1917,6 @@ browserify-des@^1.0.0: inherits "^2.0.1" safe-buffer "^5.1.2" -browserify-mime@~1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/browserify-mime/-/browserify-mime-1.2.9.tgz#aeb1af28de6c0d7a6a2ce40adb68ff18422af31f" - integrity sha1-rrGvKN5sDXpqLOQK22j/GEIq8x8= - browserify-rsa@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" @@ -2272,7 +2370,7 @@ combine-source-map@^0.8.0, combine-source-map@~0.8.0: lodash.memoize "~3.0.3" source-map "~0.5.3" -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -3219,7 +3317,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@^3.0.2, extend@~3.0.2: +extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -3391,6 +3489,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -3976,6 +4083,11 @@ invert-kv@^2.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== +ip-regex@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" + integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= + is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" @@ -4822,13 +4934,6 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= -json-edm-parser@0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/json-edm-parser/-/json-edm-parser-0.1.2.tgz#1e60b0fef1bc0af67bc0d146dfdde5486cd615b4" - integrity sha1-HmCw/vG8CvZ7wNFG393lSGzWFbQ= - dependencies: - jsonparse "~1.2.0" - json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -4882,11 +4987,6 @@ jsonparse@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" -jsonparse@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.2.0.tgz#5c0c5685107160e72fe7489bddea0b44c2bc67bd" - integrity sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70= - jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -5112,14 +5212,6 @@ math-random@^1.0.1: resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== -md5.js@1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" - integrity sha1-6b296UogpawYsENA/Fdk1bCdkB0= - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -5411,6 +5503,11 @@ node-addon-api@^1.7.1: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492" integrity sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ== +node-fetch@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + node-forge@^0.7.1: version "0.7.6" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" @@ -6931,7 +7028,7 @@ request-promise-native@^1.0.5: stealthy-require "^1.1.1" tough-cookie "^2.3.3" -request@^2.86.0, request@^2.87.0, request@^2.88.0: +request@^2.87.0, request@^2.88.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== @@ -7116,12 +7213,7 @@ sass-graph@^2.2.4: scss-tokenizer "^0.2.3" yargs "^7.0.0" -sax@0.5.x: - version "0.5.8" - resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" - integrity sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE= - -sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -7885,6 +7977,15 @@ tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.5.0: psl "^1.1.28" punycode "^2.1.1" +tough-cookie@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" + integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg== + dependencies: + ip-regex "^2.1.0" + psl "^1.1.28" + punycode "^2.1.1" + tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" @@ -7917,6 +8018,11 @@ trim-right@^1.0.1: dependencies: glob "^7.1.2" +tslib@^1.10.0, tslib@^1.9.3: + version "1.10.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" + integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" @@ -7933,6 +8039,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tunnel@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" + integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" @@ -7990,11 +8101,6 @@ undeclared-identifiers@^1.1.2: simple-concat "^1.0.0" xtend "^4.0.1" -underscore@~1.8.3: - version "1.8.3" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" - integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI= - unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -8138,10 +8244,6 @@ util@~0.10.1: dependencies: inherits "2.0.3" -uuid@^3.0.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - uuid@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" @@ -8169,11 +8271,6 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -validator@~9.4.1: - version "9.4.1" - resolved "https://registry.yarnpkg.com/validator/-/validator-9.4.1.tgz#abf466d398b561cd243050112c6ff1de6cc12663" - integrity sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA== - vendors@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0" @@ -8386,17 +8483,18 @@ xml-name-validator@^3.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== -xml2js@0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.2.8.tgz#9b81690931631ff09d1957549faf54f4f980b3c2" - integrity sha1-m4FpCTFjH/CdGVdUn69U9PmAs8I= +xml2js@^0.4.19: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== dependencies: - sax "0.5.x" + sax ">=0.6.0" + xmlbuilder "~11.0.0" -xmlbuilder@^9.0.7: - version "9.0.7" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" - integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== xmlchars@^2.1.1: version "2.2.0"