From 8dbb3b32a22759356d34572b1c8b57c1bdcf9812 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Fri, 17 Jan 2020 17:02:15 -0500 Subject: [PATCH 01/35] Portfolio Spacing & Layout Portfolio adjustments: -Cleaned up container margins/padding -Added max-width to header and Settings section -Refined Portfolio header and nav links Other adjustments: -Reduced font size of side nav links --- styles/components/_global_layout.scss | 3 +- styles/components/_portfolio_layout.scss | 37 ++++++++++++++++++------ styles/components/_sticky_cta.scss | 3 +- styles/core/_variables.scss | 2 +- styles/elements/_sidenav.scss | 4 +-- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index 5ad748d1..22de84e8 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -22,14 +22,13 @@ body { padding-bottom: $footer-height * 2.5; .global-panel-container { - margin: $gap; flex-grow: 1; -ms-flex-negative: 1; top: $usa-banner-height + $topbar-height; position: relative; + padding: 0 $spacing-large; @include media($medium-screen) { - margin: $gap * 2; top: $usa-banner-height + $topbar-height; } } diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 81a15b69..9dc9ee6a 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -4,25 +4,29 @@ min-height: 500px; } - margin-left: 2 * $gap; + .col.col--grow { + padding: 0; + } } .portfolio-header { flex-direction: column; + align-items: center; + margin: $spacing-medium 0; + max-width: 90rem; @include media($small-screen) { flex-direction: row; } - margin-bottom: $gap * 1; - .col--grow { overflow: inherit; } &__name { @include h1; + padding-right: $spacing-medium; h1 { - margin: 0 $gap ($gap * 2) 0; + margin: 0; font-size: 3.5rem; } @@ -30,6 +34,7 @@ font-size: $small-font-size; margin: 0 0 (-$gap * 0.5); color: $color-gray-medium; + max-width: 100%; } } @@ -38,9 +43,15 @@ font-size: $small-font-size; .icon-link { - padding: $gap; + padding: 0; border-radius: 0; color: $color-blue-darkest; + min-width: 10rem; + min-height: 10rem; + + .col { + margin: 0 auto; + } &:hover { background-color: $color-aqua-lightest; @@ -82,11 +93,19 @@ margin-bottom: 3 * $gap; } -.portfolio-content { - margin: (4 * $gap) $gap 0 $gap; +.portfolio-admin { + margin: $spacing-large 0; + max-width: 90rem; +} +.portfolio-content { .panel { padding-bottom: 2rem; + max-width: 90rem; + } + + hr { + max-width: 90rem; } a.add-new-button { @@ -289,8 +308,8 @@ } .portfolio-funding { - padding: 2 * $gap; - padding-top: 0; + max-width: 90rem; + margin: $spacing-large 0; .panel { @include shadow-panel; diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index a62dc326..12d67d8e 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -20,9 +20,10 @@ .sticky-cta-container { display: flex; align-items: center; + max-width: 90rem; .usa-button { - margin: $gap $gap * 1.5 $gap 0; + margin: 0; width: 20rem; height: 3.2rem; font-size: $small-font-size; diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 122739c4..70524e97 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -189,4 +189,4 @@ $spacing-x-small: 0.5rem; $spacing-small: 1rem; $spacing-md-small: 1.5rem; $spacing-medium: 2rem; -$spacing-large: 3rem; +$spacing-large: 4rem; diff --git a/styles/elements/_sidenav.scss b/styles/elements/_sidenav.scss index 23a4f05f..81a55b4e 100644 --- a/styles/elements/_sidenav.scss +++ b/styles/elements/_sidenav.scss @@ -113,8 +113,8 @@ text-overflow: ellipsis; &--active { - @include h4; - + font-size: $base-font-size; + font-weight: $font-bold; background-color: $color-aqua-lightest !important; color: $color-primary-darker !important; box-shadow: inset ($gap / 2) 0 0 0 $color-primary-darker; From 8c0e88c5c49a1fa4c852c48728f88f86b59ddea5 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Mon, 20 Jan 2020 17:03:18 -0500 Subject: [PATCH 02/35] Topbar, Portfolio Header, & Content alignment --- styles/components/_empty_state.scss | 23 +++++++++++++++------- styles/components/_global_layout.scss | 1 + styles/components/_portfolio_layout.scss | 25 +++++++++++++++++------- styles/components/_sticky_cta.scss | 7 ++++--- styles/components/_topbar.scss | 3 ++- styles/core/_variables.scss | 2 +- styles/elements/_panels.scss | 10 ++++------ styles/sections/_home.scss | 8 ++++---- templates/portfolios/admin.html | 2 +- templates/user/edit.html | 2 +- 10 files changed, 52 insertions(+), 31 deletions(-) diff --git a/styles/components/_empty_state.scss b/styles/components/_empty_state.scss index b0b73b16..1259f438 100644 --- a/styles/components/_empty_state.scss +++ b/styles/components/_empty_state.scss @@ -1,8 +1,6 @@ .empty-state { - padding: $gap * 3; - max-width: 100%; - background-color: $color-gray-lightest; - margin-top: $gap * 5; + max-width: $max-panel-width; + background-color: #F6F6F7; &--white { background-color: $color-white; @@ -18,17 +16,28 @@ margin-top: 3rem; } + h3 { + margin: 0 0 1rem; + padding: 3.2rem 2.4rem 0; + } + + p { + margin: 0; + padding: 0 $gap * 3; + } + hr { - margin-left: -$gap * 3; - margin-right: -$gap * 3; + margin: $gap * 4 0 0; } &__footer { text-align: center; + background-color: $color-gray-lightest; + padding: $gap * 3; a.usa-button { width: 60%; - display: inline-block; + margin: 0 auto; } } } diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index 22de84e8..ff84b3a0 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -28,6 +28,7 @@ body { position: relative; padding: 0 $spacing-large; + @include media($medium-screen) { top: $usa-banner-height + $topbar-height; } diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 9dc9ee6a..00beae67 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -5,15 +5,16 @@ } .col.col--grow { + position: relative; padding: 0; } } .portfolio-header { flex-direction: column; - align-items: center; - margin: $spacing-medium 0; - max-width: 90rem; + margin: $gap * 2 0; + max-width: $max-panel-width; + @include media($small-screen) { flex-direction: row; } @@ -25,6 +26,12 @@ &__name { @include h1; padding-right: $spacing-medium; + position: absolute; + top: 50%; + left: 0; + -webkit-transform: translateX(0%) translateY(-50%); + transform: translateX(0%) translateY(-50%); + h1 { margin: 0; font-size: 3.5rem; @@ -64,6 +71,7 @@ &.active { color: $color-blue; background-color: $color-gray-lightest; + text-decoration: none; &:hover { background-color: $color-aqua-lightest; @@ -95,17 +103,17 @@ .portfolio-admin { margin: $spacing-large 0; - max-width: 90rem; + max-width: $max-panel-width; } .portfolio-content { .panel { padding-bottom: 2rem; - max-width: 90rem; + max-width: $max-panel-width; } hr { - max-width: 90rem; + max-width: $max-panel-width; } a.add-new-button { @@ -263,6 +271,7 @@ .portfolio-applications { margin-top: $gap * 5; + max-width: $max-panel-width; &__header { &--title { @@ -308,7 +317,7 @@ } .portfolio-funding { - max-width: 90rem; + max-width: $max-panel-width; margin: $spacing-large 0; .panel { @@ -378,6 +387,8 @@ } .portfolio-reports { + max-width: $max-panel-width; + &__header { margin-bottom: 4 * $gap; diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index 12d67d8e..c3a7b0ea 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -24,9 +24,6 @@ .usa-button { margin: 0; - width: 20rem; - height: 3.2rem; - font-size: $small-font-size; } } @@ -43,6 +40,10 @@ &-buttons { display: flex; + a { + font-size: 1.5rem; + } + .action-group { margin: 0; diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index a64a1344..7f7a3e6d 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -4,14 +4,15 @@ height: $topbar-height; position: fixed; top: $usa-banner-height; - width: 100%; z-index: 10; + width: 100%; &__navigation { display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; + max-width: 1180px; a { color: $color-white; diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 70524e97..feb245ad 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -16,7 +16,7 @@ $footer-height: 5rem; $usa-banner-height: 2.8rem; $sidenav-expanded-width: 25rem; $sidenav-collapsed-width: 10rem; -$max-panel-width: 80rem; +$max-panel-width: 90rem; $home-pg-icon-width: 6rem; /* diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index 8ecd36c6..df681767 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -19,10 +19,7 @@ } @mixin panel-margin { - margin-top: 0; - margin-left: 0; - margin-right: 0; - margin-bottom: $site-margins-mobile * 6; + margin: $spacing-large 0; @include media($medium-screen) { margin-bottom: $site-margins * 8; @@ -56,9 +53,10 @@ @include panel-theme-default; @include panel-margin; @include shadow-panel; + max-width: $max-panel-width; &__content { - padding: $gap * 2; + padding: 3.2rem 2.4rem; } &__body { @@ -66,7 +64,7 @@ } &__heading { - padding: $gap * 2; + padding: 3.2rem 2.4rem; @include media($medium-screen) { padding: $gap * 4; diff --git a/styles/sections/_home.scss b/styles/sections/_home.scss index b0d715d2..936f919c 100644 --- a/styles/sections/_home.scss +++ b/styles/sections/_home.scss @@ -1,12 +1,12 @@ .home { - margin: $gap * 3; + .sticky-cta { margin: -1.6rem -1.6rem 0 -1.6rem; } &__content { - margin: 4rem; - max-width: 900px; + margin: $spacing-large 0; + max-width: $max-panel-width; &--descriptions { .col { @@ -29,7 +29,7 @@ background-color: $color-white; .home-container { - max-width: 90rem; + max-width: $max-panel-width; margin-left: auto; margin-right: auto; margin-bottom: 8rem; diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html index 3c2a9b9c..264e40f9 100644 --- a/templates/portfolios/admin.html +++ b/templates/portfolios/admin.html @@ -22,7 +22,7 @@ {{ TextInput(portfolio_form.name, validation="portfolioName", optional=False) }} {{ TextInput(portfolio_form.description, paragraph=True) }}
- {{ SaveButton(text='Save Changes', additional_classes='usa-button-big') }} + {{ SaveButton(text='Save Changes') }}
diff --git a/templates/user/edit.html b/templates/user/edit.html index fc4ac64f..226dc167 100644 --- a/templates/user/edit.html +++ b/templates/user/edit.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} -
+
{% include "fragments/flash.html" %} From 4dcd49269f2728e9b8d2e2cfcb649363fc8638ac Mon Sep 17 00:00:00 2001 From: hmbrink Date: Tue, 21 Jan 2020 10:45:40 -0500 Subject: [PATCH 03/35] Topbar testing --- styles/components/_portfolio_layout.scss | 11 ----------- styles/components/_topbar.scss | 8 ++++++++ styles/core/_util.scss | 4 ++++ templates/base_public.html | 2 +- templates/portfolios/new/step_1.html | 8 +++++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 00beae67..ee7b1ed1 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -3,11 +3,6 @@ @include grid-row; min-height: 500px; } - - .col.col--grow { - position: relative; - padding: 0; - } } .portfolio-header { @@ -25,12 +20,6 @@ &__name { @include h1; - padding-right: $spacing-medium; - position: absolute; - top: 50%; - left: 0; - -webkit-transform: translateX(0%) translateY(-50%); - transform: translateX(0%) translateY(-50%); h1 { margin: 0; diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 7f7a3e6d..03a74f55 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -65,3 +65,11 @@ justify-content: flex-end; } } + +.login-topbar .topbar__navigation { + max-width: 100%; +} + +.login-topbar .topbar__context .topbar__link-icon { + margin: 0 0 0 0.8rem; +} diff --git a/styles/core/_util.scss b/styles/core/_util.scss index 5203da45..0790a121 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -94,3 +94,7 @@ hr { margin: ($gap * 3) ($site-margins * -4); } } + +.usa-section { + padding: 0; +} diff --git a/templates/base_public.html b/templates/base_public.html index 7b872471..07447633 100644 --- a/templates/base_public.html +++ b/templates/base_public.html @@ -17,7 +17,7 @@
{% include 'components/usa_header.html' %} - {% include 'navigation/topbar.html' %} + {% block content %}{% endblock %} diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index 4a499821..abc6d25e 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -10,9 +10,11 @@
{% include "fragments/flash.html" %} -
-

{{ "portfolios.header" | translate }}

-

{{ "New Portfolio" }}

+
+
+

{{ "portfolios.header" | translate }}

+

{{ "New Portfolio" }}

+
{{ StickyCTA(text="Create New Portfolio") }} From 32451c69afdf0efdb6de5e20d5ad3c6c2cd2e266 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Tue, 21 Jan 2020 15:27:30 -0500 Subject: [PATCH 04/35] Topbar max-width --- styles/components/_topbar.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 03a74f55..c536fb01 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,7 +12,6 @@ flex-direction: row; align-items: stretch; justify-content: space-between; - max-width: 1180px; a { color: $color-white; From 4fd83be37f69a228ac0f79d66c77dbf2994fbb7e Mon Sep 17 00:00:00 2001 From: hmbrink Date: Tue, 21 Jan 2020 15:31:48 -0500 Subject: [PATCH 05/35] Portfolio Header --- styles/components/_portfolio_layout.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index ee7b1ed1..58039596 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -16,10 +16,14 @@ .col--grow { overflow: inherit; + display: table; + min-height: 10rem; } &__name { @include h1; + display: table-cell; + vertical-align: middle; h1 { margin: 0; From ae3144c82ff432eea34d8252ca759f65206a9751 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Tue, 21 Jan 2020 15:41:03 -0500 Subject: [PATCH 06/35] removed extra grid column padding --- styles/core/_grid.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/styles/core/_grid.scss b/styles/core/_grid.scss index d060198d..d22a866f 100644 --- a/styles/core/_grid.scss +++ b/styles/core/_grid.scss @@ -41,7 +41,6 @@ &.col--grow { flex: 1 auto; - padding-right: $spacing-small; } &.col--half { From 16db93c2daa38025c798a525d1d4bf3f27843c80 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Wed, 22 Jan 2020 14:25:56 -0500 Subject: [PATCH 07/35] Topbar navigation --- styles/components/_empty_state.scss | 2 +- styles/components/_footer.scss | 7 ++++--- styles/components/_global_layout.scss | 1 - styles/components/_topbar.scss | 5 +++-- styles/sections/_home.scss | 1 - 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/styles/components/_empty_state.scss b/styles/components/_empty_state.scss index 1259f438..71c9e742 100644 --- a/styles/components/_empty_state.scss +++ b/styles/components/_empty_state.scss @@ -1,6 +1,6 @@ .empty-state { max-width: $max-panel-width; - background-color: #F6F6F7; + background-color: #f6f6f7; &--white { background-color: $color-white; diff --git a/styles/components/_footer.scss b/styles/components/_footer.scss index bb248e4d..bd2ede65 100644 --- a/styles/components/_footer.scss +++ b/styles/components/_footer.scss @@ -3,9 +3,7 @@ background-color: $color-white; border-top: 1px solid $color-gray-lightest; display: flex; - flex-direction: row-reverse; align-items: center; - padding: $gap * 1.5; position: fixed; left: 0; bottom: 0; @@ -13,8 +11,11 @@ height: $footer-height; color: $color-gray-dark; font-size: 1.5rem; + padding: 0 $gap * 1.5; &__login { - padding-left: 0.8rem; + width: 100%; + max-width: 1165px; + text-align: right; } } diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index ff84b3a0..22de84e8 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -28,7 +28,6 @@ body { position: relative; padding: 0 $spacing-large; - @include media($medium-screen) { top: $usa-banner-height + $topbar-height; } diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index c536fb01..c61872ae 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,6 +12,7 @@ flex-direction: row; align-items: stretch; justify-content: space-between; + max-width: 1180px; a { color: $color-white; @@ -66,9 +67,9 @@ } .login-topbar .topbar__navigation { - max-width: 100%; + max-width: 100%; } .login-topbar .topbar__context .topbar__link-icon { - margin: 0 0 0 0.8rem; + margin: 0 0 0 0.8rem; } diff --git a/styles/sections/_home.scss b/styles/sections/_home.scss index 936f919c..23d9d2e1 100644 --- a/styles/sections/_home.scss +++ b/styles/sections/_home.scss @@ -1,5 +1,4 @@ .home { - .sticky-cta { margin: -1.6rem -1.6rem 0 -1.6rem; } From 01341be95b6dc542b4570fe66f15b09618b0a2b7 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Wed, 22 Jan 2020 14:34:23 -0500 Subject: [PATCH 08/35] Profile max-width --- styles/components/_global_layout.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index 22de84e8..2f5e0c4a 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -31,5 +31,9 @@ body { @include media($medium-screen) { top: $usa-banner-height + $topbar-height; } + + .user-edit { + max-width: $max-panel-width; + } } } From 0f2ba887d9d60c24f1c09c3fcfffae0930cf2a4a Mon Sep 17 00:00:00 2001 From: hmbrink Date: Thu, 23 Jan 2020 13:13:04 -0500 Subject: [PATCH 09/35] Large spacing variable --- styles/components/_footer.scss | 2 +- styles/components/_global_layout.scss | 2 +- styles/components/_portfolio_layout.scss | 4 ++-- styles/components/_topbar.scss | 2 +- styles/core/_variables.scss | 1 + styles/sections/_home.scss | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/styles/components/_footer.scss b/styles/components/_footer.scss index bd2ede65..881ab9a7 100644 --- a/styles/components/_footer.scss +++ b/styles/components/_footer.scss @@ -15,7 +15,7 @@ &__login { width: 100%; - max-width: 1165px; + max-width: 1175px; text-align: right; } } diff --git a/styles/components/_global_layout.scss b/styles/components/_global_layout.scss index 2f5e0c4a..9b21b41f 100644 --- a/styles/components/_global_layout.scss +++ b/styles/components/_global_layout.scss @@ -26,7 +26,7 @@ body { -ms-flex-negative: 1; top: $usa-banner-height + $topbar-height; position: relative; - padding: 0 $spacing-large; + padding: 0 $large-spacing; @include media($medium-screen) { top: $usa-banner-height + $topbar-height; diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 58039596..af083468 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -95,7 +95,7 @@ } .portfolio-admin { - margin: $spacing-large 0; + margin: $large-spacing 0; max-width: $max-panel-width; } @@ -311,7 +311,7 @@ .portfolio-funding { max-width: $max-panel-width; - margin: $spacing-large 0; + margin: $large-spacing 0; .panel { @include shadow-panel; diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index c61872ae..6d84f426 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: 1180px; + max-width: 1190px; a { color: $color-white; diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index feb245ad..12657ca4 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -18,6 +18,7 @@ $sidenav-expanded-width: 25rem; $sidenav-collapsed-width: 10rem; $max-panel-width: 90rem; $home-pg-icon-width: 6rem; +$large-spacing: 4rem; /* * USWDS Variables diff --git a/styles/sections/_home.scss b/styles/sections/_home.scss index 23d9d2e1..52636a61 100644 --- a/styles/sections/_home.scss +++ b/styles/sections/_home.scss @@ -4,7 +4,7 @@ } &__content { - margin: $spacing-large 0; + margin: $large-spacing 0; max-width: $max-panel-width; &--descriptions { From 84d0a326943cbfd9a7d73e32e1719f0d7ba88a39 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Thu, 23 Jan 2020 16:25:03 -0500 Subject: [PATCH 10/35] Update TO form to account for new TO rules: alpha numeric, between 13 and 17 characters, dashes should be stripped, and coerce to uppercase --- atst/forms/task_order.py | 20 ++++++++++++++-- tests/domain/test_task_orders.py | 12 ++++++++-- tests/forms/test_task_order.py | 34 ++++++++++++++++++++++++++++ tests/routes/task_orders/test_new.py | 8 +++---- tests/test_access.py | 2 +- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 6b209bf8..a1f82784 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -10,11 +10,13 @@ from wtforms.fields.html5 import DateField from wtforms.validators import ( Required, Length, + Optional, NumberRange, ValidationError, ) from flask_wtf import FlaskForm import numbers + from atst.forms.validators import Number, AlphaNumeric from .data import JEDI_CLIN_TYPES @@ -60,6 +62,20 @@ def validate_date_in_range(form, field): ) +def remove_dashes(value): + if value: + return value.replace("-", "") + else: + return None + + +def coerce_upper(value): + if value: + return value.upper() + else: + return None + + class CLINForm(FlaskForm): jedi_clin_type = SelectField( translate("task_orders.form.clin_type_label"), @@ -149,8 +165,8 @@ class AttachmentForm(BaseForm): class TaskOrderForm(BaseForm): number = StringField( label=translate("forms.task_order.number_description"), - filters=[remove_empty_string], - validators=[Number(), Length(max=13)], + filters=[remove_empty_string, remove_dashes, coerce_upper], + validators=[AlphaNumeric(), Length(min=13, max=17), Optional()], ) pdf = FormField( AttachmentForm, diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 49b9bae6..42da74e6 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -71,7 +71,7 @@ def test_update_adds_clins(): def test_update_does_not_duplicate_clins(): task_order = TaskOrderFactory.create( - number="3453453456", create_clins=[{"number": "123"}, {"number": "456"}] + number="3453453456123", create_clins=[{"number": "123"}, {"number": "456"}] ) clins = [ { @@ -93,7 +93,7 @@ def test_update_does_not_duplicate_clins(): ] task_order = TaskOrders.update( task_order_id=task_order.id, - number="0000000000", + number="0000000000000", clins=clins, pdf={"filename": "sample.pdf", "object_name": "1234567"}, ) @@ -170,3 +170,11 @@ def test_update_enforces_unique_number(): dupe_task_order = TaskOrderFactory.create() with pytest.raises(AlreadyExistsError): TaskOrders.update(dupe_task_order.id, task_order.number, [], None) + + +def test_allows_alphanumeric_number(): + portfolio = PortfolioFactory.create() + valid_to_numbers = ["1234567890123", "ABC1234567890"] + + for number in valid_to_numbers: + assert TaskOrders.create(portfolio.id, number, [], None) diff --git a/tests/forms/test_task_order.py b/tests/forms/test_task_order.py index 97759c81..ae4fd3c6 100644 --- a/tests/forms/test_task_order.py +++ b/tests/forms/test_task_order.py @@ -112,3 +112,37 @@ def test_no_number(): http_request_form_data = {} form = TaskOrderForm(http_request_form_data) assert form.data["number"] is None + + +def test_number_allows_alphanumeric(): + valid_to_numbers = ["1234567890123", "ABC1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + + +def test_number_allows_between_13_and_17_characters(): + valid_to_numbers = ["123456789012345", "ABCDEFG1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + + +def test_number_strips_dashes(): + valid_to_numbers = ["123-456789-012345", "ABCD-EFG12345-67890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + assert not "-" in form.number.data + + +def test_number_case_coerces_all_caps(): + valid_to_numbers = ["12345678012345", "AbcEFg1234567890"] + + for number in valid_to_numbers: + form = TaskOrderForm({"number": number}) + assert form.validate() + assert form.number.data == number.upper() diff --git a/tests/routes/task_orders/test_new.py b/tests/routes/task_orders/test_new.py index 0aef88ed..8390e187 100644 --- a/tests/routes/task_orders/test_new.py +++ b/tests/routes/task_orders/test_new.py @@ -158,7 +158,7 @@ def test_task_orders_form_step_two_add_number(client, user_session, task_order): def test_task_orders_submit_form_step_two_add_number(client, user_session, task_order): user_session(task_order.portfolio.owner) - form_data = {"number": "1234567890"} + form_data = {"number": "abc-1234567890"} response = client.post( url_for( "task_orders.submit_form_step_two_add_number", task_order_id=task_order.id @@ -167,7 +167,7 @@ def test_task_orders_submit_form_step_two_add_number(client, user_session, task_ ) assert response.status_code == 302 - assert task_order.number == "1234567890" + assert task_order.number == "ABC1234567890" # pragma: allowlist secret def test_task_orders_submit_form_step_two_enforces_unique_number( @@ -194,7 +194,7 @@ def test_task_orders_submit_form_step_two_add_number_existing_to( client, user_session, task_order ): user_session(task_order.portfolio.owner) - form_data = {"number": "0000000000"} + form_data = {"number": "0000000000000"} original_number = task_order.number response = client.post( url_for( @@ -203,7 +203,7 @@ def test_task_orders_submit_form_step_two_add_number_existing_to( data=form_data, ) assert response.status_code == 302 - assert task_order.number == "0000000000" + assert task_order.number == "0000000000000" assert task_order.number != original_number diff --git a/tests/test_access.py b/tests/test_access.py index b0dac527..f8879024 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -663,7 +663,7 @@ def test_task_orders_new_get_routes(get_url_assert_status): def test_task_orders_new_post_routes(post_url_assert_status): post_routes = [ ("task_orders.submit_form_step_one_add_pdf", {"pdf": ""}), - ("task_orders.submit_form_step_two_add_number", {"number": "1234567890"}), + ("task_orders.submit_form_step_two_add_number", {"number": "1234567890123"}), ( "task_orders.submit_form_step_three_add_clins", { From b20e2971d7b839bd1b9e38d9f737dc68238eecda Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Thu, 23 Jan 2020 16:41:09 -0500 Subject: [PATCH 11/35] Update TO number validator to account for dashes and variable character length --- js/components/__tests__/text_input.test.js | 98 ++++++++++++++++++++++ js/lib/input_validations.js | 6 +- tests/render_vue_component.py | 14 ++++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 js/components/__tests__/text_input.test.js diff --git a/js/components/__tests__/text_input.test.js b/js/components/__tests__/text_input.test.js new file mode 100644 index 00000000..c290b23a --- /dev/null +++ b/js/components/__tests__/text_input.test.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' + +import textinput from '../text_input' + +import { makeTestWrapper } from '../../test_utils/component_test_helpers' + +const ToNumberWrapperComponent = makeTestWrapper({ + components: { + textinput, + }, + templatePath: 'text_input_to_number.html', + data: function() { + const { validation, initialValue } = this.initialData + return { validation, initialValue } + }, +}) + +describe('TextInput Validates Correctly', () => { + describe('taskOrderNumber validator', () => { + it('Should initialize with the validator and no validation icon', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + expect(wrapper.contains('.usa-input--success')).toBe(false) + expect(wrapper.contains('.usa-input--error')).toBe(false) + expect(wrapper.contains('.usa-input--validation--taskOrderNumber')).toBe( + true + ) + }) + + it('Should allow valid TO numbers', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + + var textInputField = wrapper.find('input[id="number"]') + var hiddenField = wrapper.find('input[name="number"]') + const validToNumbers = [ + '12345678901234567', + '1234567890123', + 'abc1234567890', // pragma: allowlist secret + 'abc-1234567890', + 'DC12-123-1234567890', + 'fg34-987-1234567890', + ] + + for (const number of validToNumbers) { + // set value to be a valid TO number + textInputField.setValue(number) + // manually trigger change event in hidden fields + hiddenField.trigger('change') + // check for validation classes + expect(wrapper.contains('.usa-input--success')).toBe(true) + expect(wrapper.contains('.usa-input--error')).toBe(false) + } + }) + + it('Should not allow invalid TO numbers', () => { + const wrapper = mount(ToNumberWrapperComponent, { + propsData: { + name: 'testTextInput', + initialData: { + validation: 'taskOrderNumber', + }, + }, + }) + + var textInputField = wrapper.find('input[id="number"]') + var hiddenField = wrapper.find('input[name="number"]') + const invalidToNumbers = [ + '1234567890', + '12345678901234567890', // pragma: allowlist secret + '123:4567890123', + '123_1234567890', + ] + + for (const number of invalidToNumbers) { + // set value to be a valid TO number + textInputField.setValue(number) + // manually trigger change event in hidden fields + hiddenField.trigger('change') + // check for validation classes + expect(wrapper.contains('.usa-input--success')).toBe(false) + expect(wrapper.contains('.usa-input--error')).toBe(true) + } + }) + }) +}) diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index e2dc03b7..9f113aa6 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -106,9 +106,9 @@ export default { }, taskOrderNumber: { mask: false, - match: /^.{13}$/, - unmask: [], - validationError: 'TO number must be 13 digits', + match: /(^[0-9a-zA-Z]{13,17}$)/, + unmask: ['-'], + validationError: 'TO number must be between 13 and 17 characters', }, usPhone: { mask: [ diff --git a/tests/render_vue_component.py b/tests/render_vue_component.py index 62106a67..49fb6ee9 100644 --- a/tests/render_vue_component.py +++ b/tests/render_vue_component.py @@ -35,6 +35,7 @@ class TaskOrderPdfForm(Form): class TaskOrderForm(Form): pdf = FormField(TaskOrderPdfForm, label="task_order_pdf") + number = StringField(label="task_order_number", default="number") @pytest.fixture @@ -63,6 +64,12 @@ def multi_checkbox_input_macro(env): return getattr(multi_checkbox_template.module, "MultiCheckboxInput") +@pytest.fixture +def text_input_macro(env): + text_input_template = env.get_template("components/text_input.html") + return getattr(text_input_template.module, "TextInput") + + @pytest.fixture def initial_value_form(scope="function"): return InitialValueForm() @@ -170,3 +177,10 @@ def test_make_pop_date_range(env, app): index=1, ) write_template(pop_date_range, "pop_date_range.html") + + +def test_make_text_input_template(text_input_macro, task_order_form): + text_input_to_number = text_input_macro( + task_order_form.number, validation="taskOrderNumber" + ) + write_template(text_input_to_number, "text_input_to_number.html") From 660717017261faa9397898c537dd133d4c852d1f Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Fri, 24 Jan 2020 09:25:17 -0500 Subject: [PATCH 12/35] Refactor to use enums --- atst/forms/task_order.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index a1f82784..8d40c015 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -63,17 +63,11 @@ def validate_date_in_range(form, field): def remove_dashes(value): - if value: - return value.replace("-", "") - else: - return None + return value.replace("-", "") if value else None def coerce_upper(value): - if value: - return value.upper() - else: - return None + return value.upper() if value else None class CLINForm(FlaskForm): From 08610ffde2ba322d5e411723eef7fe9cf73768c6 Mon Sep 17 00:00:00 2001 From: "Jay R. Newlin (PromptWorks)" Date: Fri, 24 Jan 2020 09:52:19 -0500 Subject: [PATCH 13/35] Added new tests to account for changes to Portfolio Manager (Member) UI --- uitests/Resend_Portfolio_Member_Invite.html | 639 ++++++++++++++++++++ uitests/Revoke_Portfolio_Member_Invite.html | 624 +++++++++++++++++++ 2 files changed, 1263 insertions(+) create mode 100644 uitests/Resend_Portfolio_Member_Invite.html create mode 100644 uitests/Revoke_Portfolio_Member_Invite.html diff --git a/uitests/Resend_Portfolio_Member_Invite.html b/uitests/Resend_Portfolio_Member_Invite.html new file mode 100644 index 00000000..b369438f --- /dev/null +++ b/uitests/Resend_Portfolio_Member_Invite.html @@ -0,0 +1,639 @@ + + + + + + +Resend Portfolio Member Invite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Resend Portfolio Member Invite
waitForPageToLoad
open/login-dev?username=brandon
waitForPageToLoad
waitForElementPresentcss=a[href="/user"] > .topbar__link-label
assertTextcss=a[href="/user"] > .topbar__link-label*Brandon Buchannan*
waitForPageToLoad
waitForElementPresentcss=a[href="/logout"] > .topbar__link-label
clickcss=a[href="/logout"] > .topbar__link-label
waitForPageToLoad
waitForElementPresentcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading
assertTextcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading*Logged out*
waitForPageToLoad
open/login-dev
waitForPageToLoad
waitForElementPresentcss=.home__content > h1
assertTextcss=.home__content > h1JEDI Cloud Services
waitForPageToLoad
waitForElementPresentcss=a[href="/portfolios/new"]
clickcss=a[href="/portfolios/new"]
waitForPageToLoad
waitForElementPresentcss=.portfolio-header__name > h1
assertTextcss=.portfolio-header__name > h1*New Portfolio*
waitForPageToLoad
waitForElementPresentcss=.sticky-cta-text > h3
assertTextcss=.sticky-cta-text > h3*Name and Describe Portfolio*
waitForPageToLoad
waitForElementPresentcss=#name
typecss=#nameTatooine Energy Maintenance Systems ${alphanumeric}
waitForPageToLoad
waitForElementPresentcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
clickcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
waitForPageToLoad
waitForElementPresentcss=input[type="submit"]
clickcss=input[type="submit"]
waitForPageToLoad
waitForElementPresentcss=.empty-state > h3
assertTextcss=.empty-state > h3*You don't have any Applications yet*
waitForPageToLoad
waitForElementPresentcss=.icon.icon--cog > svg
clickcss=.icon.icon--cog > svg
waitForPageToLoad
waitForElementPresentcss=.portfolio-header__name > h1
assertTextcss=.portfolio-header__name > h1*Tatooine Energy Maintenance Systems*
waitForPageToLoad
waitForElementPresentcss=th.table-cell--third
assertElementPresentcss=th.table-cell--third
waitForPageToLoad
waitForElementPresentcss=button.usa-button.usa-button-primary.usa-button-big
assertTextcss=button.usa-button.usa-button-primary.usa-button-bigSave Changes
waitForPageToLoad
waitForElementPresentcss=a.usa-button.usa-button-secondary.add-new-button
clickcss=a.usa-button.usa-button-secondary.add-new-button
waitForPageToLoad
waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
waitForPageToLoad
waitForElementPresentcss=#user_data-first_name
typecss=#user_data-first_nameBrandon
waitForPageToLoad
waitForElementPresentcss=#user_data-last_name
typecss=#user_data-last_nameBuchannan
waitForPageToLoad
waitForElementPresentcss=#user_data-email
typecss=#user_data-emailjay+brandon@promptworks.com
waitForPageToLoad
waitForElementPresentcss=#user_data-dod_id
typecss=#user_data-dod_id3456789012
waitForPageToLoad
waitForElementPresentcss=input[type="button"]
clickcss=input[type="button"]
waitForPageToLoad
waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
waitForPageToLoad
waitForElementPresentcss=#perms_app_mgmt-None
clickcss=#perms_app_mgmt-None
waitForPageToLoad
waitForElementPresentcss=#perms_funding-None
clickcss=#perms_funding-None
waitForPageToLoad
waitForElementPresentcss=#perms_reporting-None
clickcss=#perms_reporting-None
waitForPageToLoad
waitForElementPresentcss=#perms_portfolio_mgmt-None
typecss=#perms_portfolio_mgmt-Noneedit_portfolio_admin
waitForPageToLoad
waitForElementPresentcss=input[type="submit"].action-group__action
clickcss=input[type="submit"].action-group__action
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
waitForPageToLoad
waitForElementPresentcss=.usa-alert-body
assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16 > path
clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16 > path
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(2)
clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(2)
waitForPageToLoad
waitForElementPresentcss=.portfolio-content > div:nth-of-type(4) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
assertTextcss=.portfolio-content > div:nth-of-type(4) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1*Verify Member Information*
waitForPageToLoad
waitForElementPresentcss=.action-group__action.usa-button
clickcss=.action-group__action.usa-button
waitForPageToLoad
waitForElementPresentcss=.usa-alert-text
assertTextcss=.usa-alert-text*jay+brandon@promptworks.com has been sent an invitation to access this Portfolio*
+ + \ No newline at end of file diff --git a/uitests/Revoke_Portfolio_Member_Invite.html b/uitests/Revoke_Portfolio_Member_Invite.html new file mode 100644 index 00000000..947bf526 --- /dev/null +++ b/uitests/Revoke_Portfolio_Member_Invite.html @@ -0,0 +1,624 @@ + + + + + + +Revoke Portfolio Member Invite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Revoke Portfolio Member Invite
waitForPageToLoad
open/login-dev?username=brandon
waitForPageToLoad
waitForElementPresentcss=a[href="/user"] > .topbar__link-label
assertTextcss=a[href="/user"] > .topbar__link-label*Brandon Buchannan*
waitForPageToLoad
waitForElementPresentcss=a[href="/logout"] > .topbar__link-label
clickcss=a[href="/logout"] > .topbar__link-label
waitForPageToLoad
waitForElementPresentcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading
assertTextcss=.col > .usa-alert.usa-alert-info:nth-of-type(2) > .usa-alert-body > h3.usa-alert-heading*Logged out*
waitForPageToLoad
open/login-dev
waitForPageToLoad
waitForElementPresentcss=.home__content > h1
assertTextcss=.home__content > h1JEDI Cloud Services
waitForPageToLoad
waitForElementPresentcss=a[href="/portfolios/new"]
clickcss=a[href="/portfolios/new"]
waitForPageToLoad
waitForElementPresentcss=.portfolio-header__name > h1
assertTextcss=.portfolio-header__name > h1*New Portfolio*
waitForPageToLoad
waitForElementPresentcss=.sticky-cta-text > h3
assertTextcss=.sticky-cta-text > h3*Name and Describe Portfolio*
waitForPageToLoad
waitForElementPresentcss=#name
typecss=#nameTatooine Energy Maintenance Systems ${alphanumeric}
waitForPageToLoad
waitForElementPresentcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
clickcss=fieldset.usa-input__choices > ul > li:nth-of-type(5) > label
waitForPageToLoad
waitForElementPresentcss=input[type="submit"]
clickcss=input[type="submit"]
waitForPageToLoad
waitForElementPresentcss=.empty-state > h3
assertTextcss=.empty-state > h3*You don't have any Applications yet*
waitForPageToLoad
waitForElementPresentcss=.icon.icon--cog > svg
clickcss=.icon.icon--cog > svg
waitForPageToLoad
waitForElementPresentcss=.portfolio-header__name > h1
assertTextcss=.portfolio-header__name > h1*Tatooine Energy Maintenance Systems*
waitForPageToLoad
waitForElementPresentcss=th.table-cell--third
assertElementPresentcss=th.table-cell--third
waitForPageToLoad
waitForElementPresentcss=button.usa-button.usa-button-primary.usa-button-big
assertTextcss=button.usa-button.usa-button-primary.usa-button-bigSave Changes
waitForPageToLoad
waitForElementPresentcss=a.usa-button.usa-button-secondary.add-new-button
clickcss=a.usa-button.usa-button-secondary.add-new-button
waitForPageToLoad
waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
waitForPageToLoad
waitForElementPresentcss=#user_data-first_name
typecss=#user_data-first_nameBrandon
waitForPageToLoad
waitForElementPresentcss=#user_data-last_name
typecss=#user_data-last_nameBuchannan
waitForPageToLoad
waitForElementPresentcss=#user_data-email
typecss=#user_data-emailjay+brandon@promptworks.com
waitForPageToLoad
waitForElementPresentcss=#user_data-dod_id
typecss=#user_data-dod_id3456789012
waitForPageToLoad
waitForElementPresentcss=input[type="button"]
clickcss=input[type="button"]
waitForPageToLoad
waitForElementPresentcss=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
waitForPageToLoad
waitForElementPresentcss=#perms_app_mgmt-None
clickcss=#perms_app_mgmt-None
waitForPageToLoad
waitForElementPresentcss=#perms_funding-None
clickcss=#perms_funding-None
waitForPageToLoad
waitForElementPresentcss=#perms_reporting-None
clickcss=#perms_reporting-None
waitForPageToLoad
waitForElementPresentcss=#perms_portfolio_mgmt-None
typecss=#perms_portfolio_mgmt-Noneedit_portfolio_admin
waitForPageToLoad
waitForElementPresentcss=input[type="submit"].action-group__action
clickcss=input[type="submit"].action-group__action
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
waitForPageToLoad
waitForElementPresentcss=.usa-alert-body
assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
waitForPageToLoad
waitForElementPresentcss=.accordion-table__item-toggle-content > a:nth-of-type(3)
clickcss=.accordion-table__item-toggle-content > a:nth-of-type(3)
waitForPageToLoad
waitForElementPresentcss=form[action] > h1
assertTextcss=form[action] > h1*Revoke Invite*
waitForPageToLoad
waitForElementPresentcss=button[type="submit"].action-group__action
clickcss=button[type="submit"].action-group__action
+ + \ No newline at end of file From cd0081050d7a17ed2a0c09b3c6dd83f0710b4373 Mon Sep 17 00:00:00 2001 From: "Jay R. Newlin (PromptWorks)" Date: Fri, 24 Jan 2020 09:52:56 -0500 Subject: [PATCH 14/35] Regular test updates due to app UI changes --- uitests/Application_Index_with_App.html | 22 +- uitests/Create_New_Application.html | 21 +- uitests/Create_New_TO.html | 8 +- uitests/Edit_App_Member.html | 45 +-- uitests/Edit_Portfolio_Member.html | 372 +++++++----------- uitests/New_App_Step_1.html | 2 +- uitests/New_App_Step_2.html | 2 +- uitests/New_App_Step_2_-_Add_Env.html | 2 +- uitests/New_App_Step_3.html | 21 +- uitests/New_Portfolio.html | 2 +- uitests/New_Portfolio_Member.html | 38 +- uitests/Portfolio_Settings.html | 32 +- uitests/Reports_-_Basics.html | 8 +- uitests/Reports_-_Empty_State.html | 2 +- uitests/Reports_-_Follow_Add_App_Button.html | 8 +- uitests/Reports_-_Follow_TO_link.html | 12 +- ...orts_-_with_TO,_App,_and_Environments.html | 28 +- uitests/Reports_-_with_expired_TO.html | 28 +- uitests/Resend_App_Member_Invite.html | 51 +-- uitests/Revoke_App_Member_Invite.html | 22 +- uitests/Revoke_Environment_Access.html | 34 +- ...TO_Index_(Landing)_Page_-_Empty_State.html | 17 +- uitests/TO_Index_with_Draft_TO.html | 8 +- uitests/TO_Index_with_TO.html | 8 +- uitests/TO_Index_with_Unsigned_TO.html | 8 +- uitests/TO_Index_with_expired_TO.html | 8 +- uitests/TO_Index_with_future_TO.html | 8 +- uitests/TO_Step_1.html | 2 +- uitests/TO_Step_2.html | 2 +- uitests/TO_Step_3.html | 2 +- uitests/TO_Step_3_-_Add_CLIN.html | 2 +- uitests/TO_Step_4.html | 6 +- uitests/TO_Step_5.html | 6 +- 33 files changed, 246 insertions(+), 591 deletions(-) diff --git a/uitests/Application_Index_with_App.html b/uitests/Application_Index_with_App.html index 2c2c120c..c7efd48a 100644 --- a/uitests/Application_Index_with_App.html +++ b/uitests/Application_Index_with_App.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Create_New_Application.html b/uitests/Create_New_Application.html index 24ee7094..564c1886 100644 --- a/uitests/Create_New_Application.html +++ b/uitests/Create_New_Application.html @@ -160,7 +160,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -413,28 +413,13 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -449,7 +434,7 @@ Imported from: AT-AT CI - login--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Create_New_TO.html b/uitests/Create_New_TO.html index 460026cd..1d85c2bc 100644 --- a/uitests/Create_New_TO.html +++ b/uitests/Create_New_TO.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -189,12 +189,12 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Edit_App_Member.html b/uitests/Edit_App_Member.html index b3af2f7e..e4e92fa8 100644 --- a/uitests/Edit_App_Member.html +++ b/uitests/Edit_App_Member.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -656,28 +640,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=.environment_roles.environment-roles-new > .form-row:nth-of-type(1) > .form-col.form-col--third > fieldset.usa-input__choices > select[name="environment_roles-2-role"] type css=.environment_roles.environment-roles-new > .form-row:nth-of-type(1) > .form-col.form-col--third > fieldset.usa-input__choices > select[name="environment_roles-2-role"] -Business Read-only +CONTRIBUTOR waitForPageToLoad @@ -761,13 +730,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(3) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(3) > .env-role__role assertText -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(3) > .env-role__role -*Business Read-only* +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(3) > .env-role__role +*Contributor* diff --git a/uitests/Edit_Portfolio_Member.html b/uitests/Edit_Portfolio_Member.html index 71d82e8f..3fd58cee 100644 --- a/uitests/Edit_Portfolio_Member.html +++ b/uitests/Edit_Portfolio_Member.html @@ -16,7 +16,7 @@ - + @@ -174,7 +174,7 @@ Imported from: AT-AT CI - New Portfolio--> - + @@ -192,7 +192,7 @@ Imported from: AT-AT CI - New Portfolio--> - + @@ -291,29 +291,12 @@ Imported from: AT-AT CI - Portfolio Settings--> Imported from: AT-AT CI - Portfolio Settings--> - + - - - - - - - - - - - - - - - - - + @@ -331,41 +314,7 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -375,12 +324,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -391,13 +340,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + + @@ -487,13 +436,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + + @@ -503,12 +452,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -519,12 +468,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -535,12 +484,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - + @@ -551,60 +500,12 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -615,22 +516,6 @@ Imported from: AT-AT CI - Portfolio Settings--> - - - - - - - - - - - - - - - - @@ -647,12 +532,75 @@ Imported from: AT-AT CI - Portfolio Settings--> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -660,105 +608,59 @@ Imported from: AT-AT CI - Portfolio Settings--> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -768,13 +670,13 @@ Imported from: AT-AT CI - Portfolio Settings--> - + - - + +
assertText css=.sticky-cta-text > h3*Create New Portfolio**Name and Describe Portfolio*
waitForPageToLoad
type css=#nameTatooine Energy Maintenance SystemsTatooine Energy Maintenance Systems ${alphanumeric}
waitForPageToLoad
waitForElementPresentcss=.panel__content > p:nth-of-type(2)css=th.table-cell--third
assertElementPresentcss=.panel__content > p:nth-of-type(2)
waitForPageToLoad
waitForElementPresentcss=td.name
assertElementPresentcss=td.namecss=th.table-cell--third
assertText css=button.usa-button.usa-button-primary.usa-button-bigSave
waitForPageToLoad
waitForElementPresentcss=button.usa-button.usa-button-primary
assertTextcss=button.usa-button.usa-button-primary*Update*
waitForPageToLoad
waitForElementPresentcss=input.usa-button.usa-button-primary
assertTextcss=input.usa-button.usa-button-primarySaveSave Changes
waitForPageToLoad
waitForElementPresentcss=a.icon-link.modal-linkcss=a.usa-button.usa-button-secondary.add-new-button
clickcss=a.icon-link.modal-linkcss=a.usa-button.usa-button-secondary.add-new-button
waitForElementPresentcss=#add-port-mem > div > div:nth-of-type(1) > h1css=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-port-mem > div > div:nth-of-type(1) > h1*Invite new portfolio member*css=#add-portfolio-manager > div > div > div.member-form > h2*Add Manager*
waitForPageToLoad
waitForElementPresentcss=#add-port-mem > div > div:nth-of-type(2) > h1css=#add-portfolio-manager > div > div > div.member-form > h2
assertTextcss=#add-port-mem > div > div:nth-of-type(2) > h1*Assign member permissions*css=#add-portfolio-manager > div > div > div.member-form > h2*Set Portfolio Permissions*
waitForPageToLoad
waitForElementPresentcss=#permission_sets-perms_app_mgmtcss=#perms_app_mgmt-None
clickcss=#permission_sets-perms_app_mgmtcss=#perms_app_mgmt-None
waitForElementPresentcss=#permission_sets-perms_app_mgmt > option:nth-of-type(1)css=#perms_funding-None
clickcss=#permission_sets-perms_app_mgmt > option:nth-of-type(1)css=#perms_funding-None
waitForElementPresentcss=#permission_sets-perms_fundingcss=#perms_reporting-None
clickcss=#permission_sets-perms_fundingcss=#perms_reporting-None
waitForElementPresentcss=#permission_sets-perms_funding > option:nth-of-type(1)
clickcss=#permission_sets-perms_funding > option:nth-of-type(1)
waitForPageToLoad
waitForElementPresentcss=#permission_sets-perms_reporting
clickcss=#permission_sets-perms_reporting
waitForPageToLoad
waitForElementPresentcss=#permission_sets-perms_reporting > option:nth-of-type(1)
clickcss=#permission_sets-perms_reporting > option:nth-of-type(1)
waitForPageToLoad
waitForElementPresentcss=#permission_sets-perms_portfolio_mgmtcss=#perms_portfolio_mgmt-None
typecss=#permission_sets-perms_portfolio_mgmtcss=#perms_portfolio_mgmt-None edit_portfolio_admin
waitForElementPresentcss=#permission_sets-perms_portfolio_mgmt > option:nth-of-type(2)
clickcss=#permission_sets-perms_portfolio_mgmt > option:nth-of-type(2)
waitForPageToLoad
waitForElementPresent css=input[type="submit"].action-group__action
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.namecss=table.atat-table > tbody > tr > td > span.label.label--success.label--below
assertTextcss=table.atat-table > tbody > tr > td > span.label.label--success.label--below*invite pending*
waitForPageToLoad
waitForElementPresentcss=.usa-alert-body
assertTextcss=.usa-alert-body*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.*
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item__toggler > .icon.icon--ellipsis > svg.svg-inline--fa.fa-ellipsis-h.fa-w-16
waitForPageToLoad
waitForElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1)
clickcss=table.atat-table > tbody > tr:nth-of-type(2) > td.toggle-menu__container > .toggle-menu > .accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1)
waitForPageToLoad
waitForElementPresentcss=.portfolio-content > div:nth-of-type(3) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
assertElementPresentcss=table.atat-table > tbody > tr:nth-of-type(2) > td.namecss=.portfolio-content > div:nth-of-type(3) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > .modal__form--header > h1
waitForElementPresentcss=.usa-alert-body > p:nth-of-type(2)css=.portfolio-perms > div:nth-of-type(2) > .usa-input.input__inline-fields.checked > fieldset.usa-input__choices > legend > label
clickcss=.portfolio-perms > div:nth-of-type(2) > .usa-input.input__inline-fields.checked > fieldset.usa-input__choices > legend > label
waitForPageToLoad
waitForElementPresentcss=.portfolio-perms > div:nth-of-type(4) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label
clickcss=.portfolio-perms > div:nth-of-type(4) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label
waitForPageToLoad
waitForElementPresentcss=.action-group__action.usa-button
clickcss=.action-group__action.usa-button
waitForPageToLoad
waitForElementPresentcss=h3.usa-alert-heading
assertTextcss=.usa-alert-body > p:nth-of-type(2)*You have successfully invited Brandon Buchannan to the portfolio.*
waitForPageToLoad
waitForElementPresentcss=select[name="members_permissions-1-perms_app_mgmt"]
typecss=select[name="members_permissions-1-perms_app_mgmt"]edit_portfolio_application_management
waitForPageToLoad
waitForElementPresentcss=select[name="members_permissions-1-perms_app_mgmt"] > option:nth-of-type(2)
clickcss=select[name="members_permissions-1-perms_app_mgmt"] > option:nth-of-type(2)
waitForPageToLoad
waitForElementPresentcss=select[name="members_permissions-1-perms_reporting"]
typecss=select[name="members_permissions-1-perms_reporting"]edit_portfolio_reports
waitForPageToLoad
waitForElementPresentcss=select[name="members_permissions-1-perms_reporting"] > option:nth-of-type(2)
clickcss=select[name="members_permissions-1-perms_reporting"] > option:nth-of-type(2)
waitForPageToLoad
waitForElementPresentcss=input[type="submit"]
clickcss=input[type="submit"]
waitForPageToLoad
waitForElementPresentcss=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading
assertTextcss=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-headingcss=h3.usa-alert-heading *Success!*
waitForElementPresentcss=.usa-alert-body > p:nth-of-type(2)css=.usa-alert-text
assertTextcss=.usa-alert-body > p:nth-of-type(2)*You have successfully updated access permissions for members of Tatooine Energy Maintenance Systems.*css=.usa-alert-text*You have successfully updated access permissions for*
diff --git a/uitests/New_App_Step_1.html b/uitests/New_App_Step_1.html index 05f25f84..83f70ab6 100644 --- a/uitests/New_App_Step_1.html +++ b/uitests/New_App_Step_1.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_2.html b/uitests/New_App_Step_2.html index 3deecee8..50338788 100644 --- a/uitests/New_App_Step_2.html +++ b/uitests/New_App_Step_2.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_2_-_Add_Env.html b/uitests/New_App_Step_2_-_Add_Env.html index a2ed4f8d..7450d2e3 100644 --- a/uitests/New_App_Step_2_-_Add_Env.html +++ b/uitests/New_App_Step_2_-_Add_Env.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_App_Step_3.html b/uitests/New_App_Step_3.html index 2371a8a4..f8e4948c 100644 --- a/uitests/New_App_Step_3.html +++ b/uitests/New_App_Step_3.html @@ -170,7 +170,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -640,28 +640,13 @@ Imported from: AT-AT CI - New App Step 1--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - -waitForElementPresent css=[name=environment_roles-0-role] type css=[name=environment_roles-0-role] -Basic Access +ADMIN waitForPageToLoad @@ -676,7 +661,7 @@ Imported from: AT-AT CI - New App Step 1--> type css=[name=environment_roles-1-role] -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/New_Portfolio.html b/uitests/New_Portfolio.html index ea3aee7d..cb3f3c26 100644 --- a/uitests/New_Portfolio.html +++ b/uitests/New_Portfolio.html @@ -96,7 +96,7 @@ assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/New_Portfolio_Member.html b/uitests/New_Portfolio_Member.html index 35c052df..ab4480ee 100644 --- a/uitests/New_Portfolio_Member.html +++ b/uitests/New_Portfolio_Member.html @@ -165,7 +165,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -275,22 +275,6 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel__content > p:nth-of-type(2) - - - -assertElementPresent -css=.panel__content > p:nth-of-type(2) - - - -waitForPageToLoad - - - - - -waitForElementPresent css=th.table-cell--third @@ -320,22 +304,6 @@ Imported from: AT-AT CI - New Portfolio--> - - -waitForElementPresent -css=button.usa-button.usa-button-primary - - - -assertText -css=button.usa-button.usa-button-primary -*Update* - - -waitForPageToLoad - - - waitForElementPresent css=a.usa-button.usa-button-secondary.add-new-button @@ -554,7 +522,9 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.usa-alert-body -*You have successfully invited Brandon Buchannan to the portfolio.* +*Brandon Buchannan's invitation has been sent + +Brandon Buchannan's access to this Portfolio is pending until they sign in for the first time.* diff --git a/uitests/Portfolio_Settings.html b/uitests/Portfolio_Settings.html index 4996fdbb..4c3ea61d 100644 --- a/uitests/Portfolio_Settings.html +++ b/uitests/Portfolio_Settings.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -204,21 +204,6 @@ Imported from: AT-AT CI - login--> waitForElementPresent -css=.panel__content > p:nth-of-type(2) - - - -assertElementPresent -css=.panel__content > p:nth-of-type(2) - - - -waitForPageToLoad - - - - -waitForElementPresent css=th.table-cell--third @@ -242,21 +227,6 @@ Imported from: AT-AT CI - login--> css=button.usa-button.usa-button-primary.usa-button-big Save Changes - -waitForPageToLoad - - - - -waitForElementPresent -css=button.usa-button.usa-button-primary - - - -assertText -css=button.usa-button.usa-button-primary -*Update* - diff --git a/uitests/Reports_-_Basics.html b/uitests/Reports_-_Basics.html index 84d65469..ecb8e962 100644 --- a/uitests/Reports_-_Basics.html +++ b/uitests/Reports_-_Basics.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_Empty_State.html b/uitests/Reports_-_Empty_State.html index d0ae8235..448a3cb0 100644 --- a/uitests/Reports_-_Empty_State.html +++ b/uitests/Reports_-_Empty_State.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/Reports_-_Follow_Add_App_Button.html b/uitests/Reports_-_Follow_Add_App_Button.html index 1b4658bd..c1607b2f 100644 --- a/uitests/Reports_-_Follow_Add_App_Button.html +++ b/uitests/Reports_-_Follow_Add_App_Button.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -211,12 +211,12 @@ Imported from: AT-AT CI - Create New TO--> Imported from: AT-AT CI - Create New TO--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_Follow_TO_link.html b/uitests/Reports_-_Follow_TO_link.html index 627838b2..7c3d2e6d 100644 --- a/uitests/Reports_-_Follow_TO_link.html +++ b/uitests/Reports_-_Follow_TO_link.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -211,12 +211,12 @@ Imported from: AT-AT CI - Create New TO--> Imported from: AT-AT CI - Create New TO--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary @@ -841,7 +841,7 @@ Imported from: AT-AT CI - Create New TO--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -856,7 +856,7 @@ Imported from: AT-AT CI - Create New TO--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* diff --git a/uitests/Reports_-_with_TO,_App,_and_Environments.html b/uitests/Reports_-_with_TO,_App,_and_Environments.html index 9bb67a36..6fbc96e2 100644 --- a/uitests/Reports_-_with_TO,_App,_and_Environments.html +++ b/uitests/Reports_-_with_TO,_App,_and_Environments.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -583,12 +567,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Reports_-_with_expired_TO.html b/uitests/Reports_-_with_expired_TO.html index 8970b8b8..df76aa00 100644 --- a/uitests/Reports_-_with_expired_TO.html +++ b/uitests/Reports_-_with_expired_TO.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -583,12 +567,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/Resend_App_Member_Invite.html b/uitests/Resend_App_Member_Invite.html index 2fb72573..def438b5 100644 --- a/uitests/Resend_App_Member_Invite.html +++ b/uitests/Resend_App_Member_Invite.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -626,12 +610,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.user-info > .usa-input.usa-input--validation--requiredField:nth-of-type(1) > input[id="first_name"][type="text"] +css=.user-info > .usa-input.usa-input--validation--name:nth-of-type(1) > input[id="first_name"][type="text"] assertText -css=.user-info > .usa-input.usa-input--validation--requiredField:nth-of-type(1) > input[id="first_name"][type="text"] +css=.user-info > .usa-input.usa-input--validation--name:nth-of-type(1) > input[id="first_name"][type="text"] *Brandon* @@ -641,12 +625,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] assertText -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] *Resend Invite* @@ -656,12 +640,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] click -css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > button[type="submit"] +css=.panel > div:nth-of-type(2) > .modal.form-content--app-mem > .modal__container > .modal__dialog > .modal__body > form[action] > .action-group > input[type="submit"] @@ -671,28 +655,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading - - - -assertText -css=.usa-alert.usa-alert-success > .usa-alert-body > h3.usa-alert-heading -*Application invitation resent* - - -waitForPageToLoad - - - - -waitForElementPresent css=.usa-alert.usa-alert-success > .usa-alert-body > .usa-alert-text assertText css=.usa-alert.usa-alert-success > .usa-alert-body > .usa-alert-text -*You have successfully resent the invite for Brandon Buchannan* +*jay+brandon@promptworks.com has been sent an invitation to access this Application* diff --git a/uitests/Revoke_App_Member_Invite.html b/uitests/Revoke_App_Member_Invite.html index c0055261..847afe97 100644 --- a/uitests/Revoke_App_Member_Invite.html +++ b/uitests/Revoke_App_Member_Invite.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad diff --git a/uitests/Revoke_Environment_Access.html b/uitests/Revoke_Environment_Access.html index abb4d1dc..6506733b 100644 --- a/uitests/Revoke_Environment_Access.html +++ b/uitests/Revoke_Environment_Access.html @@ -169,7 +169,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -439,29 +439,13 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -click -css=.application-perms > div:nth-of-type(3) > .usa-input.input__inline-fields > fieldset.usa-input__choices > legend > label - - - -waitForPageToLoad - - - - - -waitForElementPresent css=#environment_roles-0-role-None type css=#environment_roles-0-role-None -Basic Access +ADMIN waitForPageToLoad @@ -477,7 +461,7 @@ Imported from: AT-AT CI - New Portfolio--> type css=#environment_roles-1-role-None -Network Admin +BILLING_READ waitForPageToLoad @@ -581,12 +565,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.accordion-table__item-toggle-content.app-member-menu__toggle > a:nth-of-type(1) +css=.accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1) click -css=.accordion-table__item-toggle-content.app-member-menu__toggle > a:nth-of-type(1) +css=.accordion-table__item-toggle-content.toggle-menu__toggle > a:nth-of-type(1) @@ -671,12 +655,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.action-group > input[type="submit"].usa-button.usa-button-primary.action-group__action +css=.action-group > input[type="submit"].usa-button.action-group__action click -css=.action-group > input[type="submit"].usa-button.usa-button-primary.action-group__action +css=.action-group > input[type="submit"].usa-button.action-group__action @@ -686,12 +670,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(1) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(1) > .env-role__role assertText -css=table.atat-table > tbody > tr:nth-of-type(1) > td.env_role--td > .row:nth-of-type(1) > .env-role__role +css=table.atat-table > tbody > tr:nth-of-type(1) > td.toggle-menu__container > .row:nth-of-type(1) > .env-role__role *None* diff --git a/uitests/TO_Index_(Landing)_Page_-_Empty_State.html b/uitests/TO_Index_(Landing)_Page_-_Empty_State.html index 15dae03e..bc0e7d5a 100644 --- a/uitests/TO_Index_(Landing)_Page_-_Empty_State.html +++ b/uitests/TO_Index_(Landing)_Page_-_Empty_State.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -237,21 +237,6 @@ Imported from: AT-AT CI - login--> css=.empty-state__footer > a.usa-button.usa-button-primary - -waitForPageToLoad - - - - -waitForElementPresent -css=.sticky-cta-buttons > a.usa-button.usa-button-primary - - - -assertElementPresent -css=.sticky-cta-buttons > a.usa-button.usa-button-primary - - diff --git a/uitests/TO_Index_with_Draft_TO.html b/uitests/TO_Index_with_Draft_TO.html index 77a6e543..6e34eb31 100644 --- a/uitests/TO_Index_with_Draft_TO.html +++ b/uitests/TO_Index_with_Draft_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_TO.html b/uitests/TO_Index_with_TO.html index 8997f6b0..8065848c 100644 --- a/uitests/TO_Index_with_TO.html +++ b/uitests/TO_Index_with_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_Unsigned_TO.html b/uitests/TO_Index_with_Unsigned_TO.html index 3b3f1786..49998dbe 100644 --- a/uitests/TO_Index_with_Unsigned_TO.html +++ b/uitests/TO_Index_with_Unsigned_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_expired_TO.html b/uitests/TO_Index_with_expired_TO.html index 9bccba7e..47459209 100644 --- a/uitests/TO_Index_with_expired_TO.html +++ b/uitests/TO_Index_with_expired_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Index_with_future_TO.html b/uitests/TO_Index_with_future_TO.html index 3401bd3e..d04b0530 100644 --- a/uitests/TO_Index_with_future_TO.html +++ b/uitests/TO_Index_with_future_TO.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -200,12 +200,12 @@ Imported from: AT-AT CI - New Portfolio--> waitForElementPresent -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary - + click -css=.sticky-cta-buttons > .usa-button.usa-button-primary +css=.empty-state__footer > .usa-button.usa-button-primary diff --git a/uitests/TO_Step_1.html b/uitests/TO_Step_1.html index 53e0c1b2..ac031608 100644 --- a/uitests/TO_Step_1.html +++ b/uitests/TO_Step_1.html @@ -101,7 +101,7 @@ Imported from: AT-AT CI - login--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_2.html b/uitests/TO_Step_2.html index 1bd56535..464d0f51 100644 --- a/uitests/TO_Step_2.html +++ b/uitests/TO_Step_2.html @@ -106,7 +106,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_3.html b/uitests/TO_Step_3.html index 84ba584d..ffdf0ae4 100644 --- a/uitests/TO_Step_3.html +++ b/uitests/TO_Step_3.html @@ -111,7 +111,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_3_-_Add_CLIN.html b/uitests/TO_Step_3_-_Add_CLIN.html index c5ab24a1..4617ebdd 100644 --- a/uitests/TO_Step_3_-_Add_CLIN.html +++ b/uitests/TO_Step_3_-_Add_CLIN.html @@ -116,7 +116,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad diff --git a/uitests/TO_Step_4.html b/uitests/TO_Step_4.html index 1968c48d..8bf2f648 100644 --- a/uitests/TO_Step_4.html +++ b/uitests/TO_Step_4.html @@ -116,7 +116,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -748,7 +748,7 @@ Imported from: AT-AT CI - TO Step 2--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -763,7 +763,7 @@ Imported from: AT-AT CI - TO Step 2--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* waitForPageToLoad diff --git a/uitests/TO_Step_5.html b/uitests/TO_Step_5.html index d5d5d7ee..4eb53ab9 100644 --- a/uitests/TO_Step_5.html +++ b/uitests/TO_Step_5.html @@ -121,7 +121,7 @@ Imported from: AT-AT CI - New Portfolio--> assertText css=.sticky-cta-text > h3 -*Create New Portfolio* +*Name and Describe Portfolio* waitForPageToLoad @@ -791,7 +791,7 @@ Imported from: AT-AT CI - TO Step 3--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(1) > .summary-item__value--large -*$100,000.00* +*$800,000.00* waitForPageToLoad @@ -807,7 +807,7 @@ Imported from: AT-AT CI - TO Step 3--> assertText css=.row > .col.col--grow.summary-item:nth-of-type(2) > .summary-item__value--large -*$800,000.00* +*$100,000.00* waitForPageToLoad From e6291ad8506132e9609f3fbb09581b0cb3e8454e Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Fri, 24 Jan 2020 10:51:45 -0500 Subject: [PATCH 15/35] Refactor EmptyState macro and update styling for view only empty state based on new designs --- templates/applications/index.html | 5 +-- templates/components/empty_state.html | 24 +++++++---- .../reports/application_and_env_spending.html | 9 +--- templates/task_orders/index.html | 5 +-- translations.yaml | 41 +++++++++++-------- 5 files changed, 44 insertions(+), 40 deletions(-) diff --git a/templates/applications/index.html b/templates/applications/index.html index 00a9b0e8..fd5a5fa7 100644 --- a/templates/applications/index.html +++ b/templates/applications/index.html @@ -24,11 +24,8 @@ {% if not portfolio.applications %} {{ EmptyState( - header="portfolios.applications.empty_state.header"|translate, - message="portfolios.applications.empty_state.message"|translate, - button_text="portfolios.applications.empty_state.button_text"|translate, + resource='applications', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), - view_only_text="portfolios.applications.empty_state.view_only_text"|translate, user_can_create=can_create_applications, ) }} diff --git a/templates/components/empty_state.html b/templates/components/empty_state.html index 9989e4f8..8357252e 100644 --- a/templates/components/empty_state.html +++ b/templates/components/empty_state.html @@ -1,14 +1,22 @@ -{% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %} +{% macro EmptyState(resource, button_link, user_can_create=False) %} + {% if user_can_create %} + {% set perms = 'edit' %} + {% else %} + {% set perms = 'view' %} + {% endif %} + + {% set header = "empty_state.{}.header.{}".format(resource, perms) | translate | safe %} + {% set message = "empty_state.{}.message.{}".format(resource, perms) | translate | safe %} + {% set button_text = "empty_state.{}.button_text".format(resource) | translate | safe %} +

{{ header }}

{{ message }}

-
- + {%- endif %}
{% endmacro %} diff --git a/templates/portfolios/reports/application_and_env_spending.html b/templates/portfolios/reports/application_and_env_spending.html index 783b29ac..44efd76e 100644 --- a/templates/portfolios/reports/application_and_env_spending.html +++ b/templates/portfolios/reports/application_and_env_spending.html @@ -6,17 +6,10 @@ {% if not portfolio.applications %} {% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %} - {% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate) - if can_create_applications - else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate) - %} {{ EmptyState( - header='portfolios.reports.empty_state.message' | translate, - message=message, - button_text="portfolios.applications.empty_state.button_text"|translate, + resource='applications_reporting', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), - view_only_text="portfolios.applications.empty_state.view_only_text"|translate, user_can_create=can_create_applications, ) }} diff --git a/templates/task_orders/index.html b/templates/task_orders/index.html index a720c789..8caeeab2 100644 --- a/templates/task_orders/index.html +++ b/templates/task_orders/index.html @@ -85,11 +85,8 @@ {% endcall %} {% else %} {{ EmptyState( - header="task_orders.empty_state.header"|translate, - message="task_orders.empty_state.message"|translate, + resource="task_orders", button_link=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id), - button_text="task_orders.empty_state.button_text"|translate, - view_only_text="task_orders.empty_state.view_only_text"|translate, user_can_create=user_can(permissions.CREATE_TASK_ORDER), ) }} {% endif %} diff --git a/translations.yaml b/translations.yaml index d9615c4d..8c4c194f 100644 --- a/translations.yaml +++ b/translations.yaml @@ -84,6 +84,31 @@ email: application_invite: "{inviter_name} has invited you to a JEDI cloud application" portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio" environment_ready: JEDI cloud environment ready +empty_state: + applications: + header: + edit: You don’t have any Applications yet + view: This portfolio has no Applications + message: + edit: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same. + view: A Portfolio member with Edit Application permissions can add Applications to this Portfolio. + button_text: Create Your First Application + applications_reporting: + header: + edit: Nothing to report. + view: Nothing to report. + message: + edit: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started. + view: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments. + button_text: Add a new application + task_orders: + header: + edit: Add approved task orders + view: This Portfolio has no Task Orders + message: + edit: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. + view: A Portfolio member with Edit Funding permissions can fund this Portfolio with approved Task Orders. + button_text: Add Task Order flash: application: created: @@ -370,11 +395,6 @@ portfolios: add_member: Add Team Member add_another_environment: Add another environment create_button: Create Application - empty_state: - header: You don't have any Applications yet - message: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same. - button_text: Create Your First Application - view_only_text: Contact your portfolio administrator to add an application. new: step_1_header: Name and Describe New Application step_1_button_text: "Next: Add Environments" @@ -482,12 +502,6 @@ portfolios: header: Funding Duration tooltip: Funding duration is the period of time that there is a valid task order funding the portfolio. estimate_warning: Reports displayed in JEDI are estimates and not a system of record. - empty_state: - message: Nothing to report. - sub_message: - can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started. - cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments. - action_label: "Add a new application" total_value: header: Total Portfolio Value tooltip: Total portfolio value is all obligated and projected funds for all task orders in this portfolio. @@ -549,11 +563,6 @@ task_orders: sticky_header_text: "Add a Task Order" sticky_header_review_text: Review Changes sticky_header_context: "Step {step} of 5" - empty_state: - header: Add approved task orders - message: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. - button_text: Add Task Order - view_only_text: Contact your portfolio administrator to add a Task Order. sign: digital_signature_description: I confirm the uploaded Task Order is signed by the appropriate, duly warranted Agency Contracting Officer who authorized me to upload the Task Order. confirmation_description: I confirm that the information entered here in matches that of the submitted Task Order. From 7857fffc1c5b625ca9a5c41a50ef8c4f57d5d690 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Fri, 24 Jan 2020 11:53:29 -0500 Subject: [PATCH 16/35] Add alert for when portfolio isn't funded, Update styling for env pending label --- static/icons/clock.svg | 1 + styles/elements/_labels.scss | 2 +- .../applications/fragments/environments.html | 37 +++++++++++-------- templates/components/label.html | 5 +++ translations.yaml | 1 + 5 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 static/icons/clock.svg diff --git a/static/icons/clock.svg b/static/icons/clock.svg new file mode 100644 index 00000000..ef1d84a1 --- /dev/null +++ b/static/icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/styles/elements/_labels.scss b/styles/elements/_labels.scss index 8c044f8d..1e431957 100644 --- a/styles/elements/_labels.scss +++ b/styles/elements/_labels.scss @@ -21,7 +21,7 @@ text-transform: uppercase; &--default { - background-color: $color-gray-dark; + background-color: $color-gray; } &--info { diff --git a/templates/applications/fragments/environments.html b/templates/applications/fragments/environments.html index fa4e5959..d0934268 100644 --- a/templates/applications/fragments/environments.html +++ b/templates/applications/fragments/environments.html @@ -1,3 +1,4 @@ +{% from "components/alert.html" import Alert %} {% from "components/icon.html" import Icon %} {% from "components/label.html" import Label %} {% from 'components/save_button.html' import SaveButton %} @@ -10,10 +11,13 @@ new_env_form) %}

{{ "portfolios.applications.settings.environments" | translate }}

+ {% if portfolio.num_task_orders == 0 -%} + {{ Alert(message="portfolios.applications.environments.funding_alert"|translate({'name': portfolio.name})) }} + {%- endif %} + {% if g.matchesPath("application-environments") -%} + {% include "fragments/flash.html" %} + {%- endif %}
- {% if g.matchesPath("application-environments") -%} - {% include "fragments/flash.html" %} - {%- endif %} {% if 0 == environments_obj | length -%}

@@ -30,14 +34,21 @@

  • - - - {{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }} - - + {% if not env["pending"] -%} + + + {{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }} + + + {% else -%} + + {{ env['name'] }} + + {{ Label(type="pending_creation", classes='label--below')}} + {%- endif %} {% if user_can(permissions.EDIT_ENVIRONMENT) -%} {{ ToggleButton( @@ -57,10 +68,6 @@ classes="environment-list__item__members" ) }} -
    - {% if env['pending'] -%} - {{ Label(type="changes_pending", classes='label--below')}} - {%- endif %}
    diff --git a/templates/components/label.html b/templates/components/label.html index 27f1c1b1..4d2679f1 100644 --- a/templates/components/label.html +++ b/templates/components/label.html @@ -9,6 +9,11 @@ "text": "changes pending", "color": "default", }, + "pending_creation": { + "icon": "clock", + "text": "pending creation", + "color": "default", + }, "ppoc": {"text": "primary point of contact"} } %} diff --git a/translations.yaml b/translations.yaml index d9615c4d..9f85ac63 100644 --- a/translations.yaml +++ b/translations.yaml @@ -417,6 +417,7 @@ portfolios: add_subscription: Add new subscription blank_slate: This Application has no environments disabled: ": Access Suspended" + funding_alert: "Application environments will not be created until the {name} portfolio is funded." environments_heading: Application Environments existing_application_title: "{application_name} Application Settings" member_count: "{count} Members" From 02438dc39bc3e09acaa2480e1f6f30dfdf1eb0f6 Mon Sep 17 00:00:00 2001 From: dandds Date: Sat, 25 Jan 2020 14:30:17 -0500 Subject: [PATCH 17/35] Query for applications that need to be provisioned. Adds a method to the Applications domain class that can return a list of UUIDs for applications that are ready to be provisioned. It requires that: - the associated portfolio and state machine have a state of COMPLETED - the application not have been marked deleted - the application not have an existing cloud_id - the application does not have an existing claim on it --- ...598199f6_add_applications_claimed_until.py | 30 +++++++++++++++++++ atst/domain/applications.py | 27 ++++++++++++++++- atst/models/application.py | 5 +++- tests/domain/test_applications.py | 18 +++++++++++ tests/factories.py | 8 +++++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/07e0598199f6_add_applications_claimed_until.py diff --git a/alembic/versions/07e0598199f6_add_applications_claimed_until.py b/alembic/versions/07e0598199f6_add_applications_claimed_until.py new file mode 100644 index 00000000..ada20eaf --- /dev/null +++ b/alembic/versions/07e0598199f6_add_applications_claimed_until.py @@ -0,0 +1,30 @@ +"""add applications.claimed_until + +Revision ID: 07e0598199f6 +Revises: 26319c44a8d5 +Create Date: 2020-01-25 13:33:17.711548 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '07e0598199f6' # pragma: allowlist secret +down_revision = '26319c44a8d5' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('applications', sa.Column('claimed_until', sa.TIMESTAMP(timezone=True), nullable=True)) + op.add_column('applications', sa.Column('cloud_id', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('applications', 'claimed_until') + op.drop_column('applications', 'cloud_id') + # ### end Alembic commands ### diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 3dbb9953..4e7f3c8a 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -1,5 +1,9 @@ -from . import BaseDomainClass from flask import g +from sqlalchemy import func, or_ +from typing import List +from uuid import UUID + +from . import BaseDomainClass from atst.database import db from atst.domain.application_roles import ApplicationRoles from atst.domain.environments import Environments @@ -10,7 +14,10 @@ from atst.models import ( ApplicationRole, ApplicationRoleStatus, EnvironmentRole, + Portfolio, + PortfolioStateMachine, ) +from atst.models.mixins.state_machines import FSMStates from atst.utils import first_or_none, commit_or_raise_already_exists_error @@ -118,3 +125,21 @@ class Applications(BaseDomainClass): db.session.commit() return invitation + + @classmethod + def get_applications_pending_creation(cls) -> List[UUID]: + results = ( + db.session.query(Application.id) + .join(Portfolio) + .join(PortfolioStateMachine) + .filter(PortfolioStateMachine.state == FSMStates.COMPLETED) + .filter(Application.deleted == False) + .filter(Application.cloud_id == None) + .filter( + or_( + Application.claimed_until == None, + Application.claimed_until >= func.now(), + ) + ) + ).all() + return [id_ for id_, in results] diff --git a/atst/models/application.py b/atst/models/application.py index a7bdadba..1af9e39f 100644 --- a/atst/models/application.py +++ b/atst/models/application.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint +from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint, TIMESTAMP from sqlalchemy.orm import relationship, synonym from atst.models.base import Base @@ -40,6 +40,9 @@ class Application( ), ) + cloud_id = Column(String) + claimed_until = Column(TIMESTAMP(timezone=True)) + @property def users(self): return set(role.user for role in self.members) diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 9fda3114..8ddc0867 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta import pytest from uuid import uuid4 @@ -196,3 +197,20 @@ def test_update_does_not_duplicate_names_within_portfolio(): with pytest.raises(AlreadyExistsError): Applications.update(dupe_application, {"name": name}) + + +def test_get_applications_pending_creation(): + now = datetime.now() + later = now + timedelta(minutes=30) + + portfolio1 = PortfolioFactory.create(state="COMPLETED") + app_ready = ApplicationFactory.create(portfolio=portfolio1) + + app_claimed = ApplicationFactory.create(portfolio=portfolio1, claimed_until=later) + + portfolio2 = PortfolioFactory.create(state="UNSTARTED") + app_not_ready = ApplicationFactory.create(portfolio=portfolio2) + + uuids = Applications.get_applications_pending_creation() + + assert [app_ready.id] == uuids diff --git a/tests/factories.py b/tests/factories.py index d9af7c40..b7a63243 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -7,6 +7,7 @@ import datetime from atst.forms import data from atst.models import * +from atst.models.mixins.state_machines import FSMStates from atst.domain.invitations import PortfolioInvitations from atst.domain.permission_sets import PermissionSets @@ -121,6 +122,7 @@ class PortfolioFactory(Base): owner = kwargs.pop("owner", UserFactory.create()) members = kwargs.pop("members", []) with_task_orders = kwargs.pop("task_orders", []) + state = kwargs.pop("state", None) portfolio = super()._create(model_class, *args, **kwargs) @@ -161,6 +163,12 @@ class PortfolioFactory(Base): permission_sets=perms_set, ) + if state: + state = getattr(FSMStates, state) + fsm = PortfolioStateMachineFactory.create(state=state, portfolio=portfolio) + # setting it in the factory is not working for some reason + fsm.state = state + portfolio.applications = applications portfolio.task_orders = task_orders return portfolio From bfc06920630442099318889722105c568d0b58ed Mon Sep 17 00:00:00 2001 From: dandds Date: Sat, 25 Jan 2020 15:50:01 -0500 Subject: [PATCH 18/35] Remove multiple job failure tables in favor of one. We don't know yet how useful the job failue tables will be, and maintaining multiple failure tables--one for every entity involved in CSP provisioning--is burdensome. This collapses them all into a single table that track the entity type (environment, portfolio, etc.) and the entity ID. That way we can construct queries when needed to find task results. --- .../508957112ed6_combine_job_failures.py | 60 +++++++++++++++++++ atst/jobs.py | 57 +++++++----------- atst/models/__init__.py | 6 +- atst/models/environment.py | 2 - atst/models/environment_role.py | 2 - atst/models/job_failure.py | 27 ++++----- atst/models/mixins/__init__.py | 1 - atst/models/mixins/job_failure.py | 14 ----- tests/test_jobs.py | 28 +++++---- 9 files changed, 114 insertions(+), 83 deletions(-) create mode 100644 alembic/versions/508957112ed6_combine_job_failures.py delete mode 100644 atst/models/mixins/job_failure.py diff --git a/alembic/versions/508957112ed6_combine_job_failures.py b/alembic/versions/508957112ed6_combine_job_failures.py new file mode 100644 index 00000000..9d40bb12 --- /dev/null +++ b/alembic/versions/508957112ed6_combine_job_failures.py @@ -0,0 +1,60 @@ +"""combine job failures + +Revision ID: 508957112ed6 +Revises: 07e0598199f6 +Create Date: 2020-01-25 15:03:06.377442 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '508957112ed6' # pragma: allowlist secret +down_revision = '07e0598199f6' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('job_failures', + sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.String(), nullable=False), + sa.Column('entity', sa.String(), nullable=False), + sa.Column('entity_id', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('environment_job_failures') + op.drop_table('environment_role_job_failures') + op.drop_table('portfolio_job_failures') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('portfolio_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('portfolio_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['portfolio_id'], ['portfolios.id'], name='portfolio_job_failures_portfolio_id_fkey'), + sa.PrimaryKeyConstraint('id', name='portfolio_job_failures_pkey') + ) + op.create_table('environment_role_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('environment_role_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['environment_role_id'], ['environment_roles.id'], name='environment_role_job_failures_environment_role_id_fkey'), + sa.PrimaryKeyConstraint('id', name='environment_role_job_failures_pkey') + ) + op.create_table('environment_job_failures', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('task_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('environment_id', postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['environment_id'], ['environments.id'], name='environment_job_failures_environment_id_fkey'), + sa.PrimaryKeyConstraint('id', name='environment_job_failures_pkey') + ) + op.drop_table('job_failures') + # ### end Alembic commands ### diff --git a/atst/jobs.py b/atst/jobs.py index ab52cf17..47fefb71 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -3,12 +3,7 @@ import pendulum from atst.database import db from atst.queue import celery -from atst.models import ( - EnvironmentJobFailure, - EnvironmentRoleJobFailure, - EnvironmentRole, - PortfolioJobFailure, -) +from atst.models import EnvironmentRole, JobFailure from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException from atst.domain.environments import Environments from atst.domain.portfolios import Portfolios @@ -17,32 +12,26 @@ from atst.models.utils import claim_for_update from atst.utils.localization import translate -class RecordPortfolioFailure(celery.Task): +class RecordFailure(celery.Task): + _ENTITIES = [ + "portfolio_id", + "application_id", + "environment_id", + "environment_role_id", + ] + + def _derive_entity_info(self, kwargs): + matches = [e for e in self._ENTITIES if e in kwargs.keys()] + if matches: + match = matches[0] + return {"entity": match.replace("_id", ""), "entity_id": kwargs[match]} + else: + return None + def on_failure(self, exc, task_id, args, kwargs, einfo): - if "portfolio_id" in kwargs: - failure = PortfolioJobFailure( - portfolio_id=kwargs["portfolio_id"], task_id=task_id - ) - db.session.add(failure) - db.session.commit() - - -class RecordEnvironmentFailure(celery.Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): - if "environment_id" in kwargs: - failure = EnvironmentJobFailure( - environment_id=kwargs["environment_id"], task_id=task_id - ) - db.session.add(failure) - db.session.commit() - - -class RecordEnvironmentRoleFailure(celery.Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): - if "environment_role_id" in kwargs: - failure = EnvironmentRoleJobFailure( - environment_role_id=kwargs["environment_role_id"], task_id=task_id - ) + info = self._derive_entity_info(kwargs) + if info: + failure = JobFailure(**info, task_id=task_id) db.session.add(failure) db.session.commit() @@ -143,17 +132,17 @@ def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): fsm.trigger_next_transition() -@celery.task(bind=True, base=RecordPortfolioFailure) +@celery.task(bind=True, base=RecordFailure) def provision_portfolio(self, portfolio_id=None): do_work(do_provision_portfolio, self, app.csp.cloud, portfolio_id=portfolio_id) -@celery.task(bind=True, base=RecordEnvironmentFailure) +@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) -@celery.task(bind=True, base=RecordEnvironmentFailure) +@celery.task(bind=True, base=RecordFailure) def create_atat_admin_user(self, environment_id=None): do_work( do_create_atat_admin_user, self, app.csp.cloud, environment_id=environment_id diff --git a/atst/models/__init__.py b/atst/models/__init__.py index f6c48306..dfb1c19d 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -7,11 +7,7 @@ from .audit_event import AuditEvent from .clin import CLIN, JEDICLINType from .environment import Environment from .environment_role import EnvironmentRole, CSPRole -from .job_failure import ( - EnvironmentJobFailure, - EnvironmentRoleJobFailure, - PortfolioJobFailure, -) +from .job_failure import JobFailure from .notification_recipient import NotificationRecipient from .permissions import Permissions from .permission_set import PermissionSet diff --git a/atst/models/environment.py b/atst/models/environment.py index 115f3ed7..a0713c63 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -30,8 +30,6 @@ class Environment( claimed_until = Column(TIMESTAMP(timezone=True)) - job_failures = relationship("EnvironmentJobFailure") - roles = relationship( "EnvironmentRole", back_populates="environment", diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 21f033e0..24aaeb7e 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -32,8 +32,6 @@ class EnvironmentRole( ) application_role = relationship("ApplicationRole") - job_failures = relationship("EnvironmentRoleJobFailure") - csp_user_id = Column(String()) claimed_until = Column(TIMESTAMP(timezone=True)) diff --git a/atst/models/job_failure.py b/atst/models/job_failure.py index 7a7f010a..5f9eee6c 100644 --- a/atst/models/job_failure.py +++ b/atst/models/job_failure.py @@ -1,22 +1,21 @@ -from sqlalchemy import Column, ForeignKey +from celery.result import AsyncResult +from sqlalchemy import Column, String, Integer from atst.models.base import Base import atst.models.mixins as mixins -class EnvironmentJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "environment_job_failures" +class JobFailure(Base, mixins.TimestampsMixin): + __tablename__ = "job_failures" - environment_id = Column(ForeignKey("environments.id"), nullable=False) + id = Column(Integer(), primary_key=True) + task_id = Column(String(), nullable=False) + entity = Column(String(), nullable=False) + entity_id = Column(String(), nullable=False) + @property + def task(self): + if not hasattr(self, "_task"): + self._task = AsyncResult(self.task_id) -class EnvironmentRoleJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "environment_role_job_failures" - - environment_role_id = Column(ForeignKey("environment_roles.id"), nullable=False) - - -class PortfolioJobFailure(Base, mixins.JobFailureMixin): - __tablename__ = "portfolio_job_failures" - - portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False) + return self._task diff --git a/atst/models/mixins/__init__.py b/atst/models/mixins/__init__.py index 955171ab..e95b2516 100644 --- a/atst/models/mixins/__init__.py +++ b/atst/models/mixins/__init__.py @@ -3,5 +3,4 @@ from .auditable import AuditableMixin from .permissions import PermissionsMixin from .deletable import DeletableMixin from .invites import InvitesMixin -from .job_failure import JobFailureMixin from .state_machines import FSMMixin diff --git a/atst/models/mixins/job_failure.py b/atst/models/mixins/job_failure.py deleted file mode 100644 index c4f4cfa4..00000000 --- a/atst/models/mixins/job_failure.py +++ /dev/null @@ -1,14 +0,0 @@ -from celery.result import AsyncResult -from sqlalchemy import Column, String, Integer - - -class JobFailureMixin(object): - id = Column(Integer(), primary_key=True) - task_id = Column(String(), nullable=False) - - @property - def task(self): - if not hasattr(self, "_task"): - self._task = AsyncResult(self.task_id) - - return self._task diff --git a/tests/test_jobs.py b/tests/test_jobs.py index ff8e4602..9734bd75 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -8,8 +8,7 @@ from atst.domain.csp.cloud import MockCloudProvider from atst.domain.portfolios import Portfolios from atst.jobs import ( - RecordEnvironmentFailure, - RecordEnvironmentRoleFailure, + RecordFailure, dispatch_create_environment, dispatch_create_atat_admin_user, dispatch_provision_portfolio, @@ -29,7 +28,7 @@ from tests.factories import ( PortfolioStateMachineFactory, ApplicationRoleFactory, ) -from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus +from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure @pytest.fixture(autouse=True, scope="function") @@ -43,8 +42,17 @@ def portfolio(): return portfolio -def test_environment_job_failure(celery_app, celery_worker): - @celery_app.task(bind=True, base=RecordEnvironmentFailure) +def _find_failure(session, entity, id_): + return ( + session.query(JobFailure) + .filter(JobFailure.entity == entity) + .filter(JobFailure.entity_id == id_) + .one() + ) + + +def test_environment_job_failure(session, celery_app, celery_worker): + @celery_app.task(bind=True, base=RecordFailure) def _fail_hard(self, environment_id=None): raise ValueError("something bad happened") @@ -56,13 +64,12 @@ def test_environment_job_failure(celery_app, celery_worker): with pytest.raises(ValueError): task.get() - assert environment.job_failures - job_failure = environment.job_failures[0] + job_failure = _find_failure(session, "environment", str(environment.id)) assert job_failure.task == task -def test_environment_role_job_failure(celery_app, celery_worker): - @celery_app.task(bind=True, base=RecordEnvironmentRoleFailure) +def test_environment_role_job_failure(session, celery_app, celery_worker): + @celery_app.task(bind=True, base=RecordFailure) def _fail_hard(self, environment_role_id=None): raise ValueError("something bad happened") @@ -74,8 +81,7 @@ def test_environment_role_job_failure(celery_app, celery_worker): with pytest.raises(ValueError): task.get() - assert role.job_failures - job_failure = role.job_failures[0] + job_failure = _find_failure(session, "environment_role", str(role.id)) assert job_failure.task == task From 8810a59e0adc62736777986592063570287d834a Mon Sep 17 00:00:00 2001 From: dandds Date: Sat, 25 Jan 2020 17:29:17 -0500 Subject: [PATCH 19/35] Orchestration for creating app management groups. This adds: - A Celery beat task for enqueuing application creation tasks - A Celery task for creating the application - Payload and Response dataclasses for creating management groups It also does some incidental cleanup. --- atst/domain/csp/cloud.py | 113 +++++++++++++++++-- atst/jobs.py | 35 ++++++ atst/queue.py | 4 + tests/domain/cloud/test_azure_csp.py | 12 +- tests/domain/cloud/test_payloads.py | 72 ++++++++++++ tests/domain/test_portfolio_state_machine.py | 1 - tests/test_jobs.py | 36 ++++++ 7 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 tests/domain/cloud/test_payloads.py diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index d22f9475..eff5a6d8 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, validator from flask import current_app as app from atst.models.user import User -from atst.models.application import Application from atst.models.environment import Environment from atst.models.environment_role import EnvironmentRole from atst.utils import snake_to_camel @@ -376,6 +375,60 @@ class BillingInstructionCSPResult(AliasModel): } +AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/" + +MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$" + + +class ManagementGroupCSPPayload(BaseCSPPayload): + """ + :param: management_group_name: Just pass a UUID for this. + :param: display_name: This can contain any character and + spaces, but should be 90 characters or fewer long. + :param: parent_id: This should be the fully qualified Azure ID, + i.e. /providers/Microsoft.Management/managementGroups/[management group ID] + """ + + management_group_name: Optional[str] + display_name: str + parent_id: str + + @validator("management_group_name", pre=True, always=True) + def supply_management_group_name_default(cls, name): + if name: + if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None: + raise ValueError( + f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}" + ) + + return name[0:90] + else: + return str(uuid4()) + + @validator("display_name", pre=True, always=True) + def enforce_display_name_length(cls, name): + return name[0:90] + + @validator("parent_id", pre=True, always=True) + def enforce_parent_id_pattern(cls, id_): + if AZURE_MGMNT_PATH not in id_: + return f"{AZURE_MGMNT_PATH}{id_}" + else: + return id_ + + +class ManagementGroupCSPResponse(AliasModel): + id: str + + +class ApplicationCSPPayload(ManagementGroupCSPPayload): + pass + + +class ApplicationCSPResult(ManagementGroupCSPResponse): + pass + + class CloudProviderInterface: def set_secret(self, secret_key: str, secret_value: str): raise NotImplementedError() @@ -806,6 +859,15 @@ class MockCloudProvider(CloudProviderInterface): if self._with_authorization and credentials != self._auth_credentials: raise self.AUTHENTICATION_EXCEPTION + def create_application(self, payload: ApplicationCSPPayload): + self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) + + id_ = f"{AZURE_MGMNT_PATH}{payload.management_group_name}" + return ApplicationCSPResult(id=id_) + + def get_credentials(self, scope="portfolio", tenant_id=None): + return self.root_creds() + AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI @@ -840,7 +902,7 @@ class AzureSDKProvider(object): self.graphrbac = graphrbac self.credentials = credentials self.identity = identity - self.exceptions = exceptions + # self.exceptions = exceptions self.secrets = secrets self.requests = requests # may change to a JEDI cloud @@ -908,7 +970,7 @@ class AzureCloudProvider(CloudProviderInterface): credentials, management_group_id, display_name, parent_id, ) - return management_group + return ManagementGroupCSPResponse(**management_group) def create_atat_admin_user( self, auth_credentials: Dict, csp_environment_id: str @@ -947,16 +1009,19 @@ class AzureCloudProvider(CloudProviderInterface): "role_name": role_assignment_id, } - def _create_application(self, auth_credentials: Dict, application: Application): - management_group_name = str(uuid4()) # can be anything, not just uuid - display_name = application.name # Does this need to be unique? - credentials = self._get_credential_obj(auth_credentials) - parent_id = "?" # application.portfolio.csp_details.management_group_id + def create_application(self, payload: ApplicationCSPPayload): + creds = payload.creds + credentials = self._get_credential_obj(creds, resource=AZURE_MANAGEMENT_API) - return self._create_management_group( - credentials, management_group_name, display_name, parent_id, + response = self._create_management_group( + credentials, + payload.management_group_name, + payload.display_name, + payload.parent_id, ) + return ApplicationCSPResult(**response) + def _create_management_group( self, credentials, management_group_id, display_name, parent_id=None, ): @@ -978,6 +1043,9 @@ class AzureCloudProvider(CloudProviderInterface): # result is a synchronous wait, might need to do a poll instead to handle first mgmt group create # since we were told it could take 10+ minutes to complete, unless this handles that polling internally + # TODO: what to do is status is not 'Succeeded' on the + # response object? Will it always raise its own error + # instead? return create_request.result() def _create_subscription( @@ -1289,6 +1357,7 @@ class AzureCloudProvider(CloudProviderInterface): # we likely only want the budget ID, can be updated or replaced? response = {"id": "id"} + return self._ok({"budget_id": response["id"]}) def _get_management_service_principal(self): @@ -1406,3 +1475,27 @@ class AzureCloudProvider(CloudProviderInterface): "secret_key": self.secret_key, "tenant_id": self.tenant_id, } + + def get_credentials(self, scope="portfolio", tenant_id=None): + """ + This could be implemented to determine, based on type, whether to return creds for: + - scope="atat": the ATAT main app registration in ATAT's home tenant + - scope="tenantadmin": the tenant administrator credentials + - scope="portfolio": the credentials for the ATAT SP in the portfolio tenant + """ + if scope == "atat": + return self._root_creds + elif scope == "tenantadmin": + # magic with key vault happens + return { + "client_id": "some id", + "secret_key": "very secret", + "tenant_id": tenant_id, + } + elif scope == "portfolio": + # magic with key vault happens + return { + "client_id": "some id", + "secret_key": "very secret", + "tenant_id": tenant_id, + } diff --git a/atst/jobs.py b/atst/jobs.py index 47fefb71..37f73450 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -5,11 +5,13 @@ from atst.database import db from atst.queue import celery from atst.models import EnvironmentRole, JobFailure from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException +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.utils.localization import translate +from atst.domain.csp.cloud import ApplicationCSPPayload class RecordFailure(celery.Task): @@ -51,6 +53,28 @@ def send_notification_mail(recipients, subject, body): app.mailer.send(recipients, subject, body) +def do_create_application(csp: CloudProviderInterface, application_id=None): + application = Applications.get(application_id) + + with claim_for_update(application) as application: + + if application.cloud_id is not None: + return + + csp_details = application.portfolio.csp_data + parent_id = csp_details.get("root_management_group_id") + tenant_id = csp_details.get("tenant_id") + creds = csp.get_credentials(tenant_id) + payload = ApplicationCSPPayload( + creds=creds, display_name=application.name, parent_id=parent_id + ) + + app_result = csp.create_application(payload) + application.cloud_id = app_result.id + db.session.add(application) + db.session.commit() + + def do_create_environment(csp: CloudProviderInterface, environment_id=None): environment = Environments.get(environment_id) @@ -137,6 +161,11 @@ def provision_portfolio(self, portfolio_id=None): do_work(do_provision_portfolio, self, app.csp.cloud, portfolio_id=portfolio_id) +@celery.task(bind=True, base=RecordFailure) +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_environment(self, environment_id=None): do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id) @@ -165,6 +194,12 @@ def dispatch_provision_portfolio(self): provision_portfolio.delay(portfolio_id=portfolio_id) +@celery.task(bind=True) +def dispatch_create_application(self): + for application_id in Applications.get_applications_pending_creation(): + create_application.delay(application_id=application_id) + + @celery.task(bind=True) def dispatch_create_environment(self): for environment_id in Environments.get_environments_pending_creation( diff --git a/atst/queue.py b/atst/queue.py index 1dce690c..70718150 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -11,6 +11,10 @@ def update_celery(celery, app): "task": "atst.jobs.dispatch_provision_portfolio", "schedule": 60, }, + "beat-dispatch_create_application": { + "task": "atst.jobs.dispatch_create_application", + "schedule": 60, + }, "beat-dispatch_create_environment": { "task": "atst.jobs.dispatch_create_environment", "schedule": 60, diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index 9e87e7a7..c98a43ee 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -18,6 +18,7 @@ from atst.domain.csp.cloud import ( TaskOrderBillingVerificationCSPResult, TenantCSPPayload, TenantCSPResult, + ApplicationCSPPayload, ) from tests.mock_azure import mock_azure, AUTH_CREDENTIALS @@ -67,8 +68,8 @@ def test_create_subscription_succeeds(mock_azure: AzureCloudProvider): def mock_management_group_create(mock_azure, spec_dict): - mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = Mock( - **spec_dict + mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = ( + spec_dict ) @@ -89,7 +90,10 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider): mock_management_group_create(mock_azure, {"id": "Test Id"}) - result = mock_azure._create_application(AUTH_CREDENTIALS, application) + payload = ApplicationCSPPayload( + creds={}, display_name=application.name, parent_id=str(uuid4()) + ) + result = mock_azure.create_application(payload) assert result.id == "Test Id" @@ -150,7 +154,7 @@ def test_create_tenant(mock_azure: AzureCloudProvider): **dict( creds=creds, user_id="admin", - password="JediJan13$coot", + password="JediJan13$coot", # pragma: allowlist secret domain_name="jediccpospawnedtenant2", first_name="Tedry", last_name="Tenet", diff --git a/tests/domain/cloud/test_payloads.py b/tests/domain/cloud/test_payloads.py new file mode 100644 index 00000000..08ca147c --- /dev/null +++ b/tests/domain/cloud/test_payloads.py @@ -0,0 +1,72 @@ +import pytest + +from pydantic import ValidationError + +from atst.domain.csp.cloud import ( + AZURE_MGMNT_PATH, + ManagementGroupCSPPayload, + ManagementGroupCSPResponse, +) + + +def test_ManagementGroupCSPPayload_management_group_name(): + # supplies management_group_name when absent + payload = ManagementGroupCSPPayload( + creds={}, display_name="Council of Naboo", parent_id="Galactic_Senate" + ) + assert payload.management_group_name + # validates management_group_name + with pytest.raises(ValidationError): + payload = ManagementGroupCSPPayload( + creds={}, + management_group_name="council of Naboo 1%^&", + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + # shortens management_group_name to fit + name = "council_of_naboo" + for _ in range(90): + name = f"{name}1" + + assert len(name) > 90 + payload = ManagementGroupCSPPayload( + creds={}, + management_group_name=name, + display_name="Council of Naboo", + parent_id="Galactic_Senate", + ) + assert len(payload.management_group_name) == 90 + + +def test_ManagementGroupCSPPayload_display_name(): + # shortens display_name to fit + name = "Council of Naboo" + for _ in range(90): + name = f"{name}1" + assert len(name) > 90 + payload = ManagementGroupCSPPayload( + creds={}, display_name=name, parent_id="Galactic_Senate" + ) + assert len(payload.display_name) == 90 + + +def test_ManagementGroupCSPPayload_parent_id(): + full_path = f"{AZURE_MGMNT_PATH}Galactic_Senate" + # adds full path + payload = ManagementGroupCSPPayload( + creds={}, display_name="Council of Naboo", parent_id="Galactic_Senate" + ) + assert payload.parent_id == full_path + # keeps full path + payload = ManagementGroupCSPPayload( + creds={}, display_name="Council of Naboo", parent_id=full_path + ) + assert payload.parent_id == full_path + + +def test_ManagementGroupCSPResponse_id(): + full_id = "/path/to/naboo-123" + response = ManagementGroupCSPResponse( + **{"id": "/path/to/naboo-123", "other": "stuff"} + ) + assert response.id == full_id diff --git a/tests/domain/test_portfolio_state_machine.py b/tests/domain/test_portfolio_state_machine.py index 0d37c9c7..330d5195 100644 --- a/tests/domain/test_portfolio_state_machine.py +++ b/tests/domain/test_portfolio_state_machine.py @@ -141,7 +141,6 @@ def test_fsm_transition_start(portfolio: Portfolio): config = {"billing_account_name": "billing_account_name"} for expected_state in expected_states: - print(expected_state) collected_data = dict( list(csp_data.items()) + list(portfolio_data.items()) + list(config.items()) ) diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 9734bd75..2ac5f408 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -10,6 +10,7 @@ from atst.domain.portfolios import Portfolios from atst.jobs import ( RecordFailure, dispatch_create_environment, + dispatch_create_application, dispatch_create_atat_admin_user, dispatch_provision_portfolio, dispatch_provision_user, @@ -17,6 +18,7 @@ from atst.jobs import ( do_provision_user, do_provision_portfolio, do_create_environment, + do_create_application, do_create_atat_admin_user, ) from atst.models.utils import claim_for_update @@ -26,6 +28,7 @@ from tests.factories import ( EnvironmentRoleFactory, PortfolioFactory, PortfolioStateMachineFactory, + ApplicationFactory, ApplicationRoleFactory, ) from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure @@ -105,6 +108,24 @@ def test_create_environment_job_is_idempotent(csp, session): csp.create_environment.assert_not_called() +def test_create_application_job(session, csp): + portfolio = PortfolioFactory.create( + csp_data={"tenant_id": str(uuid4()), "root_management_group_id": str(uuid4())} + ) + application = ApplicationFactory.create(portfolio=portfolio, cloud_id=None) + do_create_application(csp, application.id) + session.refresh(application) + + assert application.cloud_id + + +def test_create_application_job_is_idempotent(csp): + application = ApplicationFactory.create(cloud_id=uuid4()) + do_create_application(csp, application.id) + + csp.create_application.assert_not_called() + + def test_create_atat_admin_user(csp, session): environment = EnvironmentFactory.create(cloud_id="something") do_create_atat_admin_user(csp, environment.id) @@ -145,6 +166,21 @@ def test_dispatch_create_environment(session, monkeypatch): mock.delay.assert_called_once_with(environment_id=e1.id) +def test_dispatch_create_application(monkeypatch): + portfolio = PortfolioFactory.create(state="COMPLETED") + app = ApplicationFactory.create(portfolio=portfolio) + + mock = Mock() + monkeypatch.setattr("atst.jobs.create_application", mock) + + # When dispatch_create_application is called + dispatch_create_application.run() + + # It should cause the create_application task to be called once + # with the application id + mock.delay.assert_called_once_with(application_id=app.id) + + def test_dispatch_create_atat_admin_user(session, monkeypatch): portfolio = PortfolioFactory.create( applications=[ From ff172b43b257b7d66a5da8d67cb83ffd9c3bbd78 Mon Sep 17 00:00:00 2001 From: dandds Date: Sun, 26 Jan 2020 12:45:18 -0500 Subject: [PATCH 20/35] Fix some import errors. There is an issue with circular imports because the PortfolioStateMachine model imports some error classes from the cloud module. The cloud module was importing some other models in turn, which was causing the issue. Since we plan to pass all data as dataclass payloads to the cloud interfacem, I removed the type hints that referenced specific SQLAlchemy models and removed the imports. --- atst/domain/csp/cloud.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index eff5a6d8..b106e9ee 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -6,9 +6,6 @@ from pydantic import BaseModel, validator from flask import current_app as app -from atst.models.user import User -from atst.models.environment import Environment -from atst.models.environment_role import EnvironmentRole from atst.utils import snake_to_camel from .policy import AzurePolicyManager @@ -439,9 +436,7 @@ class CloudProviderInterface: def root_creds(self) -> Dict: raise NotImplementedError() - def create_environment( - self, auth_credentials: Dict, user: User, environment: Environment - ) -> str: + def create_environment(self, auth_credentials: Dict, user, environment) -> str: """Create a new environment in the CSP. Arguments: @@ -489,7 +484,7 @@ class CloudProviderInterface: raise NotImplementedError() def create_or_update_user( - self, auth_credentials: Dict, user_info: EnvironmentRole, csp_role_id: str + self, auth_credentials: Dict, user_info, csp_role_id: str ) -> str: """Creates a user or updates an existing user's role. @@ -889,6 +884,7 @@ class AzureSDKProvider(object): import azure.common.credentials as credentials import azure.identity as identity from azure.keyvault import secrets + from azure.core import exceptions from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD import adal @@ -902,7 +898,7 @@ class AzureSDKProvider(object): self.graphrbac = graphrbac self.credentials = credentials self.identity = identity - # self.exceptions = exceptions + self.exceptions = exceptions self.secrets = secrets self.requests = requests # may change to a JEDI cloud @@ -951,9 +947,7 @@ class AzureCloudProvider(CloudProviderInterface): exc_info=1, ) - def create_environment( - self, auth_credentials: Dict, user: User, environment: Environment - ): + def create_environment(self, auth_credentials: Dict, user, environment): # since this operation would only occur within a tenant, should we source the tenant # via lookup from environment once we've created the portfolio csp data schema # something like this: From 37a5218a1d40cb04545d862c58fba2b3e1d633da Mon Sep 17 00:00:00 2001 From: dandds Date: Sun, 26 Jan 2020 18:38:02 -0500 Subject: [PATCH 21/35] Split and barrel cloud module for merge. --- atst/domain/csp/__init__.py | 4 +- atst/domain/csp/cloud.py | 1495 ----------------- atst/domain/csp/cloud/__init__.py | 3 + atst/domain/csp/cloud/azure_cloud_provider.py | 657 ++++++++ .../csp/cloud/cloud_provider_interface.py | 120 ++ atst/domain/csp/cloud/exceptions.py | 131 ++ atst/domain/csp/cloud/mock_cloud_provider.py | 354 ++++ atst/domain/csp/cloud/models.py | 290 ++++ atst/domain/csp/{ => cloud}/policy.py | 0 atst/jobs.py | 5 +- atst/models/portfolio_state_machine.py | 2 +- atst/routes/applications/settings.py | 2 +- script/include/test_functions.inc.sh | 2 +- tests/domain/cloud/test_azure_csp.py | 18 +- tests/domain/cloud/test_payloads.py | 2 +- tests/domain/cloud/test_policy.py | 2 +- tests/routes/applications/test_settings.py | 2 +- 17 files changed, 1577 insertions(+), 1512 deletions(-) delete mode 100644 atst/domain/csp/cloud.py create mode 100644 atst/domain/csp/cloud/__init__.py create mode 100644 atst/domain/csp/cloud/azure_cloud_provider.py create mode 100644 atst/domain/csp/cloud/cloud_provider_interface.py create mode 100644 atst/domain/csp/cloud/exceptions.py create mode 100644 atst/domain/csp/cloud/mock_cloud_provider.py create mode 100644 atst/domain/csp/cloud/models.py rename atst/domain/csp/{ => cloud}/policy.py (100%) diff --git a/atst/domain/csp/__init__.py b/atst/domain/csp/__init__.py index f15ac1cd..62b28f94 100644 --- a/atst/domain/csp/__init__.py +++ b/atst/domain/csp/__init__.py @@ -45,6 +45,8 @@ def get_stage_csp_class(stage, class_type): """ cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}" try: - return getattr(importlib.import_module("atst.domain.csp.cloud"), cls_name) + return getattr( + importlib.import_module("atst.domain.csp.cloud.models"), cls_name + ) except AttributeError: print("could not import CSP Result class <%s>" % cls_name) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py deleted file mode 100644 index b106e9ee..00000000 --- a/atst/domain/csp/cloud.py +++ /dev/null @@ -1,1495 +0,0 @@ -import re -from typing import Dict, List, Optional -from uuid import uuid4 - -from pydantic import BaseModel, validator - -from flask import current_app as app - -from atst.utils import snake_to_camel -from .policy import AzurePolicyManager - - -class GeneralCSPException(Exception): - pass - - -class OperationInProgressException(GeneralCSPException): - """Throw this for instances when the CSP reports that the current entity is already - being operated on/created/deleted/etc - """ - - def __init__(self, operation_desc): - self.operation_desc = operation_desc - - @property - def message(self): - return "An operation for this entity is already in progress: {}".format( - self.operation_desc - ) - - -class AuthenticationException(GeneralCSPException): - """Throw this for instances when there is a problem with the auth credentials: - * Missing credentials - * Incorrect credentials - * Other credential problems - """ - - def __init__(self, auth_error): - self.auth_error = auth_error - - @property - def message(self): - return "An error occurred with authentication: {}".format(self.auth_error) - - -class AuthorizationException(GeneralCSPException): - """Throw this for instances when the current credentials are not authorized - for the current action. - """ - - def __init__(self, auth_error): - self.auth_error = auth_error - - @property - def message(self): - return "An error occurred with authorization: {}".format(self.auth_error) - - -class ConnectionException(GeneralCSPException): - """A general problem with the connection, timeouts or unresolved endpoints - """ - - def __init__(self, connection_error): - self.connection_error = connection_error - - @property - def message(self): - return "Could not connect to cloud provider: {}".format(self.connection_error) - - -class UnknownServerException(GeneralCSPException): - """An error occured on the CSP side (5xx) and we don't know why - """ - - def __init__(self, server_error): - self.server_error = server_error - - @property - def message(self): - return "A server error occured: {}".format(self.server_error) - - -class EnvironmentCreationException(GeneralCSPException): - """If there was an error in creating the environment - """ - - def __init__(self, env_identifier, reason): - self.env_identifier = env_identifier - self.reason = reason - - @property - def message(self): - return "The envionment {} couldn't be created: {}".format( - self.env_identifier, self.reason - ) - - -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 - """ - - def __init__(self, user_csp_id, reason): - self.user_csp_id = user_csp_id - self.reason = reason - - @property - def message(self): - return "Failed to suspend or delete user {}: {}".format( - self.user_csp_id, self.reason - ) - - -class BaselineProvisionException(GeneralCSPException): - """If there's any issues standing up whatever is required - for an environment baseline - """ - - def __init__(self, env_identifier, reason): - self.env_identifier = env_identifier - self.reason = reason - - @property - def message(self): - return "Could not complete baseline provisioning for environment ({}): {}".format( - self.env_identifier, self.reason - ) - - -class AliasModel(BaseModel): - """ - This provides automatic camel <-> snake conversion for serializing to/from json - You can override the alias generation in subclasses by providing a Config that defines - a fields property with a dict mapping variables to their cast names, for cases like: - * some_url:someURL - * user_object_id:objectId - """ - - class Config: - alias_generator = snake_to_camel - allow_population_by_field_name = True - - -class BaseCSPPayload(AliasModel): - # {"username": "mock-cloud", "pass": "shh"} - creds: Dict - - def dict(self, *args, **kwargs): - exclude = {"creds"} - if "exclude" not in kwargs: - kwargs["exclude"] = exclude - else: - kwargs["exclude"].update(exclude) - - return super().dict(*args, **kwargs) - - -class TenantCSPPayload(BaseCSPPayload): - user_id: str - password: str - domain_name: str - first_name: str - last_name: str - country_code: str - password_recovery_email_address: str - - -class TenantCSPResult(AliasModel): - user_id: str - tenant_id: str - user_object_id: str - - tenant_admin_username: Optional[str] - tenant_admin_password: Optional[str] - - class Config: - fields = { - "user_object_id": "objectId", - } - - def dict(self, *args, **kwargs): - exclude = {"tenant_admin_username", "tenant_admin_password"} - if "exclude" not in kwargs: - kwargs["exclude"] = exclude - else: - kwargs["exclude"].update(exclude) - - return super().dict(*args, **kwargs) - - def get_creds(self): - return { - "tenant_admin_username": self.tenant_admin_username, - "tenant_admin_password": self.tenant_admin_password, - "tenant_id": self.tenant_id, - } - - -class BillingProfileAddress(AliasModel): - company_name: str - address_line_1: str - city: str - region: str - country: str - postal_code: str - - -class BillingProfileCLINBudget(AliasModel): - clin_budget: Dict - """ - "clinBudget": { - "amount": 0, - "startDate": "2019-12-18T16:47:40.909Z", - "endDate": "2019-12-18T16:47:40.909Z", - "externalReferenceId": "string" - } - """ - - -class BillingProfileCreationCSPPayload(BaseCSPPayload): - tenant_id: str - billing_profile_display_name: str - billing_account_name: str - enabled_azure_plans: Optional[List[str]] - address: BillingProfileAddress - - @validator("enabled_azure_plans", pre=True, always=True) - def default_enabled_azure_plans(cls, v): - """ - Normally you'd implement this by setting the field with a value of: - dataclasses.field(default_factory=list) - but that prevents the object from being correctly pickled, so instead we need - to rely on a validator to ensure this has an empty value when not specified - """ - return v or [] - - class Config: - fields = {"billing_profile_display_name": "displayName"} - - -class BillingProfileCreationCSPResult(AliasModel): - billing_profile_verify_url: str - billing_profile_retry_after: int - - class Config: - fields = { - "billing_profile_verify_url": "Location", - "billing_profile_retry_after": "Retry-After", - } - - -class BillingProfileVerificationCSPPayload(BaseCSPPayload): - billing_profile_verify_url: str - - -class BillingInvoiceSection(AliasModel): - invoice_section_id: str - invoice_section_name: str - - class Config: - fields = {"invoice_section_id": "id", "invoice_section_name": "name"} - - -class BillingProfileProperties(AliasModel): - address: BillingProfileAddress - billing_profile_display_name: str - invoice_sections: List[BillingInvoiceSection] - - class Config: - fields = {"billing_profile_display_name": "displayName"} - - -class BillingProfileVerificationCSPResult(AliasModel): - billing_profile_id: str - billing_profile_name: str - billing_profile_properties: BillingProfileProperties - - class Config: - fields = { - "billing_profile_id": "id", - "billing_profile_name": "name", - "billing_profile_properties": "properties", - } - - -class BillingProfileTenantAccessCSPPayload(BaseCSPPayload): - tenant_id: str - user_object_id: str - billing_account_name: str - billing_profile_name: str - - -class BillingProfileTenantAccessCSPResult(AliasModel): - billing_role_assignment_id: str - billing_role_assignment_name: str - - class Config: - fields = { - "billing_role_assignment_id": "id", - "billing_role_assignment_name": "name", - } - - -class TaskOrderBillingCreationCSPPayload(BaseCSPPayload): - billing_account_name: str - billing_profile_name: str - - -class TaskOrderBillingCreationCSPResult(AliasModel): - task_order_billing_verify_url: str - task_order_retry_after: int - - class Config: - fields = { - "task_order_billing_verify_url": "Location", - "task_order_retry_after": "Retry-After", - } - - -class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload): - task_order_billing_verify_url: str - - -class BillingProfileEnabledPlanDetails(AliasModel): - enabled_azure_plans: List[Dict] - - -class TaskOrderBillingVerificationCSPResult(AliasModel): - billing_profile_id: str - billing_profile_name: str - billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails - - class Config: - fields = { - "billing_profile_id": "id", - "billing_profile_name": "name", - "billing_profile_enabled_plan_details": "properties", - } - - -class BillingInstructionCSPPayload(BaseCSPPayload): - initial_clin_amount: float - initial_clin_start_date: str - initial_clin_end_date: str - initial_clin_type: str - initial_task_order_id: str - billing_account_name: str - billing_profile_name: str - - -class BillingInstructionCSPResult(AliasModel): - reported_clin_name: str - - class Config: - fields = { - "reported_clin_name": "name", - } - - -AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/" - -MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$" - - -class ManagementGroupCSPPayload(BaseCSPPayload): - """ - :param: management_group_name: Just pass a UUID for this. - :param: display_name: This can contain any character and - spaces, but should be 90 characters or fewer long. - :param: parent_id: This should be the fully qualified Azure ID, - i.e. /providers/Microsoft.Management/managementGroups/[management group ID] - """ - - management_group_name: Optional[str] - display_name: str - parent_id: str - - @validator("management_group_name", pre=True, always=True) - def supply_management_group_name_default(cls, name): - if name: - if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None: - raise ValueError( - f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}" - ) - - return name[0:90] - else: - return str(uuid4()) - - @validator("display_name", pre=True, always=True) - def enforce_display_name_length(cls, name): - return name[0:90] - - @validator("parent_id", pre=True, always=True) - def enforce_parent_id_pattern(cls, id_): - if AZURE_MGMNT_PATH not in id_: - return f"{AZURE_MGMNT_PATH}{id_}" - else: - return id_ - - -class ManagementGroupCSPResponse(AliasModel): - id: str - - -class ApplicationCSPPayload(ManagementGroupCSPPayload): - pass - - -class ApplicationCSPResult(ManagementGroupCSPResponse): - pass - - -class CloudProviderInterface: - def set_secret(self, secret_key: str, secret_value: str): - raise NotImplementedError() - - def get_secret(self, secret_key: str): - raise NotImplementedError() - - def root_creds(self) -> Dict: - raise NotImplementedError() - - def create_environment(self, auth_credentials: Dict, user, environment) -> str: - """Create a new environment in the CSP. - - Arguments: - auth_credentials -- Object containing CSP account credentials - user -- ATAT user authorizing the environment creation - environment -- ATAT Environment model - - Returns: - string: ID of created environment - - Raises: - AuthenticationException: Problem with the credentials - AuthorizationException: Credentials not authorized for current action(s) - ConnectionException: Issue with the CSP API connection - UnknownServerException: Unknown issue on the CSP side - EnvironmentExistsException: Environment already exists and has been created - """ - raise NotImplementedError() - - def create_atat_admin_user( - self, auth_credentials: Dict, csp_environment_id: str - ) -> Dict: - """Creates a new, programmatic user in the CSP. Grants this user full permissions to administer - the CSP. - - Arguments: - auth_credentials -- Object containing CSP account credentials - csp_environment_id -- ID of the CSP Environment the admin user should be created in - - Returns: - object: Object representing new remote admin user, including credentials - Something like: - { - "user_id": string, - "credentials": dict, # structure TBD based on csp - } - - Raises: - AuthenticationException: Problem with the credentials - AuthorizationException: Credentials not authorized for current action(s) - ConnectionException: Issue with the CSP API connection - UnknownServerException: Unknown issue on the CSP side - UserProvisioningException: Problem creating the root user - """ - raise NotImplementedError() - - def create_or_update_user( - self, auth_credentials: Dict, user_info, csp_role_id: str - ) -> str: - """Creates a user or updates an existing user's role. - - Arguments: - auth_credentials -- Object containing CSP account credentials - user_info -- instance of EnvironmentRole containing user data - if it has a csp_user_id it will try to update that user - csp_role_id -- The id of the role the user should be given in the CSP - - Returns: - string: Returns the interal csp_user_id of the created/updated user account - - Raises: - AuthenticationException: Problem with the credentials - AuthorizationException: Credentials not authorized for current action(s) - ConnectionException: Issue with the CSP API connection - UnknownServerException: Unknown issue on the CSP side - UserProvisioningException: User couldn't be created or modified - """ - raise NotImplementedError() - - def disable_user(self, auth_credentials: Dict, csp_user_id: str) -> bool: - """Revoke all privileges for a user. Used to prevent user access while a full - delete is being processed. - - Arguments: - auth_credentials -- Object containing CSP account credentials - csp_user_id -- CSP internal user identifier - - Returns: - bool -- True on success - - Raises: - AuthenticationException: Problem with the credentials - AuthorizationException: Credentials not authorized for current action(s) - ConnectionException: Issue with the CSP API connection - UnknownServerException: Unknown issue on the CSP side - UserRemovalException: User couldn't be suspended - """ - raise NotImplementedError() - - def get_calculator_url(self) -> str: - """Returns the calculator url for the CSP. - This will likely be a static property elsewhere once a CSP is chosen. - """ - raise NotImplementedError() - - def get_environment_login_url(self, environment) -> str: - """Returns the login url for a given environment - This may move to be a computed property on the Environment domain object - """ - raise NotImplementedError() - - def create_subscription(self, environment): - """Returns True if a new subscription has been created or raises an - exception if an error occurs while creating a subscription. - """ - raise NotImplementedError() - - -class MockCloudProvider(CloudProviderInterface): - - # TODO: All of these constants - AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.") - AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.") - NETWORK_EXCEPTION = ConnectionException("Network failure.") - SERVER_EXCEPTION = UnknownServerException("Not our fault.") - - SERVER_FAILURE_PCT = 1 - NETWORK_FAILURE_PCT = 7 - ENV_CREATE_FAILURE_PCT = 12 - ATAT_ADMIN_CREATE_FAILURE_PCT = 12 - UNAUTHORIZED_RATE = 2 - - def __init__( - self, config, with_delay=True, with_failure=True, with_authorization=True - ): - from time import sleep - import random - - self._with_delay = with_delay - self._with_failure = with_failure - self._with_authorization = with_authorization - self._sleep = sleep - self._random = random - - def root_creds(self): - return self._auth_credentials - - def set_secret(self, secret_key: str, secret_value: str): - pass - - def get_secret(self, secret_key: str): - return {} - - def create_environment(self, auth_credentials, user, environment): - self._authorize(auth_credentials) - - self._delay(1, 5) - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise( - self.ENV_CREATE_FAILURE_PCT, - EnvironmentCreationException( - environment.id, "Could not create environment." - ), - ) - - csp_environment_id = self._id() - - self._delay(1, 5) - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise( - self.ATAT_ADMIN_CREATE_FAILURE_PCT, - BaselineProvisionException( - csp_environment_id, "Could not create environment baseline." - ), - ) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return csp_environment_id - - def create_atat_admin_user(self, auth_credentials, csp_environment_id): - self._authorize(auth_credentials) - - self._delay(1, 5) - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise( - self.ATAT_ADMIN_CREATE_FAILURE_PCT, - UserProvisioningException( - csp_environment_id, "atat_admin", "Could not create admin user." - ), - ) - - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return {"id": self._id(), "credentials": self._auth_credentials} - - def create_tenant(self, payload: TenantCSPPayload): - """ - payload is an instance of TenantCSPPayload data class - """ - - self._authorize(payload.creds) - - self._delay(1, 5) - - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return TenantCSPResult( - **{ - "tenant_id": "", - "user_id": "", - "user_object_id": "", - "tenant_admin_username": "test", - "tenant_admin_password": "test", - } - ).dict() - - def create_billing_profile_creation( - self, payload: BillingProfileCreationCSPPayload - ): - # response will be mostly the same as the body, but we only really care about the id - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return BillingProfileCreationCSPResult( - **dict( - billing_profile_verify_url="https://zombo.com", - billing_profile_retry_after=10, - ) - ).dict() - - def create_billing_profile_verification( - self, payload: BillingProfileVerificationCSPPayload - ): - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - return BillingProfileVerificationCSPResult( - **{ - "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB", - "name": "KQWI-W2SU-BG7-TGB", - "properties": { - "address": { - "addressLine1": "123 S Broad Street, Suite 2400", - "city": "Philadelphia", - "companyName": "Promptworks", - "country": "US", - "postalCode": "19109", - "region": "PA", - }, - "currency": "USD", - "displayName": "Test Billing Profile", - "enabledAzurePlans": [], - "hasReadAccess": True, - "invoiceDay": 5, - "invoiceEmailOptIn": False, - "invoiceSections": [ - { - "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB", - "name": "CHCO-BAAR-PJA-TGB", - "properties": {"displayName": "Test Billing Profile"}, - "type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections", - } - ], - }, - "type": "Microsoft.Billing/billingAccounts/billingProfiles", - } - ).dict() - - def create_billing_profile_tenant_access(self, payload): - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return BillingProfileTenantAccessCSPResult( - **{ - "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", - "name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", - "properties": { - "createdOn": "2020-01-14T14:39:26.3342192+00:00", - "createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03", - "principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", - "principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435", - "roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000", - "scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB", - }, - "type": "Microsoft.Billing/billingRoleAssignments", - } - ).dict() - - def create_task_order_billing_creation( - self, payload: TaskOrderBillingCreationCSPPayload - ): - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return TaskOrderBillingCreationCSPResult( - **{"Location": "https://somelocation", "Retry-After": "10"} - ).dict() - - def create_task_order_billing_verification( - self, payload: TaskOrderBillingVerificationCSPPayload - ): - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return TaskOrderBillingVerificationCSPResult( - **{ - "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/XC36-GRNZ-BG7-TGB", - "name": "XC36-GRNZ-BG7-TGB", - "properties": { - "address": { - "addressLine1": "123 S Broad Street, Suite 2400", - "city": "Philadelphia", - "companyName": "Promptworks", - "country": "US", - "postalCode": "19109", - "region": "PA", - }, - "currency": "USD", - "displayName": "First Portfolio Billing Profile", - "enabledAzurePlans": [ - { - "productId": "DZH318Z0BPS6", - "skuId": "0001", - "skuDescription": "Microsoft Azure Plan", - } - ], - "hasReadAccess": True, - "invoiceDay": 5, - "invoiceEmailOptIn": False, - }, - "type": "Microsoft.Billing/billingAccounts/billingProfiles", - } - ).dict() - - def create_billing_instruction(self, payload: BillingInstructionCSPPayload): - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - - return BillingInstructionCSPResult( - **{ - "name": "TO1:CLIN001", - "properties": { - "amount": 1000.0, - "endDate": "2020-03-01T00:00:00+00:00", - "startDate": "2020-01-01T00:00:00+00:00", - }, - "type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions", - } - ).dict() - - def create_or_update_user(self, auth_credentials, user_info, csp_role_id): - self._authorize(auth_credentials) - - self._delay(1, 5) - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - self._maybe_raise( - self.ATAT_ADMIN_CREATE_FAILURE_PCT, - UserProvisioningException( - user_info.environment.id, - user_info.application_role.user_id, - "Could not create user.", - ), - ) - - self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) - return self._id() - - def disable_user(self, auth_credentials, csp_user_id): - self._authorize(auth_credentials) - self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) - self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) - - self._maybe_raise( - self.ATAT_ADMIN_CREATE_FAILURE_PCT, - UserRemovalException(csp_user_id, "Could not disable user."), - ) - - return self._maybe(12) - - def create_subscription(self, environment): - self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) - - return True - - def get_calculator_url(self): - return "https://www.rackspace.com/en-us/calculator" - - def get_environment_login_url(self, environment): - """Returns the login url for a given environment - """ - return "https://www.mycloud.com/my-env-login" - - def _id(self): - return uuid4().hex - - def _delay(self, min_secs, max_secs): - if self._with_delay: - duration = self._random.randrange(min_secs, max_secs) - self._sleep(duration) - - def _maybe(self, pct): - return not self._with_failure or self._random.randrange(0, 100) < pct - - def _maybe_raise(self, pct, exc): - if self._with_failure and self._maybe(pct): - raise exc - - @property - def _auth_credentials(self): - return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret - - def _authorize(self, credentials): - self._delay(1, 5) - if self._with_authorization and credentials != self._auth_credentials: - raise self.AUTHENTICATION_EXCEPTION - - def create_application(self, payload: ApplicationCSPPayload): - self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) - - id_ = f"{AZURE_MGMNT_PATH}{payload.management_group_name}" - return ApplicationCSPResult(id=id_) - - def get_credentials(self, scope="portfolio", tenant_id=None): - return self.root_creds() - - -AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD -AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI -SUBSCRIPTION_ID_REGEX = re.compile( - "subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})", - re.I, -) - -# This needs to be a fully pathed role definition identifier, not just a UUID -REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000" -AZURE_MANAGEMENT_API = "https://management.azure.com" - - -class AzureSDKProvider(object): - def __init__(self): - from azure.mgmt import subscription, authorization, managementgroups - from azure.mgmt.resource import policy - import azure.graphrbac as graphrbac - import azure.common.credentials as credentials - import azure.identity as identity - from azure.keyvault import secrets - from azure.core import exceptions - - from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD - import adal - import requests - - self.subscription = subscription - self.policy = policy - self.managementgroups = managementgroups - self.authorization = authorization - self.adal = adal - self.graphrbac = graphrbac - self.credentials = credentials - self.identity = identity - self.exceptions = exceptions - self.secrets = secrets - self.requests = requests - # may change to a JEDI cloud - self.cloud = AZURE_PUBLIC_CLOUD - - -class AzureCloudProvider(CloudProviderInterface): - def __init__(self, config, azure_sdk_provider=None): - self.config = config - - self.client_id = config["AZURE_CLIENT_ID"] - self.secret_key = config["AZURE_SECRET_KEY"] - self.tenant_id = config["AZURE_TENANT_ID"] - self.vault_url = config["AZURE_VAULT_URL"] - - if azure_sdk_provider is None: - self.sdk = AzureSDKProvider() - else: - self.sdk = azure_sdk_provider - - self.policy_manager = AzurePolicyManager(config["AZURE_POLICY_LOCATION"]) - - def set_secret(self, secret_key, secret_value): - credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( - vault_url=self.vault_url, credential=credential, - ) - try: - return secret_client.set_secret(secret_key, secret_value) - except self.exceptions.HttpResponseError: - app.logger.error( - f"Could not SET secret in Azure keyvault for key {secret_key}.", - exc_info=1, - ) - - def get_secret(self, secret_key): - credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( - vault_url=self.vault_url, credential=credential, - ) - try: - return secret_client.get_secret(secret_key).value - except self.exceptions.HttpResponseError: - app.logger.error( - f"Could not GET secret in Azure keyvault for key {secret_key}.", - exc_info=1, - ) - - def create_environment(self, auth_credentials: Dict, user, environment): - # since this operation would only occur within a tenant, should we source the tenant - # via lookup from environment once we've created the portfolio csp data schema - # something like this: - # environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None) - # though we'd probably source the whole credentials for these calls from the portfolio csp - # data, as it would have to be where we store the creds for the at-at user within the portfolio tenant - # credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds()) - credentials = self._get_credential_obj(self._root_creds) - display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format - management_group_id = "?" # management group id chained from environment - parent_id = "?" # from environment.application - - management_group = self._create_management_group( - credentials, management_group_id, display_name, parent_id, - ) - - return ManagementGroupCSPResponse(**management_group) - - def create_atat_admin_user( - self, auth_credentials: Dict, csp_environment_id: str - ) -> Dict: - root_creds = self._root_creds - credentials = self._get_credential_obj(root_creds) - - sub_client = self.sdk.subscription.SubscriptionClient(credentials) - subscription = sub_client.subscriptions.get(csp_environment_id) - - managment_principal = self._get_management_service_principal() - - auth_client = self.sdk.authorization.AuthorizationManagementClient( - credentials, - # TODO: Determine which subscription this needs to point at - # Once we're in a multi-sub environment - subscription.id, - ) - - # Create role assignment for - role_assignment_id = str(uuid4()) - role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters( - role_definition_id=REMOTE_ROOT_ROLE_DEF_ID, - principal_id=managment_principal.id, - ) - - auth_client.role_assignments.create( - scope=f"/subscriptions/{subscription.id}/", - role_assignment_name=role_assignment_id, - parameters=role_assignment_create_params, - ) - - return { - "csp_user_id": managment_principal.object_id, - "credentials": managment_principal.password_credentials, - "role_name": role_assignment_id, - } - - def create_application(self, payload: ApplicationCSPPayload): - creds = payload.creds - credentials = self._get_credential_obj(creds, resource=AZURE_MANAGEMENT_API) - - response = self._create_management_group( - credentials, - payload.management_group_name, - payload.display_name, - payload.parent_id, - ) - - return ApplicationCSPResult(**response) - - def _create_management_group( - self, credentials, management_group_id, display_name, parent_id=None, - ): - mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials) - create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo( - id=parent_id - ) - create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails( - parent=create_parent_grp_info - ) - mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest( - name=management_group_id, - display_name=display_name, - details=create_mgmt_grp_details, - ) - create_request = mgmgt_group_client.management_groups.create_or_update( - management_group_id, mgmt_grp_create - ) - - # result is a synchronous wait, might need to do a poll instead to handle first mgmt group create - # since we were told it could take 10+ minutes to complete, unless this handles that polling internally - # TODO: what to do is status is not 'Succeeded' on the - # response object? Will it always raise its own error - # instead? - return create_request.result() - - def _create_subscription( - self, - credentials, - display_name, - billing_profile_id, - sku_id, - management_group_id, - billing_account_name, - invoice_section_name, - ): - sub_client = self.sdk.subscription.SubscriptionClient(credentials) - - billing_profile_id = "?" # where do we source this? - sku_id = AZURE_SKU_ID - # These 2 seem like something that might be worthwhile to allow tiebacks to - # TOs filed for the environment - billing_account_name = "?" # from TO? - invoice_section_name = "?" # from TO? - - body = self.sdk.subscription.models.ModernSubscriptionCreationParameters( - display_name=display_name, - billing_profile_id=billing_profile_id, - sku_id=sku_id, - management_group_id=management_group_id, - ) - - # We may also want to create billing sections in the enrollment account - sub_creation_operation = sub_client.subscription_factory.create_subscription( - billing_account_name, invoice_section_name, body - ) - - # the resulting object from this process is a link to the new subscription - # not a subscription model, so we'll have to unpack the ID - new_sub = sub_creation_operation.result() - - subscription_id = self._extract_subscription_id(new_sub.subscription_link) - if subscription_id: - return subscription_id - else: - # troublesome error, subscription should exist at this point - # but we just don't have a valid ID - pass - - def _create_policy_definition( - self, credentials, subscription_id, management_group_id, properties, - ): - """ - Requires credentials that have AZURE_MANAGEMENT_API - specified as the resource. The Service Principal - specified in the credentials must have the "Resource - Policy Contributor" role assigned with a scope at least - as high as the management group specified by - management_group_id. - - Arguments: - credentials -- ServicePrincipalCredentials - subscription_id -- str, ID of the subscription (just the UUID, not the path) - management_group_id -- str, ID of the management group (just the UUID, not the path) - properties -- dictionary, the "properties" section of a valid Azure policy definition document - - Returns: - azure.mgmt.resource.policy.[api version].models.PolicyDefinition: the PolicyDefinition object provided to Azure - - Raises: - TBD - """ - # TODO: which subscription would this be? - client = self.sdk.policy.PolicyClient(credentials, subscription_id) - - definition = client.policy_definitions.models.PolicyDefinition( - policy_type=properties.get("policyType"), - mode=properties.get("mode"), - display_name=properties.get("displayName"), - description=properties.get("description"), - policy_rule=properties.get("policyRule"), - parameters=properties.get("parameters"), - ) - - name = properties.get("displayName") - - return client.policy_definitions.create_or_update_at_management_group( - policy_definition_name=name, - parameters=definition, - management_group_id=management_group_id, - ) - - def create_tenant(self, payload: TenantCSPPayload): - sp_token = self._get_sp_token(payload.creds) - if sp_token is None: - raise AuthenticationException("Could not resolve token for tenant creation") - - create_tenant_body = payload.dict(by_alias=True) - - create_tenant_headers = { - "Authorization": f"Bearer {sp_token}", - } - - result = self.sdk.requests.post( - "https://management.azure.com/providers/Microsoft.SignUp/createTenant?api-version=2020-01-01-preview", - json=create_tenant_body, - headers=create_tenant_headers, - ) - - if result.status_code == 200: - return self._ok( - TenantCSPResult( - **result.json(), - tenant_admin_password=payload.password, - tenant_admin_username=payload.user_id, - ) - ) - else: - return self._error(result.json()) - - def create_billing_profile_creation( - self, payload: BillingProfileCreationCSPPayload - ): - sp_token = self._get_sp_token(payload.creds) - if sp_token is None: - raise AuthenticationException( - "Could not resolve token for billing profile creation" - ) - - create_billing_account_body = payload.dict(by_alias=True) - - create_billing_account_headers = { - "Authorization": f"Bearer {sp_token}", - } - - billing_account_create_url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles?api-version=2019-10-01-preview" - - result = self.sdk.requests.post( - billing_account_create_url, - json=create_billing_account_body, - headers=create_billing_account_headers, - ) - - if result.status_code == 202: - # 202 has location/retry after headers - return self._ok(BillingProfileCreationCSPResult(**result.headers)) - elif result.status_code == 200: - # NB: Swagger docs imply call can sometimes resolve immediately - return self._ok(BillingProfileVerificationCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_billing_profile_verification( - self, payload: BillingProfileVerificationCSPPayload - ): - sp_token = self._get_sp_token(payload.creds) - if sp_token is None: - raise AuthenticationException( - "Could not resolve token for billing profile validation" - ) - - auth_header = { - "Authorization": f"Bearer {sp_token}", - } - - result = self.sdk.requests.get( - payload.billing_profile_verify_url, headers=auth_header - ) - - if result.status_code == 202: - # 202 has location/retry after headers - return self._ok(BillingProfileCreationCSPResult(**result.headers)) - elif result.status_code == 200: - return self._ok(BillingProfileVerificationCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_billing_profile_tenant_access( - self, payload: BillingProfileTenantAccessCSPPayload - ): - sp_token = self._get_sp_token(payload.creds) - request_body = { - "properties": { - "principalTenantId": payload.tenant_id, # from tenant creation - "principalId": payload.user_object_id, # from tenant creationn - "roleDefinitionId": f"/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000", - } - } - - headers = { - "Authorization": f"Bearer {sp_token}", - } - - url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/createBillingRoleAssignment?api-version=2019-10-01-preview" - - result = self.sdk.requests.post(url, headers=headers, json=request_body) - if result.status_code == 201: - return self._ok(BillingProfileTenantAccessCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_task_order_billing_creation( - self, payload: TaskOrderBillingCreationCSPPayload - ): - sp_token = self._get_sp_token(payload.creds) - request_body = [ - { - "op": "replace", - "path": "/enabledAzurePlans", - "value": [{"skuId": "0001"}], - } - ] - - request_headers = { - "Authorization": f"Bearer {sp_token}", - } - - url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}?api-version=2019-10-01-preview" - - result = self.sdk.requests.patch( - url, headers=request_headers, json=request_body - ) - - if result.status_code == 202: - # 202 has location/retry after headers - return self._ok(TaskOrderBillingCreationCSPResult(**result.headers)) - elif result.status_code == 200: - return self._ok(TaskOrderBillingVerificationCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_task_order_billing_verification( - self, payload: TaskOrderBillingVerificationCSPPayload - ): - sp_token = self._get_sp_token(payload.creds) - if sp_token is None: - raise AuthenticationException( - "Could not resolve token for task order billing validation" - ) - - auth_header = { - "Authorization": f"Bearer {sp_token}", - } - - result = self.sdk.requests.get( - payload.task_order_billing_verify_url, headers=auth_header - ) - - if result.status_code == 202: - # 202 has location/retry after headers - return self._ok(TaskOrderBillingCreationCSPResult(**result.headers)) - elif result.status_code == 200: - return self._ok(TaskOrderBillingVerificationCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_billing_instruction(self, payload: BillingInstructionCSPPayload): - sp_token = self._get_sp_token(payload.creds) - if sp_token is None: - raise AuthenticationException( - "Could not resolve token for task order billing validation" - ) - - request_body = { - "properties": { - "amount": payload.initial_clin_amount, - "startDate": payload.initial_clin_start_date, - "endDate": payload.initial_clin_end_date, - } - } - - url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/instructions/{payload.initial_task_order_id}:CLIN00{payload.initial_clin_type}?api-version=2019-10-01-preview" - - auth_header = { - "Authorization": f"Bearer {sp_token}", - } - - result = self.sdk.requests.put(url, headers=auth_header, json=request_body) - - if result.status_code == 200: - return self._ok(BillingInstructionCSPResult(**result.json())) - else: - return self._error(result.json()) - - def create_remote_admin(self, creds, tenant_details): - # create app/service principal within tenant, with name constructed from tenant details - # assign principal global admin - - # needs to call out to CLI with tenant owner username/password, prototyping for that underway - - # return identifier and creds to consumer for storage - response = {"clientId": "string", "secretKey": "string", "tenantId": "string"} - return self._ok( - { - "client_id": response["clientId"], - "secret_key": response["secret_key"], - "tenant_id": response["tenantId"], - } - ) - - def force_tenant_admin_pw_update(self, creds, tenant_owner_id): - # use creds to update to force password recovery? - # not sure what the endpoint/method for this is, yet - - return self._ok() - - def create_billing_alerts(self, TBD): - # TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations - # TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment - # TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates - # TODO: determine what the keys in the Notifications dict are supposed to be - # we may need to rotate budget objects when new TOs/CLINs are reported? - - # we likely only want the budget ID, can be updated or replaced? - response = {"id": "id"} - - return self._ok({"budget_id": response["id"]}) - - def _get_management_service_principal(self): - # we really should be using graph.microsoft.com, but i'm getting - # "expired token" errors for that - # graph_resource = "https://graph.microsoft.com" - graph_resource = "https://graph.windows.net" - graph_creds = self._get_credential_obj( - self._root_creds, resource=graph_resource - ) - # I needed to set permissions for the graph.windows.net API before I - # could get this to work. - - # how do we scope the graph client to the new subscription rather than - # the cloud0 subscription? tenant id seems to be separate from subscription id - graph_client = self.sdk.graphrbac.GraphRbacManagementClient( - graph_creds, self._root_creds.get("tenant_id") - ) - - # do we need to create a new application to manage each subscripition - # or should we manage access to each subscription from a single service - # principal with multiple role assignments? - app_display_name = "?" # name should reflect the subscription it exists - app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters( - display_name=app_display_name - ) - - # we need the appropriate perms here: - # https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http - # https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names - # set app perms in app registration portal - # https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph - app: self.sdk.graphrbac.models.Application = graph_client.applications.create( - app_create_param - ) - - # create a new service principle for the new application, which should be scoped - # to the new subscription - app_id = app.app_id - sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters( - app_id=app_id, account_enabled=True - ) - - service_principal = graph_client.service_principals.create(sp_create_params) - - return service_principal - - def _extract_subscription_id(self, subscription_url): - sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url) - - if sub_id_match: - return sub_id_match.group(1) - - def _get_sp_token(self, creds): - home_tenant_id = creds.get("home_tenant_id") - client_id = creds.get("client_id") - secret_key = creds.get("secret_key") - - # TODO: Make endpoints consts or configs - authentication_endpoint = "https://login.microsoftonline.com/" - resource = "https://management.azure.com/" - - context = self.sdk.adal.AuthenticationContext( - authentication_endpoint + home_tenant_id - ) - - # TODO: handle failure states here - token_response = context.acquire_token_with_client_credentials( - resource, client_id, secret_key - ) - - return token_response.get("accessToken", None) - - def _get_credential_obj(self, creds, resource=None): - return self.sdk.credentials.ServicePrincipalCredentials( - client_id=creds.get("client_id"), - secret=creds.get("secret_key"), - tenant=creds.get("tenant_id"), - resource=resource, - cloud_environment=self.sdk.cloud, - ) - - def _get_client_secret_credential_obj(self, creds): - return self.sdk.identity.ClientSecretCredential( - tenant_id=creds.get("tenant_id"), - client_id=creds.get("client_id"), - client_secret=creds.get("secret_key"), - ) - - def _make_tenant_admin_cred_obj(self, username, password): - return self.sdk.credentials.UserPassCredentials(username, password) - - def _ok(self, body=None): - return self._make_response("ok", body) - - def _error(self, body=None): - return self._make_response("error", body) - - def _make_response(self, status, body=dict()): - """Create body for responses from API - - Arguments: - status {string} -- "ok" or "error" - body {dict} -- dict containing details of response or error, if applicable - - Returns: - dict -- status of call with body containing details - """ - return {"status": status, "body": body} - - @property - def _root_creds(self): - return { - "client_id": self.client_id, - "secret_key": self.secret_key, - "tenant_id": self.tenant_id, - } - - def get_credentials(self, scope="portfolio", tenant_id=None): - """ - This could be implemented to determine, based on type, whether to return creds for: - - scope="atat": the ATAT main app registration in ATAT's home tenant - - scope="tenantadmin": the tenant administrator credentials - - scope="portfolio": the credentials for the ATAT SP in the portfolio tenant - """ - if scope == "atat": - return self._root_creds - elif scope == "tenantadmin": - # magic with key vault happens - return { - "client_id": "some id", - "secret_key": "very secret", - "tenant_id": tenant_id, - } - elif scope == "portfolio": - # magic with key vault happens - return { - "client_id": "some id", - "secret_key": "very secret", - "tenant_id": tenant_id, - } diff --git a/atst/domain/csp/cloud/__init__.py b/atst/domain/csp/cloud/__init__.py new file mode 100644 index 00000000..99128d9c --- /dev/null +++ b/atst/domain/csp/cloud/__init__.py @@ -0,0 +1,3 @@ +from .azure_cloud_provider import AzureCloudProvider +from .cloud_provider_interface import CloudProviderInterface +from .mock_cloud_provider import MockCloudProvider diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py new file mode 100644 index 00000000..5bbcdd54 --- /dev/null +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -0,0 +1,657 @@ +import re +from typing import Dict +from uuid import uuid4 + +from .cloud_provider_interface import CloudProviderInterface +from .exceptions import AuthenticationException +from .models import ( + ApplicationCSPPayload, + ApplicationCSPResult, + BillingInstructionCSPPayload, + BillingInstructionCSPResult, + BillingProfileCreationCSPPayload, + BillingProfileCreationCSPResult, + BillingProfileTenantAccessCSPPayload, + BillingProfileTenantAccessCSPResult, + BillingProfileVerificationCSPPayload, + BillingProfileVerificationCSPResult, + ManagementGroupCSPResponse, + TaskOrderBillingCreationCSPPayload, + TaskOrderBillingCreationCSPResult, + TaskOrderBillingVerificationCSPPayload, + TaskOrderBillingVerificationCSPResult, + TenantCSPPayload, + TenantCSPResult, +) +from .policy import AzurePolicyManager + + +AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD +AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI +SUBSCRIPTION_ID_REGEX = re.compile( + "subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})", + re.I, +) + +# This needs to be a fully pathed role definition identifier, not just a UUID +REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000" +AZURE_MANAGEMENT_API = "https://management.azure.com" + + +class AzureSDKProvider(object): + def __init__(self): + from azure.mgmt import subscription, authorization, managementgroups + from azure.mgmt.resource import policy + import azure.graphrbac as graphrbac + import azure.common.credentials as credentials + import azure.identity as identity + from azure.keyvault import secrets + from azure.core import exceptions + + from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD + import adal + import requests + + self.subscription = subscription + self.policy = policy + self.managementgroups = managementgroups + self.authorization = authorization + self.adal = adal + self.graphrbac = graphrbac + self.credentials = credentials + self.identity = identity + self.exceptions = exceptions + self.secrets = secrets + self.requests = requests + # may change to a JEDI cloud + self.cloud = AZURE_PUBLIC_CLOUD + + +class AzureCloudProvider(CloudProviderInterface): + def __init__(self, config, azure_sdk_provider=None): + self.config = config + + self.client_id = config["AZURE_CLIENT_ID"] + self.secret_key = config["AZURE_SECRET_KEY"] + self.tenant_id = config["AZURE_TENANT_ID"] + self.vault_url = config["AZURE_VAULT_URL"] + + if azure_sdk_provider is None: + self.sdk = AzureSDKProvider() + else: + self.sdk = azure_sdk_provider + + self.policy_manager = AzurePolicyManager(config["AZURE_POLICY_LOCATION"]) + + def set_secret(self, secret_key, secret_value): + credential = self._get_client_secret_credential_obj({}) + secret_client = self.secrets.SecretClient( + vault_url=self.vault_url, credential=credential, + ) + try: + return secret_client.set_secret(secret_key, secret_value) + except self.exceptions.HttpResponseError: + app.logger.error( + f"Could not SET secret in Azure keyvault for key {secret_key}.", + exc_info=1, + ) + + def get_secret(self, secret_key): + credential = self._get_client_secret_credential_obj({}) + secret_client = self.secrets.SecretClient( + vault_url=self.vault_url, credential=credential, + ) + try: + return secret_client.get_secret(secret_key).value + except self.exceptions.HttpResponseError: + app.logger.error( + f"Could not GET secret in Azure keyvault for key {secret_key}.", + exc_info=1, + ) + + def create_environment(self, auth_credentials: Dict, user, environment): + # since this operation would only occur within a tenant, should we source the tenant + # via lookup from environment once we've created the portfolio csp data schema + # something like this: + # environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None) + # though we'd probably source the whole credentials for these calls from the portfolio csp + # data, as it would have to be where we store the creds for the at-at user within the portfolio tenant + # credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds()) + credentials = self._get_credential_obj(self._root_creds) + display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format + management_group_id = "?" # management group id chained from environment + parent_id = "?" # from environment.application + + management_group = self._create_management_group( + credentials, management_group_id, display_name, parent_id, + ) + + return ManagementGroupCSPResponse(**management_group) + + def create_atat_admin_user( + self, auth_credentials: Dict, csp_environment_id: str + ) -> Dict: + root_creds = self._root_creds + credentials = self._get_credential_obj(root_creds) + + sub_client = self.sdk.subscription.SubscriptionClient(credentials) + subscription = sub_client.subscriptions.get(csp_environment_id) + + managment_principal = self._get_management_service_principal() + + auth_client = self.sdk.authorization.AuthorizationManagementClient( + credentials, + # TODO: Determine which subscription this needs to point at + # Once we're in a multi-sub environment + subscription.id, + ) + + # Create role assignment for + role_assignment_id = str(uuid4()) + role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters( + role_definition_id=REMOTE_ROOT_ROLE_DEF_ID, + principal_id=managment_principal.id, + ) + + auth_client.role_assignments.create( + scope=f"/subscriptions/{subscription.id}/", + role_assignment_name=role_assignment_id, + parameters=role_assignment_create_params, + ) + + return { + "csp_user_id": managment_principal.object_id, + "credentials": managment_principal.password_credentials, + "role_name": role_assignment_id, + } + + def create_application(self, payload: ApplicationCSPPayload): + creds = payload.creds + credentials = self._get_credential_obj(creds, resource=AZURE_MANAGEMENT_API) + + response = self._create_management_group( + credentials, + payload.management_group_name, + payload.display_name, + payload.parent_id, + ) + + return ApplicationCSPResult(**response) + + def _create_management_group( + self, credentials, management_group_id, display_name, parent_id=None, + ): + mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials) + create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo( + id=parent_id + ) + create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails( + parent=create_parent_grp_info + ) + mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest( + name=management_group_id, + display_name=display_name, + details=create_mgmt_grp_details, + ) + create_request = mgmgt_group_client.management_groups.create_or_update( + management_group_id, mgmt_grp_create + ) + + # result is a synchronous wait, might need to do a poll instead to handle first mgmt group create + # since we were told it could take 10+ minutes to complete, unless this handles that polling internally + # TODO: what to do is status is not 'Succeeded' on the + # response object? Will it always raise its own error + # instead? + return create_request.result() + + def _create_subscription( + self, + credentials, + display_name, + billing_profile_id, + sku_id, + management_group_id, + billing_account_name, + invoice_section_name, + ): + sub_client = self.sdk.subscription.SubscriptionClient(credentials) + + billing_profile_id = "?" # where do we source this? + sku_id = AZURE_SKU_ID + # These 2 seem like something that might be worthwhile to allow tiebacks to + # TOs filed for the environment + billing_account_name = "?" # from TO? + invoice_section_name = "?" # from TO? + + body = self.sdk.subscription.models.ModernSubscriptionCreationParameters( + display_name=display_name, + billing_profile_id=billing_profile_id, + sku_id=sku_id, + management_group_id=management_group_id, + ) + + # We may also want to create billing sections in the enrollment account + sub_creation_operation = sub_client.subscription_factory.create_subscription( + billing_account_name, invoice_section_name, body + ) + + # the resulting object from this process is a link to the new subscription + # not a subscription model, so we'll have to unpack the ID + new_sub = sub_creation_operation.result() + + subscription_id = self._extract_subscription_id(new_sub.subscription_link) + if subscription_id: + return subscription_id + else: + # troublesome error, subscription should exist at this point + # but we just don't have a valid ID + pass + + def _create_policy_definition( + self, credentials, subscription_id, management_group_id, properties, + ): + """ + Requires credentials that have AZURE_MANAGEMENT_API + specified as the resource. The Service Principal + specified in the credentials must have the "Resource + Policy Contributor" role assigned with a scope at least + as high as the management group specified by + management_group_id. + + Arguments: + credentials -- ServicePrincipalCredentials + subscription_id -- str, ID of the subscription (just the UUID, not the path) + management_group_id -- str, ID of the management group (just the UUID, not the path) + properties -- dictionary, the "properties" section of a valid Azure policy definition document + + Returns: + azure.mgmt.resource.policy.[api version].models.PolicyDefinition: the PolicyDefinition object provided to Azure + + Raises: + TBD + """ + # TODO: which subscription would this be? + client = self.sdk.policy.PolicyClient(credentials, subscription_id) + + definition = client.policy_definitions.models.PolicyDefinition( + policy_type=properties.get("policyType"), + mode=properties.get("mode"), + display_name=properties.get("displayName"), + description=properties.get("description"), + policy_rule=properties.get("policyRule"), + parameters=properties.get("parameters"), + ) + + name = properties.get("displayName") + + return client.policy_definitions.create_or_update_at_management_group( + policy_definition_name=name, + parameters=definition, + management_group_id=management_group_id, + ) + + def create_tenant(self, payload: TenantCSPPayload): + sp_token = self._get_sp_token(payload.creds) + if sp_token is None: + raise AuthenticationException("Could not resolve token for tenant creation") + + create_tenant_body = payload.dict(by_alias=True) + + create_tenant_headers = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.post( + "https://management.azure.com/providers/Microsoft.SignUp/createTenant?api-version=2020-01-01-preview", + json=create_tenant_body, + headers=create_tenant_headers, + ) + + if result.status_code == 200: + return self._ok( + TenantCSPResult( + **result.json(), + tenant_admin_password=payload.password, + tenant_admin_username=payload.user_id, + ) + ) + else: + return self._error(result.json()) + + def create_billing_profile_creation( + self, payload: BillingProfileCreationCSPPayload + ): + sp_token = self._get_sp_token(payload.creds) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for billing profile creation" + ) + + create_billing_account_body = payload.dict(by_alias=True) + + create_billing_account_headers = { + "Authorization": f"Bearer {sp_token}", + } + + billing_account_create_url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles?api-version=2019-10-01-preview" + + result = self.sdk.requests.post( + billing_account_create_url, + json=create_billing_account_body, + headers=create_billing_account_headers, + ) + + if result.status_code == 202: + # 202 has location/retry after headers + return self._ok(BillingProfileCreationCSPResult(**result.headers)) + elif result.status_code == 200: + # NB: Swagger docs imply call can sometimes resolve immediately + return self._ok(BillingProfileVerificationCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_billing_profile_verification( + self, payload: BillingProfileVerificationCSPPayload + ): + sp_token = self._get_sp_token(payload.creds) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for billing profile validation" + ) + + auth_header = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.get( + payload.billing_profile_verify_url, headers=auth_header + ) + + if result.status_code == 202: + # 202 has location/retry after headers + return self._ok(BillingProfileCreationCSPResult(**result.headers)) + elif result.status_code == 200: + return self._ok(BillingProfileVerificationCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_billing_profile_tenant_access( + self, payload: BillingProfileTenantAccessCSPPayload + ): + sp_token = self._get_sp_token(payload.creds) + request_body = { + "properties": { + "principalTenantId": payload.tenant_id, # from tenant creation + "principalId": payload.user_object_id, # from tenant creationn + "roleDefinitionId": f"/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000", + } + } + + headers = { + "Authorization": f"Bearer {sp_token}", + } + + url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/createBillingRoleAssignment?api-version=2019-10-01-preview" + + result = self.sdk.requests.post(url, headers=headers, json=request_body) + if result.status_code == 201: + return self._ok(BillingProfileTenantAccessCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_task_order_billing_creation( + self, payload: TaskOrderBillingCreationCSPPayload + ): + sp_token = self._get_sp_token(payload.creds) + request_body = [ + { + "op": "replace", + "path": "/enabledAzurePlans", + "value": [{"skuId": "0001"}], + } + ] + + request_headers = { + "Authorization": f"Bearer {sp_token}", + } + + url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}?api-version=2019-10-01-preview" + + result = self.sdk.requests.patch( + url, headers=request_headers, json=request_body + ) + + if result.status_code == 202: + # 202 has location/retry after headers + return self._ok(TaskOrderBillingCreationCSPResult(**result.headers)) + elif result.status_code == 200: + return self._ok(TaskOrderBillingVerificationCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_task_order_billing_verification( + self, payload: TaskOrderBillingVerificationCSPPayload + ): + sp_token = self._get_sp_token(payload.creds) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for task order billing validation" + ) + + auth_header = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.get( + payload.task_order_billing_verify_url, headers=auth_header + ) + + if result.status_code == 202: + # 202 has location/retry after headers + return self._ok(TaskOrderBillingCreationCSPResult(**result.headers)) + elif result.status_code == 200: + return self._ok(TaskOrderBillingVerificationCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_billing_instruction(self, payload: BillingInstructionCSPPayload): + sp_token = self._get_sp_token(payload.creds) + if sp_token is None: + raise AuthenticationException( + "Could not resolve token for task order billing validation" + ) + + request_body = { + "properties": { + "amount": payload.initial_clin_amount, + "startDate": payload.initial_clin_start_date, + "endDate": payload.initial_clin_end_date, + } + } + + url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/instructions/{payload.initial_task_order_id}:CLIN00{payload.initial_clin_type}?api-version=2019-10-01-preview" + + auth_header = { + "Authorization": f"Bearer {sp_token}", + } + + result = self.sdk.requests.put(url, headers=auth_header, json=request_body) + + if result.status_code == 200: + return self._ok(BillingInstructionCSPResult(**result.json())) + else: + return self._error(result.json()) + + def create_remote_admin(self, creds, tenant_details): + # create app/service principal within tenant, with name constructed from tenant details + # assign principal global admin + + # needs to call out to CLI with tenant owner username/password, prototyping for that underway + + # return identifier and creds to consumer for storage + response = {"clientId": "string", "secretKey": "string", "tenantId": "string"} + return self._ok( + { + "client_id": response["clientId"], + "secret_key": response["secret_key"], + "tenant_id": response["tenantId"], + } + ) + + def force_tenant_admin_pw_update(self, creds, tenant_owner_id): + # use creds to update to force password recovery? + # not sure what the endpoint/method for this is, yet + + return self._ok() + + def create_billing_alerts(self, TBD): + # TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations + # TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment + # TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates + # TODO: determine what the keys in the Notifications dict are supposed to be + # we may need to rotate budget objects when new TOs/CLINs are reported? + + # we likely only want the budget ID, can be updated or replaced? + response = {"id": "id"} + + return self._ok({"budget_id": response["id"]}) + + def _get_management_service_principal(self): + # we really should be using graph.microsoft.com, but i'm getting + # "expired token" errors for that + # graph_resource = "https://graph.microsoft.com" + graph_resource = "https://graph.windows.net" + graph_creds = self._get_credential_obj( + self._root_creds, resource=graph_resource + ) + # I needed to set permissions for the graph.windows.net API before I + # could get this to work. + + # how do we scope the graph client to the new subscription rather than + # the cloud0 subscription? tenant id seems to be separate from subscription id + graph_client = self.sdk.graphrbac.GraphRbacManagementClient( + graph_creds, self._root_creds.get("tenant_id") + ) + + # do we need to create a new application to manage each subscripition + # or should we manage access to each subscription from a single service + # principal with multiple role assignments? + app_display_name = "?" # name should reflect the subscription it exists + app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters( + display_name=app_display_name + ) + + # we need the appropriate perms here: + # https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http + # https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names + # set app perms in app registration portal + # https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph + app: self.sdk.graphrbac.models.Application = graph_client.applications.create( + app_create_param + ) + + # create a new service principle for the new application, which should be scoped + # to the new subscription + app_id = app.app_id + sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters( + app_id=app_id, account_enabled=True + ) + + service_principal = graph_client.service_principals.create(sp_create_params) + + return service_principal + + def _extract_subscription_id(self, subscription_url): + sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url) + + if sub_id_match: + return sub_id_match.group(1) + + def _get_sp_token(self, creds): + home_tenant_id = creds.get("home_tenant_id") + client_id = creds.get("client_id") + secret_key = creds.get("secret_key") + + # TODO: Make endpoints consts or configs + authentication_endpoint = "https://login.microsoftonline.com/" + resource = "https://management.azure.com/" + + context = self.sdk.adal.AuthenticationContext( + authentication_endpoint + home_tenant_id + ) + + # TODO: handle failure states here + token_response = context.acquire_token_with_client_credentials( + resource, client_id, secret_key + ) + + return token_response.get("accessToken", None) + + def _get_credential_obj(self, creds, resource=None): + return self.sdk.credentials.ServicePrincipalCredentials( + client_id=creds.get("client_id"), + secret=creds.get("secret_key"), + tenant=creds.get("tenant_id"), + resource=resource, + cloud_environment=self.sdk.cloud, + ) + + def _get_client_secret_credential_obj(self, creds): + return self.sdk.identity.ClientSecretCredential( + tenant_id=creds.get("tenant_id"), + client_id=creds.get("client_id"), + client_secret=creds.get("secret_key"), + ) + + def _make_tenant_admin_cred_obj(self, username, password): + return self.sdk.credentials.UserPassCredentials(username, password) + + def _ok(self, body=None): + return self._make_response("ok", body) + + def _error(self, body=None): + return self._make_response("error", body) + + def _make_response(self, status, body=dict()): + """Create body for responses from API + + Arguments: + status {string} -- "ok" or "error" + body {dict} -- dict containing details of response or error, if applicable + + Returns: + dict -- status of call with body containing details + """ + return {"status": status, "body": body} + + @property + def _root_creds(self): + return { + "client_id": self.client_id, + "secret_key": self.secret_key, + "tenant_id": self.tenant_id, + } + + def get_credentials(self, scope="portfolio", tenant_id=None): + """ + This could be implemented to determine, based on type, whether to return creds for: + - scope="atat": the ATAT main app registration in ATAT's home tenant + - scope="tenantadmin": the tenant administrator credentials + - scope="portfolio": the credentials for the ATAT SP in the portfolio tenant + """ + if scope == "atat": + return self._root_creds + elif scope == "tenantadmin": + # magic with key vault happens + return { + "client_id": "some id", + "secret_key": "very secret", + "tenant_id": tenant_id, + } + elif scope == "portfolio": + # magic with key vault happens + return { + "client_id": "some id", + "secret_key": "very secret", + "tenant_id": tenant_id, + } diff --git a/atst/domain/csp/cloud/cloud_provider_interface.py b/atst/domain/csp/cloud/cloud_provider_interface.py new file mode 100644 index 00000000..5f4b9ab5 --- /dev/null +++ b/atst/domain/csp/cloud/cloud_provider_interface.py @@ -0,0 +1,120 @@ +from typing import Dict + + +class CloudProviderInterface: + def set_secret(self, secret_key: str, secret_value: str): + raise NotImplementedError() + + def get_secret(self, secret_key: str): + raise NotImplementedError() + + def root_creds(self) -> Dict: + raise NotImplementedError() + + def create_environment(self, auth_credentials: Dict, user, environment) -> str: + """Create a new environment in the CSP. + + Arguments: + auth_credentials -- Object containing CSP account credentials + user -- ATAT user authorizing the environment creation + environment -- ATAT Environment model + + Returns: + string: ID of created environment + + Raises: + AuthenticationException: Problem with the credentials + AuthorizationException: Credentials not authorized for current action(s) + ConnectionException: Issue with the CSP API connection + UnknownServerException: Unknown issue on the CSP side + EnvironmentExistsException: Environment already exists and has been created + """ + raise NotImplementedError() + + def create_atat_admin_user( + self, auth_credentials: Dict, csp_environment_id: str + ) -> Dict: + """Creates a new, programmatic user in the CSP. Grants this user full permissions to administer + the CSP. + + Arguments: + auth_credentials -- Object containing CSP account credentials + csp_environment_id -- ID of the CSP Environment the admin user should be created in + + Returns: + object: Object representing new remote admin user, including credentials + Something like: + { + "user_id": string, + "credentials": dict, # structure TBD based on csp + } + + Raises: + AuthenticationException: Problem with the credentials + AuthorizationException: Credentials not authorized for current action(s) + ConnectionException: Issue with the CSP API connection + UnknownServerException: Unknown issue on the CSP side + UserProvisioningException: Problem creating the root user + """ + raise NotImplementedError() + + def create_or_update_user( + self, auth_credentials: Dict, user_info, csp_role_id: str + ) -> str: + """Creates a user or updates an existing user's role. + + Arguments: + auth_credentials -- Object containing CSP account credentials + user_info -- instance of EnvironmentRole containing user data + if it has a csp_user_id it will try to update that user + csp_role_id -- The id of the role the user should be given in the CSP + + Returns: + string: Returns the interal csp_user_id of the created/updated user account + + Raises: + AuthenticationException: Problem with the credentials + AuthorizationException: Credentials not authorized for current action(s) + ConnectionException: Issue with the CSP API connection + UnknownServerException: Unknown issue on the CSP side + UserProvisioningException: User couldn't be created or modified + """ + raise NotImplementedError() + + def disable_user(self, auth_credentials: Dict, csp_user_id: str) -> bool: + """Revoke all privileges for a user. Used to prevent user access while a full + delete is being processed. + + Arguments: + auth_credentials -- Object containing CSP account credentials + csp_user_id -- CSP internal user identifier + + Returns: + bool -- True on success + + Raises: + AuthenticationException: Problem with the credentials + AuthorizationException: Credentials not authorized for current action(s) + ConnectionException: Issue with the CSP API connection + UnknownServerException: Unknown issue on the CSP side + UserRemovalException: User couldn't be suspended + """ + raise NotImplementedError() + + def get_calculator_url(self) -> str: + """Returns the calculator url for the CSP. + This will likely be a static property elsewhere once a CSP is chosen. + """ + raise NotImplementedError() + + def get_environment_login_url(self, environment) -> str: + """Returns the login url for a given environment + This may move to be a computed property on the Environment domain object + """ + raise NotImplementedError() + + def create_subscription(self, environment): + """Returns True if a new subscription has been created or raises an + exception if an error occurs while creating a subscription. + """ + raise NotImplementedError() diff --git a/atst/domain/csp/cloud/exceptions.py b/atst/domain/csp/cloud/exceptions.py new file mode 100644 index 00000000..6ed47dff --- /dev/null +++ b/atst/domain/csp/cloud/exceptions.py @@ -0,0 +1,131 @@ +class GeneralCSPException(Exception): + pass + + +class OperationInProgressException(GeneralCSPException): + """Throw this for instances when the CSP reports that the current entity is already + being operated on/created/deleted/etc + """ + + def __init__(self, operation_desc): + self.operation_desc = operation_desc + + @property + def message(self): + return "An operation for this entity is already in progress: {}".format( + self.operation_desc + ) + + +class AuthenticationException(GeneralCSPException): + """Throw this for instances when there is a problem with the auth credentials: + * Missing credentials + * Incorrect credentials + * Other credential problems + """ + + def __init__(self, auth_error): + self.auth_error = auth_error + + @property + def message(self): + return "An error occurred with authentication: {}".format(self.auth_error) + + +class AuthorizationException(GeneralCSPException): + """Throw this for instances when the current credentials are not authorized + for the current action. + """ + + def __init__(self, auth_error): + self.auth_error = auth_error + + @property + def message(self): + return "An error occurred with authorization: {}".format(self.auth_error) + + +class ConnectionException(GeneralCSPException): + """A general problem with the connection, timeouts or unresolved endpoints + """ + + def __init__(self, connection_error): + self.connection_error = connection_error + + @property + def message(self): + return "Could not connect to cloud provider: {}".format(self.connection_error) + + +class UnknownServerException(GeneralCSPException): + """An error occured on the CSP side (5xx) and we don't know why + """ + + def __init__(self, server_error): + self.server_error = server_error + + @property + def message(self): + return "A server error occured: {}".format(self.server_error) + + +class EnvironmentCreationException(GeneralCSPException): + """If there was an error in creating the environment + """ + + def __init__(self, env_identifier, reason): + self.env_identifier = env_identifier + self.reason = reason + + @property + def message(self): + return "The envionment {} couldn't be created: {}".format( + self.env_identifier, self.reason + ) + + +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 + """ + + def __init__(self, user_csp_id, reason): + self.user_csp_id = user_csp_id + self.reason = reason + + @property + def message(self): + return "Failed to suspend or delete user {}: {}".format( + self.user_csp_id, self.reason + ) + + +class BaselineProvisionException(GeneralCSPException): + """If there's any issues standing up whatever is required + for an environment baseline + """ + + def __init__(self, env_identifier, reason): + self.env_identifier = env_identifier + self.reason = reason + + @property + def message(self): + return "Could not complete baseline provisioning for environment ({}): {}".format( + self.env_identifier, self.reason + ) diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py new file mode 100644 index 00000000..39bc6da3 --- /dev/null +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -0,0 +1,354 @@ +from uuid import uuid4 + +from atst.domain.csp.cloud.exceptions import ( + BaselineProvisionException, + EnvironmentCreationException, + GeneralCSPException, + UserProvisioningException, + UserRemovalException, +) +from atst.domain.csp.cloud.models import BillingProfileTenantAccessCSPResult + +from .cloud_provider_interface import CloudProviderInterface +from .exceptions import ( + AuthenticationException, + AuthorizationException, + ConnectionException, + UnknownServerException, +) +from .models import ( + AZURE_MGMNT_PATH, + ApplicationCSPPayload, + ApplicationCSPResult, + BillingInstructionCSPPayload, + BillingInstructionCSPResult, + BillingProfileCreationCSPPayload, + BillingProfileCreationCSPResult, + BillingProfileVerificationCSPPayload, + BillingProfileVerificationCSPResult, + TaskOrderBillingCreationCSPPayload, + TaskOrderBillingCreationCSPResult, + TaskOrderBillingVerificationCSPPayload, + TaskOrderBillingVerificationCSPResult, + TenantCSPPayload, + TenantCSPResult, +) + + +class MockCloudProvider(CloudProviderInterface): + + # TODO: All of these constants + AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.") + AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.") + NETWORK_EXCEPTION = ConnectionException("Network failure.") + SERVER_EXCEPTION = UnknownServerException("Not our fault.") + + SERVER_FAILURE_PCT = 1 + NETWORK_FAILURE_PCT = 7 + ENV_CREATE_FAILURE_PCT = 12 + ATAT_ADMIN_CREATE_FAILURE_PCT = 12 + UNAUTHORIZED_RATE = 2 + + def __init__( + self, config, with_delay=True, with_failure=True, with_authorization=True + ): + from time import sleep + import random + + self._with_delay = with_delay + self._with_failure = with_failure + self._with_authorization = with_authorization + self._sleep = sleep + self._random = random + + def root_creds(self): + return self._auth_credentials + + def set_secret(self, secret_key: str, secret_value: str): + pass + + def get_secret(self, secret_key: str): + return {} + + def create_environment(self, auth_credentials, user, environment): + self._authorize(auth_credentials) + + self._delay(1, 5) + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise( + self.ENV_CREATE_FAILURE_PCT, + EnvironmentCreationException( + environment.id, "Could not create environment." + ), + ) + + csp_environment_id = self._id() + + self._delay(1, 5) + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise( + self.ATAT_ADMIN_CREATE_FAILURE_PCT, + BaselineProvisionException( + csp_environment_id, "Could not create environment baseline." + ), + ) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return csp_environment_id + + def create_atat_admin_user(self, auth_credentials, csp_environment_id): + self._authorize(auth_credentials) + + self._delay(1, 5) + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise( + self.ATAT_ADMIN_CREATE_FAILURE_PCT, + UserProvisioningException( + csp_environment_id, "atat_admin", "Could not create admin user." + ), + ) + + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return {"id": self._id(), "credentials": self._auth_credentials} + + def create_tenant(self, payload: TenantCSPPayload): + """ + payload is an instance of TenantCSPPayload data class + """ + + self._authorize(payload.creds) + + self._delay(1, 5) + + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return TenantCSPResult( + **{ + "tenant_id": "", + "user_id": "", + "user_object_id": "", + "tenant_admin_username": "test", + "tenant_admin_password": "test", + } + ).dict() + + def create_billing_profile_creation( + self, payload: BillingProfileCreationCSPPayload + ): + # response will be mostly the same as the body, but we only really care about the id + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return BillingProfileCreationCSPResult( + **dict( + billing_profile_verify_url="https://zombo.com", + billing_profile_retry_after=10, + ) + ).dict() + + def create_billing_profile_verification( + self, payload: BillingProfileVerificationCSPPayload + ): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + return BillingProfileVerificationCSPResult( + **{ + "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB", + "name": "KQWI-W2SU-BG7-TGB", + "properties": { + "address": { + "addressLine1": "123 S Broad Street, Suite 2400", + "city": "Philadelphia", + "companyName": "Promptworks", + "country": "US", + "postalCode": "19109", + "region": "PA", + }, + "currency": "USD", + "displayName": "Test Billing Profile", + "enabledAzurePlans": [], + "hasReadAccess": True, + "invoiceDay": 5, + "invoiceEmailOptIn": False, + "invoiceSections": [ + { + "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB", + "name": "CHCO-BAAR-PJA-TGB", + "properties": {"displayName": "Test Billing Profile"}, + "type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections", + } + ], + }, + "type": "Microsoft.Billing/billingAccounts/billingProfiles", + } + ).dict() + + def create_billing_profile_tenant_access(self, payload): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return BillingProfileTenantAccessCSPResult( + **{ + "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", + "name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", + "properties": { + "createdOn": "2020-01-14T14:39:26.3342192+00:00", + "createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03", + "principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d", + "principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435", + "roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000", + "scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB", + }, + "type": "Microsoft.Billing/billingRoleAssignments", + } + ).dict() + + def create_task_order_billing_creation( + self, payload: TaskOrderBillingCreationCSPPayload + ): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return TaskOrderBillingCreationCSPResult( + **{"Location": "https://somelocation", "Retry-After": "10"} + ).dict() + + def create_task_order_billing_verification( + self, payload: TaskOrderBillingVerificationCSPPayload + ): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return TaskOrderBillingVerificationCSPResult( + **{ + "id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/XC36-GRNZ-BG7-TGB", + "name": "XC36-GRNZ-BG7-TGB", + "properties": { + "address": { + "addressLine1": "123 S Broad Street, Suite 2400", + "city": "Philadelphia", + "companyName": "Promptworks", + "country": "US", + "postalCode": "19109", + "region": "PA", + }, + "currency": "USD", + "displayName": "First Portfolio Billing Profile", + "enabledAzurePlans": [ + { + "productId": "DZH318Z0BPS6", + "skuId": "0001", + "skuDescription": "Microsoft Azure Plan", + } + ], + "hasReadAccess": True, + "invoiceDay": 5, + "invoiceEmailOptIn": False, + }, + "type": "Microsoft.Billing/billingAccounts/billingProfiles", + } + ).dict() + + def create_billing_instruction(self, payload: BillingInstructionCSPPayload): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + + return BillingInstructionCSPResult( + **{ + "name": "TO1:CLIN001", + "properties": { + "amount": 1000.0, + "endDate": "2020-03-01T00:00:00+00:00", + "startDate": "2020-01-01T00:00:00+00:00", + }, + "type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions", + } + ).dict() + + def create_or_update_user(self, auth_credentials, user_info, csp_role_id): + self._authorize(auth_credentials) + + self._delay(1, 5) + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise( + self.ATAT_ADMIN_CREATE_FAILURE_PCT, + UserProvisioningException( + user_info.environment.id, + user_info.application_role.user_id, + "Could not create user.", + ), + ) + + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + return self._id() + + def disable_user(self, auth_credentials, csp_user_id): + self._authorize(auth_credentials) + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + + self._maybe_raise( + self.ATAT_ADMIN_CREATE_FAILURE_PCT, + UserRemovalException(csp_user_id, "Could not disable user."), + ) + + return self._maybe(12) + + def create_subscription(self, environment): + self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) + + return True + + def get_calculator_url(self): + return "https://www.rackspace.com/en-us/calculator" + + def get_environment_login_url(self, environment): + """Returns the login url for a given environment + """ + return "https://www.mycloud.com/my-env-login" + + def _id(self): + return uuid4().hex + + def _delay(self, min_secs, max_secs): + if self._with_delay: + duration = self._random.randrange(min_secs, max_secs) + self._sleep(duration) + + def _maybe(self, pct): + return not self._with_failure or self._random.randrange(0, 100) < pct + + def _maybe_raise(self, pct, exc): + if self._with_failure and self._maybe(pct): + raise exc + + @property + def _auth_credentials(self): + return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret + + def _authorize(self, credentials): + self._delay(1, 5) + if self._with_authorization and credentials != self._auth_credentials: + raise self.AUTHENTICATION_EXCEPTION + + def create_application(self, payload: ApplicationCSPPayload): + self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) + + id_ = f"{AZURE_MGMNT_PATH}{payload.management_group_name}" + return ApplicationCSPResult(id=id_) + + def get_credentials(self, scope="portfolio", tenant_id=None): + return self.root_creds() diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py new file mode 100644 index 00000000..93ac7d8d --- /dev/null +++ b/atst/domain/csp/cloud/models.py @@ -0,0 +1,290 @@ +from typing import Dict, List, Optional +import re +from uuid import uuid4 + +from pydantic import BaseModel, validator + +from atst.utils import snake_to_camel + + +class AliasModel(BaseModel): + """ + This provides automatic camel <-> snake conversion for serializing to/from json + You can override the alias generation in subclasses by providing a Config that defines + a fields property with a dict mapping variables to their cast names, for cases like: + * some_url:someURL + * user_object_id:objectId + """ + + class Config: + alias_generator = snake_to_camel + allow_population_by_field_name = True + + +class BaseCSPPayload(AliasModel): + # {"username": "mock-cloud", "pass": "shh"} + creds: Dict + + def dict(self, *args, **kwargs): + exclude = {"creds"} + if "exclude" not in kwargs: + kwargs["exclude"] = exclude + else: + kwargs["exclude"].update(exclude) + + return super().dict(*args, **kwargs) + + +class TenantCSPPayload(BaseCSPPayload): + user_id: str + password: str + domain_name: str + first_name: str + last_name: str + country_code: str + password_recovery_email_address: str + + +class TenantCSPResult(AliasModel): + user_id: str + tenant_id: str + user_object_id: str + + tenant_admin_username: Optional[str] + tenant_admin_password: Optional[str] + + class Config: + fields = { + "user_object_id": "objectId", + } + + def dict(self, *args, **kwargs): + exclude = {"tenant_admin_username", "tenant_admin_password"} + if "exclude" not in kwargs: + kwargs["exclude"] = exclude + else: + kwargs["exclude"].update(exclude) + + return super().dict(*args, **kwargs) + + def get_creds(self): + return { + "tenant_admin_username": self.tenant_admin_username, + "tenant_admin_password": self.tenant_admin_password, + "tenant_id": self.tenant_id, + } + + +class BillingProfileAddress(AliasModel): + company_name: str + address_line_1: str + city: str + region: str + country: str + postal_code: str + + +class BillingProfileCLINBudget(AliasModel): + clin_budget: Dict + """ + "clinBudget": { + "amount": 0, + "startDate": "2019-12-18T16:47:40.909Z", + "endDate": "2019-12-18T16:47:40.909Z", + "externalReferenceId": "string" + } + """ + + +class BillingProfileCreationCSPPayload(BaseCSPPayload): + tenant_id: str + billing_profile_display_name: str + billing_account_name: str + enabled_azure_plans: Optional[List[str]] + address: BillingProfileAddress + + @validator("enabled_azure_plans", pre=True, always=True) + def default_enabled_azure_plans(cls, v): + """ + Normally you'd implement this by setting the field with a value of: + dataclasses.field(default_factory=list) + but that prevents the object from being correctly pickled, so instead we need + to rely on a validator to ensure this has an empty value when not specified + """ + return v or [] + + class Config: + fields = {"billing_profile_display_name": "displayName"} + + +class BillingProfileCreationCSPResult(AliasModel): + billing_profile_verify_url: str + billing_profile_retry_after: int + + class Config: + fields = { + "billing_profile_verify_url": "Location", + "billing_profile_retry_after": "Retry-After", + } + + +class BillingProfileVerificationCSPPayload(BaseCSPPayload): + billing_profile_verify_url: str + + +class BillingInvoiceSection(AliasModel): + invoice_section_id: str + invoice_section_name: str + + class Config: + fields = {"invoice_section_id": "id", "invoice_section_name": "name"} + + +class BillingProfileProperties(AliasModel): + address: BillingProfileAddress + billing_profile_display_name: str + invoice_sections: List[BillingInvoiceSection] + + class Config: + fields = {"billing_profile_display_name": "displayName"} + + +class BillingProfileVerificationCSPResult(AliasModel): + billing_profile_id: str + billing_profile_name: str + billing_profile_properties: BillingProfileProperties + + class Config: + fields = { + "billing_profile_id": "id", + "billing_profile_name": "name", + "billing_profile_properties": "properties", + } + + +class BillingProfileTenantAccessCSPPayload(BaseCSPPayload): + tenant_id: str + user_object_id: str + billing_account_name: str + billing_profile_name: str + + +class BillingProfileTenantAccessCSPResult(AliasModel): + billing_role_assignment_id: str + billing_role_assignment_name: str + + class Config: + fields = { + "billing_role_assignment_id": "id", + "billing_role_assignment_name": "name", + } + + +class TaskOrderBillingCreationCSPPayload(BaseCSPPayload): + billing_account_name: str + billing_profile_name: str + + +class TaskOrderBillingCreationCSPResult(AliasModel): + task_order_billing_verify_url: str + task_order_retry_after: int + + class Config: + fields = { + "task_order_billing_verify_url": "Location", + "task_order_retry_after": "Retry-After", + } + + +class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload): + task_order_billing_verify_url: str + + +class BillingProfileEnabledPlanDetails(AliasModel): + enabled_azure_plans: List[Dict] + + +class TaskOrderBillingVerificationCSPResult(AliasModel): + billing_profile_id: str + billing_profile_name: str + billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails + + class Config: + fields = { + "billing_profile_id": "id", + "billing_profile_name": "name", + "billing_profile_enabled_plan_details": "properties", + } + + +class BillingInstructionCSPPayload(BaseCSPPayload): + initial_clin_amount: float + initial_clin_start_date: str + initial_clin_end_date: str + initial_clin_type: str + initial_task_order_id: str + billing_account_name: str + billing_profile_name: str + + +class BillingInstructionCSPResult(AliasModel): + reported_clin_name: str + + class Config: + fields = { + "reported_clin_name": "name", + } + + +AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/" + +MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$" + + +class ManagementGroupCSPPayload(BaseCSPPayload): + """ + :param: management_group_name: Just pass a UUID for this. + :param: display_name: This can contain any character and + spaces, but should be 90 characters or fewer long. + :param: parent_id: This should be the fully qualified Azure ID, + i.e. /providers/Microsoft.Management/managementGroups/[management group ID] + """ + + management_group_name: Optional[str] + display_name: str + parent_id: str + + @validator("management_group_name", pre=True, always=True) + def supply_management_group_name_default(cls, name): + if name: + if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None: + raise ValueError( + f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}" + ) + + return name[0:90] + else: + return str(uuid4()) + + @validator("display_name", pre=True, always=True) + def enforce_display_name_length(cls, name): + return name[0:90] + + @validator("parent_id", pre=True, always=True) + def enforce_parent_id_pattern(cls, id_): + if AZURE_MGMNT_PATH not in id_: + return f"{AZURE_MGMNT_PATH}{id_}" + else: + return id_ + + +class ManagementGroupCSPResponse(AliasModel): + id: str + + +class ApplicationCSPPayload(ManagementGroupCSPPayload): + pass + + +class ApplicationCSPResult(ManagementGroupCSPResponse): + pass diff --git a/atst/domain/csp/policy.py b/atst/domain/csp/cloud/policy.py similarity index 100% rename from atst/domain/csp/policy.py rename to atst/domain/csp/cloud/policy.py diff --git a/atst/jobs.py b/atst/jobs.py index 37f73450..7a4a3792 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -4,14 +4,15 @@ import pendulum from atst.database import db from atst.queue import celery from atst.models import EnvironmentRole, JobFailure -from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException +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.utils.localization import translate -from atst.domain.csp.cloud import ApplicationCSPPayload +from atst.domain.csp.cloud.models import ApplicationCSPPayload class RecordFailure(celery.Task): diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index a0cc77cd..cdd82da9 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -8,7 +8,7 @@ from transitions.extensions.states import add_state_features, Tags from flask import current_app as app -from atst.domain.csp.cloud import ConnectionException, UnknownServerException +from atst.domain.csp.cloud.exceptions import ConnectionException, UnknownServerException from atst.domain.csp import MockCSP, AzureCSP, get_stage_csp_class from atst.database import db from atst.models.types import Id diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index b4e75fc1..443989db 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -13,7 +13,7 @@ from atst.domain.environments import Environments from atst.domain.applications import Applications from atst.domain.application_roles import ApplicationRoles from atst.domain.audit_log import AuditLog -from atst.domain.csp.cloud import GeneralCSPException +from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.common import Paginator from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations diff --git a/script/include/test_functions.inc.sh b/script/include/test_functions.inc.sh index d6bf0b4d..bfd4d343 100644 --- a/script/include/test_functions.inc.sh +++ b/script/include/test_functions.inc.sh @@ -8,7 +8,7 @@ run_python_lint() { } run_python_typecheck() { - run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud.py" + run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud/__init__.py" return $? } diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index c98a43ee..228b78e4 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -1,11 +1,17 @@ +from uuid import uuid4 from unittest.mock import Mock -from uuid import uuid4 +from tests.factories import ApplicationFactory, EnvironmentFactory +from tests.mock_azure import AUTH_CREDENTIALS, mock_azure -from atst.domain.csp.cloud import ( - AzureCloudProvider, - BillingProfileCreationCSPResult, +from atst.domain.csp.cloud import AzureCloudProvider +from atst.domain.csp.cloud.models import ( + ApplicationCSPPayload, + ApplicationCSPResult, + BillingInstructionCSPPayload, + BillingInstructionCSPResult, BillingProfileCreationCSPPayload, + BillingProfileCreationCSPResult, BillingProfileTenantAccessCSPPayload, BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, @@ -18,12 +24,8 @@ from atst.domain.csp.cloud import ( TaskOrderBillingVerificationCSPResult, TenantCSPPayload, TenantCSPResult, - ApplicationCSPPayload, ) -from tests.mock_azure import mock_azure, AUTH_CREDENTIALS -from tests.factories import EnvironmentFactory, ApplicationFactory - creds = { "home_tenant_id": "tenant_id", diff --git a/tests/domain/cloud/test_payloads.py b/tests/domain/cloud/test_payloads.py index 08ca147c..d92a4840 100644 --- a/tests/domain/cloud/test_payloads.py +++ b/tests/domain/cloud/test_payloads.py @@ -2,7 +2,7 @@ import pytest from pydantic import ValidationError -from atst.domain.csp.cloud import ( +from atst.domain.csp.cloud.models import ( AZURE_MGMNT_PATH, ManagementGroupCSPPayload, ManagementGroupCSPResponse, diff --git a/tests/domain/cloud/test_policy.py b/tests/domain/cloud/test_policy.py index c0189262..18f0a7ab 100644 --- a/tests/domain/cloud/test_policy.py +++ b/tests/domain/cloud/test_policy.py @@ -1,4 +1,4 @@ -from atst.domain.csp.policy import AzurePolicyManager, AzurePolicy +from atst.domain.csp.cloud.policy import AzurePolicyManager, AzurePolicy def test_portfolio_definitions(): diff --git a/tests/routes/applications/test_settings.py b/tests/routes/applications/test_settings.py index 37c71878..8f2595f2 100644 --- a/tests/routes/applications/test_settings.py +++ b/tests/routes/applications/test_settings.py @@ -12,7 +12,7 @@ from atst.domain.application_roles import ApplicationRoles from atst.domain.environment_roles import EnvironmentRoles from atst.domain.invitations import ApplicationInvitations from atst.domain.common import Paginator -from atst.domain.csp.cloud import GeneralCSPException +from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.permission_sets import PermissionSets from atst.models.application_role import Status as ApplicationRoleStatus from atst.models.environment_role import CSPRole, EnvironmentRole From a10d733fb7e400c3cede347585a0acdeb8c5c3c0 Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 27 Jan 2020 06:27:18 -0500 Subject: [PATCH 22/35] Fix test and LGTM warnings. - Fixes LGTM warnings for an unused import and equality comparisons to None in SQLAlchemy filters. - Removes part of a unit test asserting that the claimed_until locking mechanism works correctly. If I recall correctly, this does not work in unit tests because the test takes place inside a transaction, and the database provider does evaluate the current time until the transaction is written. --- .../versions/07e0598199f6_add_applications_claimed_until.py | 1 - atst/domain/applications.py | 6 +++--- tests/domain/test_applications.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/alembic/versions/07e0598199f6_add_applications_claimed_until.py b/alembic/versions/07e0598199f6_add_applications_claimed_until.py index ada20eaf..9c5d3abc 100644 --- a/alembic/versions/07e0598199f6_add_applications_claimed_until.py +++ b/alembic/versions/07e0598199f6_add_applications_claimed_until.py @@ -7,7 +7,6 @@ Create Date: 2020-01-25 13:33:17.711548 """ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = '07e0598199f6' # pragma: allowlist secret diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 4e7f3c8a..b9df260e 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -134,11 +134,11 @@ class Applications(BaseDomainClass): .join(PortfolioStateMachine) .filter(PortfolioStateMachine.state == FSMStates.COMPLETED) .filter(Application.deleted == False) - .filter(Application.cloud_id == None) + .filter(Application.cloud_id.is_(None)) .filter( or_( - Application.claimed_until == None, - Application.claimed_until >= func.now(), + Application.claimed_until.is_(None), + Application.claimed_until <= func.now(), ) ) ).all() diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 8ddc0867..02dd3124 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -206,7 +206,7 @@ def test_get_applications_pending_creation(): portfolio1 = PortfolioFactory.create(state="COMPLETED") app_ready = ApplicationFactory.create(portfolio=portfolio1) - app_claimed = ApplicationFactory.create(portfolio=portfolio1, claimed_until=later) + app_done = ApplicationFactory.create(portfolio=portfolio1, cloud_id="123456") portfolio2 = PortfolioFactory.create(state="UNSTARTED") app_not_ready = ApplicationFactory.create(portfolio=portfolio2) From 0ddb1f54c019452cf237f920983a2b792eaa16f7 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Fri, 24 Jan 2020 15:36:30 -0500 Subject: [PATCH 23/35] Fix typo --- translations.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations.yaml b/translations.yaml index d9615c4d..15240d4c 100644 --- a/translations.yaml +++ b/translations.yaml @@ -509,7 +509,7 @@ task_orders: tooltip: obligated_funds: Funds committed to fund your portfolio. This may represent 100% of your total Task Order value, or a portion of it. total_value: All obligated and projected funds for the Task Order’s Base and Option CLINs. - expended_funds: All funds spend from the Task Order so far. + expended_funds: All funds spent from the Task Order so far. form: add_clin: Add Another CLIN add_to_header: Enter the Task Order number From 99edf3df907b36fc140ad6cfed831f71315ef5fc Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Mon, 27 Jan 2020 11:20:52 -0500 Subject: [PATCH 24/35] Save valid TO form fields when clicking previous in TO builder --- atst/routes/task_orders/new.py | 38 +++++++++++++--- templates/task_orders/builder_base.html | 17 ++++++-- templates/task_orders/step_2.html | 2 +- templates/task_orders/step_3.html | 2 +- tests/routes/task_orders/test_new.py | 58 +++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index f6e53b75..eaf6f2b2 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -70,7 +70,12 @@ def update_task_order(form, portfolio_id=None, task_order_id=None, flash_invalid def update_and_render_next( - form_data, next_page, current_template, portfolio_id=None, task_order_id=None + form_data, + next_page, + current_template, + portfolio_id=None, + task_order_id=None, + previous=False, ): form = None if task_order_id: @@ -80,8 +85,9 @@ def update_and_render_next( form = TaskOrderForm(form_data) task_order = update_task_order(form, portfolio_id, task_order_id) - if task_order: - return redirect(url_for(next_page, task_order_id=task_order.id)) + if task_order or previous: + to_id = task_order.id if task_order else task_order_id + return redirect(url_for(next_page, task_order_id=to_id)) else: return ( render_task_orders_edit( @@ -210,12 +216,21 @@ def form_step_two_add_number(task_order_id): @task_orders_bp.route("/task_orders//form/step_2", methods=["POST"]) @user_can(Permissions.CREATE_TASK_ORDER, message="update task order form") def submit_form_step_two_add_number(task_order_id): + previous = http_request.args.get("previous", "False").lower() == "true" form_data = {**http_request.form} - next_page = "task_orders.form_step_three_add_clins" + next_page = ( + "task_orders.form_step_three_add_clins" + if not previous + else "task_orders.form_step_one_add_pdf" + ) current_template = "task_orders/step_2.html" return update_and_render_next( - form_data, next_page, current_template, task_order_id=task_order_id + form_data, + next_page, + current_template, + task_order_id=task_order_id, + previous=previous, ) @@ -230,12 +245,21 @@ def form_step_three_add_clins(task_order_id): @task_orders_bp.route("/task_orders//form/step_3", methods=["POST"]) @user_can(Permissions.CREATE_TASK_ORDER, message="update task order form") def submit_form_step_three_add_clins(task_order_id): + previous = http_request.args.get("previous", "False").lower() == "true" form_data = {**http_request.form} - next_page = "task_orders.form_step_four_review" + next_page = ( + "task_orders.form_step_four_review" + if not previous + else "task_orders.form_step_two_add_number" + ) current_template = "task_orders/step_3.html" return update_and_render_next( - form_data, next_page, current_template, task_order_id=task_order_id + form_data, + next_page, + current_template, + task_order_id=task_order_id, + previous=previous, ) diff --git a/templates/task_orders/builder_base.html b/templates/task_orders/builder_base.html index f5b88c3a..5b819b27 100644 --- a/templates/task_orders/builder_base.html +++ b/templates/task_orders/builder_base.html @@ -39,9 +39,20 @@ {% endblock %} {% if step != "1" %} - - Previous - + {% if step == "2" or step == "3" -%} + + {% else -%} + + Previous + + {%- endif %} {% endif %} Date: Mon, 27 Jan 2020 12:08:18 -0500 Subject: [PATCH 25/35] Move text from TO builder into translations file --- templates/task_orders/builder_base.html | 14 +++++++++----- templates/task_orders/form_header.html | 2 +- templates/task_orders/step_1.html | 2 +- templates/task_orders/step_2.html | 2 +- templates/task_orders/step_4.html | 2 +- translations.yaml | 9 +++++++++ 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/templates/task_orders/builder_base.html b/templates/task_orders/builder_base.html index 5b819b27..9ee8dd0c 100644 --- a/templates/task_orders/builder_base.html +++ b/templates/task_orders/builder_base.html @@ -14,10 +14,14 @@ {% call Modal(name='cancel', dismissable=True) %}
    -

    Do you want to save this draft?

    +

    {{ 'task_orders.form.builder_base.cancel_modal' | translate }}

    - - + +
    {% endcall %} @@ -44,13 +48,13 @@ type="submit" class="usa-button usa-button-secondary" formaction="{{ previous_button_link }}"> - Previous + {{ "common.previous" | translate }} {% else -%}
    - Previous + {{ "common.previous" | translate }} {%- endif %} {% endif %} diff --git a/templates/task_orders/form_header.html b/templates/task_orders/form_header.html index 3356a8c9..7db9a313 100644 --- a/templates/task_orders/form_header.html +++ b/templates/task_orders/form_header.html @@ -7,7 +7,7 @@ {%- endif %} {% if to_number %}

    - Task Order Number: {{ to_number }} + {{ "task_orders.form.builder_base.to_number" | translate({ "number": to_number }) | safe }}

    {% endif %} {% if description %} diff --git a/templates/task_orders/step_1.html b/templates/task_orders/step_1.html index 177a41a8..1e2911e5 100644 --- a/templates/task_orders/step_1.html +++ b/templates/task_orders/step_1.html @@ -10,7 +10,7 @@ {% set action = url_for("task_orders.submit_form_step_one_add_pdf", portfolio_id=portfolio.id) %} {% endif %} -{% set next_button_text = "Next: Add TO Number" %} +{% set next_button_text = "task_orders.form.step_1.next_button" | translate %} {% set step = "1" %} {% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %} diff --git a/templates/task_orders/step_2.html b/templates/task_orders/step_2.html index e40f4bfc..6be0dec7 100644 --- a/templates/task_orders/step_2.html +++ b/templates/task_orders/step_2.html @@ -4,7 +4,7 @@ {% from "task_orders/form_header.html" import TOFormStepHeader %} {% set action = url_for("task_orders.submit_form_step_two_add_number", task_order_id=task_order_id) %} -{% set next_button_text = "Next: Add Base CLIN" %} +{% set next_button_text = "task_orders.form.step_2.next_button" | translate %} {% set previous_button_link = url_for("task_orders.submit_form_step_two_add_number", task_order_id=task_order_id, previous=True) %} {% set step = "2" %} {% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %} diff --git a/templates/task_orders/step_4.html b/templates/task_orders/step_4.html index 770a40c4..bc17ac01 100644 --- a/templates/task_orders/step_4.html +++ b/templates/task_orders/step_4.html @@ -12,7 +12,7 @@ - Next: Confirm + {{ "task_orders.form.step_4.next_button" | translate }} {% endblock %} diff --git a/translations.yaml b/translations.yaml index 15240d4c..b3caebc7 100644 --- a/translations.yaml +++ b/translations.yaml @@ -514,6 +514,11 @@ task_orders: add_clin: Add Another CLIN add_to_header: Enter the Task Order number add_to_description: Please input your 13-digit Task Order number. This number may be listed under "Order Number" if your Contracting Officer used form 1149, or "Delivery Order/Call No." if form 1155 was used. Moving forward, this portion of funding will be referenced by the recorded Task Order number. + builder_base: + cancel_modal: Do you want to save this draft? + delete_draft: No, delete it + save_draft: Yes, save for later + to_number: "Task Order Number: {number}" clin_title: Enter Contract Line Items clin_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)." clin_details: CLIN Details @@ -536,12 +541,16 @@ task_orders: step_1: title: Upload your approved Task Order (TO) description: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future. + next_button: "Next: Add TO Number" + step_2: + next_button: "Next: Add Base CLIN" step_3: next_button: "Next: Review Task Order" percent_obligated: "% of Funds Obligated" step_4: documents: Documents clins: CLIN Summary + next_button: "Next: Confirm" step_5: cta_text: Verify Your Information description: Prior to submitting the Task Order, you must acknowledge, by marking the appropriate box below, that the uploaded Task Order is signed by an appropriate, duly warranted Contracting Officer who has the authority to execute the uploaded Task Order on your Agency’s behalf and has authorized you to upload the Task Order in accordance with Agency policy and procedures. You must further acknowledge, by marking the appropriate box below, that all information entered herein matches that of the submitted Task Order. From 8093edbf0396c7a384ac91b16ecfce7654ac1810 Mon Sep 17 00:00:00 2001 From: hmbrink Date: Mon, 27 Jan 2020 13:02:06 -0500 Subject: [PATCH 26/35] User icon Added user icon from designs for top navigation --- static/icons/user.svg | 1 + templates/navigation/topbar.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 static/icons/user.svg diff --git a/static/icons/user.svg b/static/icons/user.svg new file mode 100644 index 00000000..a0da4770 --- /dev/null +++ b/static/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/navigation/topbar.html b/templates/navigation/topbar.html index 2715c86f..f48f04ea 100644 --- a/templates/navigation/topbar.html +++ b/templates/navigation/topbar.html @@ -11,7 +11,7 @@
    {% if g.current_user %} - {{ Icon('avatar', classes='topbar__link-icon') }} + {{ Icon('user', classes='topbar__link-icon') }} {{ g.current_user.first_name + " " + g.current_user.last_name }} From 49a1a219ae36d23ca59239eaf1139e16464070dd Mon Sep 17 00:00:00 2001 From: dandds Date: Thu, 23 Jan 2020 06:25:35 -0500 Subject: [PATCH 27/35] Script for setting up database user, schema, and seed data. This script is for bootstrapping the initial database. It can be run via a container, but requires that a Postgres superuser's credentials be provided via our normal config. That way the superuser can provision a less-privileged user for the application's database connection. --- atst/domain/users.py | 7 +++- script/database_setup.py | 80 ++++++++++++++++++++++++++++++++++++++++ script/reset_database.py | 4 +- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 script/database_setup.py diff --git a/atst/domain/users.py b/atst/domain/users.py index 5e09ce22..e5fdbad7 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -93,10 +93,13 @@ class Users(object): return user @classmethod - def give_ccpo_perms(cls, user): + def give_ccpo_perms(cls, user, commit=True): user.permission_sets = PermissionSets.get_all() db.session.add(user) - db.session.commit() + + if commit: + db.session.commit() + return user @classmethod diff --git a/script/database_setup.py b/script/database_setup.py new file mode 100644 index 00000000..bab95890 --- /dev/null +++ b/script/database_setup.py @@ -0,0 +1,80 @@ +# Add root application dir to the python path +import os +import sys +from contextlib import contextmanager + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(parent_dir) + +import sqlalchemy +from alembic import config as alembic_config +import yaml + +from atst.app import make_config, make_app +from atst.database import db +from atst.domain.users import Users +from atst.models import User +from reset_database import reset_database + + +def database_setup(username, password, dbname, ccpo_users): + """docstring for database_setup""" + print( + f"Creating Postgres user role for '{username}' and granting all privileges to database '{dbname}'." + ) + try: + _create_database_user(username, password, dbname) + except sqlalchemy.exc.ProgrammingError as err: + raise err + print(f"Postgres user role '{username}' already exists.") + + print("Applying schema and seeding roles and permissions.") + reset_database() + print("Creating initial set of CCPO users.") + _add_ccpo_users(ccpo_users) + + +def _create_database_user(username, password, dbname): + conn = db.engine.connect() + + meta = sqlalchemy.MetaData(bind=conn) + meta.reflect() + + trans = conn.begin() + engine = trans.connection.engine + + engine.execute( + f"CREATE ROLE {username} WITH LOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD '{password}';\n" + + f"GRANT ALL PRIVILEGES ON DATABASE {dbname} TO {username};\n" + + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {username}; \n" + + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO {username}; \n" + + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO {username}; \n" + ) + + trans.commit() + + +def _add_ccpo_users(ccpo_users): + for user_data in ccpo_users: + user = User(**user_data) + Users.give_ccpo_perms(user, commit=False) + db.session.add(user) + + db.session.commit() + + +def _load_yaml(file_): + with open(file_) as f: + return yaml.safe_load(f) + + +if __name__ == "__main__": + config = make_config({"DISABLE_CRL_CHECK": True, "DEBUG": False}) + app = make_app(config) + with app.app_context(): + dbname = config.get("PGDATABASE", "atat") + username = sys.argv[1] + password = sys.argv[2] + ccpo_user_file = sys.argv[3] + ccpo_users = _load_yaml(ccpo_user_file) + database_setup(username, password, dbname, ccpo_users) diff --git a/script/reset_database.py b/script/reset_database.py index cfa63298..dda1c1ba 100644 --- a/script/reset_database.py +++ b/script/reset_database.py @@ -16,7 +16,9 @@ from atst.app import make_config, make_app def reset_database(): conn = db.engine.connect() - meta = sqlalchemy.MetaData(bind=conn, reflect=True) + meta = sqlalchemy.MetaData(bind=conn) + meta.reflect() + trans = conn.begin() # drop all tables From a8f6befc178b3367646d65d0b585a5e4ada4b637 Mon Sep 17 00:00:00 2001 From: dandds Date: Thu, 23 Jan 2020 15:35:35 -0500 Subject: [PATCH 28/35] secrets-tool command for bootstrapping database. This additional secrets-tool command can be used to run the database bootsrapping script (`script/database_setup.py`) inside an ATAT docker container against the Azure database. It sources the necessary keys from Key Vault. --- script/database_setup.py | 3 - terraform/secrets-tool/README.md | 38 ++++- terraform/secrets-tool/commands/database.py | 134 ++++++++++++++++++ terraform/secrets-tool/postgres-user.yaml | 4 + terraform/secrets-tool/secrets-tool | 8 +- .../secrets-tool/utils/keyvault/secrets.py | 11 +- 6 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 terraform/secrets-tool/commands/database.py create mode 100644 terraform/secrets-tool/postgres-user.yaml diff --git a/script/database_setup.py b/script/database_setup.py index bab95890..623dfd8b 100644 --- a/script/database_setup.py +++ b/script/database_setup.py @@ -1,13 +1,11 @@ # Add root application dir to the python path import os import sys -from contextlib import contextmanager parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) sys.path.append(parent_dir) import sqlalchemy -from alembic import config as alembic_config import yaml from atst.app import make_config, make_app @@ -25,7 +23,6 @@ def database_setup(username, password, dbname, ccpo_users): try: _create_database_user(username, password, dbname) except sqlalchemy.exc.ProgrammingError as err: - raise err print(f"Postgres user role '{username}' already exists.") print("Applying schema and seeding roles and permissions.") diff --git a/terraform/secrets-tool/README.md b/terraform/secrets-tool/README.md index 28b44817..9ce07497 100644 --- a/terraform/secrets-tool/README.md +++ b/terraform/secrets-tool/README.md @@ -15,7 +15,7 @@ With both usernames and passwords generated, the application only needs to make Ex. ``` { - 'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl', + 'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl', 'postgres_root_password': "2+[A@E4:C=ubb/#R#'n Date: Sat, 25 Jan 2020 12:21:52 -0500 Subject: [PATCH 29/35] Create database with separate script. Creating the ATAT database requires a separate connection to one of the default Postgres databases, like `postgres`. This updates the scripts and secrets-tool command to handle creating the database. It also removes database creation from Terraform and updates the documentation. --- .dockerignore | 7 +--- notes.md | 6 +++ script/create_database.py | 41 +++++++++++++++++++++ terraform/README.md | 31 +++++++++++++--- terraform/modules/postgres/main.tf | 8 ---- terraform/modules/postgres/outputs.tf | 3 -- terraform/secrets-tool/README.md | 11 ++++++ terraform/secrets-tool/commands/database.py | 23 ++++++++---- users.yml | 3 ++ 9 files changed, 105 insertions(+), 28 deletions(-) create mode 100644 notes.md create mode 100644 script/create_database.py create mode 100644 users.yml diff --git a/.dockerignore b/.dockerignore index 7b9644ad..5674e27e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,11 +21,8 @@ LICENSE # Skip envrc .envrc -# Skip ansible-container stuff -ansible* -container.yml -meta.yml -requirements.yml +# Skip terraform +terraform # Skip kubernetes and Docker config stuff deploy diff --git a/notes.md b/notes.md new file mode 100644 index 00000000..4c1dd84f --- /dev/null +++ b/notes.md @@ -0,0 +1,6 @@ +- for setting up the database: + - create database + - create postgres user password? could we do this as a key? + - create user secret in application key vault + - execute SQL to create user +- we need an initial image to seed ACR with diff --git a/script/create_database.py b/script/create_database.py new file mode 100644 index 00000000..f21a857e --- /dev/null +++ b/script/create_database.py @@ -0,0 +1,41 @@ +# Add root application dir to the python path +import os +import sys + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.append(parent_dir) + +import sqlalchemy + +from atst.app import make_config + + +def _root_connection(config, root_db): + # Assemble DATABASE_URI value + database_uri = "postgresql://{}:{}@{}:{}/{}".format( # pragma: allowlist secret + config.get("PGUSER"), + config.get("PGPASSWORD"), + config.get("PGHOST"), + config.get("PGPORT"), + root_db, + ) + engine = sqlalchemy.create_engine(database_uri) + return engine.connect() + + +def create_database(conn, dbname): + conn.execute("commit") + conn.execute(f"CREATE DATABASE {dbname};") + conn.close() + + return True + + +if __name__ == "__main__": + dbname = sys.argv[1] + config = make_config() + + conn = _root_connection(config, "postgres") + + print(f"Creating database {dbname}") + create_database(conn, dbname) diff --git a/terraform/README.md b/terraform/README.md index ec0fbdeb..8c8e7beb 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -1,11 +1,11 @@ # ATAT Terraform Welcome! You've found the ATAT IaC configurations. -ATAT is configured using terraform and a wrapper script called `secrets-tool`. With `terraform` we can configure infrastructure in a programatic way and ensure consistency across environments. +ATAT is configured using terraform and a wrapper script called `secrets-tool`. With `terraform` we can configure infrastructure in a programatic way and ensure consistency across environments. ## Directory Structure -**modules/** - Terraform modules. These are modules that can be re-used for multiple environments. +**modules/** - Terraform modules. These are modules that can be re-used for multiple environments. **providers/** - Specific environment configurations. (dev,production, etc) @@ -92,7 +92,7 @@ Check the output for errors. Sometimes the syntax is valid, but some of the conf # After running TF (Manual Steps) -## VM Scale Set +## VM Scale Set After running terraform, we need to make a manual change to the VM Scale Set that is used in the kubernetes. Terraform has a bug that is not applying this as of `v1.40` of the `azurerm` provider. In order to get the `SystemAssigned` identity to be set, it needs to be set manually in the console. @@ -253,7 +253,7 @@ Uncomment the `backend {}` section in the `provider.tf` file. Once uncommented, *Say `yes` to the question* -Now we need to update the Update `variables.tf` with the principals for the users in `admin_users` variable map. If these are not defined yet, just leave it as an empty set. +Now we need to update the Update `variables.tf` with the principals for the users in `admin_users` variable map. If these are not defined yet, just leave it as an empty set. Next, we'll create the operator keyvault. @@ -281,4 +281,25 @@ secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ cr `terraform apply` -*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)* \ No newline at end of file +*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)* + +*Then we need an instance of the container* + +Change directories to the repo root. Ensure that you've checked out the staging or master branch: + +`docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest` + +*Create secrets for ATAT database user* + +Change directories back to terraform/secrets-tool. There is a sample file there. Make sure you know the URL for the aplication Key Vault (distinct from the operator Key Vault). Run: + +`secrets-tool secrets --keyvault [application key vault URL] load -f ./postgres-user.yaml + +*Create the database, database user, schema, and initial data set* + + +This is discussed in more detail [here](https://github.com/dod-ccpo/atst/tree/staging/terraform/secrets-tool#setting-up-the-initial-atat-database). Be sure to read the requirements section. + +``` +secrets-tool database --keyvault [operator key vault URL] provision --app-keyvault [application key vault URL] --dbname jedidev-atat --dbhost [database host name] --ccpo-users /full/path/to/users.yml +``` diff --git a/terraform/modules/postgres/main.tf b/terraform/modules/postgres/main.tf index c3252264..29b6cc53 100644 --- a/terraform/modules/postgres/main.tf +++ b/terraform/modules/postgres/main.tf @@ -35,11 +35,3 @@ resource "azurerm_postgresql_virtual_network_rule" "sql" { subnet_id = var.subnet_id ignore_missing_vnet_service_endpoint = true } - -resource "azurerm_postgresql_database" "db" { - name = "${var.name}-${var.environment}-atat" - resource_group_name = azurerm_resource_group.sql.name - server_name = azurerm_postgresql_server.sql.name - charset = "UTF8" - collation = "en-US" -} diff --git a/terraform/modules/postgres/outputs.tf b/terraform/modules/postgres/outputs.tf index 1ff1dd65..e69de29b 100644 --- a/terraform/modules/postgres/outputs.tf +++ b/terraform/modules/postgres/outputs.tf @@ -1,3 +0,0 @@ -output "db_name" { - value = azurerm_postgresql_database.db.name -} diff --git a/terraform/secrets-tool/README.md b/terraform/secrets-tool/README.md index 9ce07497..bd407607 100644 --- a/terraform/secrets-tool/README.md +++ b/terraform/secrets-tool/README.md @@ -45,6 +45,17 @@ Requirements: - docker - A copy of the ATAT docker image. This can be built in the repo root with: `docker build . --build-arg CSP=azure -f ./Dockerfile -t atat:latest` - You need to know the hostname for the Postgres database. Your IP must either be whitelisted in its firewall rules or you must be behind the VPN. +- You will need a YAML file listing all the CCPO users to be added to the database, with the format: + +``` +- dod_id: "2323232323" + first_name: "Luke" + last_name: "Skywalker" +- dod_id: "5656565656" + first_name: "Han" + last_name: "Solo" +``` + - There should be a password for the ATAT database user in the application Key Vault, preferably named `PGPASSWORD`. You can load this by running `secrets-tool --keyvault [operator key vault url] load -f postgres-user.yml` and supplying YAML like: ``` diff --git a/terraform/secrets-tool/commands/database.py b/terraform/secrets-tool/commands/database.py index e181741c..98f404a5 100644 --- a/terraform/secrets-tool/commands/database.py +++ b/terraform/secrets-tool/commands/database.py @@ -116,19 +116,28 @@ def provision( logger.info("starting docker process") - cmd = ( - f"docker run -e PGHOST={dbhost}" - +f" -e PGPASSWORD=\"{root_password}\"" + create_database_cmd = ( + f"docker run -e PGHOST='{dbhost}'" + +f" -e PGPASSWORD='{root_password}'" +f" -e PGUSER='{root_name}@{dbhost}'" - +f" -e PGDATABASE=\"{dbname}\"" - +f" -e REDIS_HOST=host.docker.internal" + +f" -e PGDATABASE='{dbname}'" + +f" -e PGSSLMODE=require" + +f" {container}" + +f" .venv/bin/python script/create_database.py {dbname}" + ) + _run_cmd(create_database_cmd) + + seed_database_cmd = ( + f"docker run -e PGHOST='{dbhost}'" + +f" -e PGPASSWORD='{root_password}'" + +f" -e PGUSER='{root_name}@{dbhost}'" + +f" -e PGDATABASE='{dbname}'" +f" -e PGSSLMODE=require" +f" -v {ccpo_users}:/opt/atat/atst/users.yml" +f" {container}" +f" .venv/bin/python script/database_setup.py {user_username} '{user_password}' users.yml" ) - print(cmd) - _run_cmd(cmd) + _run_cmd(seed_database_cmd) database.add_command(provision) diff --git a/users.yml b/users.yml new file mode 100644 index 00000000..561031f2 --- /dev/null +++ b/users.yml @@ -0,0 +1,3 @@ +- dod_id: "2323232323" + first_name: "hi" + last_name: "bye" From adacb6ff1913a4c56e79d893922cae64d292b16a Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 27 Jan 2020 13:16:56 -0500 Subject: [PATCH 30/35] Cleanup cruft --- notes.md | 6 ----- script/database_setup.py | 9 ++++--- terraform/secrets-tool/commands/database.py | 26 ++++++++++----------- users.yml | 3 --- 4 files changed, 17 insertions(+), 27 deletions(-) delete mode 100644 notes.md delete mode 100644 users.yml diff --git a/notes.md b/notes.md deleted file mode 100644 index 4c1dd84f..00000000 --- a/notes.md +++ /dev/null @@ -1,6 +0,0 @@ -- for setting up the database: - - create database - - create postgres user password? could we do this as a key? - - create user secret in application key vault - - execute SQL to create user -- we need an initial image to seed ACR with diff --git a/script/database_setup.py b/script/database_setup.py index 623dfd8b..7784be05 100644 --- a/script/database_setup.py +++ b/script/database_setup.py @@ -16,7 +16,6 @@ from reset_database import reset_database def database_setup(username, password, dbname, ccpo_users): - """docstring for database_setup""" print( f"Creating Postgres user role for '{username}' and granting all privileges to database '{dbname}'." ) @@ -42,10 +41,10 @@ def _create_database_user(username, password, dbname): engine.execute( f"CREATE ROLE {username} WITH LOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD '{password}';\n" - + f"GRANT ALL PRIVILEGES ON DATABASE {dbname} TO {username};\n" - + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {username}; \n" - + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO {username}; \n" - + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO {username}; \n" + f"GRANT ALL PRIVILEGES ON DATABASE {dbname} TO {username};\n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {username}; \n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO {username}; \n" + f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO {username}; \n" ) trans.commit() diff --git a/terraform/secrets-tool/commands/database.py b/terraform/secrets-tool/commands/database.py index 98f404a5..144ceee6 100644 --- a/terraform/secrets-tool/commands/database.py +++ b/terraform/secrets-tool/commands/database.py @@ -118,24 +118,24 @@ def provision( create_database_cmd = ( f"docker run -e PGHOST='{dbhost}'" - +f" -e PGPASSWORD='{root_password}'" - +f" -e PGUSER='{root_name}@{dbhost}'" - +f" -e PGDATABASE='{dbname}'" - +f" -e PGSSLMODE=require" - +f" {container}" - +f" .venv/bin/python script/create_database.py {dbname}" + f" -e PGPASSWORD='{root_password}'" + f" -e PGUSER='{root_name}@{dbhost}'" + f" -e PGDATABASE='{dbname}'" + f" -e PGSSLMODE=require" + f" {container}" + f" .venv/bin/python script/create_database.py {dbname}" ) _run_cmd(create_database_cmd) seed_database_cmd = ( f"docker run -e PGHOST='{dbhost}'" - +f" -e PGPASSWORD='{root_password}'" - +f" -e PGUSER='{root_name}@{dbhost}'" - +f" -e PGDATABASE='{dbname}'" - +f" -e PGSSLMODE=require" - +f" -v {ccpo_users}:/opt/atat/atst/users.yml" - +f" {container}" - +f" .venv/bin/python script/database_setup.py {user_username} '{user_password}' users.yml" + f" -e PGPASSWORD='{root_password}'" + f" -e PGUSER='{root_name}@{dbhost}'" + f" -e PGDATABASE='{dbname}'" + f" -e PGSSLMODE=require" + f" -v {ccpo_users}:/opt/atat/atst/users.yml" + f" {container}" + f" .venv/bin/python script/database_setup.py {user_username} '{user_password}' users.yml" ) _run_cmd(seed_database_cmd) diff --git a/users.yml b/users.yml deleted file mode 100644 index 561031f2..00000000 --- a/users.yml +++ /dev/null @@ -1,3 +0,0 @@ -- dod_id: "2323232323" - first_name: "hi" - last_name: "bye" From 15ac65752ff9f1246141693b0bad043a0744b0ed Mon Sep 17 00:00:00 2001 From: hmbrink Date: Mon, 27 Jan 2020 13:46:56 -0500 Subject: [PATCH 31/35] New portfolio header adjustments --- styles/components/_empty_state.scss | 2 +- styles/components/_portfolio_layout.scss | 4 ++++ templates/portfolios/new/step_1.html | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/styles/components/_empty_state.scss b/styles/components/_empty_state.scss index 71c9e742..e18a2faf 100644 --- a/styles/components/_empty_state.scss +++ b/styles/components/_empty_state.scss @@ -1,6 +1,6 @@ .empty-state { max-width: $max-panel-width; - background-color: #f6f6f7; + background-color: $color-gray-lightest; &--white { background-color: $color-white; diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index b5be6090..77b8b179 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -5,6 +5,10 @@ } } +.portfolio-header-new .portfolio-header__name { + padding: 1.6rem 0; +} + .portfolio-header { flex-direction: column; margin: $gap * 2 0; diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index dddc9047..3305d924 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -13,7 +13,7 @@

    {{ "portfolios.header" | translate }}

    -

    {{ "New Portfolio" }}

    +

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

    {{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }} From f0122c794d5b78578784e5765ad03940164858dc Mon Sep 17 00:00:00 2001 From: hmbrink Date: Mon, 27 Jan 2020 14:09:42 -0500 Subject: [PATCH 32/35] New portfolio header padding --- styles/components/_portfolio_layout.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 77b8b179..4a45cc5f 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -6,7 +6,7 @@ } .portfolio-header-new .portfolio-header__name { - padding: 1.6rem 0; + padding: 1.6rem 0; } .portfolio-header { From b630433aa80c41a7e72dc6a0036c21a1a74a370f Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 28 Jan 2020 14:10:55 -0500 Subject: [PATCH 33/35] Disable container privilege escalation. Per Azure best practice, disable a container's ability to escalate its privileges. https://docs.microsoft.com/en-us/azure/aks/developer-best-practices-pod-security#secure-pod-access-to-resources --- deploy/azure/azure.yml | 8 ++++++++ deploy/azure/crls-sync.yaml | 2 ++ deploy/shared/migration.yaml | 2 ++ 3 files changed, 12 insertions(+) diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index f988d5fc..d58f77a7 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -29,6 +29,8 @@ spec: containers: - name: atst image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false env: - name: UWSGI_PROCESSES value: "2" @@ -64,6 +66,8 @@ spec: cpu: 940m - name: nginx image: nginx:alpine + securityContext: + allowPrivilegeEscalation: false ports: - containerPort: 8342 name: main-upgrade @@ -189,6 +193,8 @@ spec: containers: - name: atst-worker image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false args: [ "/opt/atat/atst/.venv/bin/python", @@ -261,6 +267,8 @@ spec: containers: - name: atst-beat image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false args: [ "/opt/atat/atst/.venv/bin/python", diff --git a/deploy/azure/crls-sync.yaml b/deploy/azure/crls-sync.yaml index 5fdcd7b8..221a0d8c 100644 --- a/deploy/azure/crls-sync.yaml +++ b/deploy/azure/crls-sync.yaml @@ -20,6 +20,8 @@ spec: containers: - name: crls image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false command: [ "/bin/sh", "-c" ] diff --git a/deploy/shared/migration.yaml b/deploy/shared/migration.yaml index b5161114..4944aa0c 100644 --- a/deploy/shared/migration.yaml +++ b/deploy/shared/migration.yaml @@ -16,6 +16,8 @@ spec: containers: - name: migration image: $CONTAINER_IMAGE + securityContext: + allowPrivilegeEscalation: false command: [ "/bin/sh", "-c" ] From abd03be806e8ca2f61373c147eb6019ca6f5c0fd Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 27 Jan 2020 15:00:20 -0500 Subject: [PATCH 34/35] Store and pull tenant creds from Key Vault. The tenant ID should be hashed and used as the key for the JSON blob of relevant creds for any given tenant. Azure CSP interface methods that need to source creds should call the internal `_source_creds` method, either with a `tenant_id` or no parameters. That method will source the creds. If a tenant ID is provided, it will source them from the Key Vault. If not provided, it will return the default creds for the app registration in the home tenant. --- atst/domain/csp/cloud/azure_cloud_provider.py | 61 +++++++++++-------- atst/domain/csp/cloud/mock_cloud_provider.py | 8 ++- atst/domain/csp/cloud/models.py | 57 ++++++++++++++++- atst/jobs.py | 5 +- atst/models/portfolio_state_machine.py | 2 +- atst/utils/__init__.py | 6 ++ tests/domain/cloud/test_azure_csp.py | 23 ++++++- .../{test_payloads.py => test_models.py} | 51 ++++++++++++---- tests/mock_azure.py | 7 +++ tests/utils/test_hash.py | 16 +++++ 10 files changed, 186 insertions(+), 50 deletions(-) rename tests/domain/cloud/{test_payloads.py => test_models.py} (54%) create mode 100644 tests/utils/test_hash.py diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 0ed18d9d..84a9238c 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -1,3 +1,4 @@ +import json import re from secrets import token_urlsafe from typing import Dict @@ -16,6 +17,7 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + KeyVaultCredentials, ManagementGroupCSPResponse, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, @@ -25,6 +27,7 @@ from .models import ( TenantCSPResult, ) from .policy import AzurePolicyManager +from atst.utils import sha256_hex AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI @@ -85,7 +88,7 @@ class AzureCloudProvider(CloudProviderInterface): def set_secret(self, secret_key, secret_value): credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( + secret_client = self.sdk.secrets.SecretClient( vault_url=self.vault_url, credential=credential, ) try: @@ -98,7 +101,7 @@ class AzureCloudProvider(CloudProviderInterface): def get_secret(self, secret_key): credential = self._get_client_secret_credential_obj({}) - secret_client = self.secrets.SecretClient( + secret_client = self.sdk.secrets.SecretClient( vault_url=self.vault_url, credential=credential, ) try: @@ -166,8 +169,15 @@ class AzureCloudProvider(CloudProviderInterface): } def create_application(self, payload: ApplicationCSPPayload): - creds = payload.creds - credentials = self._get_credential_obj(creds, resource=AZURE_MANAGEMENT_API) + 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, + }, + resource=AZURE_MANAGEMENT_API, + ) response = self._create_management_group( credentials, @@ -632,26 +642,23 @@ class AzureCloudProvider(CloudProviderInterface): "tenant_id": self.tenant_id, } - def get_credentials(self, scope="portfolio", tenant_id=None): - """ - This could be implemented to determine, based on type, whether to return creds for: - - scope="atat": the ATAT main app registration in ATAT's home tenant - - scope="tenantadmin": the tenant administrator credentials - - scope="portfolio": the credentials for the ATAT SP in the portfolio tenant - """ - if scope == "atat": - return self._root_creds - elif scope == "tenantadmin": - # magic with key vault happens - return { - "client_id": "some id", - "secret_key": "very secret", - "tenant_id": tenant_id, - } - elif scope == "portfolio": - # magic with key vault happens - return { - "client_id": "some id", - "secret_key": "very secret", - "tenant_id": tenant_id, - } + def _source_creds(self, tenant_id=None) -> KeyVaultCredentials: + if tenant_id: + return self._source_tenant_creds(tenant_id) + else: + return KeyVaultCredentials( + root_tenant_id=self._root_creds.get("tenant_id"), + root_sp_client_id=self._root_creds.get("client_id"), + root_sp_key=self._root_creds.get("secret_key"), + ) + + def update_tenant_creds(self, tenant_id, secret): + hashed = sha256_hex(tenant_id) + self.set_secret(hashed, json.dumps(secret)) + + return secret + + def _source_tenant_creds(self, tenant_id): + hashed = sha256_hex(tenant_id) + raw_creds = self.get_secret(hashed) + return KeyVaultCredentials(**json.loads(raw_creds)) diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index 6df61003..10d62e15 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -347,8 +347,12 @@ class MockCloudProvider(CloudProviderInterface): def create_application(self, payload: ApplicationCSPPayload): self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) - id_ = f"{AZURE_MGMNT_PATH}{payload.management_group_name}" - return ApplicationCSPResult(id=id_) + return ApplicationCSPResult( + id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" + ) def get_credentials(self, scope="portfolio", tenant_id=None): return self.root_creds() + + def update_tenant_creds(self, tenant_id, secret): + return secret diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 369bed31..b4ff9232 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -2,7 +2,7 @@ from typing import Dict, List, Optional import re from uuid import uuid4 -from pydantic import BaseModel, validator +from pydantic import BaseModel, validator, root_validator from atst.utils import snake_to_camel @@ -241,7 +241,7 @@ AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/" MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$" -class ManagementGroupCSPPayload(BaseCSPPayload): +class ManagementGroupCSPPayload(AliasModel): """ :param: management_group_name: Just pass a UUID for this. :param: display_name: This can contain any character and @@ -250,6 +250,7 @@ class ManagementGroupCSPPayload(BaseCSPPayload): i.e. /providers/Microsoft.Management/managementGroups/[management group ID] """ + tenant_id: str management_group_name: Optional[str] display_name: str parent_id: str @@ -288,3 +289,55 @@ class ApplicationCSPPayload(ManagementGroupCSPPayload): class ApplicationCSPResult(ManagementGroupCSPResponse): pass + + +class KeyVaultCredentials(BaseModel): + root_sp_client_id: Optional[str] + root_sp_key: Optional[str] + root_tenant_id: Optional[str] + + tenant_id: Optional[str] + + tenant_admin_username: Optional[str] + tenant_admin_password: Optional[str] + + tenant_sp_client_id: Optional[str] + tenant_sp_key: Optional[str] + + @root_validator(pre=True) + def enforce_admin_creds(cls, values): + tenant_id = values.get("tenant_id") + username = values.get("tenant_admin_username") + password = values.get("tenant_admin_password") + if any([username, password]) and not all([tenant_id, username, password]): + raise ValueError( + "tenant_id, tenant_admin_username, and tenant_admin_password must all be set if any one is" + ) + + return values + + @root_validator(pre=True) + def enforce_sp_creds(cls, values): + tenant_id = values.get("tenant_id") + client_id = values.get("tenant_sp_client_id") + key = values.get("tenant_sp_key") + if any([client_id, key]) and not all([tenant_id, client_id, key]): + raise ValueError( + "tenant_id, tenant_sp_client_id, and tenant_sp_key must all be set if any one is" + ) + + return values + + @root_validator(pre=True) + def enforce_root_creds(cls, values): + sp_creds = [ + values.get("root_tenant_id"), + values.get("root_sp_client_id"), + values.get("root_sp_key"), + ] + if any(sp_creds) and not all(sp_creds): + raise ValueError( + "root_tenant_id, root_sp_client_id, and root_sp_key must all be set if any one is" + ) + + return values diff --git a/atst/jobs.py b/atst/jobs.py index 7a4a3792..14256336 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -59,15 +59,14 @@ def do_create_application(csp: CloudProviderInterface, application_id=None): with claim_for_update(application) as application: - if application.cloud_id is not None: + if application.cloud_id: return csp_details = application.portfolio.csp_data parent_id = csp_details.get("root_management_group_id") tenant_id = csp_details.get("tenant_id") - creds = csp.get_credentials(tenant_id) payload = ApplicationCSPPayload( - creds=creds, display_name=application.name, parent_id=parent_id + tenant_id=tenant_id, display_name=application.name, parent_id=parent_id ) app_result = csp.create_application(payload) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index cf42710b..be9324b1 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -175,7 +175,7 @@ class PortfolioStateMachine( tenant_id = new_creds.get("tenant_id") secret = self.csp.get_secret(tenant_id, new_creds) secret.update(new_creds) - self.csp.set_secret(tenant_id, secret) + self.csp.update_tenant_creds(tenant_id, secret) except PydanticValidationError as exc: app.logger.error( f"Failed to cast response to valid result class {self.__repr__()}:", diff --git a/atst/utils/__init__.py b/atst/utils/__init__.py index 09c63dea..79d5362a 100644 --- a/atst/utils/__init__.py +++ b/atst/utils/__init__.py @@ -1,3 +1,4 @@ +import hashlib import re from sqlalchemy.exc import IntegrityError @@ -41,3 +42,8 @@ def commit_or_raise_already_exists_error(message): except IntegrityError: db.session.rollback() raise AlreadyExistsError(message) + + +def sha256_hex(string): + hsh = hashlib.sha256(string.encode()) + return hsh.digest().hex() diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index 0d23d6c0..39fa2f77 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -1,5 +1,7 @@ +import pytest +import json from uuid import uuid4 -from unittest.mock import Mock +from unittest.mock import Mock, patch from tests.factories import ApplicationFactory, EnvironmentFactory from tests.mock_azure import AUTH_CREDENTIALS, mock_azure @@ -84,13 +86,28 @@ def test_create_environment_succeeds(mock_azure: AzureCloudProvider): assert result.id == "Test Id" +# mock the get_secret so it returns a JSON string +MOCK_CREDS = { + "tenant_id": str(uuid4()), + "tenant_sp_client_id": str(uuid4()), + "tenant_sp_key": "1234", +} + + +def mock_get_secret(azure, func): + azure.get_secret = func + + return azure + + def test_create_application_succeeds(mock_azure: AzureCloudProvider): application = ApplicationFactory.create() - mock_management_group_create(mock_azure, {"id": "Test Id"}) + mock_azure = mock_get_secret(mock_azure, lambda *a, **k: json.dumps(MOCK_CREDS)) + payload = ApplicationCSPPayload( - creds={}, display_name=application.name, parent_id=str(uuid4()) + tenant_id="1234", display_name=application.name, parent_id=str(uuid4()) ) result = mock_azure.create_application(payload) diff --git a/tests/domain/cloud/test_payloads.py b/tests/domain/cloud/test_models.py similarity index 54% rename from tests/domain/cloud/test_payloads.py rename to tests/domain/cloud/test_models.py index d92a4840..d9fc963d 100644 --- a/tests/domain/cloud/test_payloads.py +++ b/tests/domain/cloud/test_models.py @@ -4,6 +4,7 @@ from pydantic import ValidationError from atst.domain.csp.cloud.models import ( AZURE_MGMNT_PATH, + KeyVaultCredentials, ManagementGroupCSPPayload, ManagementGroupCSPResponse, ) @@ -12,25 +13,25 @@ from atst.domain.csp.cloud.models import ( def test_ManagementGroupCSPPayload_management_group_name(): # supplies management_group_name when absent payload = ManagementGroupCSPPayload( - creds={}, display_name="Council of Naboo", parent_id="Galactic_Senate" + tenant_id="any-old-id", + display_name="Council of Naboo", + parent_id="Galactic_Senate", ) assert payload.management_group_name # validates management_group_name with pytest.raises(ValidationError): payload = ManagementGroupCSPPayload( - creds={}, + tenant_id="any-old-id", management_group_name="council of Naboo 1%^&", display_name="Council of Naboo", parent_id="Galactic_Senate", ) # shortens management_group_name to fit - name = "council_of_naboo" - for _ in range(90): - name = f"{name}1" + name = "council_of_naboo".ljust(95, "1") assert len(name) > 90 payload = ManagementGroupCSPPayload( - creds={}, + tenant_id="any-old-id", management_group_name=name, display_name="Council of Naboo", parent_id="Galactic_Senate", @@ -40,12 +41,10 @@ def test_ManagementGroupCSPPayload_management_group_name(): def test_ManagementGroupCSPPayload_display_name(): # shortens display_name to fit - name = "Council of Naboo" - for _ in range(90): - name = f"{name}1" + name = "Council of Naboo".ljust(95, "1") assert len(name) > 90 payload = ManagementGroupCSPPayload( - creds={}, display_name=name, parent_id="Galactic_Senate" + tenant_id="any-old-id", display_name=name, parent_id="Galactic_Senate" ) assert len(payload.display_name) == 90 @@ -54,12 +53,14 @@ def test_ManagementGroupCSPPayload_parent_id(): full_path = f"{AZURE_MGMNT_PATH}Galactic_Senate" # adds full path payload = ManagementGroupCSPPayload( - creds={}, display_name="Council of Naboo", parent_id="Galactic_Senate" + tenant_id="any-old-id", + display_name="Council of Naboo", + parent_id="Galactic_Senate", ) assert payload.parent_id == full_path # keeps full path payload = ManagementGroupCSPPayload( - creds={}, display_name="Council of Naboo", parent_id=full_path + tenant_id="any-old-id", display_name="Council of Naboo", parent_id=full_path ) assert payload.parent_id == full_path @@ -70,3 +71,29 @@ def test_ManagementGroupCSPResponse_id(): **{"id": "/path/to/naboo-123", "other": "stuff"} ) assert response.id == full_id + + +def test_KeyVaultCredentials_enforce_admin_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(tenant_id="an id", tenant_admin_username="C3PO") + assert KeyVaultCredentials( + tenant_id="an id", + tenant_admin_username="C3PO", + tenant_admin_password="beep boop", + ) + + +def test_KeyVaultCredentials_enforce_sp_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(tenant_id="an id", tenant_sp_client_id="C3PO") + assert KeyVaultCredentials( + tenant_id="an id", tenant_sp_client_id="C3PO", tenant_sp_key="beep boop" + ) + + +def test_KeyVaultCredentials_enforce_root_creds(): + with pytest.raises(ValidationError): + KeyVaultCredentials(root_tenant_id="an id", root_sp_client_id="C3PO") + assert KeyVaultCredentials( + root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop" + ) diff --git a/tests/mock_azure.py b/tests/mock_azure.py index 7fa67667..4f37848e 100644 --- a/tests/mock_azure.py +++ b/tests/mock_azure.py @@ -72,6 +72,12 @@ def mock_secrets(): return Mock(spec=secrets) +def mock_identity(): + import azure.identity as identity + + return Mock(spec=identity) + + class MockAzureSDK(object): def __init__(self): from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD @@ -88,6 +94,7 @@ class MockAzureSDK(object): self.requests = mock_requests() # may change to a JEDI cloud self.cloud = AZURE_PUBLIC_CLOUD + self.identity = mock_identity() @pytest.fixture(scope="function") diff --git a/tests/utils/test_hash.py b/tests/utils/test_hash.py new file mode 100644 index 00000000..5cfb8489 --- /dev/null +++ b/tests/utils/test_hash.py @@ -0,0 +1,16 @@ +import random +import re +import string + +from atst.utils import sha256_hex + + +def test_sha256_hex(): + sample = "".join( + random.choices(string.ascii_uppercase + string.digits, k=random.randrange(200)) + ) + hashed = sha256_hex(sample) + assert re.match("^[a-zA-Z0-9]+$", hashed) + assert len(hashed) == 64 + hashed_again = sha256_hex(sample) + assert hashed == hashed_again From 6edc7b138b0745d0b3f9d99da76c445fe954c5a7 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 29 Jan 2020 14:39:56 -0500 Subject: [PATCH 35/35] Set SESSION_COOKIE_SECURE for deployed environments. This sets the "Secure" attribute on cookies sent to the client: https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_SECURE --- README.md | 1 + config/base.ini | 1 + deploy/azure/atst-envvars-configmap.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 2681346e..d846d486 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ To generate coverage reports for the Javascript tests: - `SESSION_COOKIE_DOMAIN`: String value specifying the name to use for the session cookie. This should be set to the root domain so that it is valid for both the main site and the authentication subdomain. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_DOMAIN - `SESSION_KEY_PREFIX`: A prefix that is added before all session keys: https://pythonhosted.org/Flask-Session/#configuration - `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/ +- `SESSION_COOKIE_SECURE`: https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_SECURE - `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed. - `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout. - `STATIC_URL`: URL specifying where static assets are hosted. diff --git a/config/base.ini b/config/base.ini index 6fbcce73..3504e3cd 100644 --- a/config/base.ini +++ b/config/base.ini @@ -43,6 +43,7 @@ SERVER_NAME SESSION_COOKIE_NAME=atat SESSION_COOKIE_DOMAIN SESSION_KEY_PREFIX=session: +SESSION_COOKIE_SECURE=false SESSION_TYPE = redis SESSION_USE_SIGNER = True SQLALCHEMY_ECHO = False diff --git a/deploy/azure/atst-envvars-configmap.yml b/deploy/azure/atst-envvars-configmap.yml index edd049a7..0d3e5312 100644 --- a/deploy/azure/atst-envvars-configmap.yml +++ b/deploy/azure/atst-envvars-configmap.yml @@ -32,6 +32,7 @@ data: REDIS_HOST: atat.redis.cache.windows.net:6380 REDIS_TLS: "true" SESSION_COOKIE_DOMAIN: atat.code.mil + SESSION_COOKIE_SECURE: "true" STATIC_URL: https://atat-cdn.azureedge.net/static/ TZ: UTC UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini