Merge branch 'staging' into cloudzero-k8s

This commit is contained in:
dandds 2020-01-21 10:23:50 -05:00 committed by GitHub
commit 2ab9790f3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 2025 additions and 491 deletions

View File

@ -152,7 +152,7 @@
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
"is_secret": false,
"is_verified": false,
"line_number": 665,
"line_number": 649,
"type": "Hex High Entropy String"
}
]

View File

@ -73,7 +73,8 @@ RUN apk update && \
postgresql-client \
postgresql-dev \
postgresql-libs \
uwsgi-logfile
uwsgi-logfile \
uwsgi-python3
COPY --from=builder /install/.venv/ ./.venv/
COPY --from=builder /install/alembic/ ./alembic/

View File

@ -159,6 +159,7 @@ def map_config(config):
"ENV": config["default"]["ENVIRONMENT"],
"BROKER_URL": config["default"]["REDIS_URI"],
"DEBUG": config["default"].getboolean("DEBUG"),
"DEBUG_MAILER": config["default"].getboolean("DEBUG_MAILER"),
"SQLALCHEMY_ECHO": config["default"].getboolean("SQLALCHEMY_ECHO"),
"PORT": int(config["default"]["PORT"]),
"SQLALCHEMY_DATABASE_URI": config["default"]["DATABASE_URI"],
@ -289,7 +290,7 @@ def make_crl_validator(app):
def make_mailer(app):
if app.config["DEBUG"]:
if app.config["DEBUG"] or app.config["DEBUG_MAILER"]:
mailer_connection = mailer.RedisConnection(app.redis)
else:
mailer_connection = mailer.SMTPConnection(

View File

@ -19,9 +19,6 @@ from atst.domain.exceptions import UnauthorizedError
def filter_perm_sets_data(member):
perm_sets_data = {
"perms_portfolio_mgmt": bool(
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN)
),
"perms_app_mgmt": bool(
member.has_permission_set(
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
@ -33,24 +30,43 @@ def filter_perm_sets_data(member):
"perms_reporting": bool(
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_REPORTS)
),
"perms_portfolio_mgmt": bool(
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN)
),
}
return perm_sets_data
def filter_members_data(members_list, portfolio):
def filter_members_data(members_list):
members_data = []
for member in members_list:
members_data.append(
{
"role_id": member.id,
"user_name": member.user_name,
"permission_sets": filter_perm_sets_data(member),
"status": member.display_status,
"ppoc": PermissionSets.PORTFOLIO_POC in member.permission_sets,
# add in stuff here for forms
}
permission_sets = filter_perm_sets_data(member)
ppoc = (
PermissionSets.get(PermissionSets.PORTFOLIO_POC) in member.permission_sets
)
member_data = {
"role_id": member.id,
"user_name": member.user_name,
"permission_sets": filter_perm_sets_data(member),
"status": member.display_status,
"ppoc": ppoc,
"form": member_forms.PermissionsForm(permission_sets),
}
if not ppoc:
member_data["update_invite_form"] = (
member_forms.NewForm(user_data=member.latest_invitation)
if member.latest_invitation and member.latest_invitation.can_resend
else member_forms.NewForm()
)
member_data["invite_token"] = (
member.latest_invitation.token
if member.latest_invitation and member.latest_invitation.can_resend
else None
)
members_data.append(member_data)
return sorted(members_data, key=lambda member: member["user_name"])
@ -75,7 +91,7 @@ def render_admin_page(portfolio, form=None):
"portfolios/admin.html",
form=form,
portfolio_form=portfolio_form,
members=filter_members_data(member_list, portfolio),
members=filter_members_data(member_list),
new_manager_form=member_forms.NewForm(),
assign_ppoc_form=assign_ppoc_form,
portfolio=portfolio,
@ -93,26 +109,27 @@ def admin(portfolio_id):
return render_admin_page(portfolio)
@portfolios_bp.route("/portfolios/<portfolio_id>/update_ppoc", methods=["POST"])
@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc")
def update_ppoc(portfolio_id):
role_id = http_request.form.get("role_id")
portfolio = Portfolios.get(g.current_user, portfolio_id)
new_ppoc_role = PortfolioRoles.get_by_id(role_id)
PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role)
flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name)
return redirect(
url_for(
"portfolios.admin",
portfolio_id=portfolio.id,
fragment="primary-point-of-contact",
_anchor="primary-point-of-contact",
)
)
# Updating PPoC is a post-MVP feature
# @portfolios_bp.route("/portfolios/<portfolio_id>/update_ppoc", methods=["POST"])
# @user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc")
# def update_ppoc(portfolio_id): # pragma: no cover
# role_id = http_request.form.get("role_id")
#
# portfolio = Portfolios.get(g.current_user, portfolio_id)
# new_ppoc_role = PortfolioRoles.get_by_id(role_id)
#
# PortfolioRoles.make_ppoc(portfolio_role=new_ppoc_role)
#
# flash("primary_point_of_contact_changed", ppoc_name=new_ppoc_role.full_name)
#
# return redirect(
# url_for(
# "portfolios.admin",
# portfolio_id=portfolio.id,
# fragment="primary-point-of-contact",
# _anchor="primary-point-of-contact",
# )
# )
@portfolios_bp.route("/portfolios/<portfolio_id>/edit", methods=["POST"])
@ -166,3 +183,30 @@ def remove_member(portfolio_id, portfolio_role_id):
fragment="portfolio-members",
)
)
@portfolios_bp.route(
"/portfolios/<portfolio_id>/members/<portfolio_role_id>", methods=["POST"]
)
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members")
def update_member(portfolio_id, portfolio_role_id):
form_data = http_request.form
form = member_forms.PermissionsForm(formdata=form_data)
portfolio_role = PortfolioRoles.get_by_id(portfolio_role_id)
portfolio = Portfolios.get(user=g.current_user, portfolio_id=portfolio_id)
if form.validate() and portfolio.owner_role != portfolio_role:
PortfolioRoles.update(portfolio_role, form.data["permission_sets"])
flash("update_portfolio_member", member_name=portfolio_role.full_name)
return redirect(
url_for(
"portfolios.admin",
portfolio_id=portfolio_id,
_anchor="portfolio-members",
fragment="portfolio-members",
)
)
else:
flash("update_portfolio_member_error", member_name=portfolio_role.full_name)
return (render_admin_page(portfolio), 400)

View File

@ -54,13 +54,22 @@ def revoke_invitation(portfolio_id, portfolio_token):
)
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation")
def resend_invitation(portfolio_id, portfolio_token):
invite = PortfolioInvitations.resend(g.current_user, portfolio_token)
send_portfolio_invitation(
invitee_email=invite.email,
inviter_name=g.current_user.full_name,
token=invite.token,
)
flash("resend_portfolio_invitation", user_name=invite.user_name)
form = member_forms.NewForm(http_request.form)
if form.validate():
invite = PortfolioInvitations.resend(
g.current_user, portfolio_token, form.data["user_data"]
)
send_portfolio_invitation(
invitee_email=invite.email,
inviter_name=g.current_user.full_name,
token=invite.token,
)
flash("resend_portfolio_invitation", user_name=invite.user_name)
else:
user_name = f"{form['user_data']['first_name'].data} {form['user_data']['last_name'].data}"
flash("resend_portfolio_invitation_error", user_name=user_name)
return redirect(
url_for(
"portfolios.admin",

View File

@ -128,6 +128,11 @@ MESSAGES = {
"message": "flash.portfolio_invite.resent.message",
"category": "success",
},
"resend_portfolio_invitation_error": {
"title": "flash.portfolio_invite.error.title",
"message": "flash.portfolio_invite.error.message",
"category": "error",
},
"revoked_portfolio_access": {
"title": "flash.portfolio_member.revoked.title",
"message": "flash.portfolio_member.revoked.message",
@ -153,6 +158,16 @@ MESSAGES = {
"message": "flash.task_order.submitted.message",
"category": "success",
},
"update_portfolio_member": {
"title": "flash.portfolio_member.update.title",
"message": "flash.portfolio_member.update.message",
"category": "success",
},
"update_portfolio_member_error": {
"title": "flash.portfolio_member.update_error.title",
"message": "flash.portfolio_member.update_error.message",
"category": "error",
},
"updated_application_team_settings": {
"title": "flash.success",
"message": "flash.updated_application_team_settings",

View File

@ -15,6 +15,7 @@ CRL_FAIL_OPEN = false
CRL_STORAGE_CONTAINER = crls
CSP=mock
DEBUG = true
DEBUG_MAILER = false
DISABLE_CRL_CHECK = false
ENVIRONMENT = dev
LIMIT_CONCURRENT_SESSIONS = false

View File

@ -29,6 +29,13 @@ spec:
containers:
- name: atst
image: $CONTAINER_IMAGE
env:
- name: UWSGI_PROCESSES
value: "2"
- name: UWSGI_THREADS
value: "2"
- name: UWSGI_ENABLE_THREADS
value: "1"
envFrom:
- configMapRef:
name: atst-envvars
@ -50,11 +57,11 @@ spec:
mountPath: "/config"
resources:
requests:
memory: 200Mi
cpu: 400m
memory: 400Mi
cpu: 940m
limits:
memory: 200Mi
cpu: 400m
memory: 400Mi
cpu: 940m
- name: nginx
image: nginx:alpine
ports:
@ -86,10 +93,10 @@ spec:
resources:
requests:
memory: 20Mi
cpu: 10m
cpu: 25m
limits:
memory: 20Mi
cpu: 10m
cpu: 25m
volumes:
- name: nginx-client-ca-bundle
configMap:
@ -309,6 +316,7 @@ metadata:
namespace: atat
spec:
loadBalancerIP: 13.92.235.6
externalTrafficPolicy: Local
ports:
- port: 80
targetPort: 8342
@ -329,6 +337,7 @@ metadata:
namespace: atat
spec:
loadBalancerIP: 23.100.24.41
externalTrafficPolicy: Local
ports:
- port: 80
targetPort: 8343

View File

@ -10,6 +10,7 @@ data:
callable = app
module = app
socket = /var/run/uwsgi/uwsgi.socket
plugins-dir = /usr/lib/uwsgi
plugin = python3
plugin = logfile
virtualenv = /opt/atat/atst/.venv

View File

@ -0,0 +1,16 @@
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: atst
spec:
minReplicas: 1
maxReplicas: 2
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: atst-worker
spec:
minReplicas: 1
maxReplicas: 2

View File

@ -5,6 +5,7 @@ resources:
- namespace.yml
- reset-cron-job.yml
patchesStrategicMerge:
- autoscaling.yml
- ports.yml
- envvars.yml
- flex_vol.yml

View File

@ -40,9 +40,7 @@ reset_db() {
local database_name="${1}"
# If the DB exists, drop it
set +e
dropdb "${database_name}"
set -e
dropdb --if-exists "${database_name}"
# Create a fresh DB
createdb "${database_name}"

View File

@ -22,7 +22,7 @@ check_for_existing_virtual_environment() {
local target_python_version_regex="^Python ${python_version}"
# Check for existing venv, and if one exists, save the Python version string
existing_venv_version=$($(pipenv --py) --version)
existing_venv_version=$($(pipenv --py 2> /dev/null) --version 2> /dev/null)
if [ "$?" = "0" ]; then
# Existing venv; see if the Python version matches
if [[ "${existing_venv_version}" =~ ${target_python_version_regex} ]]; then

View File

@ -6,7 +6,7 @@
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# create upload directory for app
mkdir uploads | true
mkdir -p uploads
# Enable database resetting
RESET_DB="true"

View File

@ -39,6 +39,7 @@
@import "components/sticky_cta.scss";
@import "components/error_page.scss";
@import "components/member_form.scss";
@import "components/toggle_menu.scss";
@import "sections/login";
@import "sections/home";

View File

@ -130,10 +130,6 @@
&--th {
width: 50%;
}
&--td {
position: relative;
}
}
.row {
@ -154,55 +150,6 @@
margin-right: $gap * 6;
}
}
.app-member-menu {
position: absolute;
top: $gap;
right: $gap * 2;
.accordion-table__item__toggler {
padding: $gap / 3;
border: 1px solid $color-gray-lighter;
border-radius: 3px;
cursor: pointer;
&:hover,
&--active {
background-color: $color-aqua-lightest;
}
.icon {
margin: $gap / 2;
}
}
&__toggle {
position: absolute;
right: 0;
top: 30px;
background-color: $color-white;
border: 1px solid $color-gray-light;
z-index: 1;
margin-top: 0;
a {
display: block;
padding: $gap;
border-bottom: 1px solid $color-gray-lighter;
text-decoration: none;
color: $color-black;
cursor: pointer;
&:last-child {
border-bottom: 0;
}
&:hover {
background-color: $color-aqua-lightest;
}
}
}
}
}
#add-new-env {

View File

@ -0,0 +1,58 @@
.toggle-menu {
position: absolute;
top: $gap;
right: $gap * 2;
&__container {
position: relative;
}
.accordion-table__item__toggler {
padding: $gap / 3;
border: 1px solid $color-gray-lighter;
border-radius: 3px;
cursor: pointer;
&:hover,
&--active {
background-color: $color-aqua-lightest;
}
.icon {
margin: $gap / 2;
}
}
&__toggle {
position: absolute;
right: 0;
top: 30px;
background-color: $color-white;
border: 1px solid $color-gray-light;
z-index: 1;
margin-top: 0;
a {
display: block;
padding: $gap;
border-bottom: 1px solid $color-gray-lighter;
text-decoration: none;
color: $color-black;
cursor: pointer;
white-space: nowrap;
&:last-child {
border-bottom: 0;
}
&:hover {
background-color: $color-aqua-lightest;
}
&.disabled {
color: $color-gray;
pointer-events: none;
}
}
}
}

View File

@ -12,10 +12,13 @@
flex-direction: row;
align-items: stretch;
justify-content: space-between;
a {
color: $color-white;
}
}
&__link {
color: $color-white !important;
display: inline-flex;
align-items: center;
height: $topbar-height;
@ -23,20 +26,28 @@
text-decoration: none;
&-label {
@include h5;
text-decoration: underline;
padding-left: $gap;
font-size: $h5-font-size;
font-weight: $font-semibold;
text-decoration: none;
}
&-icon {
margin-left: $gap;
margin: 0 $gap 0 0;
@include icon-color($color-white);
}
.icon--logout {
margin: 0 0 0 $gap;
}
&--home {
padding-left: $gap / 2;
padding: 0 ($gap * 2);
.topbar__link-label {
font-size: $base-font-size;
font-weight: $font-bold;
text-transform: uppercase;
}
}
&:hover {

View File

@ -5,6 +5,7 @@
{% from "components/modal.html" import Modal %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from "components/save_button.html" import SaveButton %}
{% from "components/toggle_menu.html" import ToggleMenu %}
{% macro MemberManagementTemplate(
application,
@ -38,16 +39,17 @@
{% call Modal(modal_name, classes="form-content--app-mem") %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
<hr class="full-width">
</div>
<base-form inline-template>
<form id='{{ modal_name }}' method="POST" action="{{ url_for(action_update, application_id=application.id, application_role_id=member.role_id,) }}">
{{ member.form.csrf_token }}
{{ member_fields.PermsFields(form=member.form, member_role_id=member.role_id) }}
<div class="action-group">
{{ SaveButton(text='Update', element='input', additional_classes='action-group__action') }}
<a class='action-group__action usa-button usa-button-secondary' v-on:click="closeModal('{{ modal_name }}')">{{ "common.cancel" | translate }}</a>
</div>
{{ member_form.SubmitStep(
name=modal_name,
form=member_fields.PermsFields(form=member.form, member_role_id=member.role_id),
submit_text="Update",
previous=False,
modal=modal_name,
) }}
</form>
</base-form>
{% endcall %}
@ -57,16 +59,17 @@
{% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
<div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
<hr class="full-width">
</div>
<base-form inline-template :enable-save="true">
<form id='{{ resend_invite_modal }}' method="POST" action="{{ url_for('applications.resend_invite', application_id=application.id, application_role_id=member.role_id) }}">
{{ member.update_invite_form.csrf_token }}
{{ member_fields.InfoFields(member.update_invite_form) }}
<div class="action-group">
{{ SaveButton(text="Resend Invite")}}
<a class='action-group__action' v-on:click="closeModal('{{ resend_invite_modal }}')">{{ "common.cancel" | translate }}</a>
</div>
{{ member_form.SubmitStep(
name=resend_invite_modal,
form=member_fields.InfoFields(member.update_invite_form),
submit_text="Resend Invite",
previous=False,
modal=resend_invite_modal,
) }}
</form>
</base-form>
{% endcall %}
@ -119,7 +122,7 @@
</div>
{% endfor %}
</td>
<td class="env_role--td">
<td class="toggle-menu__container">
{% for env in member.environment_roles %}
<div class="row">
<span class="env-role__environment">
@ -131,32 +134,21 @@
</div>
{% endfor %}
{% if user_can(permissions.EDIT_APPLICATION_MEMBER) -%}
<toggle-menu inline-template v-cloak>
<div class="app-member-menu">
<span v-if="isVisible" class="accordion-table__item__toggler accordion-table__item__toggler--active">
{{ Icon('ellipsis')}}
</span>
<span v-else class="accordion-table__item__toggler">
{{ Icon('ellipsis')}}
</span>
<div v-show="isVisible" class="accordion-table__item-toggle-content app-member-menu__toggle">
<a v-on:click="openModal('{{ perms_modal }}')">
{{ "portfolios.applications.members.menu.edit" | translate }}
</a>
{% if invite_pending or invite_expired -%}
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
<a v-on:click='openModal("{{ resend_invite_modal }}")'>
{{ "portfolios.applications.members.menu.resend" | translate }}
</a>
{% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%}
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke' | translate }}</a>
{%- endif %}
{%- endif %}
</div>
</div>
</toggle-menu>
{% call ToggleMenu() %}
<a v-on:click="openModal('{{ perms_modal }}')">
{{ "portfolios.applications.members.menu.edit" | translate }}
</a>
{% if invite_pending or invite_expired -%}
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
<a v-on:click='openModal("{{ resend_invite_modal }}")'>
{{ "portfolios.applications.members.menu.resend" | translate }}
</a>
{% if user_can(permissions.DELETE_APPLICATION_MEMBER) -%}
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke' | translate }}</a>
{%- endif %}
{%- endif %}
{% endcall %}
{%- endif %}
</td>
</tr>

View File

@ -25,7 +25,7 @@
<div class='usa-alert-body'>
{% if vue_template %}
<h3 class='usa-alert-heading' v-html='title'></h3>
<h3 class='usa-alert-heading' v-text='title'></h3>
{% elif title %}
<h3 class='usa-alert-heading'>{{ title | safe }}</h3>
{% endif %}

View File

@ -57,7 +57,7 @@
<span class='usa-input__message'>{{ "forms.task_order.clin_funding_errors.obligated_amount_error" | translate }}</span>
</template>
<template v-else-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>

View File

@ -68,7 +68,7 @@
<input type='hidden' v-bind:value='rawValue' :name='name' />
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
<template v-else>
<span class='usa-input__message'></span>

View File

@ -54,7 +54,7 @@
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
</fieldset>

View File

@ -48,7 +48,7 @@
{{ field(disabled=disabled) }}
<template v-if='showError'>
<span class='usa-input__message' v-html='validationError'></span>
<span class='usa-input__message' v-text='validationError'></span>
</template>
</fieldset>

View File

@ -107,7 +107,7 @@
/>
{% if show_validation %}
<span v-if='showError' class='usa-input__message' v-html='validationError'></span>
<span v-if='showError' class='usa-input__message' v-text='validationError'></span>
{% endif %}
</div>

View File

@ -0,0 +1,17 @@
{% from "components/icon.html" import Icon %}
{% macro ToggleMenu() %}
<toggle-menu inline-template v-cloak>
<div class="toggle-menu">
<span v-if="isVisible" class="accordion-table__item__toggler accordion-table__item__toggler--active">
{{ Icon('ellipsis')}}
</span>
<span v-else class="accordion-table__item__toggler">
{{ Icon('ellipsis')}}
</span>
<div v-show="isVisible" class="accordion-table__item-toggle-content toggle-menu__toggle">
{{ caller() }}
</div>
</div>
</toggle-menu>
{% endmacro %}

View File

@ -59,10 +59,6 @@
<hr>
{% if user_can(permissions.VIEW_PORTFOLIO_POC) %}
{% include "portfolios/fragments/primary_point_of_contact.html" %}
{% endif %}
{% if user_can(permissions.VIEW_PORTFOLIO_USERS) %}
{% include "portfolios/fragments/portfolio_members.html" %}
{% endif %}

View File

@ -1,80 +0,0 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from "components/alert.html" import Alert %}
{% from "components/options_input.html" import OptionsInput %}
{% set step_one %}
<hr class="full-width">
<h1>{{ "fragments.ppoc.update_ppoc_title" | translate }}</h1>
{{
Alert(
level="warning",
title=("fragments.ppoc.alert.title" | translate),
message=("fragments.ppoc.alert.message" | translate),
)
}}
<div class='form-row'>
<div class='form-col form-col--half'>
{{
OptionsInput(
assign_ppoc_form.role_id,
optional=False
)
}}
</div>
<div class='form-col form-col--half'>
</div>
</div>
<div class='action-group'>
<input
type='button'
v-on:click="next()"
v-bind:disabled="!canSave"
class='action-group__action usa-button'
value='{{ "fragments.ppoc.assign_user_button_text" | translate }}'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('change-ppoc-form')">
{{ "common.cancel" | translate }}
</a>
</div>
{% endset %}
{% set step_two %}
<hr class="full-width">
<h1>{{ "fragments.ppoc.update_ppoc_confirmation_title" | translate }}</h1>
{{
Alert(
level="info",
title=("fragments.ppoc.confirm_alert.title" | translate),
)
}}
<div class='action-group'>
<input
type="submit"
class='action-group__action usa-button'
form="change-ppoc-form"
value='{{ "common.confirm" | translate }}'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('change-ppoc-form')">
{{ "common.cancel" | translate }}
</a>
</div>
{% endset %}
<div class="flex-reverse-row">
{% set disable_ppoc_button = 1 == portfolio.members |length %}
<button type="button" class="usa-button usa-button-primary" v-on:click="openModal('change-ppoc-form')" {% if disable_ppoc_button %}disabled{% endif %}>
{{ "fragments.ppoc.update_btn" | translate }}
</button>
{{
MultiStepModalForm(
'change-ppoc-form',
assign_ppoc_form,
form_action=url_for("portfolios.update_ppoc", portfolio_id=portfolio.id),
steps=[step_one, step_two],
)
}}
</div>

View File

@ -5,6 +5,92 @@
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from 'components/save_button.html' import SaveButton %}
{% import "portfolios/fragments/member_form_fields.html" as member_form_fields %}
{% from "components/toggle_menu.html" import ToggleMenu %}
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) -%}
{% for member in members -%}
{% if not member.ppoc -%}
{% set invite_pending = member.status == 'invite_pending' %}
{% set invite_expired = member.status == 'invite_expired' %}
{% set modal_name = "edit_member-{}".format(loop.index) %}
{% call Modal(modal_name, classes="form-content--app-mem") %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
</div>
<base-form inline-template>
<form id='{{ modal_name }}' method="POST" action="{{ url_for('portfolios.update_member', portfolio_id=portfolio.id, portfolio_role_id=member.role_id) }}">
{{ member.form.csrf_token }}
{{ member_form.SubmitStep(
name=modal_name,
form=member_form_fields.PermsFields(member.form, member_role_id=member.role_id),
submit_text="Save Changes",
previous=False,
modal=modal_name,
) }}
</form>
</base-form>
{% endcall %}
{% if invite_pending or invite_expired -%}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
{% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
<div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
</div>
<base-form inline-template :enable-save="true">
<form id='{{ resend_invite_modal }}' method="POST" action="{{ url_for('portfolios.resend_invitation', portfolio_id=portfolio.id, portfolio_token=member.invite_token) }}">
{{ member.update_invite_form.csrf_token }}
{{ member_form.SubmitStep(
name=resend_invite_modal,
form=member_form_fields.InfoFields(member.update_invite_form.user_data),
submit_text="Resend Invite",
previous=False,
modal=resend_invite_modal
) }}
</form>
</base-form>
{% endcall %}
{% set revoke_invite_modal = "revoke_invite-{}".format(member.role_id) %}
{% call Modal(name=revoke_invite_modal) %}
<form method="post" action="{{ url_for('portfolios.revoke_invitation', portfolio_id=portfolio.id, portfolio_token=member.invite_token) }}">
{{ member.form.csrf_token }}
<h1>{{ "invites.revoke" | translate }}</h1>
<hr class="full-width">
{{ "invites.revoke_modal_text" | translate({"application": portfolio.name}) }}
<div class="action-group">
<button class="action-group__action usa-button usa-button-primary" type="submit">{{ "invites.revoke" | translate }}</button>
<button class='action-group__action usa-button usa-button-secondary' v-on:click='closeModal("{{revoke_invite_modal}}")' type="button">{{ "common.cancel" | translate }}</button>
</div>
</form>
{% endcall %}
{% else %}
{% set remove_manager_modal = "remove_manager-{}".format(member.role_id) %}
{% call Modal(name=remove_manager_modal, dismissable=False) %}
<h1>{{ "portfolios.admin.alert_header" | translate }}</h1>
<hr class="full-width">
{{
Alert(
title="portfolios.admin.alert_title" | translate,
message="portfolios.admin.alert_message" | translate,
level="warning"
)
}}
<div class="action-group">
<form method="POST" action="{{ url_for('portfolios.remove_member', portfolio_id=portfolio.id, portfolio_role_id=member.role_id)}}">
{{ member.form.csrf_token }}
<button class="usa-button usa-button-danger">
{{ "portfolios.members.archive_button" | translate }}
</button>
</form>
<a v-on:click="closeModal('{{ modal_id }}')" class="action-group__action icon-link icon-link--default">{{ "common.cancel" | translate }}</a>
</div>
{% endcall %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endif %}
<h3>Portfolio Managers</h3>
<div class="panel">
@ -19,6 +105,14 @@
</thead>
<tbody>
{% for member in members -%}
{% set invite_pending = member.status == 'invite_pending' %}
{% set invite_expired = member.status == 'invite_expired' %}
{% set current_user = current_member_id == member.role_id %}
{% set perms_modal = "edit_member-{}".format(loop.index) %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
{% set revoke_invite_modal = "revoke_invite-{}".format(member.role_id) %}
{% set remove_manager_modal = "remove_manager-{}".format(member.role_id) %}
<tr>
<td>
<strong>{{ member.user_name }}{% if member.role_id == current_member_id %} (You){% endif %}</strong>
@ -28,14 +122,33 @@
{% endif %}
{{ Label(type=member.status, classes='label--below')}}
</td>
<td>
<td class="toggle-menu__container">
{% for perm, value in member.permission_sets.items() -%}
<div>
{% if value -%}
{% if value -%}
<div>
{{ ("portfolios.admin.members.{}.{}".format(perm, value)) | translate }}
{%- endif %}
</div>
</div>
{%- endif %}
{%-endfor %}
{% if user_can(permissions.EDIT_PORTFOLIO_USERS) -%}
{% call ToggleMenu() %}
<a
{% if not member.ppoc %}v-on:click="openModal('{{ perms_modal }}')"{% endif %}
class="{% if member.ppoc %}disabled{% endif %}">
Edit Permissions
</a>
{% if invite_pending or invite_expired -%}
<a v-on:click="openModal('{{ resend_invite_modal }}')">Resend Invite</a>
<a v-on:click="openModal('{{ revoke_invite_modal }}')">Revoke Invite</a>
{% else %}
<a
{% if not current_user %}v-on:click="openModal('{{ remove_manager_modal }}')"{% endif %}
class="{% if current_user %}disabled{% endif %}">
Remove Manager
</a>
{%- endif %}
{% endcall %}
{%- endif %}
</td>
</tr>
{%- endfor %}
@ -60,13 +173,13 @@
form=member_form_fields.InfoFields(new_manager_form.user_data),
next_button_text="Next: Permissions",
previous=False,
modal=new_manager_modal_name,
modal=new_manager_modal,
),
member_form.SubmitStep(
name=new_manager_modal,
form=member_form_fields.PermsFields(new_manager_form),
submit_text="Add Mananger",
modal=new_manager_modal_name,
modal=new_manager_modal,
)
],
) }}

View File

@ -1,25 +0,0 @@
<section id="primary-point-of-contact" class="panel">
<div class="panel__content">
{% if g.matchesPath("primary-point-of-contact") %}
{% include "fragments/flash.html" %}
{% endif %}
<h2>{{ "fragments.ppoc.title" | translate }}</h2>
<p>{{ "fragments.ppoc.subtitle" | translate }}</p>
<p>
<strong>
{{ portfolio.owner.first_name }}
{{ portfolio.owner.last_name }}
</strong>
<br />
{{ portfolio.owner.email }}
<br />
{{ portfolio.owner.phone_number | usPhone }}
</p>
{% if user_can(permissions.EDIT_PORTFOLIO_POC) %}
{% include "portfolios/fragments/change_ppoc.html" %}
{% endif %}
</div>
</section>

View File

@ -1 +1,2 @@
.terraform
.vscode/

View File

@ -57,6 +57,7 @@ To create all the resources we need for this environment we'll need to enable so
This registers the specific feature for _SystemAssigned_ principals
```
az feature register --namespace Microsoft.ContainerService --name MSIPreview
az feature register --namespace Microsoft.ContainerService --name NodePublicIPPreview
```
To apply the registration, run the following
@ -133,6 +134,42 @@ module "keyvault" {
}
```
## Setting the Redis key in KeyVault
Redis auth is provided by a simple key that is randomly generated by Azure. This is a simple task for `secrets-tool`.
First, get the key from the portal. You can navigate to the redis cluster, and click on either "Show Keys", or "Access Keys"
![Redis Keys](images/redis-keys.png)
In order to set the secret, make sure you specify the keyvault that is used by the application. In dev, its simply called "keyvault", where the operator keyvault has a different name.
```
secrets-tool secrets --keyvault https://cloudzero-dev-keyvault.vault.azure.net/ create --key REDIS-PASSWORD --value "<redis key>"
```
You'll see output similar to the following if it was successful
```
2020-01-17 14:04:42,996 - utils.keyvault.secrets - DEBUG - Set value for key: REDIS-PASSWORD
```
## Setting the Azure Storage Key
Azure storage is very similar to how Redis has a generated key. This generated key is what is used at the time of writing this doc.
Grab the key from the "Access Keys" tab on the cloud storage bucket
![Cloud Storage Keys](images/azure-storage.png)
Now create the secret in KeyVault. This secret should also be in the application specific KeyVault.
```
secrets-tool secrets --keyvault https://cloudzero-dev-keyvault.vault.azure.net/ create --key AZURE-STORAGE-KEY --value "<storage key>"
```
You'll see output similar to the following if it was successful
```
2020-01-17 14:14:59,426 - utils.keyvault.secrets - DEBUG - Set value for key: AZURE-STORAGE-KEY
```
# Shutting down and environment
To shutdown and remove an environment completely as to not incur any costs you would need to run a `terraform destroy`.
@ -170,4 +207,77 @@ https://login.microsoftonline.com/common/oauth2/authorize?client_id=41b23e61-6c1
TODO
## Downloading a client profile
TODO
TODO
# Quick Steps
Copy paste (mostly)
*Register Preview features*
See [Registering Features](#Preview_Features)
*Edit provider.tf and turn off remote bucket temporarily (comment out backend {} section)*
```
provider "azurerm" {
version = "=1.40.0"
}
provider "azuread" {
# Whilst version is optional, we /strongly recommend/ using it to pin the version of the Provider being used
version = "=0.7.0"
}
terraform {
#backend "azurerm" {
#resource_group_name = "cloudzero-dev-tfstate"
#storage_account_name = "cloudzerodevtfstate"
#container_name = "tfstate"
#key = "dev.terraform.tfstate"
#}
}
```
`terraform init`
`terraform plan -target=module.tf_state`
Ensure the state bucket is created.
*create the container in the portal (or cli).*
This simply involves going to the bucket in the azure portal and creating the container.
Now is the tricky part. For this, we will be switching from local state (files) to remote state (stored in the azure bucket)
Uncomment the `backend {}` section in the `provider.tf` file. Once uncommented, we will re-run the init. This will attempt to copy the local state to the remote bucket.
`terraform init`
*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.
Next, we'll create the operator keyvault.
`terraform plan -target=module.operator_keyvault`
Next, we'll pre-populate some secrets using the secrets-tool. Follow the install/setup section in the README.md first. Then populate the secrets with a definition file as described in the following link.
https://github.com/dod-ccpo/atst/tree/staging/terraform/secrets-tool#populating-secrets-from-secrets-definition-file
*Create service principal for AKS*
```
az ad sp create-for-rbac
```
Take note of the output, you'll need it in the next step to store the secret and `client_id` in keyvault.
This also involves using secrets-tool. Substitute your keyvault url.
```
secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ create --key k8s-client-id --value [value]
secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ create --key k8s-client-secret --value [value]
```
*Next we'll apply the rest of the TF configuration*
`terraform plan` # Make sure this looks correct
`terraform apply`

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -19,7 +19,8 @@ resource "azurerm_key_vault" "keyvault" {
}
}
resource "azurerm_key_vault_access_policy" "keyvault" {
resource "azurerm_key_vault_access_policy" "keyvault_k8s_policy" {
count = length(var.principal_id) > 0 ? 1 : 0
key_vault_id = azurerm_key_vault.keyvault.id
tenant_id = data.azurerm_client_config.current.tenant_id
@ -34,3 +35,38 @@ resource "azurerm_key_vault_access_policy" "keyvault" {
]
}
# Admin Access
resource "azurerm_key_vault_access_policy" "keyvault_admin_policy" {
for_each = var.admin_principals
key_vault_id = azurerm_key_vault.keyvault.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = each.value
key_permissions = [
"get",
"list",
"create",
"update",
"delete",
]
secret_permissions = [
"get",
"list",
"set",
]
# backup create delete deleteissuers get getissuers import list listissuers managecontacts manageissuers purge recover restore setissuers update
certificate_permissions = [
"get",
"list",
"create",
"import",
"listissuers",
"manageissuers",
"deleteissuers",
"backup",
"update",
]
}

View File

@ -0,0 +1,7 @@
output "id" {
value = azurerm_key_vault.keyvault.id
}
output "url" {
value = azurerm_key_vault.keyvault.vault_uri
}

View File

@ -27,3 +27,8 @@ variable "principal_id" {
type = string
description = "The service principal_id of the k8s cluster"
}
variable "admin_principals" {
type = map
description = "A list of user principals who need access to manage the keyvault"
}

View File

@ -75,13 +75,11 @@ variable "storage_auto_grow" {
variable "administrator_login" {
type = string
description = "Administrator login"
default = "atat_master" # FIXME - Remove with wrapper using KeyVault
}
variable "administrator_login_password" {
type = string
description = "Administrator password"
default = "eI0l7yswwtuhHpwzoVjwRKdAcuGNsg" # FIXME - Remove with wrapper using KeyVault
}
variable "postgres_version" {

View File

@ -1,9 +1,11 @@
module "keyvault" {
source = "../../modules/keyvault"
name = var.name
region = var.region
owner = var.owner
environment = var.environment
tenant_id = var.tenant_id
principal_id = "f9bcbe58-8b73-4957-aee2-133dc3e58063"
source = "../../modules/keyvault"
name = var.name
region = var.region
owner = var.owner
environment = var.environment
tenant_id = var.tenant_id
principal_id = "f9bcbe58-8b73-4957-aee2-133dc3e58063"
admin_principals = var.admin_users
}

View File

@ -1,8 +1,20 @@
module "sql" {
source = "../../modules/postgres"
name = var.name
owner = var.owner
environment = var.environment
region = var.region
subnet_id = module.vpc.subnets # FIXME - Should be a map of subnets and specify private
data "azurerm_key_vault_secret" "postgres_username" {
name = "postgres-root-user"
key_vault_id = module.operator_keyvault.id
}
data "azurerm_key_vault_secret" "postgres_password" {
name = "postgres-root-password"
key_vault_id = module.operator_keyvault.id
}
module "sql" {
source = "../../modules/postgres"
name = var.name
owner = var.owner
environment = var.environment
region = var.region
subnet_id = module.vpc.subnets # FIXME - Should be a map of subnets and specify private
administrator_login = data.azurerm_key_vault_secret.postgres_username.value
administrator_login_password = data.azurerm_key_vault_secret.postgres_password.value
}

View File

@ -0,0 +1,10 @@
module "operator_keyvault" {
source = "../../modules/keyvault"
name = "operator"
region = var.region
owner = var.owner
environment = var.environment
tenant_id = var.tenant_id
principal_id = ""
admin_principals = var.admin_users
}

View File

@ -71,3 +71,11 @@ variable "tenant_id" {
type = string
default = "b5ab0e1e-09f8-4258-afb7-fb17654bc5b3"
}
variable "admin_users" {
type = map
default = {
"Rob Gil" = "2ca63d41-d058-4e06-aef6-eb517a53b631"
"Daniel Corrigan" = "d5bb69c2-3b88-4e96-b1a2-320400f1bf1b"
}
}

4
terraform/secrets-tool/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bin/
include/
lib/

View File

@ -0,0 +1,63 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
requests = "==2.22.0"
adal = "==1.2.2"
antlr4-python3-runtime = "==4.7.2"
applicationinsights = "==0.11.9"
argcomplete = "==1.10.3"
astroid = "==2.3.3"
azure-cli-core = "==2.0.77"
azure-cli-nspkg = "==3.0.4"
azure-cli-telemetry = "==1.0.4"
azure-common = "==1.1.23"
azure-core = "==1.1.1"
azure-identity = "==1.1.0"
azure-keyvault = "==4.0.0"
azure-keyvault-keys = "==4.0.0"
azure-keyvault-secrets = "==4.0.0"
azure-mgmt-resource = "==4.0.0"
azure-nspkg = "==3.0.2"
bcrypt = "==3.1.7"
certifi = "==2019.11.28"
cffi = "==1.13.2"
chardet = "==3.0.4"
click = "==7.0"
colorama = "==0.4.3"
coloredlogs = "==10.0"
cryptography = "==2.8"
humanfriendly = "==4.18"
idna = "==2.8"
isodate = "==0.6.0"
isort = "==4.3.21"
jmespath = "==0.9.4"
knack = "==0.6.3"
lazy-object-proxy = "==1.4.3"
mccabe = "==0.6.1"
msal = "==1.0.0"
msal-extensions = "==0.1.3"
msrest = "==0.6.10"
msrestazure = "==0.6.2"
oauthlib = "==3.1.0"
paramiko = "==2.7.1"
portalocker = "==1.5.2"
pycparser = "==2.19"
Pygments = "==2.5.2"
PyJWT = "==1.7.1"
pylint = "==2.4.4"
PyNaCl = "==1.3.0"
pyOpenSSL = "==19.1.0"
python-dateutil = "==2.8.1"
PyYAML = "==5.2"
requests-oauthlib = "==1.3.0"
six = "==1.13.0"
tabulate = "==0.8.6"
typed-ast = "==1.4.0"
urllib3 = "==1.25.7"
wrapt = "==1.11.2"
[dev-packages]
pytest = "*"

670
terraform/secrets-tool/Pipfile.lock generated Normal file
View File

@ -0,0 +1,670 @@
{
"_meta": {
"hash": {
"sha256": "83c0cc35bbf74c0b7620a5b7f4f9fab4324e86442e12284d1b9ffcc12a371696"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"adal": {
"hashes": [
"sha256:5a7f1e037c6290c6d7609cab33a9e5e988c2fbec5c51d1c4c649ee3faff37eaf",
"sha256:fd17e5661f60634ddf96a569b95d34ccb8a98de60593d729c28bdcfe360eaad1"
],
"index": "pypi",
"version": "==1.2.2"
},
"antlr4-python3-runtime": {
"hashes": [
"sha256:168cdcec8fb9152e84a87ca6fd261b3d54c8f6358f42ab3b813b14a7193bb50b"
],
"index": "pypi",
"markers": "python_version >= '3.0'",
"version": "==4.7.2"
},
"applicationinsights": {
"hashes": [
"sha256:30a11aafacea34f8b160fbdc35254c9029c7e325267874e3c68f6bdbcd6ed2c3",
"sha256:b88bc5a41385d8e516489128d5e63f8c52efe597a3579b1718d1ab2f7cf150a2"
],
"index": "pypi",
"version": "==0.11.9"
},
"argcomplete": {
"hashes": [
"sha256:a37f522cf3b6a34abddfedb61c4546f60023b3799b22d1cd971eacdc0861530a",
"sha256:d8ea63ebaec7f59e56e7b2a386b1d1c7f1a7ae87902c9ee17d377eaa557f06fa"
],
"index": "pypi",
"version": "==1.10.3"
},
"astroid": {
"hashes": [
"sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a",
"sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"
],
"index": "pypi",
"version": "==2.3.3"
},
"azure-cli-core": {
"hashes": [
"sha256:4281b71cf9a8278f665765c97eb3dae61fbf2dac916fc032c4acdf5ed2065210",
"sha256:d14a733dd6d6019c23dbd0b459026fef0978901d263382a1fdb71852293b9e6a"
],
"index": "pypi",
"version": "==2.0.77"
},
"azure-cli-nspkg": {
"hashes": [
"sha256:1bde56090f548c6435bd3093995cf88e4c445fb040604df8b5b5f70780d79181",
"sha256:9a1e4f3197183470e4afecfdd45c92320f6753555b06a70651f89972332ffaf6"
],
"index": "pypi",
"version": "==3.0.4"
},
"azure-cli-telemetry": {
"hashes": [
"sha256:1f239d544d309c29e827982cc20113eb57037dba16db6cdd2e0283e437e0e577",
"sha256:7b18d7520e35e134136a0f7de38403a7dbce7b1e835065bd9e965579815ddf2f"
],
"index": "pypi",
"version": "==1.0.4"
},
"azure-common": {
"hashes": [
"sha256:53b1195b8f20943ccc0e71a17849258f7781bc6db1c72edc7d6c055f79bd54e3",
"sha256:99ef36e74b6395329aada288764ce80504da16ecc8206cb9a72f55fb02e8b484"
],
"index": "pypi",
"version": "==1.1.23"
},
"azure-core": {
"hashes": [
"sha256:4d047fd4e46a958c9b63f9d5cb52e6bf7dfc5c2a1c2a81b968499335a94bb5cb",
"sha256:b44fe5b46d2bb0260cafb737ab5ee89a16d478fc1885dabe21c426c4df205502"
],
"index": "pypi",
"version": "==1.1.1"
},
"azure-identity": {
"hashes": [
"sha256:0c8e540e1b75d48c54e5cd8d599f7ea5ccf4dae260c35bebb0c8d34d22b7c4f6",
"sha256:75f4ad9abfd191bd5f3de4c6dc29980b138bf5dfbbef9bca5c548a6048473bde"
],
"index": "pypi",
"version": "==1.1.0"
},
"azure-keyvault": {
"hashes": [
"sha256:76f75cb83929f312a08616d426ad6f597f1beae180131cf445876fb88f2c8ef1",
"sha256:e85f5bd6cb4f10b3248b99bbf02e3acc6371d366846897027d4153f18025a2d7"
],
"index": "pypi",
"version": "==4.0.0"
},
"azure-keyvault-keys": {
"hashes": [
"sha256:2983fa42e20a0e6bf6b87976716129c108e613e0292d34c5b0f0c8dc1d488e89",
"sha256:38c27322637a2c52620a8b96da1942ad6a8d22d09b5a01f6fa257f7a51e52ed0"
],
"index": "pypi",
"version": "==4.0.0"
},
"azure-keyvault-secrets": {
"hashes": [
"sha256:2eae9264a8f6f59277e1a9bfdbc8b0a15969ee5a80d8efe403d7744805b4a481",
"sha256:97a602406a833e8f117c540c66059c818f4321a35168dd17365fab1e4527d718"
],
"index": "pypi",
"version": "==4.0.0"
},
"azure-mgmt-resource": {
"hashes": [
"sha256:2b909f137469c7bfa541554c3d22eb918e9191c07667a42f2c6fc684e24ac83f",
"sha256:5022263349e66dba5ddadd3bf36208d82a00e0f1bb3288e32822fc821ccd1f76"
],
"index": "pypi",
"version": "==4.0.0"
},
"azure-nspkg": {
"hashes": [
"sha256:1d0bbb2157cf57b1bef6c8c8e5b41133957364456c43b0a43599890023cca0a8",
"sha256:31a060caca00ed1ebd369fc7fe01a56768c927e404ebc92268f4d9d636435e28",
"sha256:e7d3cea6af63e667d87ba1ca4f8cd7cb4dfca678e4c55fc1cedb320760e39dd0"
],
"index": "pypi",
"version": "==3.0.2"
},
"bcrypt": {
"hashes": [
"sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89",
"sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42",
"sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294",
"sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161",
"sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752",
"sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31",
"sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5",
"sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c",
"sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0",
"sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de",
"sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e",
"sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052",
"sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09",
"sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105",
"sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133",
"sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1",
"sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7",
"sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc"
],
"index": "pypi",
"version": "==3.1.7"
},
"certifi": {
"hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
"index": "pypi",
"version": "==2019.11.28"
},
"cffi": {
"hashes": [
"sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42",
"sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04",
"sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5",
"sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54",
"sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba",
"sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57",
"sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396",
"sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12",
"sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97",
"sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43",
"sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db",
"sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3",
"sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b",
"sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579",
"sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346",
"sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159",
"sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652",
"sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e",
"sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a",
"sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506",
"sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f",
"sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d",
"sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c",
"sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20",
"sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858",
"sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc",
"sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a",
"sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3",
"sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e",
"sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410",
"sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25",
"sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b",
"sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d"
],
"index": "pypi",
"version": "==1.13.2"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"index": "pypi",
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"index": "pypi",
"version": "==7.0"
},
"colorama": {
"hashes": [
"sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
"sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
],
"index": "pypi",
"version": "==0.4.3"
},
"coloredlogs": {
"hashes": [
"sha256:34fad2e342d5a559c31b6c889e8d14f97cb62c47d9a2ae7b5ed14ea10a79eff8",
"sha256:b869a2dda3fa88154b9dd850e27828d8755bfab5a838a1c97fbc850c6e377c36"
],
"index": "pypi",
"version": "==10.0"
},
"cryptography": {
"hashes": [
"sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c",
"sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595",
"sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad",
"sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651",
"sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2",
"sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff",
"sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d",
"sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42",
"sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d",
"sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e",
"sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912",
"sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793",
"sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13",
"sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7",
"sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0",
"sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879",
"sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f",
"sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9",
"sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2",
"sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf",
"sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8"
],
"index": "pypi",
"version": "==2.8"
},
"humanfriendly": {
"hashes": [
"sha256:23057b10ad6f782e7bc3a20e3cb6768ab919f619bbdc0dd75691121bbde5591d",
"sha256:33ee8ceb63f1db61cce8b5c800c531e1a61023ac5488ccde2ba574a85be00a85"
],
"index": "pypi",
"version": "==4.18"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"index": "pypi",
"version": "==2.8"
},
"isodate": {
"hashes": [
"sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8",
"sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"
],
"index": "pypi",
"version": "==0.6.0"
},
"isort": {
"hashes": [
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
],
"index": "pypi",
"version": "==4.3.21"
},
"jmespath": {
"hashes": [
"sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6",
"sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c"
],
"index": "pypi",
"version": "==0.9.4"
},
"knack": {
"hashes": [
"sha256:b1ac92669641b902e1aef97138666a21b8852f65d83cbde03eb9ddebf82ce121",
"sha256:bd240163d4e2ce9fc8535f77519358da0afd6c0ca19f001c639c3160b57630a9"
],
"index": "pypi",
"version": "==0.6.3"
},
"lazy-object-proxy": {
"hashes": [
"sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d",
"sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449",
"sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08",
"sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a",
"sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50",
"sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd",
"sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239",
"sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb",
"sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea",
"sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e",
"sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156",
"sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142",
"sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442",
"sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62",
"sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db",
"sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531",
"sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383",
"sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a",
"sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357",
"sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
"sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
],
"index": "pypi",
"version": "==1.4.3"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"index": "pypi",
"version": "==0.6.1"
},
"msal": {
"hashes": [
"sha256:c944b833bf686dfbc973e9affdef94b77e616cb52ab397e76cde82e26b8a3373",
"sha256:ecbe3f5ac77facad16abf08eb9d8562af3bc7184be5d4d90c9ef4db5bde26340"
],
"index": "pypi",
"version": "==1.0.0"
},
"msal-extensions": {
"hashes": [
"sha256:59e171a9a4baacdbf001c66915efeaef372fb424421f1a4397115a3ddd6205dc",
"sha256:c5a32b8e1dce1c67733dcdf8aa8bebcff5ab123e779ef7bc14e416bd0da90037"
],
"index": "pypi",
"version": "==0.1.3"
},
"msrest": {
"hashes": [
"sha256:56b8b5b4556fb2a92cac640df267d560889bdc9e2921187772d4691d97bc4e8d",
"sha256:f5153bfe60ee757725816aedaa0772cbfe0bddb52cd2d6db4cb8b4c3c6c6f928"
],
"index": "pypi",
"version": "==0.6.10"
},
"msrestazure": {
"hashes": [
"sha256:63db9f646fffc9244b332090e679d1e5f283ac491ee0cc321f5116f9450deb4a",
"sha256:fecb6a72a3eb5483e4deff38210d26ae42d3f6d488a7a275bd2423a1a014b22c"
],
"index": "pypi",
"version": "==0.6.2"
},
"oauthlib": {
"hashes": [
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
"index": "pypi",
"version": "==3.1.0"
},
"paramiko": {
"hashes": [
"sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f",
"sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f"
],
"index": "pypi",
"version": "==2.7.1"
},
"portalocker": {
"hashes": [
"sha256:6f57aabb25ba176462dc7c63b86c42ad6a9b5bd3d679a9d776d0536bfb803d54",
"sha256:dac62e53e5670cb40d2ee4cdc785e6b829665932c3ee75307ad677cf5f7d2e9f"
],
"index": "pypi",
"version": "==1.5.2"
},
"pycparser": {
"hashes": [
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
],
"index": "pypi",
"version": "==2.19"
},
"pygments": {
"hashes": [
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
],
"index": "pypi",
"version": "==2.5.2"
},
"pyjwt": {
"hashes": [
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
"sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
],
"index": "pypi",
"version": "==1.7.1"
},
"pylint": {
"hashes": [
"sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd",
"sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"
],
"index": "pypi",
"version": "==2.4.4"
},
"pynacl": {
"hashes": [
"sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255",
"sha256:0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c",
"sha256:0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e",
"sha256:1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae",
"sha256:2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621",
"sha256:2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56",
"sha256:30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39",
"sha256:37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310",
"sha256:4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1",
"sha256:53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5",
"sha256:57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a",
"sha256:5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786",
"sha256:6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b",
"sha256:7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b",
"sha256:a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f",
"sha256:a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20",
"sha256:aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415",
"sha256:bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715",
"sha256:bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92",
"sha256:e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1",
"sha256:f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0"
],
"index": "pypi",
"version": "==1.3.0"
},
"pyopenssl": {
"hashes": [
"sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504",
"sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"
],
"index": "pypi",
"version": "==19.1.0"
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"index": "pypi",
"version": "==2.8.1"
},
"pyyaml": {
"hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
"sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
"sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
"sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
"sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
"sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
"sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
"sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
"sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
"sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
"sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
],
"index": "pypi",
"version": "==5.2"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"requests-oauthlib": {
"hashes": [
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
"sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
],
"index": "pypi",
"version": "==1.3.0"
},
"six": {
"hashes": [
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
],
"index": "pypi",
"version": "==1.13.0"
},
"tabulate": {
"hashes": [
"sha256:5470cc6687a091c7042cee89b2946d9235fe9f6d49c193a4ae2ac7bf386737c8"
],
"index": "pypi",
"version": "==0.8.6"
},
"typed-ast": {
"hashes": [
"sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161",
"sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
"sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
"sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
"sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
"sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47",
"sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
"sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
"sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
"sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
"sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2",
"sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e",
"sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
"sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
"sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
"sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
"sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
"sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
"sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66",
"sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
],
"index": "pypi",
"markers": "implementation_name == 'cpython' and python_version < '3.8'",
"version": "==1.4.0"
},
"urllib3": {
"hashes": [
"sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293",
"sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"
],
"index": "pypi",
"version": "==1.25.7"
},
"wheel": {
"hashes": [
"sha256:9515fe0a94e823fd90b08d22de45d7bde57c90edce705b22f5e1ecf7e1b653c8",
"sha256:e721e53864f084f956f40f96124a74da0631ac13fbbd1ba99e8e2b5e9cafdf64"
],
"version": "==0.30.0"
},
"wrapt": {
"hashes": [
"sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"
],
"index": "pypi",
"version": "==1.11.2"
}
},
"develop": {
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"version": "==19.3.0"
},
"importlib-metadata": {
"hashes": [
"sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359",
"sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8"
],
"markers": "python_version < '3.8'",
"version": "==1.4.0"
},
"more-itertools": {
"hashes": [
"sha256:1a2a32c72400d365000412fe08eb4a24ebee89997c18d3d147544f70f5403b39",
"sha256:c468adec578380b6281a114cb8a5db34eb1116277da92d7c46f904f0b52d3288"
],
"version": "==8.1.0"
},
"packaging": {
"hashes": [
"sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb",
"sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8"
],
"version": "==20.0"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"py": {
"hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.1"
},
"pyparsing": {
"hashes": [
"sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
"sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
],
"version": "==2.4.6"
},
"pytest": {
"hashes": [
"sha256:6b571215b5a790f9b41f19f3531c53a45cf6bb8ef2988bc1ff9afb38270b25fa",
"sha256:e41d489ff43948babd0fad7ad5e49b8735d5d55e26628a58673c39ff61d95de4"
],
"index": "pypi",
"version": "==5.3.2"
},
"six": {
"hashes": [
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
],
"index": "pypi",
"version": "==1.13.0"
},
"wcwidth": {
"hashes": [
"sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
"sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
],
"version": "==0.1.8"
},
"zipp": {
"hashes": [
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
],
"version": "==1.0.0"
}
}
}

View File

@ -0,0 +1,79 @@
# secrets-tool
secrets-tool is a group of utilities used to manage secrets in Azure environments.
*Features:*
- Generate secrets based on definitions defined in yaml
- Load secrets in to Azure KeyVault
- Wrapper for terraform to inject KeyVault secrets as environment variables
# Use Cases
## Populating KeyVault with initial secrets
In many environments, a complete list of secrets is sometimes forgotten or not well defined. With secrets-tool, all those secrets can be defined programatically and generated when creating new environments. This avoids putting in "test" values for passwords and guessible username/password combinations. Even usernames can be generated.
With both usernames and passwords generated, the application only needs to make a call out to KeyVault for the key that it needs (assuming the application, host, or vm has access to the secret)
Ex.
```
{
'postgres_root_user': 'EzTEzSNLKQPHuJyPdPloIDCAlcibbl',
'postgres_root_password': "2+[A@E4:C=ubb/#R#'n<p|wCW-|%q^"
}
```
## Rotating secrets
Rotating passwords is a snap! Just re-run secrets-tool and it will generate and populate new secrets.
**Be careful!! There is no safeguard to prevent you from accidentally overwriting secrets!! - To be added if desired**
## Terraform Secrets
Terraform typically expects user defined secrets to be stored in either a file, or in another service such as keyvault. The terraform wrapper feature, injects secrets from keyvault in to the environment and then runs terraform.
This provides a number of security benefits. First, secrets are not on disk. Secondly, users/operators never see the secrets fly by (passerbys or voyeurs that like to look over your shoulder when deploying to production)
# Setup
*Requirements*
- Python 3.7+
- pipenv
```
cd secrets-tool
pipenv install
pipenv shell
```
You will also need to make sure secrets-tool is in your PATH
```
echo 'PATH=$PATH:<path to secrets-tool>' > ~/.bash_profile
. ~/.bash_profile
```
`$ which secrets-tool` should show the full path
# Usage
## Defining secrets
The schema for defining secrets is very simplistic for the moment.
```yaml
---
- postgres-root-user:
type: 'username'
length: 30
- postgres-root-password:
type: 'password'
length: 30
```
In this example we're randomly generating both the username and password. `secrets-tool` is smart enough to know that a username can't have symbols in it. Passwords contain symbols, upper/lower case, and numbers. This could be made more flexible and configurable in the future.
## Populating secrets from secrets definition file
This process is as simple as specifying the keyvault and the definitions file.
```
secrets-tool secrets --keyvault https://operator-dev-keyvault.vault.azure.net/ load -f ./sample-secrets.yaml
```
## Running terraform with KeyVault secrets
This will fetch all secrets from the keyvault specified. `secrets-tool` then converts the keys to a variable name that terraform will look for. Essentially it prepends the keys found in KeyVault with `TF_VAR` and then executes terraform as a subprocess with the injected environment variables.
```
secrets-tool terraform --keyvault https://operator-dev-keyvault.vault.azure.net/ plan
```

View File

@ -0,0 +1,47 @@
import click
import logging
from utils.keyvault.secrets import SecretsClient
from utils.keyvault.secrets import SecretsLoader
logger = logging.getLogger(__name__)
#loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict]
#print(loggers)
@click.group()
@click.option('--keyvault', required=True, help="Specify the keyvault to operate on")
@click.pass_context
def secrets(ctx, keyvault):
ctx.ensure_object(dict)
ctx.obj['keyvault'] = keyvault
@click.command('create')
@click.option('--key', 'key', required=True, help="Key for the secret to create")
@click.option('--value', 'value', required=True, prompt=True, hide_input=True, confirmation_prompt=True, help="Value for the secret to create")
@click.pass_context
def create_secret(ctx, key, value):
"""Creates a secret in the specified KeyVault"""
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
keyvault.set_secret(key, value)
@click.command('list')
@click.pass_context
def list_secrets(ctx):
"""Lists the secrets in the specified KeyVault"""
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
click.echo(keyvault.list_secrets())
@click.command('load')
@click.option('-f', 'file', required=True, help="YAML file with secrets definitions")
@click.pass_context
def load_secrets(ctx, file):
"""Generate and load secrets from yaml definition"""
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
loader = SecretsLoader(yaml_file=file, keyvault=keyvault)
loader.load_secrets()
secrets.add_command(create_secret)
secrets.add_command(list_secrets)
secrets.add_command(load_secrets)

View File

@ -0,0 +1,49 @@
import click
import logging
from utils.keyvault.secrets import SecretsClient
from utils.terraform.wrapper import TFWrapper
logger = logging.getLogger(__name__)
PROCESS='terraform'
@click.group()
@click.option('--keyvault', required=True, help="Specify the keyvault to operate on")
@click.pass_context
def terraform(ctx, keyvault):
ctx.ensure_object(dict)
ctx.obj['keyvault'] = keyvault
@click.command('plan')
@click.pass_context
def plan(ctx):
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
tf = TFWrapper(keyvault)
tf.plan()
@click.command('apply')
@click.pass_context
def apply(ctx):
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
tf = TFWrapper(keyvault)
tf.apply()
@click.command('destroy')
@click.pass_context
def destroy(ctx):
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
tf = TFWrapper(keyvault)
tf.destroy()
@click.command('init')
@click.pass_context
def init(ctx):
keyvault = SecretsClient(vault_url=ctx.obj['keyvault'])
tf = TFWrapper(keyvault)
tf.init()
terraform.add_command(plan)
terraform.add_command(apply)
terraform.add_command(destroy)
terraform.add_command(init)

View File

@ -0,0 +1,28 @@
import os
import yaml
import logging.config
import logging
import coloredlogs
LOGGING_PATH=os.path.dirname(os.path.abspath(__file__))
def setup_logging(default_path='{}/logging.yaml'.format(LOGGING_PATH), default_level=logging.INFO, env_key='LOG_CFG'):
path = default_path
value = os.getenv(env_key, None)
if value:
path = value
if os.path.exists(path):
with open(path, 'rt') as f:
try:
config = yaml.safe_load(f.read())
logging.config.dictConfig(config)
coloredlogs.install()
except Exception as e:
print(e)
print('Error in Logging Configuration. Using default configs')
logging.basicConfig(level=default_level)
coloredlogs.install(level=default_level)
else:
logging.basicConfig(level=default_level)
coloredlogs.install(level=default_level)
print('Failed to load configuration file. Using default configs')

View File

@ -0,0 +1,64 @@
version: 1
disable_existing_loggers: true
formatters:
standard:
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
error:
format: "%(levelname)s <PID %(process)d:%(processName)s> %(name)s.%(funcName)s(): %(message)s"
handlers:
console:
class: logging.StreamHandler
level: DEBUG
formatter: standard
stream: ext://sys.stdout
file_handler:
class: logging.handlers.RotatingFileHandler
level: INFO
formatter: standard
filename: secrets-tool.log
maxBytes: 10485760 # 10MB
backupCount: 20
encoding: utf8
root:
level: INFO
handlers: [console]
propogate: yes
loggers:
root:
level: INFO
handlers: [console]
propogate: no
click:
level: INFO
handlers: [console]
propogate: yes
azure.keyvault:
level: INFO
handlers: [console]
propogate: yes
azure.core:
level: ERROR
handlers: [console]
propogate: no
utils.keyvault.secrets:
level: DEBUG
handlers: [console]
propogate: yes
utils.terraform.wrapper:
level: DEBUG
handlers: [console]
propogate: yes
commands:
level: INFO
handlers: [console]
propogate: yes
main:
level: INFO
handlers: [console]
propogate: no

View File

@ -0,0 +1,54 @@
adal==1.2.2
antlr4-python3-runtime==4.7.2
applicationinsights==0.11.9
argcomplete==1.10.3
astroid==2.3.3
azure-cli-core==2.0.77
azure-cli-nspkg==3.0.4
azure-cli-telemetry==1.0.4
azure-common==1.1.23
azure-core==1.1.1
azure-identity==1.1.0
azure-keyvault==4.0.0
azure-keyvault-keys==4.0.0
azure-keyvault-secrets==4.0.0
azure-mgmt-resource==4.0.0
azure-nspkg==3.0.2
bcrypt==3.1.7
certifi==2019.11.28
cffi==1.13.2
chardet==3.0.4
Click==7.0
colorama==0.4.3
coloredlogs==10.0
cryptography==2.8
humanfriendly==4.18
idna==2.8
isodate==0.6.0
isort==4.3.21
jmespath==0.9.4
knack==0.6.3
lazy-object-proxy==1.4.3
mccabe==0.6.1
msal==1.0.0
msal-extensions==0.1.3
msrest==0.6.10
msrestazure==0.6.2
oauthlib==3.1.0
paramiko==2.7.1
portalocker==1.5.2
pycparser==2.19
Pygments==2.5.2
PyJWT==1.7.1
pylint==2.4.4
PyNaCl==1.3.0
pyOpenSSL==19.1.0
python-dateutil==2.8.1
PyYAML==5.2
requests==2.22.0
requests-oauthlib==1.3.0
six==1.13.0
tabulate==0.8.6
typed-ast==1.4.0
urllib3==1.25.7
wrapt==1.11.2

View File

@ -0,0 +1,7 @@
---
- postgres-root-user:
type: 'username'
length: 30
- postgres-root-password:
type: 'password'
length: 30

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python
# CLI
import click
import config
import logging
from commands.secrets import secrets
from commands.terraform import terraform
config.setup_logging()
logger = logging.getLogger(__name__)
PROCESS='terraform'
# Define core command group
@click.group()
def cli():
pass
# Add additional command groups
cli.add_command(secrets)
cli.add_command(terraform)
if __name__ == "__main__":
try:
cli()
except Exception as e:
print(e)
'''
try:
keyvault = secrets(vault_url="https://cloudzero-dev-keyvault.vault.azure.net/")
keyvault.set_secret('dbuser','foo')
#print(keyvault.get_secret('db-user').value)
# Set env variables for TF
for secret in keyvault.list_secrets():
name = 'TF_VAR_' + secret
val = keyvault.get_secret(secret)
#print(val)
os.environ[name] = val
env = os.environ.copy()
command = "{} {}".format(PROCESS, sys.argv[1])
with subprocess.Popen(command, env=env, stdout=subprocess.PIPE, shell=True) as proc:
for line in proc.stdout:
logging.info(line.decode("utf-8") )
except Exception as e:
print(e, traceback.print_stack)
'''

View File

View File

@ -0,0 +1,10 @@
import logging
from azure.identity import InteractiveBrowserCredential
logger = logging.getLogger(__name__)
class Auth:
def __init__(self, vault_url, *args, **kwargs):
self.credentials = InteractiveBrowserCredential()
self.vault_url = vault_url

View File

@ -0,0 +1,19 @@
import logging
from azure.keyvault.keys import KeyClient
from .auth import Auth
logger = logging.getLogger(__name__)
KEY_SIZE=2048
KEY_TYPE='rsa'
class keys(Auth):
def __init__(self, *args, **kwargs):
super(keys, self).__init__(*args, **kwargs)
self.key_client = KeyClient(vault_url=self.vault_url, credential=self.credentials)
def get_key(self):
return self.key_client
def create_key(self):
pass

View File

@ -0,0 +1,109 @@
import logging
import yaml
import secrets
import string
from pathlib import Path
from .auth import Auth
from azure.keyvault.secrets import SecretClient
logger = logging.getLogger(__name__)
class SecretsClient(Auth):
def __init__(self, *args, **kwargs):
super(SecretsClient, self).__init__(*args, **kwargs)
self.secret_client = SecretClient(vault_url=self.vault_url, credential=self.credentials)
def get_secret(self, key):
secret = self.secret_client.get_secret(key)
return secret.value
def set_secret(self, key: str, value: str):
secret = self.secret_client.set_secret(key, value)
logger.debug('Set value for key: {}'.format(key))
return secret
def list_secrets(self):
secrets = list()
secret_properties = self.secret_client.list_properties_of_secrets()
for secret in secret_properties:
secrets.append(secret.name)
return secrets
class SecretsLoader():
"""
Helper class to load secrets definition, generate
the secrets a defined by the defintion, and then
load the secrets in to keyvault
"""
def __init__(self, yaml_file: str, keyvault: object):
assert Path(yaml_file).exists()
self.yaml_file = yaml_file
self.keyvault = keyvault
self.config = dict()
self._load_yaml()
self._generate_secrets()
def _load_yaml(self):
with open(self.yaml_file) as handle:
self.config = yaml.load(handle, Loader=yaml.FullLoader)
def _generate_secrets(self):
secrets = GenerateSecrets(self.config).process_definition()
self.secrets = secrets
def load_secrets(self):
for key, val in self.secrets.items():
print('{} {}'.format(key,val))
self.keyvault.set_secret(key=key, value=val)
class GenerateSecrets():
"""
Read the secrets definition and generate requiesite
secrets based on the type of secret and arguments
provided
"""
def __init__(self, definitions: dict):
self.definitions = definitions
def process_definition(self):
"""
Processes a simple definiton such as the following
```
- postgres_root_user:
type: 'username'
length: 30
- postgres_root_password:
type: 'password'
length: 30
```
This should be broken out to a function per definition type
if the scope extends in to tokens, salts, or other specialized
definitions.
"""
try:
secrets = dict()
for definition in self.definitions:
key = list(definition)
def_name = key[0]
secret = definition[key[0]]
assert len(str(secret['length'])) > 0
method = getattr(self, '_generate_'+secret['type'])
value = method(secret['length'])
#print('{}: {}'.format(key[0], value))
secrets.update({def_name: value})
logger.debug('Setting secrets to: {}'.format(secrets))
return secrets
except KeyError as e:
logger.error('Missing the {} key in the definition'.format(e))
# Types. Can be usernames, passwords, or in the future things like salted
# tokens, uuid, or other specialized types
def _generate_password(self, length: int):
self.password_characters = string.ascii_letters + string.digits + string.punctuation
return ''.join(secrets.choice(self.password_characters) for i in range(length))
def _generate_username(self, length: int):
self.username_characters = string.ascii_letters
return ''.join(secrets.choice(self.username_characters) for i in range(length))

View File

@ -0,0 +1,61 @@
import os
import logging
import subprocess
logger = logging.getLogger(__name__)
class TFWrapper:
"""
Command wrapper for terraform that injects secrets
from keyvault in to environment variables which
can then be used by terraform
"""
def __init__(self, keyvault: object):
self.keyvault = keyvault
self.env = ''
self.terraform_path = 'terraform'
self._set_env()
def _set_env(self):
# Prefix variables with TF_VAR_
for secret in self.keyvault.list_secrets():
name = 'TF_VAR_' + secret
val = self.keyvault.get_secret(secret)
os.environ[name] = val
# Set the environment with new vars
self.env = os.environ.copy()
return None
def _run_tf(self, option: str):
try:
command = '{} {}'.format(self.terraform_path, option)
with subprocess.Popen(command, env=self.env, stdout=subprocess.PIPE, shell=True) as proc:
for line in proc.stdout:
logging.info(line.decode("utf-8"))
except Exception as e:
print(e)
def plan(self):
"""
terraform plan
"""
self._run_tf(option='plan')
def init(self):
"""
terraform init
"""
self._run_tf(option='init')
def apply(self):
"""
terraform apply
"""
self._run_tf(option='apply -auto-approve')
def destroy(self):
"""
terraform destroy
"""
self._run_tf(option='destroy')

View File

@ -1,3 +1,4 @@
import pytest
from flask import url_for
from unittest.mock import MagicMock
@ -11,29 +12,6 @@ from atst.utils.localization import translate
from tests.factories import PortfolioFactory, PortfolioRoleFactory, UserFactory
def test_member_table_access(client, user_session):
admin = UserFactory.create()
portfolio = PortfolioFactory.create(owner=admin)
rando = UserFactory.create()
PortfolioRoleFactory.create(
user=rando,
portfolio=portfolio,
permission_sets=[PermissionSets.get(PermissionSets.VIEW_PORTFOLIO_ADMIN)],
)
url = url_for("portfolios.admin", portfolio_id=portfolio.id)
# editable
user_session(admin)
edit_resp = client.get(url)
assert "<select" in edit_resp.data.decode()
# not editable
user_session(rando)
view_resp = client.get(url)
assert "<select" not in view_resp.data.decode()
def test_update_portfolio_name_and_description(client, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
@ -47,6 +25,7 @@ def test_update_portfolio_name_and_description(client, user_session):
assert portfolio.description == "a portfolio for things"
@pytest.mark.skip(reason="Out of scope for MVP")
def updating_ppoc_successfully(client, old_ppoc, new_ppoc, portfolio):
response = client.post(
url_for("portfolios.update_ppoc", portfolio_id=portfolio.id, _external=True),
@ -67,6 +46,7 @@ def updating_ppoc_successfully(client, old_ppoc, new_ppoc, portfolio):
assert Permissions.EDIT_PORTFOLIO_POC not in old_ppoc.permissions
@pytest.mark.skip(reason="Out of scope for MVP")
def test_update_ppoc_no_user_id_specified(client, user_session):
portfolio = PortfolioFactory.create()
@ -80,6 +60,7 @@ def test_update_ppoc_no_user_id_specified(client, user_session):
assert response.status_code == 404
@pytest.mark.skip(reason="Out of scope for MVP")
def test_update_ppoc_to_member_not_on_portfolio(client, user_session):
portfolio = PortfolioFactory.create()
original_ppoc = portfolio.owner
@ -97,6 +78,7 @@ def test_update_ppoc_to_member_not_on_portfolio(client, user_session):
assert portfolio.owner.id == original_ppoc.id
@pytest.mark.skip(reason="Out of scope for MVP")
def test_update_ppoc_when_ppoc(client, user_session):
portfolio = PortfolioFactory.create()
original_ppoc = portfolio.owner_role
@ -113,7 +95,8 @@ def test_update_ppoc_when_ppoc(client, user_session):
)
def test_update_ppoc_when_cpo(client, user_session):
@pytest.mark.skip(reason="Out of scope for MVP")
def test_update_ppoc_when_ccpo(client, user_session):
ccpo = UserFactory.create_ccpo()
portfolio = PortfolioFactory.create()
original_ppoc = portfolio.owner_role
@ -130,6 +113,7 @@ def test_update_ppoc_when_cpo(client, user_session):
)
@pytest.mark.skip(reason="Out of scope for MVP")
def test_update_ppoc_when_not_ppoc(client, user_session):
portfolio = PortfolioFactory.create()
new_owner = UserFactory.create()
@ -145,15 +129,6 @@ def test_update_ppoc_when_not_ppoc(client, user_session):
assert response.status_code == 404
def test_portfolio_admin_screen_when_ppoc(client, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
response = client.get(url_for("portfolios.admin", portfolio_id=portfolio.id))
assert response.status_code == 200
assert portfolio.name in response.data.decode()
assert translate("fragments.ppoc.update_btn").encode("utf8") in response.data
def test_portfolio_admin_screen_when_not_ppoc(client, user_session):
portfolio = PortfolioFactory.create()
user = UserFactory.create()
@ -254,3 +229,54 @@ def test_remove_portfolio_member_ppoc(client, user_session):
PortfolioRoles.get(portfolio_id=portfolio.id, user_id=portfolio.owner.id).status
== PortfolioRoleStatus.ACTIVE
)
def test_portfolios_update_member(client, user_session):
portfolio = PortfolioFactory.create()
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio,
permission_sets=[PermissionSets.get(PermissionSets.EDIT_PORTFOLIO_ADMIN)],
)
form_data = {
"perms_app_mgmt": "y",
}
user_session(portfolio.owner)
response = client.post(
url_for(
"portfolios.update_member",
portfolio_id=portfolio.id,
portfolio_role_id=portfolio_role.id,
),
data=form_data,
follow_redirects=False,
)
assert response.status_code == 302
assert portfolio_role.has_permission_set(
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
)
assert not portfolio_role.has_permission_set(PermissionSets.EDIT_PORTFOLIO_ADMIN)
def test_can_not_update_ppoc_permissions(client, user_session):
portfolio = PortfolioFactory.create()
owner = portfolio.owner
form_data = {
"perms_app_mgmt": "y",
}
user_session(owner)
response = client.post(
url_for(
"portfolios.update_member",
portfolio_id=portfolio.id,
portfolio_role_id=portfolio.owner_role.id,
),
data=form_data,
follow_redirects=False,
)
assert response.status_code == 400

View File

@ -219,11 +219,11 @@ def test_resend_invitation_sends_email(monkeypatch, client, user_session):
monkeypatch.setattr("atst.jobs.send_mail.delay", job_mock)
user = UserFactory.create()
portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(
portfolio_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = PortfolioInvitationFactory.create(
user_id=user.id, role=ws_role, status=InvitationStatus.PENDING
user_id=user.id, role=portfolio_role, status=InvitationStatus.PENDING
)
user_session(portfolio.owner)
client.post(
@ -231,48 +231,23 @@ def test_resend_invitation_sends_email(monkeypatch, client, user_session):
"portfolios.resend_invitation",
portfolio_id=portfolio.id,
portfolio_token=invite.token,
)
),
data={
"user_data-dod_id": user.dod_id,
"user_data-first_name": user.first_name,
"user_data-last_name": user.last_name,
"user_data-email": user.email,
},
)
assert job_mock.called
def test_existing_member_invite_resent_to_email_submitted_in_form(
monkeypatch, client, user_session
):
job_mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail.delay", job_mock)
portfolio = PortfolioFactory.create()
user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = PortfolioInvitationFactory.create(
user_id=user.id,
role=ws_role,
status=InvitationStatus.PENDING,
email="example@example.com",
)
user_session(portfolio.owner)
client.post(
url_for(
"portfolios.resend_invitation",
portfolio_id=portfolio.id,
portfolio_token=invite.token,
)
)
assert user.email != "example@example.com"
ordered_args, _unordered_args = job_mock.call_args
recipients, _subject, _message = ordered_args
assert recipients[0] == "example@example.com"
_DEFAULT_PERMS_FORM_DATA = {
"permission_sets-perms_app_mgmt": False,
"permission_sets-perms_funding": False,
"permission_sets-perms_reporting": False,
"permission_sets-perms_portfolio_mgmt": False,
"permission_sets-perms_app_mgmt": "n",
"permission_sets-perms_funding": "n",
"permission_sets-perms_reporting": "n",
"permission_sets-perms_portfolio_mgmt": "n",
}

View File

@ -373,6 +373,24 @@ def test_portfolios_edit_access(post_url_assert_status):
post_url_assert_status(rando, url, 404)
# portfolios.update_member
def test_portfolios_update_member_access(post_url_assert_status):
ccpo = user_with(PermissionSets.EDIT_PORTFOLIO_ADMIN)
owner = user_with()
rando = user_with()
portfolio = PortfolioFactory.create(owner=owner)
portfolio_role = PortfolioRoleFactory.create(portfolio=portfolio)
url = url_for(
"portfolios.update_member",
portfolio_id=portfolio.id,
portfolio_role_id=portfolio_role.id,
)
post_url_assert_status(ccpo, url, 302)
post_url_assert_status(owner, url, 302)
post_url_assert_status(rando, url, 404)
# applications.new
def test_applications_new_access(get_url_assert_status):
ccpo = user_with(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT)

View File

@ -16,15 +16,10 @@ base_public:
login: Log in
title_tag: JEDI Cloud
home:
about_cloud:
part1: Procuring portfolio-level cloud services via a JEDI Cloud Task Order provides numerous benefits.
part2: Portfolio purchases allow for centralized management of task orders, which reduces administrative workload on individual programs or other sub-component activities.
add_portfolio_button_text: Add New Portfolio
new_portfolio: New Portfolio
get_started: Get Started
head: JEDI Cloud Services
your_project: Your Project
your_project_descrip: Your portfolio is where all task orders pertaining to a specific project or set of related projects live. In JEDI, every task order in your portfolio has four components.
funding_descrip: The Task Orders section allows you to enter, manage, and edit awarded TOs associated to a specific Portfolio.
applications_descrip: The Applications section allows you to easily create and define new Applications within a Portfolio, as well as manage user permissions and Environments.
reports_descrip: The Reports section allows you to view and monitor funding usage within a specific Portfolio.
@ -37,12 +32,10 @@ ccpo:
confirm_user_title: Confirm new CCPO user
confirm_user_text: Please confirm that the user details below match the user being given CCPO permissions.
confirm_button: Confirm and Add User
return_link: Return to list of CCPO users
user_not_found_title: User not found
user_not_found_text: To add someone as a CCPO user, they must already have an ATAT account.
disable_user:
alert_message: "Confirm removing CCPO superuser access from {user_name}"
remove_button: Remove Access
common:
applications: Applications
cancel: Cancel
@ -50,41 +43,24 @@ common:
confirm: Confirm
continue: Continue
delete: Delete
deactivate: Deactivate
delete_confirm: 'Please type the word {word} to confirm:'
dod_id: DoD ID
disable: Disable
edit: Edit
email: Email
lorem: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
members: Members
name: Name
next: Next
'yes': 'Yes'
'no': 'No'
previous: Previous
response_label: Response required
save: Save
save_changes: Save Changes
task_orders: Task Orders
undo: Undo
view: View
resource_names:
environments: Environments
choose_role: Choose a role
components:
date_selector:
day: Day
month: Month
year: Year
modal:
destructive_message: You will no longer be able to access this {resource}
destructive_title: Warning! This action is permanent
totals_box:
obligated_funds: Total Obligated Funds
obligated_text: This amount strictly calculates Base CLINs, and may represent 100% of your total task order budget, or just a portion if you also have Optional Base or Optional CLINs.
total_amount: Total Possible Task Order Funds
total_text: This amount represents the total value of all Base and Option CLINs, including any extensions listed within your task order.
usa_header:
flag_image_alt: U.S. Flag
official_message: An official website of the United States government
@ -113,7 +89,6 @@ flash:
title: Application Saved
message: '{application_name} has been successfully created. You may continue on to provision environments and assign team members now, or come back and complete these tasks at a later time.'
updated: 'You have successfully updated the {application_name} application.'
deleted: 'You have successfully deleted the {application_name} application. To view the retained activity log, visit the portfolio administration page.'
name_error:
message: 'The application name {name} has already been used in this portfolio. Please enter a unique name.'
env_name_error:
@ -169,10 +144,19 @@ flash:
revoked:
title: Removed portfolio access
message: Portfolio access successfully removed from {member_name}.
update:
title: Success!
message: You have successfully updated access permissions for {member_name}.
update_error:
title: Permissions for {member_name} could not be updated
message: An unexpected problem occurred with your request, please try again. If the problem persists, contact an administrator.
portfolio_invite:
resent:
title: Invitation resent
message: Successfully sent a new invitation to {user_name}.
error:
title: Portfolio invitation error
message: There was an error processing the invitation for {user_name}.
session_expired:
title: Session Expired
message: Your session expired due to inactivity. Please log in again to continue.
@ -270,7 +254,6 @@ forms:
</p>
</div>
defense_component:
label: "Select DoD component(s) funding your Portfolio:"
choices:
air_force: Air Force
army: Army
@ -293,8 +276,6 @@ forms:
upload_error: There was an error uploading your file. Please try again. If you encounter repeated problems uploading this file, please contact CCPO.
size_error: The file you have selected is too large. Please choose a file no larger than 64MB.
filename_error: File names can only contain the characters A-Z, 0-9, space, hyphen, underscore, and period.
defense_component_label: Select DoD component(s) funding your Portfolio
file_format_not_allowed: Only PDF or PNG files can be uploaded.
number_description: Task order number (13 digits)
pop_errors:
date_order: PoP start date must be before end date.
@ -306,35 +287,15 @@ forms:
clin_funding_errors:
obligated_amount_error: Obligated amount must be less than or equal to total amount
funding_range_error: Dollar amount must be from $0.00 to $1,000,000,000.00
team_experience:
built_1: Built, migrated, or consulted on 1-2 applications
built_3: Built, migrated, or consulted on 3-5 applications
built_many: Built, migrated, or consulted on more than 5 applications
description: How much combined experience does your team have with development in the cloud?
label: Team experience
none: No previous experience
planned: Researched or planned a cloud build or migration
validators:
is_number_message: Please enter a valid number.
is_required: This field is required.
list_item_required_message: Please provide at least one.
list_items_unique_message: Items must be unique
name_message: 'This field accepts letters, numbers, commas, apostrophes, hyphens, and periods.'
phone_number_message: Please enter a valid 5 or 10 digit phone number.
file_length: Your file may not exceed 50 MB.
fragments:
delete_portfolio:
title: Deactivate Portfolio
subtitle: Portfolio deactivation is available only if there are no applications in the portfolio.
edit_application_form:
explain: AT-AT allows you to create multiple applications within a portfolio. Each application can then be broken down into its own customizable environments.
edit_environment_team_form:
delete_environment_title: Are you sure you want to delete this environment?
add_new_member_text: Need to add someone new to the team?
add_new_member_link: Jump to Team Settings
unassigned_title: Unassigned (No Access)
no_members: Currently no members are in this role
no_access: No Access
edit_user_form:
save_details_button: Save
portfolio_admin:
@ -376,16 +337,10 @@ navigation:
portfolios:
admin:
activity_log_title: Activity log
add_new_member: Add a new member
alert_header: Are you sure you want to delete this member?
alert_message: 'The member will be removed from the portfolio, but their log history will be retained.'
alert_title: Warning! You are about to delete a member from the portfolio.
defense_component_label: Department of Defense Component
no_members: There are currently no members in this portfolio.
permissions_info: Learn more about these permissions
portfolio_members_subheading: These members have different levels of access to the portfolio.
portfolio_members_title: Portfolio members
settings_info: Learn more about these settings
portfolio_name: Portfolio name
members:
perms_portfolio_mgmt:
@ -401,11 +356,9 @@ portfolios:
'False': View Reporting
'True': Edit Reporting
applications:
add_application_text: Add a new application
add_environment: Create an Environment
add_member: Add New Member
add_another_environment: Add another environment
app_settings_text: App settings
create_button: Create Application
empty_state:
header: You don't have any Applications yet
@ -445,49 +398,18 @@ portfolios:
step_2_button_text: "Next: Add Members"
step_3_header: Add Members to {application_name}
step_3_description: "To proceed, you will need each member's email address and DOD ID. Within this section, you will also assign Application-level permissions and environment-level roles for each member."
step_3_button_text: Save Application
create_new_env: Create a new environment.
create_new_env_info: Creating an environment gives you access to the Cloud Service Provider. This environment will function within the constraints of the task order, and any costs will be billed against the portfolio.
csp_console_text: CSP console
csp_link: Cloud Service Provider Link
remove_member:
alert:
message: '{user_name} will no longer be able to access this application or any of its environments'
button: Remove member
header: Are you sure you want to remove this team member?
delete:
alert:
message: You will lose access to this application and all environments will be removed from the CSP. Your reporting and activity will still be accessible.
button: Delete application
header: Are you sure you want to delete this application?
text: "If you delete {application_name}, all environments will be gone forever.<br>This action can not be undone."
subheading: Delete Application
enter_env_name: "Enter environment name:"
environments:
name: Name
delete:
button: Delete environment
edit_name: Edit name
environments_heading: Application Environments
existing_application_title: '{application_name} Application Settings'
member_count: '{count} members'
new_application_title: New Application
settings_heading: Application Settings
settings:
name_description: Application name and description
team_members: Application Team
environments: Application Environments
team_settings:
environments: Environments
section:
table:
delete_access: Delete Access
environment_management: Environment Management
team_management: Team Management
title: '{application_name} Team'
title: '{application_name} Team Settings'
add_to_environment: Add to existing environment
team_text: Team
members:
blank_slate: This Application has no members
form:
@ -512,19 +434,11 @@ portfolios:
title: Application Permissions
description: Application permissions allow users to provision and modify applications and teams. <a href="#"> Learn More </a>
edit_access_header: "Manage {user}'s Access"
env_access_text: "Add or revoke Environment access. Additional controls are available in the CSP console."
step_2_title: Set Permissions and Roles
add_member: Add Member
menu:
edit: Edit Roles and Permissions
resend: Resend Invite
new:
assign_roles: Assign Member Environments and Roles
learn_more: Learn more about these roles
manage_perms: 'Manage permissions for {application_name}'
manage_envs: 'Allow member to <strong>add</strong> and <strong>rename environments</strong> within the application.'
delete_envs: 'Allow member to <strong>delete environments</strong> within the application.'
manage_team: 'Allow member to <strong>add, update,</strong> and <strong>remove members</strong> from the application team.'
verify: Verify Member Information
perms_team_mgmt:
'False': View Team
@ -535,20 +449,9 @@ portfolios:
perms_del_env:
'False': ""
'True': Delete Application
index:
empty:
start_button: Start a new JEDI portfolio
title: You have no apps yet
header: PORTFOLIO
members:
archive_button: Delete member
permissions:
app_mgmt: App management
edit_access: Edit
funding: Funding
name: Name
portfolio_mgmt: Portfolio management
reporting: Reporting
reports:
estimate_warning: Reports displayed in JEDI are estimates and not a system of record.
empty_state:
@ -557,17 +460,9 @@ portfolios:
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'
task_orders:
add_new_button: Add New Task Order
review:
pdf_title: Approved Task Order
review_your_funding: Review your funding
funding_summary: CLIN Summary
task_order_number: Task Order Number
check_paragraph: Check to make sure the information you entered is correct. After submission, you will confirm this task order was signed by a contracting officer. Thereafter, you will be informed as soon as CCPO completes their review.
supporting_document:
title: Supporting document
clins:
number: TO CLIN
type: CLIN Type
@ -588,7 +483,6 @@ task_orders:
add_clin: Add another CLIN
add_to_header: Add your task order
add_to_description: Now, refer to your document to find the 13-digit task order number. It should be located at lorem ipsum dolar. From now on we'll refer to this portion of funding by the recorded task order number.
base_clin_title: CLIN
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
@ -598,8 +492,6 @@ task_orders:
clin_remove_text: 'Do you want to remove '
clin_remove_confirm: Yes, remove CLIN
clin_remove_cancel: No, go back
cloud_funding_header: Add the summary of cloud funding
cloud_funding_text: Data must match with what is in your uploaded document.
draft_alert_title: Your information has been saved
draft_alert_message: You can return to the Task Order Builder to enter missing information. Once you are finished, youll be ready to submit this request.
total_funds_label: Total CLIN Value
@ -609,16 +501,13 @@ task_orders:
pop_end_alert: "A CLIN's period of performance must end before {end_date}."
pop_example: "For example: 07 04 1776"
pop_start: Start Date
review_button: Review task order
supporting_docs_header: Upload your supporting documentation
supporting_docs_size_limit: Your file may not exceed 64MB
supporting_docs_text: Upload a single PDF containing all relevant information.
step_3:
next_button: 'Next: Review Task Order'
step_5:
title: Confirm Signature
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 Agencys 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.
alert_message: All task orders require a Contracting Officer signature.
next_button: 'Confirm & Submit'
sticky_header_text: 'Add a Task Order'
sticky_header_review_text: Review Changes
@ -628,12 +517,6 @@ 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.
new:
form_help_text: Before you can begin work in the cloud, you'll need to complete the information below and upload your approved task order for reference by the CCPO.
app_info:
project_title: Project details
subtitle: Who will be involved in the work funded by this task order?
team_title: Your team
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.

View File

@ -2,6 +2,7 @@
callable = app
module = app
socket = /var/run/uwsgi/uwsgi.socket
plugins-dir = /usr/lib/uwsgi
plugin = python3
plugin = logfile
virtualenv = /opt/atat/atst/.venv