Merge branch 'staging' into azure-custom-integration

This commit is contained in:
tomdds 2020-01-24 11:16:11 -05:00 committed by GitHub
commit 35eea8e31c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
192 changed files with 3127 additions and 3262 deletions

5
.gitignore vendored
View File

@ -31,6 +31,7 @@ static/buildinfo.*
# local log files
log/*
*.log
config/dev.ini
.env*
@ -74,3 +75,7 @@ celerybeat-schedule
js/test_templates
.mypy_cache/
# terraform
*.tfstate
*.backup

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null
},
"generated_at": "2020-01-09T16:55:07Z",
"generated_at": "2020-01-19T20:21:20Z",
"plugins_used": [
{
"base64_limit": 4.5,
@ -32,13 +32,6 @@
"is_verified": false,
"line_number": 156,
"type": "Secret Keyword"
},
{
"hashed_secret": "81b127e2222d9bfc4609053faec85300f7525463",
"is_secret": false,
"is_verified": false,
"line_number": 290,
"type": "Secret Keyword"
}
],
"alembic.ini": [
@ -89,7 +82,7 @@
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
"is_secret": false,
"is_verified": false,
"line_number": 30,
"line_number": 31,
"type": "Secret Keyword"
}
],
@ -152,7 +145,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

@ -255,6 +255,7 @@ To generate coverage reports for the Javascript tests:
- `SERVER_NAME`: Hostname for ATAT. Only needs to be specified in contexts where the hostname cannot be inferred from the request, such as Celery workers. https://flask.palletsprojects.com/en/1.1.x/config/#SERVER_NAME
- `SESSION_COOKIE_NAME`: String value specifying the name to use for the session cookie. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_NAME
- `SESSION_COOKIE_DOMAIN`: String value specifying the name to use for the session cookie. This should be set to the root domain so that it is valid for both the main site and the authentication subdomain. https://flask.palletsprojects.com/en/1.1.x/config/#SESSION_COOKIE_DOMAIN
- `SESSION_KEY_PREFIX`: A prefix that is added before all session keys: https://pythonhosted.org/Flask-Session/#configuration
- `SESSION_TYPE`: String value specifying the cookie storage backend. https://pythonhosted.org/Flask-Session/
- `SESSION_USE_SIGNER`: Boolean value specifying if the cookie sid should be signed.
- `SQLALCHEMY_ECHO`: Boolean value specifying if SQLAlchemy should log queries to stdout.
@ -362,50 +363,3 @@ fi
Also note that if the line number of a previously whitelisted secret changes, the whitelist file, `.secrets.baseline`, will be updated and needs to be committed.
## Local Kubernetes Setup
A modified version of the Kubernetes cluster can be deployed locally for
testing and development purposes.
It is strongly recommended that you backup your local K8s config (usually
`~/.kube/config`) before launching Minikube for the first time.
Before beginning:
- install the [Docker CLI](https://docs.docker.com/v17.12/install/)
- install [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/)
(this will also require installing a Hypervisor, such as VirtualBox)
### Setup
Run
```
script/minikube_setup
```
Once the script exits successfully, run
```
minikube service list
```
### Access the site
One of the two URLs given for the `atat-auth` service will load an HTTP version
of the application.
For HTTP basic auth, the username and password are both `minikube`.
### Differences from the main config
As of the time of writing, this setup does not include the following:
- SSL/TLS or the complete DoD PKI
- the cronjob for syncing CRLs and the peristent storage
- production configuration
In order for the application to run, the K8s config for Minikube includes an
additional deployment resource called `datastores`. This includes Postgres
and Redis containers. It also includes hard-coded versions of the K8s secrets
used in the regular clusters.

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"],
@ -221,7 +222,7 @@ def make_config(direct_config=None):
config.read_dict({"default": direct_config})
# Assemble DATABASE_URI value
database_uri = "postgres://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
database_uri = "postgresql://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
config.get("default", "PGUSER"),
config.get("default", "PGPASSWORD"),
config.get("default", "PGHOST"),
@ -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

@ -1,4 +1,13 @@
from flask import g, redirect, url_for, session, request, current_app as app
from flask import (
g,
redirect,
url_for,
session,
request,
current_app as app,
_request_ctx_stack as request_ctx_stack,
)
from werkzeug.datastructures import ImmutableTypeConversionDict
from atst.domain.users import Users
@ -10,7 +19,6 @@ UNPROTECTED_ROUTES = [
"atst.login_redirect",
"atst.logout",
"atst.unauthorized",
"atst.helpdocs",
"static",
"atst.about",
]
@ -57,12 +65,26 @@ def get_last_login():
return session.get("user_id") and session.get("last_login")
def _nullify_session(session):
session_key = f"{app.config.get('SESSION_KEY_PREFIX')}{session.sid}"
app.redis.delete(session_key)
request.cookies = ImmutableTypeConversionDict()
request_ctx_stack.top.session = app.session_interface.open_session(app, request)
def _current_dod_id():
return g.current_user.dod_id if session.get("user_id") else None
def logout():
if session.get("user_id"): # pragma: no branch
dod_id = g.current_user.dod_id
del session["user_id"]
del session["last_login"]
dod_id = _current_dod_id()
_nullify_session(session)
if dod_id:
app.logger.info(f"user with EDIPI {dod_id} has logged out")
else:
app.logger.info("unauthenticated user has logged out")
def _unprotected_route(request):

View File

@ -490,6 +490,12 @@ class CloudProviderInterface:
"""
raise NotImplementedError()
def create_subscription(self, environment):
"""Returns True if a new subscription has been created or raises an
exception if an error occurs while creating a subscription.
"""
raise NotImplementedError()
class MockCloudProvider(CloudProviderInterface):
@ -763,6 +769,11 @@ class MockCloudProvider(CloudProviderInterface):
return self._maybe(12)
def create_subscription(self, environment):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return True
def get_calculator_url(self):
return "https://www.rackspace.com/en-us/calculator"

View File

@ -1,14 +1,14 @@
from .forms import BaseForm, remove_empty_string
from wtforms.fields import StringField, TextAreaField, FieldList
from wtforms.validators import Required, Optional
from atst.forms.validators import ListItemRequired, ListItemsUnique
from wtforms.validators import Required, Optional, Length
from atst.forms.validators import ListItemRequired, ListItemsUnique, Name, AlphaNumeric
from atst.utils.localization import translate
class EditEnvironmentForm(BaseForm):
name = StringField(
label=translate("forms.environments.name_label"),
validators=[Required()],
validators=[Required(), Name(), Length(max=100)],
filters=[remove_empty_string],
)
@ -16,12 +16,12 @@ class EditEnvironmentForm(BaseForm):
class NameAndDescriptionForm(BaseForm):
name = StringField(
label=translate("forms.application.name_label"),
validators=[Required()],
validators=[Required(), Name(), Length(max=100)],
filters=[remove_empty_string],
)
description = TextAreaField(
label=translate("forms.application.description_label"),
validators=[Optional()],
validators=[Optional(), Length(max=1_000)],
filters=[remove_empty_string],
)
@ -31,6 +31,7 @@ class EnvironmentsForm(BaseForm):
StringField(
label=translate("forms.application.environment_names_label"),
filters=[remove_empty_string],
validators=[AlphaNumeric(), Length(max=100)],
),
validators=[
ListItemRequired(

View File

@ -1,5 +1,6 @@
from flask_wtf import FlaskForm
from wtforms.fields import FormField, FieldList, HiddenField, BooleanField
from wtforms.validators import UUID
from wtforms import Form
from .member import NewForm as BaseNewMemberForm
@ -7,11 +8,13 @@ from .data import ENV_ROLES, ENV_ROLE_NO_ACCESS as NO_ACCESS
from atst.forms.fields import SelectField
from atst.domain.permission_sets import PermissionSets
from atst.utils.localization import translate
from atst.forms.validators import AlphaNumeric
from wtforms.validators import Length
class EnvironmentForm(Form):
environment_id = HiddenField()
environment_name = HiddenField()
environment_id = HiddenField(validators=[UUID()])
environment_name = HiddenField(validators=[AlphaNumeric(), Length(max=100)])
role = SelectField(
environment_name,
choices=ENV_ROLES,
@ -43,13 +46,6 @@ class PermissionsForm(FlaskForm):
"portfolios.applications.members.form.team_mgmt.description"
),
)
perms_del_env = BooleanField(
translate("portfolios.applications.members.form.del_env.label"),
default=False,
description=translate(
"portfolios.applications.members.form.del_env.description"
),
)
@property
def data(self):
@ -63,9 +59,6 @@ class PermissionsForm(FlaskForm):
if _data["perms_team_mgmt"]:
perm_sets.append(PermissionSets.EDIT_APPLICATION_TEAM)
if _data["perms_del_env"]:
perm_sets.append(PermissionSets.DELETE_APPLICATION_ENVIRONMENTS)
_data["permission_sets"] = perm_sets
return _data

View File

@ -2,12 +2,12 @@ from flask_wtf import FlaskForm
from wtforms.validators import Required, Length
from wtforms.fields import StringField
from atst.forms.validators import IsNumber
from atst.forms.validators import Number
from atst.utils.localization import translate
class CCPOUserForm(FlaskForm):
dod_id = StringField(
translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10, max=10), IsNumber()],
validators=[Required(), Length(min=10, max=10), Number()],
)

View File

@ -9,22 +9,26 @@ from .forms import BaseForm
from .data import SERVICE_BRANCHES
from atst.models.user import User
from atst.utils.localization import translate
from wtforms.validators import Length
from atst.forms.validators import Number
from .validators import Name, DateRange, PhoneNumber
USER_FIELDS = {
"first_name": StringField(
translate("forms.edit_user.first_name_label"), validators=[Name()]
translate("forms.edit_user.first_name_label"),
validators=[Name(), Length(max=100)],
),
"last_name": StringField(
translate("forms.edit_user.last_name_label"), validators=[Name()]
translate("forms.edit_user.last_name_label"),
validators=[Name(), Length(max=100)],
),
"email": EmailField(translate("forms.edit_user.email_label"), validators=[Email()]),
"phone_number": TelField(
translate("forms.edit_user.phone_number_label"), validators=[PhoneNumber()]
),
"phone_ext": StringField("Extension"),
"phone_ext": StringField("Extension", validators=[Number(), Length(max=10)]),
"service_branch": SelectField(
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
),

View File

@ -3,16 +3,18 @@ from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length, Optional
from wtforms.fields import StringField
from atst.forms.validators import IsNumber, PhoneNumber
from atst.forms.validators import Number, PhoneNumber, Name
from atst.utils.localization import translate
class NewForm(FlaskForm):
first_name = StringField(
label=translate("forms.new_member.first_name_label"), validators=[Required()]
label=translate("forms.new_member.first_name_label"),
validators=[Required(), Name(), Length(max=100)],
)
last_name = StringField(
label=translate("forms.new_member.last_name_label"), validators=[Required()]
label=translate("forms.new_member.last_name_label"),
validators=[Required(), Name(), Length(max=100)],
)
email = EmailField(
translate("forms.new_member.email_label"), validators=[Required(), Email()]
@ -21,8 +23,8 @@ class NewForm(FlaskForm):
translate("forms.new_member.phone_number_label"),
validators=[Optional(), PhoneNumber()],
)
phone_ext = StringField("Extension")
phone_ext = StringField("Extension", validators=[Number(), Length(max=10)])
dod_id = StringField(
translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10), IsNumber()],
validators=[Required(), Length(min=10), Number()],
)

View File

@ -4,6 +4,7 @@ from wtforms.fields import (
TextAreaField,
)
from wtforms.validators import Length, InputRequired
from atst.forms.validators import Name
from wtforms.widgets import ListWidget, CheckboxInput
from .forms import BaseForm
@ -20,14 +21,18 @@ class PortfolioForm(BaseForm):
min=4,
max=100,
message=translate("forms.portfolio.name.length_validation_message"),
)
),
Name(),
],
)
description = TextAreaField(translate("forms.portfolio.description.label"),)
description = TextAreaField(
translate("forms.portfolio.description.label"), validators=[Length(max=1_000)]
)
class PortfolioCreationForm(PortfolioForm):
defense_component = SelectMultipleField(
translate("forms.portfolio.defense_component.title"),
choices=SERVICE_BRANCHES,
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),

View File

@ -7,9 +7,15 @@ from wtforms.fields import (
HiddenField,
)
from wtforms.fields.html5 import DateField
from wtforms.validators import Required, Length, NumberRange, ValidationError
from wtforms.validators import (
Required,
Length,
NumberRange,
ValidationError,
)
from flask_wtf import FlaskForm
from numbers import Number
import numbers
from atst.forms.validators import Number, AlphaNumeric
from .data import JEDI_CLIN_TYPES
from .fields import SelectField
@ -17,7 +23,7 @@ from .forms import BaseForm, remove_empty_string
from atst.utils.localization import translate
from flask import current_app as app
MAX_CLIN_AMOUNT = 1000000000
MAX_CLIN_AMOUNT = 1_000_000_000
def coerce_enum(enum_inst):
@ -29,8 +35,8 @@ def coerce_enum(enum_inst):
def validate_funding(form, field):
if (
isinstance(form.total_amount.data, Number)
and isinstance(field.data, Number)
isinstance(form.total_amount.data, numbers.Number)
and isinstance(field.data, numbers.Number)
and form.total_amount.data < field.data
):
raise ValidationError(
@ -61,7 +67,10 @@ class CLINForm(FlaskForm):
coerce=coerce_enum,
)
number = StringField(label=translate("task_orders.form.clin_number_label"))
number = StringField(
label=translate("task_orders.form.clin_number_label"),
validators=[Number(), Length(max=4)],
)
start_date = DateField(
translate("task_orders.form.pop_start"),
description=translate("task_orders.form.pop_example"),
@ -116,7 +125,10 @@ class AttachmentForm(BaseForm):
filename = HiddenField(
id="attachment_filename",
validators=[
Length(max=100, message=translate("forms.attachment.filename.length_error"))
Length(
max=100, message=translate("forms.attachment.filename.length_error")
),
AlphaNumeric(),
],
)
object_name = HiddenField(
@ -124,7 +136,8 @@ class AttachmentForm(BaseForm):
validators=[
Length(
max=40, message=translate("forms.attachment.object_name.length_error")
)
),
AlphaNumeric(),
],
)
accept = ".pdf,application/pdf"
@ -137,6 +150,7 @@ class TaskOrderForm(BaseForm):
number = StringField(
label=translate("forms.task_order.number_description"),
filters=[remove_empty_string],
validators=[Number(), Length(max=13)],
)
pdf = FormField(
AttachmentForm,

View File

@ -2,7 +2,7 @@ from datetime import datetime
import re
from werkzeug.datastructures import FileStorage
from wtforms.validators import ValidationError
from wtforms.validators import ValidationError, Regexp
import pendulum
from atst.utils.localization import translate
@ -31,12 +31,13 @@ def DateRange(lower_bound=None, upper_bound=None, message=None):
return _date_range
def IsNumber(message=translate("forms.validators.is_number_message")):
def Number(message=translate("forms.validators.is_number_message")):
def _is_number(form, field):
try:
int(field.data)
except (ValueError, TypeError):
raise ValidationError(message)
if field.data:
try:
int(field.data)
except (ValueError, TypeError):
raise ValidationError(message)
return _is_number
@ -97,3 +98,7 @@ def FileLength(max_length=50000000, message=None):
field.data.seek(0)
return _file_length
def AlphaNumeric(message=translate("forms.validators.alpha_numeric_message")):
return Regexp(regex=r"^[A-Za-z0-9\-_ \.]*$", message=message)

View File

@ -42,29 +42,11 @@ def root():
return render_template("login.html", redirect_url=redirect_url)
@bp.route("/help")
@bp.route("/help/<path:doc>")
def helpdocs(doc=None):
docs = [os.path.splitext(file)[0] for file in os.listdir("templates/help/docs")]
if doc:
return render_template("help/docs/{}.html".format(doc), docs=docs, doc=doc)
else:
return render_template("help/index.html", docs=docs, doc=doc)
@bp.route("/home")
def home():
return render_template("home.html")
@bp.route("/<path:path>")
def catch_all(path):
try:
return render_template("{}.html".format(path))
except TemplateNotFound:
raise NotFound()
def _client_s_dn():
return request.environ.get("HTTP_X_SSL_CLIENT_S_DN")

View File

@ -26,7 +26,7 @@ def has_portfolio_applications(_user, portfolio=None, **_kwargs):
def portfolio_applications(portfolio_id):
user_env_roles = EnvironmentRoles.for_user(g.current_user.id, portfolio_id)
environment_access = {
env_role.environment_id: env_role.role for env_role in user_env_roles
env_role.environment_id: env_role.role.value for env_role in user_env_roles
}
return render_template(

View File

@ -1,9 +1,10 @@
from flask import (
current_app as app,
g,
redirect,
render_template,
request as http_request,
url_for,
g,
)
from .blueprint import applications_bp
@ -64,9 +65,6 @@ def filter_perm_sets_data(member):
"perms_env_mgmt": bool(
member.has_permission_set(PermissionSets.EDIT_APPLICATION_ENVIRONMENTS)
),
"perms_del_env": bool(
member.has_permission_set(PermissionSets.DELETE_APPLICATION_ENVIRONMENTS)
),
}
return perm_sets_data
@ -509,11 +507,7 @@ def resend_invite(application_id, application_role_id):
token=new_invite.token,
)
flash(
"application_invite_resent",
user_name=new_invite.user_name,
application_name=app_role.application.name,
)
flash("application_invite_resent", email=new_invite.email)
else:
flash(
"application_invite_error",
@ -529,3 +523,31 @@ def resend_invite(application_id, application_role_id):
_anchor="application-members",
)
)
@applications_bp.route(
"/environments/<environment_id>/add_subscription", methods=["POST"]
)
@user_can(Permissions.EDIT_ENVIRONMENT, message="create new environment subscription")
def create_subscription(environment_id):
environment = Environments.get(environment_id)
try:
app.csp.cloud.create_subscription(environment)
flash("environment_subscription_success", name=environment.displayname)
except GeneralCSPException:
flash("environment_subscription_failure")
return (
render_settings_page(application=environment.application, show_flash=True),
400,
)
return redirect(
url_for(
"applications.settings",
application_id=environment.application.id,
fragment="application-environments",
_anchor="application-environments",
)
)

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", email=invite.email)
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

@ -29,7 +29,7 @@ MESSAGES = {
"category": "error",
},
"application_invite_resent": {
"title": "flash.application_invite.resent.title",
"title": None,
"message": "flash.application_invite.resent.message",
"category": "success",
},
@ -83,6 +83,16 @@ MESSAGES = {
"message": "flash.environment.deleted.message",
"category": "success",
},
"environment_subscription_failure": {
"title": "flash.environment.subscription_failure.title",
"message": "flash.environment.subscription_failure.message",
"category": "error",
},
"environment_subscription_success": {
"title": "flash.environment.subscription_success.title",
"message": "flash.environment.subscription_success.message",
"category": "success",
},
"form_errors": {
"title": "flash.form.errors.title",
"message": "flash.form.errors.message",
@ -90,7 +100,7 @@ MESSAGES = {
},
"insufficient_funds": {
"title": "flash.task_order.insufficient_funds.title",
"message": "",
"message": None,
"category": "warning",
},
"logged_out": {
@ -109,8 +119,8 @@ MESSAGES = {
"category": "success",
},
"new_portfolio_member": {
"title": "flash.success",
"message": "flash.new_portfolio_member",
"title": "flash.new_portfolio_member.title",
"message": "flash.new_portfolio_member.message",
"category": "success",
},
"portfolio_member_removed": {
@ -124,10 +134,15 @@ MESSAGES = {
"category": "success",
},
"resend_portfolio_invitation": {
"title": "flash.portfolio_invite.resent.title",
"title": None,
"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 +168,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

@ -4,6 +4,7 @@ from atst.domain.users import Users
class SessionLimiter(object):
def __init__(self, config, session, redis):
self.limit_logins = config["LIMIT_CONCURRENT_SESSIONS"]
self.session_prefix = config.get("SESSION_KEY_PREFIX", "session:")
self.session = session
self.redis = redis
@ -16,4 +17,4 @@ class SessionLimiter(object):
Users.update_last_session_id(user, session_id)
def _delete_session(self, session_id):
self.redis.delete("session:{}".format(session_id))
self.redis.delete(f"{self.session_prefix}{session_id}")

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
@ -41,6 +42,7 @@ SECRET_KEY = change_me_into_something_secret
SERVER_NAME
SESSION_COOKIE_NAME=atat
SESSION_COOKIE_DOMAIN
SESSION_KEY_PREFIX=session:
SESSION_TYPE = redis
SESSION_USE_SIGNER = True
SQLALCHEMY_ECHO = False

View File

@ -15,6 +15,7 @@ data:
CSP: azure
DEBUG: "0"
FLASK_ENV: master
LIMIT_CONCURRENT_SESSIONS: "true"
LOG_JSON: "true"
MAIL_PORT: "587"
MAIL_SENDER: postmaster@atat.code.mil

View File

@ -0,0 +1,40 @@
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
minReplicas: 2
maxReplicas: 10
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: atst
metrics:
- type: Resource
resource:
name: cpu
targetAverageUtilization: 60
---
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
labels:
app: atst
name: atst-worker
namespace: atat
spec:
minReplicas: 1
maxReplicas: 10
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: atst-worker
metrics:
- type: Resource
resource:
name: cpu
targetAverageUtilization: 60

View File

@ -15,7 +15,6 @@ spec:
selector:
matchLabels:
role: web
replicas: 4
strategy:
type: RollingUpdate
template:
@ -30,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
@ -51,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:
@ -87,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:
@ -169,7 +175,6 @@ spec:
selector:
matchLabels:
role: worker
replicas: 2
strategy:
type: RollingUpdate
template:
@ -207,10 +212,10 @@ spec:
resources:
requests:
memory: 280Mi
cpu: 20m
cpu: 400m
limits:
memory: 280Mi
cpu: 20m
cpu: 400m
volumes:
- name: pgsslrootcert
configMap:
@ -311,6 +316,7 @@ metadata:
namespace: atat
spec:
loadBalancerIP: 13.92.235.6
externalTrafficPolicy: Local
ports:
- port: 80
targetPort: 8342
@ -331,6 +337,7 @@ metadata:
namespace: atat
spec:
loadBalancerIP: 23.100.24.41
externalTrafficPolicy: Local
ports:
- port: 80
targetPort: 8343

View File

@ -12,3 +12,4 @@ resources:
- acme-challenges.yml
- aadpodidentity.yml
- nginx-snippets.yml
- autoscaling.yml

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

@ -1,35 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-config
namespace: atat
data:
uwsgi-config: |-
[uwsgi]
callable = app
module = app
socket = /var/run/uwsgi/uwsgi.socket
plugin = python3
plugin = logfile
virtualenv = /opt/atat/atst/.venv
chmod-socket = 666
; logger config
; application logs: log without modifying
logger = secondlogger stdio
log-route = secondlogger atst
log-encoder = format:secondlogger ${msg}
; default uWSGI messages (start, stop, etc.)
logger = default stdio
log-route = default ^((?!atst).)*$
log-encoder = json:default {"timestamp":"${strftime:%%FT%%T}","source":"uwsgi","severity":"DEBUG","message":"${msg}"}
log-encoder = nl
; uWSGI request logs
logger-req = stdio
log-format = request_id=%(var.HTTP_X_REQUEST_ID), pid=%(pid), remote_add=%(addr), request=%(method) %(uri), status=%(status), body_bytes_sent=%(rsize), referer=%(referer), user_agent=%(uagent), http_x_forwarded_for=%(var.HTTP_X_FORWARDED_FOR)
log-req-encoder = json {"timestamp":"${strftime:%%FT%%T}","source":"req","severity":"INFO","message":"${msg}"}
log-req-encoder = nl

View File

@ -1,15 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-envvars
namespace: atat
data:
TZ: UTC
FLASK_ENV: dev
OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini
CRL_STORAGE_PROVIDER: CLOUDFILES
LOG_JSON: "true"
REDIS_URI: "redis://redis-svc:6379"
PGHOST: postgres-svc

View File

@ -1,73 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-nginx
namespace: atat
data:
nginx-config: |-
server {
listen 8342;
server_name aws.atat.code.mil;
return 301 https://$host$request_uri;
}
server {
listen 8343;
server_name auth-aws.atat.code.mil;
return 301 https://$host$request_uri;
}
server {
server_name aws.atat.code.mil;
# access_log /var/log/nginx/access.log json;
listen 8442;
location /login-redirect {
return 301 https://auth-aws.atat.code.mil$request_uri;
}
location /login-dev {
try_files $uri @appbasicauth;
}
location / {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
location @appbasicauth {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
auth_basic "Developer Access";
auth_basic_user_file /etc/nginx/.htpasswd;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
}
server {
# access_log /var/log/nginx/access.log json;
server_name auth-aws.atat.code.mil;
listen 8443;
listen [::]:8443 ipv6only=on;
# Request and validate client certificate
ssl_verify_client on;
ssl_verify_depth 10;
ssl_client_certificate /etc/ssl/client-ca-bundle.pem;
# Guard against HTTPS -> HTTP downgrade
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always";
location / {
return 301 https://aws.atat.code.mil$request_uri;
}
location /login-redirect {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
uwsgi_param HTTP_X_SSL_CLIENT_VERIFY $ssl_client_verify;
uwsgi_param HTTP_X_SSL_CLIENT_CERT $ssl_client_raw_cert;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN $ssl_client_s_dn;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN_LEGACY $ssl_client_s_dn_legacy;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN $ssl_client_i_dn;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN_LEGACY $ssl_client_i_dn_legacy;
uwsgi_param HTTP_X_REQUEST_ID $request_id;
}
}

View File

@ -1,12 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-worker-envvars
namespace: atat
data:
TZ: UTC
DISABLE_CRL_CHECK: "True"
CRL_STORAGE_PROVIDER: CLOUDFILES
REDIS_URI: "redis://redis-svc:6379"
PGHOST: postgres-svc

View File

@ -1,61 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: db-cache
name: datastores
namespace: atat
spec:
selector:
matchLabels:
app: db-cache
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: db-cache
spec:
securityContext:
fsGroup: 101
containers:
- name: postgres
image: postgres:11-alpine
imagePullPolicy: Never
ports:
- containerPort: 5432
- name: redis
image: redis:5.0-alpine
imagePullPolicy: Never
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
namespace: atat
spec:
ports:
- name: db-port
protocol: "TCP"
port: 5432
targetPort: 5432
selector:
app: db-cache
---
apiVersion: v1
kind: Service
metadata:
name: redis-svc
namespace: atat
spec:
ports:
- name: cache-port
protocol: "TCP"
port: 6379
targetPort: 6379
selector:
app: db-cache

View File

@ -1,232 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
selector:
matchLabels:
role: web
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: web
spec:
securityContext:
fsGroup: 101
containers:
- name: atst
image: atat:latest
imagePullPolicy: Never
envFrom:
- configMapRef:
name: atst-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: nginx-client-ca-bundle
mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem"
subPath: client-ca-bundle.pem
- name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi"
- name: nginx
image: nginx:alpine
imagePullPolicy: Never
ports:
- containerPort: 8342
name: main-upgrade
- containerPort: 8442
name: main
- containerPort: 8343
name: auth-upgrade
- containerPort: 8443
name: auth
volumeMounts:
- name: nginx-config
mountPath: "/etc/nginx/conf.d/atst.conf"
subPath: atst.conf
- name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi"
- name: nginx-htpasswd
mountPath: "/etc/nginx/.htpasswd"
subPath: .htpasswd
- name: nginx-client-ca-bundle
mountPath: "/etc/ssl/"
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: nginx-client-ca-bundle
configMap:
name: nginx-client-ca-bundle
defaultMode: 0666
- name: nginx-config
configMap:
name: atst-nginx
items:
- key: nginx-config
path: atst.conf
- name: uwsgi-socket-dir
emptyDir:
medium: Memory
- name: nginx-htpasswd
secret:
secretName: atst-nginx-htpasswd
items:
- key: htpasswd
path: .htpasswd
mode: 0640
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst-worker
namespace: atat
spec:
selector:
matchLabels:
role: worker
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: worker
spec:
securityContext:
fsGroup: 101
containers:
- name: atst-worker
image: atat:latest
imagePullPolicy: Never
args: [
"/opt/atat/atst/.venv/bin/python",
"/opt/atat/atst/.venv/bin/celery",
"-A",
"celery_worker.celery",
"worker",
"--loglevel=info"
]
envFrom:
- configMapRef:
name: atst-envvars
- configMapRef:
name: atst-worker-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: atst
name: atst-beat
namespace: atat
spec:
selector:
matchLabels:
role: beat
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
role: beat
spec:
securityContext:
fsGroup: 101
containers:
- name: atst-beat
image: atat:latest
imagePullPolicy: Never
args: [
"/opt/atat/atst/.venv/bin/python",
"/opt/atat/atst/.venv/bin/celery",
"-A",
"celery_worker.celery",
"beat",
"--loglevel=info"
]
envFrom:
- configMapRef:
name: atst-envvars
- configMapRef:
name: atst-worker-envvars
volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst-main
namespace: atat
spec:
ports:
- port: 80
targetPort: 8342
name: http-main
- port: 443
targetPort: 8442
name: https-main
selector:
role: web
type: LoadBalancer
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst-auth
namespace: atat
spec:
ports:
- port: 80
targetPort: 8343
name: http-auth
- port: 443
targetPort: 8443
name: https-auth
selector:
role: web
type: LoadBalancer

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -9,13 +9,23 @@ spec:
- name: nginx-secret
flexVolume:
options:
keyvaultname: "atat-vault-test"
keyvaultobjectnames: "dhparam4096;cert;cert"
keyvaultname: "cloudzero-dev-keyvault"
# keyvaultobjectnames: "dhparam4096;cert;cert"
keyvaultobjectnames: "foo"
keyvaultobjectaliases: "FOO"
keyvaultobjecttypes: "secret"
usevmmanagedidentity: "true"
usepodidentity: "false"
- name: flask-secret
flexVolume:
options:
keyvaultname: "atat-vault-test"
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
keyvaultname: "cloudzero-dev-keyvault"
# keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
keyvaultobjectnames: "master-PGPASSWORD"
keyvaultobjectaliases: "PGPASSWORD"
keyvaultobjecttypes: "secret"
usevmmanagedidentity: "true"
usepodidentity: "false"
---
apiVersion: extensions/v1beta1
kind: Deployment
@ -28,8 +38,10 @@ spec:
- name: flask-secret
flexVolume:
options:
keyvaultname: "atat-vault-test"
keyvaultname: "cloudzero-dev-keyvault"
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
usevmmanagedidentity: "true"
usepodidentity: "false"
---
apiVersion: extensions/v1beta1
kind: Deployment
@ -42,8 +54,10 @@ spec:
- name: flask-secret
flexVolume:
options:
keyvaultname: "atat-vault-test"
keyvaultname: "cloudzero-dev-keyvault"
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
usevmmanagedidentity: "true"
usepodidentity: "false"
---
apiVersion: batch/v1beta1
kind: CronJob
@ -58,5 +72,7 @@ spec:
- name: flask-secret
flexVolume:
options:
keyvaultname: "atat-vault-test"
keyvaultname: "cloudzero-dev-keyvault"
keyvaultobjectnames: "AZURE-STORAGE-KEY;MAIL-PASSWORD;PGPASSWORD;REDIS-PASSWORD;SECRET-KEY"
usevmmanagedidentity: "true"
usepodidentity: "false"

View File

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

View File

@ -3,6 +3,9 @@ apiVersion: v1
kind: Service
metadata:
name: atst-main
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "cloudzero-dev-public"
spec:
loadBalancerIP: ""
ports:
@ -17,6 +20,9 @@ apiVersion: v1
kind: Service
metadata:
name: atst-auth
annotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-internal-subnet: "cloudzero-dev-public"
spec:
loadBalancerIP: ""
ports:

View File

@ -1,14 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst
spec:
replicas: 2
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst-worker
spec:
replicas: 1

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,7 +5,7 @@ resources:
- namespace.yml
- reset-cron-job.yml
patchesStrategicMerge:
- replica_count.yml
- autoscaling.yml
- ports.yml
- envvars.yml
- flex_vol.yml

View File

@ -1,14 +0,0 @@
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst
spec:
replicas: 2
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: atst-worker
spec:
replicas: 1

0
docs/ATATArchitecture.md Normal file
View File

32
docs/EdgeControls.md Normal file
View File

@ -0,0 +1,32 @@
# Edge Control
This document describes the expected connections and listening services.
## Transient Connections
| Service | Direction | Ports | Protocol | Encrypted? | Ciphers |
| --------|-----------|-------|----------|------------|--------------|
| Azure Container Registry | Egress | 443 | HTTP | Yes | MSFT Managed |
| DOD CRL Service | Egress | 443 | HTTP | Yes | DOD Managed |
| Azure Storage | Egress | 443 | HTTP | Yes | MSFT Managed|
| Redis | Egress | 6380 | HTTP | Yes | MSFT Managed|
| Postgres | Egress | 5432 | HTTP | Yes | MSFT Managed|
# Listening Ports / Services
| Service/App | Port | Protocol| Encrypted? | Accessible |
|-------------|---------|---------|------------|--------|
| ATAT App | 80, 443 | HTTP | Both | Load Balancer Only
| ATAT Auth | 80, 443 | HTTP | Both | Load Balancer Only
# Host List
## Dev
| Service| Host |
|--------|------|
| Redis | cloudzero-dev-redis.redis.cache.windows.net |
| Postgres| cloudzero-dev-sql.postgres.database.azure.com |
| Docker Container Registry | cloudzerodevregistry.azurecr.io |
## Production
| Service | Host |
|---------|------|
| Redis | |
| Postgres| |
| Docker Container Registry | |

View File

@ -70,7 +70,7 @@ describe('UploadInput Test', () => {
})
const component = wrapper.find(uploadinput)
const event = { target: { value: '', files: [{ name: '' }] } }
const event = { target: { value: '', files: [{ name: 'sample.pdf' }] } }
component.setMethods({
getUploader: async () => new MockUploader('token', 'objectName'),

View File

@ -101,7 +101,7 @@ export default {
if (!!this.clinNumber) {
return `CLIN ${this.clinNumber}`
} else {
return `CLIN`
return `New CLIN`
}
},
percentObligated: function() {

View File

@ -1,5 +1,6 @@
import { buildUploader } from '../lib/upload'
import { emitFieldChange } from '../lib/emitters'
import inputValidations from '../lib/input_validations'
export default {
name: 'uploadinput',
@ -28,6 +29,7 @@ export default {
changed: false,
uploadError: false,
sizeError: false,
filenameError: false,
downloadLink: '',
}
},
@ -50,6 +52,10 @@ export default {
this.sizeError = true
return
}
if (!this.validateFileName(file.name)) {
this.filenameError = true
return
}
const uploader = await this.getUploader()
const response = await uploader.upload(file)
@ -71,6 +77,10 @@ export default {
this.uploadError = true
}
},
validateFileName: function(name) {
const regex = inputValidations.restrictedFileName.match
return regex.test(name)
},
removeAttachment: function(e) {
e.preventDefault()
this.attachment = null
@ -118,7 +128,8 @@ export default {
return (
(!this.changed && this.initialErrors) ||
this.uploadError ||
this.sizeError
this.sizeError ||
this.filenameError
)
},
valid: function() {

View File

@ -9,6 +9,12 @@ export default {
unmask: [],
validationError: 'Please enter a response',
},
clinNumber: {
mask: false,
match: /^\d{4}$/,
unmask: [],
validationError: 'Please enter a 4-digit CLIN number',
},
date: {
mask: [/\d/, /\d/, '/', /\d/, /\d/, '/', /\d/, /\d/, /\d/, /\d/],
match: /(0[1-9]|1[012])[- \/.](0[1-9]|[12][0-9]|3[01])[- \/.](19|20)\d\d/,
@ -34,6 +40,20 @@ export default {
unmask: ['$', ','],
validationError: 'Please enter a dollar amount',
},
defaultStringField: {
mask: false,
match: /^[A-Za-z0-9\-_ \.]{1,100}$/,
unmask: [],
validationError:
'Please enter a response of no more than 100 alphanumeric characters',
},
defaultTextAreaField: {
mask: false,
match: /^[A-Za-z0-9\-_ \.]{1,1000}$/,
unmask: [],
validationError:
'Please enter a response of no more than 1000 alphanumeric characters',
},
clinDollars: {
mask: createNumberMask({ prefix: '$', allowDecimal: true }),
match: /^-?\d+\.?\d*$/,
@ -53,6 +73,13 @@ export default {
unmask: [','],
validationError: 'Please enter a number',
},
name: {
mask: false,
match: /.{1,100}/,
unmask: [],
validationError:
'This field accepts letters, numbers, commas, apostrophes, hyphens, and periods.',
},
phoneExt: {
mask: createNumberMask({
prefix: '',
@ -71,7 +98,7 @@ export default {
unmask: [],
validationError: 'Portfolio names can be between 4-100 characters',
},
requiredField: {
required: {
mask: false,
match: /.+/,
unmask: [],
@ -104,4 +131,11 @@ export default {
unmask: ['(', ')', '-', ' '],
validationError: 'Please enter a 10-digit phone number',
},
restrictedFileName: {
mask: false,
match: /^[A-Za-z0-9\-_ \.]+$/,
unmask: [],
validationError:
'File names can only contain the characters A-Z, 0-9, space, hyphen, underscore, and period.',
},
}

View File

@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
# Alpha numerics for random entity names
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" # pragma: allowlist secret
NEW_PORTFOLIO_CHANCE = 10
NEW_APPLICATION_CHANCE = 10
@ -29,10 +29,6 @@ def logout(l):
l.client.get("/logout")
def get_index(l):
l.client.get("/")
def get_csrf_token(response):
d = pq(response.text)
return d("#csrf_token").val()
@ -52,14 +48,9 @@ def extract_id(path):
def get_portfolios(l):
response = l.client.get("/portfolios")
response = l.client.get("/home")
d = pq(response.text)
portfolio_links = [
p.attr("href")
for p in d(
".global-panel-container .atat-table tbody tr td:first-child a"
).items()
]
portfolio_links = [p.attr("href") for p in d(".sidenav__link").items()]
force_new_portfolio = randrange(0, 100) < NEW_PORTFOLIO_CHANCE
if len(portfolio_links) == 0 or force_new_portfolio:
portfolio_links += [create_portfolio(l)]
@ -73,7 +64,7 @@ def get_portfolio(l):
d = pq(response.text)
application_links = [
p.attr("href")
for p in d(".application-list .accordion__actions a:first-child").items()
for p in d(".portfolio-applications .accordion__header-text a").items()
]
if len(application_links) > 0:
portfolio_id = extract_id(portfolio_link)
@ -161,18 +152,14 @@ class UserBehavior(TaskSequence):
login(self)
@seq_task(1)
def home(l):
get_index(l)
@seq_task(2)
def portfolios(l):
get_portfolios(l)
@seq_task(3)
@seq_task(2)
def pick_a_portfolio(l):
get_portfolio(l)
@seq_task(4)
@seq_task(3)
def pick_an_app(l):
get_app(l)
@ -189,4 +176,3 @@ class WebsiteUser(HttpLocust):
if __name__ == "__main__":
# if run as the main file, will spin up a single locust
WebsiteUser().run()

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

@ -1,33 +0,0 @@
#!/bin/bash
# script/minikube_setup: Set up local AT-AT cluster on Minikube
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
output_divider "Start Minikube"
minikube start
output_divider "Use Minikube Docker environment"
eval $(minikube docker-env)
output_divider "Build AT-AT Docker image for Minikube registry"
docker build . -t atat:latest
output_divider "Pull images for AT-AT cluster"
docker pull redis:5.0-alpine
docker pull postgres:11-alpine
docker pull nginx:alpine
output_divider "Apply AT-AT Kubernetes config to Minikube cluster"
kubectl --context=minikube create namespace atat
kubectl --context=minikube apply -f deploy/minikube/
output_divider "Create database and apply migrations"
# wait for the datastore deployment to become available
kubectl --context=minikube -n atat wait --for=condition=Available deployment/datastores
# postgres isn't necessarily running as soon as the pod is available, so wait a few
sleep 3
DB_POD=$(kubectl --context=minikube -n atat get pods -l app=db-cache -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
ATST_POD=$(kubectl --context=minikube -n atat get pods -l app=atst -o custom-columns=NAME:.metadata.name --no-headers | sed -n 1p)
kubectl --context=minikube -n atat exec -it $DB_POD -c postgres -- createdb -U postgres atat
kubectl --context=minikube -n atat exec -it $ATST_POD -c atst -- .venv/bin/python .venv/bin/alembic upgrade head

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

@ -106,7 +106,7 @@
&__expanded {
font-size: $small-font-size;
font-weight: $font-normal;
background-color: $color-gray-lightest;
background-color: $color-offwhite;
padding: $gap;
&:last-child {

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 {
@ -275,14 +222,21 @@
span.accordion-table__item__toggler {
font-weight: $font-normal;
text-decoration: underline;
font-size: $small-font-size;
&.environment-list__item__members {
float: unset;
font-size: $small-font-size;
margin-left: -$gap;
}
}
}
li.environment-list__edit {
border: 1px solid $color-gray-lighter;
padding: 0 $gap * 3 $gap * 2;
}
.activity-log {
border-top: 3px solid $color-blue;

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

@ -33,7 +33,7 @@ $title-font-size: 5.2rem;
$h1-font-size: 4rem;
$h2-font-size: 3rem;
$h3-font-size: 2.3rem;
$h4-font-size: 1.7rem;
$h4-font-size: 1.9rem;
$h5-font-size: 1.5rem;
$h6-font-size: 1.3rem;
$base-line-height: 1.5;
@ -44,6 +44,7 @@ $font-sans: "Source Sans Pro", sans-serif;
$font-serif: "Merriweather", serif;
$font-normal: 400;
$font-semibold: 600;
$font-bold: 700;
// Color

View File

@ -230,6 +230,8 @@
&--anything,
&--portfolioName,
&--requiredField,
&--defaultStringField,
&--defaultTextAreaField,
&--taskOrderNumber,
&--email {
input {

View File

@ -13,7 +13,7 @@
) }}
<div class="accordion-table__item-content new-env">
<div class="h4">{{ "portfolios.applications.enter_env_name" | translate }}</div>
{{ TextInput(new_env_form.name, label="", validation="requiredField", optional=False) }}
{{ TextInput(new_env_form.name, label="", validation="defaultStringField", optional=False) }}
<div class="action-group">
{{ SaveButton(text=('common.save' | translate), element="input", form="add-new-env") }}
<a class='action-group__action icon-link icon-link--default' v-on:click="toggle">

View File

@ -17,7 +17,7 @@
{% if 0 == environments_obj | length -%}
<div class="empty-state panel__content">
<p class="empty-state__message">
This Application has no environments
{{ 'portfolios.applications.environments.blank_slate' | translate }}
</p>
</div>
{% else %}
@ -31,8 +31,23 @@
<div class="accordion-table__item-content">
<div class="environment-list__item">
<span>
{{ env['name'] }}
<a
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
target='_blank'
rel='noopener noreferrer'>
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
</a>
</span>
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
{{
ToggleButton(
open_html="common.edit"|translate,
close_html="common.close"|translate,
section_name="edit"
)
}}
{%- endif %}
<br>
{% set members_button = "portfolios.applications.member_count" | translate({'count': env['member_count']}) %}
{{
ToggleButton(
@ -42,23 +57,9 @@
classes="environment-list__item__members"
)
}}
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
{% set edit_environment_button = "Edit" %}
{{
ToggleButton(
open_html=edit_environment_button,
close_html=edit_environment_button,
section_name="edit"
)
}}
{%- endif %}
<br>
{% if env['pending'] -%}
{{ Label(type="changes_pending", classes='label--below')}}
{% else %}
<a href='{{ url_for("applications.access_environment", environment_id=env.id)}}' target='_blank' rel='noopener noreferrer' class='application-list-item__environment__csp_link'>
<span>{{ "portfolios.applications.csp_link" | translate }} {{ Icon('link', classes="icon--tiny") }}</span>
</a>
{%- endif %}
</div>
</div>
@ -66,7 +67,7 @@
{% call ToggleSection(section_name="members") %}
<ul>
{% for member in env['members'] %}
{% set status = ": Access Suspended" if member['status'] == 'disabled' %}
{% set status = "portfolios.applications.environments.disabled"|translate if member['status'] == 'disabled' %}
<li class="accordion-table__item-toggle-content__expanded">
{{ member['user_name'] }}{{ status }}
</li>
@ -77,16 +78,28 @@
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
{% call ToggleSection(section_name="edit") %}
<ul>
<li class="accordion-table__item-toggle-content__expanded">
<li class="accordion-table__item-toggle-content__expanded environment-list__edit">
<base-form inline-template>
<form action="{{ url_for('applications.update_environment', environment_id=env['id']) }}" method="post" v-on:submit="handleSubmit">
<form
action="{{ url_for('applications.update_environment', environment_id=env['id']) }}"
method="post"
v-on:submit="handleSubmit"
class="col col--half">
{{ edit_form.csrf_token }}
{{ TextInput(edit_form.name, validation='requiredField', optional=False) }}
{{
SaveButton(
text=("common.save" | translate)
)
}}
{{ TextInput(edit_form.name, validation='defaultStringField', optional=False) }}
<div class="action-group action-group--tight">
{{
SaveButton(
text=("common.save_changes" | translate)
)
}}
</div>
<button
type="submit"
formaction="{{ url_for('applications.create_subscription', environment_id=env.id)}}"
class="usa-button usa-button-secondary">
{{ "portfolios.applications.environments.add_subscription" | translate }}
</button>
</form>
</base-form>
</li>

View File

@ -21,7 +21,7 @@
{{ sub_form.environment_name.data }}
</div>
<div class="usa-input__help">
{{ role }}
{{ "portfolios.applications.members.roles.{}".format(role) | translate }}
</div>
</div>
<div class="form-col form-col--third">
@ -89,16 +89,13 @@
{% if new %}
{% set team_mgmt = form.perms_team_mgmt.name %}
{% set env_mgmt = form.perms_env_mgmt.name %}
{% set del_env = form.perms_del_env.name %}
{% else %}
{% set team_mgmt = "perms_team_mgmt-{}".format(member_role_id) %}
{% set env_mgmt = "perms_env_mgmt-{}".format(member_role_id) %}
{% set del_env = "perms_del_env-{}".format(member_role_id) %}
{% endif %}
{{ CheckboxInput(form.perms_team_mgmt, classes="input__inline-fields", key=team_mgmt, id=team_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_env_mgmt, classes="input__inline-fields", key=env_mgmt, id=env_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_del_env, classes="input__inline-fields", key=del_env, id=del_env, optional=True) }}
</div>
<hr class="full-width">
<div class="environment_roles environment-roles-new">
@ -119,11 +116,11 @@
{% macro InfoFields(member_form) %}
<div class="user-info">
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.first_name, validation='name', optional=False) }}
{{ TextInput(member_form.last_name, validation='name', optional=False) }}
{{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
<a href="#">How do I find the DoD ID?</a>
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
</div>
{% endmacro %}

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="common.save_changes"|translate,
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>
@ -187,7 +179,7 @@
member_form.SubmitStep(
name=new_member_modal_name,
form=member_fields.PermsFields(form=new_member_form, new=True),
submit_text="portfolios.applications.members.form.add_member"|translate,
submit_text="common.save_changes"|translate,
modal=new_member_modal_name,
)
],

View File

@ -26,14 +26,14 @@
{{ form.csrf_token }}
<div class="form-row">
<div class="form-col">
{{ TextInput(form.name, optional=False) }}
{{ TextInput(form.name, validation="name", optional=False) }}
{{ ('portfolios.applications.new.step_1_form_help_text.name' | translate | safe) }}
</div>
</div>
<hr>
<div class="form-row">
<div class="form-col form-col--two-thirds">
{{ TextInput(form.description, paragraph=True, optional=True) }}
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True, optional=True) }}
{{ ('portfolios.applications.new.step_1_form_help_text.description' | translate | safe) }}
</div>
</div>

View File

@ -27,13 +27,13 @@
<span class="action-group-footer">
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
Return to Application Settings
{{ "portfolios.applications.new.step_3_button_text" | translate }}
</a>
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}">
Previous
{{ "common.previous" | translate }}
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
{{ "common.cancel" | translate }}
</a>
</span>

View File

@ -22,8 +22,8 @@
<base-form inline-template>
<form method="POST" action="{{ url_for('applications.update', application_id=application.id) }}" class="col col--half">
{{ application_form.csrf_token }}
{{ TextInput(application_form.name, optional=False) }}
{{ TextInput(application_form.description, paragraph=True, optional=True, showOptional=False) }}
{{ TextInput(application_form.name, validation="name", optional=False) }}
{{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }}
<div class="action-group action-group--tight">
{{ SaveButton(text='common.save_changes'|translate) }}
</div>

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

@ -41,7 +41,7 @@
<div class="form-row">
<div class="form-col">
{% if fields %}
{{ TextInput(fields.number, optional=False) }}
{{ TextInput(fields.number, validation='clinNumber', optional=False) }}
{% else %}
<textinput :name="'clins-' + clinIndex + '-number'" inline-template>
<div v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
@ -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>
@ -118,7 +118,7 @@
{{ CLINDollarAmount("obligated", funding_validation=True) }}
{% endif %}
<div class="h5 clin-card__title">Percent Obligated</div>
<div class="h5 clin-card__title">{{ "task_orders.form.step_3.percent_obligated" | translate }}</div>
<p id="percent-obligated" v-text='percentObligated'></p>
<hr>

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

@ -2,7 +2,7 @@
{% set class = "usa-button usa-button-primary " + additional_classes %}
{% if element == "button" %}
<button type="submit" class="{{ class }}" tabindex="0" v-bind:disabled="!canSave" {{ form_attr }} >
<button type="submit" class="{{ class }}" tabindex="0" v-bind:disabled="!canSave">
{{ text }}
</button>
{% elif element == 'input' %}

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

@ -49,6 +49,9 @@
<template v-if="sizeError">
<span class="usa-input__message">{{ "forms.task_order.size_error" | translate }}</span>
</template>
<template v-if="filenameError">
<span class="usa-input__message">{{ "forms.task_order.filename_error" | translate }}</span>
</template>
{% for error, error_messages in field.errors.items() %}
<span class="usa-input__message">{{error_messages[0]}}</span>
{% endfor %}

View File

@ -17,15 +17,6 @@
{%- endif %}
</p>
</div>
<div class="panel__body">
<hr>
<p>
{{ "common.lorem" | translate }}
</p>
<p>
<a href="#">More lorem</a>
</p>
</div>
</main>
{% endblock %}

View File

@ -11,11 +11,11 @@
<div class='panel__content'>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(form.first_name, validation='requiredField', optional=False) }}
{{ TextInput(form.first_name, validation='name', optional=False) }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(form.last_name, validation='requiredField', optional=False) }}
{{ TextInput(form.last_name, validation='name', optional=False) }}
</div>
</div>

View File

@ -1,137 +0,0 @@
{% extends "help/index.html" %}
{% set subnav = [
{"label":"Financial Verification", "href":"#financial-verification"},
{"label":"ID/IQ CLINs", "href":"#idiq-clins"},
{"label":"JEDI Cloud Applications", "href":"#jedi-cloud-applications"},
] %}
{% block doc_content %}
<h2 id='financial-verification'>Financial Verification</h2>
<h3>How to prepare for Financial Verification step?</h3>
<p>Once your request is approved, the next step is to create a Task Order (T.O.) associated with the JEDI Cloud ID/IQ. Please contact a Contracting Officer (KO) or Contracting Officer Representative (COR) to help with this step. </p>
<p>This may also involve talking to your Financial Manager (FM) to secure funding.</p>
<p>Once the Task Order (T.O.) has been created, you will need to provide information related to the task order and funding in AT-AT. This step is referred to as “Financial Verification.”</p>
<p><em>We also recommend getting familiar with the <a href="#">JEDI Cloud CLIN structures</a> so that you know which specific services are available under JEDI and categorized for contracting purposes. This will help you and the Contracting Officer create a Task Order.</em></p>
<h3>Why is this important?</h3>
<p>This step allows AT-AT and the CCPO to track and report on cloud infrastructure spending across the Department., It also enables you and your team to see your cloud usage and verify your invoices with your budget.</p>
<h3>What to prepare for Financial Verification?</h3>
<p>You will need to have these details on hand before filling out the next step.</p>
<div class='fixed-table-wrapper'>
<table>
<thead>
<tr>
<th>Item youll need to provide</th>
<th>Format / Example</th>
<th>Explanation</th>
</tr>
</thead>
<tbody>
<tr>
<td>Task Order Number associated with this request*</td>
<td><em>Example: <br>1234567899C0001</em></td>
<td>
<p>Please include the original Task Order number (including the 000X at the end); this field is not requesting any modification numbers.</p>
<p>Please note that there may be a lag between the time you have created and approved the task order to the time it is searchable within the electronic.</p>
<p>A Contracting Officer will likely be the best source for this number.</p>
</td>
</tr>
<tr>
<td>Unique Item Identifiers (UII) related to your system(s) if you already have them</td>
<td><em>Example: <br>DI 0CVA5786950 OR UN1945326361234786950</em></td>
<td>
<p>A Unique Investment Identifier is a unique code that helps the Department of Defense track and report on where and how digital assets are stored and where the budget comes from.</p>
<p>Not all applications have an existing UII number assigned.</p>
<p>This identifier can be found in SNaP-IT.</p>
</td>
</tr>
<tr>
<td>Program Element (PE) Number related to your request</td>
<td><em>Example: <br>0203752A</em></td>
<td>Program Element numbers helps the Department of Defense identify which offices' budgets are contributing towards this resource use.</td>
</tr>
<tr>
<td>Program Treasury Code</td>
<td><em>Example: <br>1200</em></td>
<td>The Treasury Code (or Appropriations Code) is a four digit number that identifies resource types.</td>
</tr>
<tr>
<td>Program BA Code</td>
<td><em>Example: <br>02</em></td>
<td>The Budget Activity Code (or BA Code) is a two digit number that is the category within each appropriation and fund account used to identify the purposes, applications, or types of activities financed by the appropriation fund.</td>
</tr>
</tbody>
</table>
</div>
<p><em>*AT-AT will search your Task Order number in available APIs and find other relevant details about your task order automatically. If we are unable to locate your Task Order, you will be asked to manually enter information such as total contract amount, CLIN amounts, and contracting officer information.</em></p>
<hr>
<h2 id='idiq-clins'>ID/IQ CLINs</h2>
<h3>How are the JEDI ID/IQ CLINs structured?</h3>
<p>We recommend sharing the following details with your contracting personnel to help accelerate the task order creation process.</p>
<p>The JEDI contract vehicle supports the following types of cloud infrastructure services.</p>
<p>Your contracting personnel will want to know which services above and contract line item numbers (CLINs) you are interested in procuring and what estimated dollar amounts to use associate. Use the <a href="#">JEDI Cloud Calculator</a> to arrive at a price estimate for each of those.</p>
<div class='fixed-table-wrapper'>
<table>
<thead>
<tr>
<th>JEDI Contract Line Item Numbers (CLINs)</th>
<th>Services supported</th>
</tr>
</thead>
<tbody>
<tr>
<td>CLIN 0001 - Unclassified IaaS and PaaS Amount</td>
<td>
<p>This CLIN covers infrastructure as a service (IaaS) features including the basic building blocks of networking features, computers (virtual or on dedicated hardware), and data storage space.</p>
<p>It also provides platform as a service (PaaS) features including resource procurement, capacity planning, software maintenance, patching, or any of the other undifferentiated heavy lifting involved in running your application.</p>
</td>
</tr>
<tr>
<td>CLIN 0003 - Unclassified Cloud Support Package</td>
<td>This CLIN covers the basic customer service support package offered including _______</td>
</tr>
<tr>
<td>CLIN 1001 - Unclassified IaaS and PaaS Amount OPTION PERIOD 1</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>CLIN 1003 - Unclassified Cloud Support Package OPTION PERIOD 1</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>CLIN 2001 - Unclassified IaaS and PaaS Amount OPTION PERIOD 2</td>
<td>&nbsp;</td>
</tr>
<tr>
<td>CLIN 2003 - Unclassified Cloud Support Package OPTION PERIOD 2</td>
<td>&nbsp;</td>
</tr>
</tbody>
</table>
</div>
<hr>
<h2 id='jedi-cloud-applications'>JEDI Cloud Applications</h2>
<h3>How are applications organized in the JEDI Cloud?</h3>
<h4>Application Structure for JEDI Cloud</h4>
<p>Separate your portfolio into applications and environments; this allows your team to manage user access to systems more securely and track expenditures for each application.</p>
<p>Heres an example:<br>
Application A has a development environment, production environment, and sandbox environment. The cloud resources in the development environment are grouped and accessed separately from the production environment and sandbox environment.</p>
<img src='{{ url_for("static", filename="img/at-at_faqs_content.svg") }}' alt='AT-AT FAQs Content'>
{% endblock %}

View File

@ -1,48 +0,0 @@
{% extends "base_public.html" %}
{% from "components/sidenav_item.html" import SidenavItem %}
{% block title %}Help | JEDI Cloud{% endblock %}
{% block content %}
<div class='global-layout'>
<div class='global-navigation'>
<div class="sidenav-container">
<div class="sidenav">
<ul class="sidenav__list sidenav__list--no-header">
{{ SidenavItem("JEDI Cloud Help",
href = url_for("atst.helpdocs"),
active = not doc,
)}}
{% for doc_item in docs %}
{% set active = doc and doc == doc_item %}
{{ SidenavItem(doc_item | title,
href = url_for("atst.helpdocs", doc=doc_item),
active = active,
)}}
{% endfor %}
</ul>
</div>
</div>
</div>
<div class='global-panel-container'>
<div class='panel'>
<div class='panel__heading panel__heading--divider'>
<h1>
{% if doc %}
<div class='h4'>JEDI Cloud Help Documentation</div>
<div class='h1'>{{ doc | title }}</div>
{% else %}
<div class='h1'>JEDI Cloud Help Documentation</span>
{% endif %}
</h1>
</div>
<div class='panel__content'>
{% block doc_content %}
<p>Welcome to the JEDI Cloud help documentation.</p>
{% endblock %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -14,7 +14,7 @@
{{ Icon('avatar', classes='topbar__link-icon') }}
<span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span>
</a>
<a href="{{ url_for('atst.helpdocs') }}" class="topbar__link">
<a href="#" class="topbar__link">
{{ Icon('question', classes='topbar__link-icon') }}
<span class="topbar__link-label">Support</span>
</a>

View File

@ -20,7 +20,7 @@
<form method="POST" action="{{ url_for('portfolios.edit', portfolio_id=portfolio.id) }}" autocomplete="false">
{{ portfolio_form.csrf_token }}
{{ TextInput(portfolio_form.name, validation="portfolioName", optional=False) }}
{{ TextInput(portfolio_form.description, paragraph=True) }}
{{ TextInput(portfolio_form.description, validation="defaultTextAreaField", paragraph=True) }}
<div class='edit-portfolio-name action-group'>
{{ SaveButton(text='Save Changes', additional_classes='usa-button-big') }}
</div>
@ -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

@ -27,11 +27,11 @@
{% macro InfoFields(member_form) %}
<div class="user-info">
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
{{ TextInput(member_form.first_name, validation='name', optional=False) }}
{{ TextInput(member_form.last_name, validation='name', optional=False) }}
{{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
<a href="#">How do I find the DoD ID?</a>
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
</div>
{% endmacro %}

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

@ -12,22 +12,22 @@
{% include "fragments/flash.html" %}
<div class='portfolio-header__name'>
<p>{{ "portfolios.header" | translate }}</p>
<h1>{{ "New Portfolio" }}</h1>
<h1>{{ "portfolios.new.title" | translate }}</h1>
</div>
{{ StickyCTA(text="Create New Portfolio") }}
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
<base-form inline-template>
<div class="row">
<form id="portfolio-create" class="col" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
<div class="form-row form-row--bordered">
<div class="form-col">
{{ TextInput(form.name, optional=False, classes="form-col") }}
{{ TextInput(form.name, validation="name", optional=False, classes="form-col") }}
{{"forms.portfolio.name.help_text" | translate | safe }}
</div>
</div>
<div class="form-row form-row--bordered">
<div class="form-col">
{{ TextInput(form.description, paragraph=True) }}
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True) }}
{{"forms.portfolio.description.help_text" | translate | safe }}
</div>
</div>
@ -39,9 +39,9 @@
</div>
<div class='action-group-footer'>
{% block next_button %}
{{ SaveButton(text=('common.save' | translate), form="portfolio-create", element="input") }}
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
{% endblock %}
<a href="{{ url_for('atst.home') }}">
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
Cancel
</a>
</form>
@ -49,4 +49,3 @@
</base-form>
</main>
{% endblock %}

View File

@ -5,15 +5,15 @@
<section class="row">
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">Total Portfolio Value</span>
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
<span class="summary-item__header-text">{{ "portfolios.reports.total_value.header" | translate }}</span>
{{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="summary-item__header-icon")}}
</h5>
<p class="summary-item__value">{{ total_portfolio_value | dollars }}</p>
</div>
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">Funding Duration</span>
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
<span class="summary-item__header-text">{{ "portfolios.reports.duration.header" | translate }}</span>
{{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="summary-item__header-icon")}}
</h5>
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
{% if earliest_pop_start_date and latest_pop_end_date %}
@ -28,8 +28,8 @@
</div>
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">Days Remaining</span>
{{Tooltip(("common.lorem" | translate), title="", classes="summary-item__header-icon")}}
<span class="summary-item__header-text">{{ "portfolios.reports.days_remaining.header" | translate }}</span>
{{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="summary-item__header-icon")}}
</h5>
<p class="summary-item__value">{{ portfolio.days_to_funding_expiration }} days</p>
</div>

View File

@ -11,16 +11,7 @@
<section class="row">
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">Total obligated funds</span>
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }}
</h4>
<p class="summary-item__value--large">
{{ obligated_funds | dollars }}
</p>
</div>
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">Total Task Order value</span>
<span class="summary-item__header-text">{{ 'task_orders.summary.total' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="summary-item__header-icon") }}
</h4>
<p class="summary-item__value--large">
@ -29,7 +20,16 @@
</div>
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">Total expended funds</span>
<span class="summary-item__header-text">{{ 'task_orders.summary.obligated' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }}
</h4>
<p class="summary-item__value--large">
{{ obligated_funds | dollars }}
</p>
</div>
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.expended' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="summary-item__header-icon") }}
</h4>
<p class="summary-item__value--large">
@ -39,7 +39,7 @@
</section>
<hr>
<section>
<h4>Documents</h4>
<h4>{{ 'task_orders.form.step_4.documents' | translate }}</h4>
<div class="panel panel__content">
{% if builder_mode %}
{{ Icon('ok', classes="icon--green icon--medium") }}
@ -52,7 +52,7 @@
</section>
<hr>
<section>
<h4>CLIN summary</h4>
<h4>{{ 'task_orders.form.step_4.clins' | translate }}</h4>
<table class="fixed-table-wrapper atat-table clin-summary">
<thead>
<tr>

View File

@ -66,7 +66,7 @@
{% call StickyCTA(text="common.task_orders"|translate) %}
{% if user_can(permissions.CREATE_TASK_ORDER) and task_orders %}
{% if user_can(permissions.CREATE_TASK_ORDER) and to_count > 0 %}
<a href="{{ url_for("task_orders.form_step_one_add_pdf", portfolio_id=portfolio.id) }}" class="usa-button usa-button-primary">
{{ "task_orders.add_new_button" | translate }}
</a>

View File

@ -16,8 +16,8 @@
{% block to_builder_form_field %}
{{ TOFormStepHeader(
title='task_orders.form.supporting_docs_header' | translate,
description='task_orders.form.supporting_docs_text' | translate,
title='task_orders.form.step_1.title' | translate,
description='task_orders.form.step_1.description' | translate,
) }}
{{ UploadInput(form.pdf, portfolio.id) }}
{% endblock %}

View File

@ -8,7 +8,7 @@
{% set next_button_text = 'task_orders.form.step_5.next_button' | translate %}
{% set previous_button_link = url_for("task_orders.form_step_four_review", task_order_id=task_order_id) %}
{% set step = "5" %}
{% set sticky_cta_text = 'task_orders.form.sticky_header_text' | translate %}
{% set sticky_cta_text = 'task_orders.form.step_5.cta_text' | translate %}
{% block to_builder_form_field %}
{{ TOFormStepHeader(

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,78 @@ 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`
*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)*

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -11,6 +11,20 @@ resource "azurerm_storage_account" "bucket" {
account_replication_type = "LRS"
}
resource "azurerm_storage_account_network_rules" "acls" {
resource_group_name = azurerm_resource_group.bucket.name
storage_account_name = azurerm_storage_account.bucket.name
default_action = var.policy
# Azure Storage CIDR ACLs do not accept /32 CIDR ranges.
ip_rules = [
for cidr in values(var.whitelist) : cidr
]
virtual_network_subnet_ids = var.subnet_ids
bypass = ["AzureServices"]
}
resource "azurerm_storage_container" "bucket" {
name = "content"
storage_account_name = azurerm_storage_account.bucket.name

View File

@ -29,3 +29,20 @@ variable "service_name" {
description = "Name of the service using this bucket"
type = string
}
variable "subnet_ids" {
description = "List of subnet_ids that will have access to this service"
type = list
}
variable "policy" {
description = "The default policy for the network access rules (Allow/Deny)"
default = "Deny"
type = string
}
variable "whitelist" {
type = map
description = "A map of whitelisted IPs and CIDR ranges. For single IPs, Azure expects just the IP, NOT a /32."
default = {}
}

View File

@ -1,3 +1,7 @@
locals {
whitelist = values(var.whitelist)
}
resource "azurerm_resource_group" "acr" {
name = "${var.name}-${var.environment}-acr"
location = var.region
@ -10,4 +14,30 @@ resource "azurerm_container_registry" "acr" {
sku = var.sku
admin_enabled = var.admin_enabled
#georeplication_locations = [azurerm_resource_group.acr.location, var.backup_region]
network_rule_set {
default_action = var.policy
ip_rule = [
for cidr in values(var.whitelist) : {
action = "Allow"
ip_range = cidr
}
]
# Dynamic rule should work, but doesn't - See https://github.com/hashicorp/terraform/issues/22340#issuecomment-518779733
#dynamic "ip_rule" {
# for_each = values(var.whitelist)
# content {
# action = "Allow"
# ip_range = ip_rule.value
# }
#}
virtual_network = [
for subnet in var.subnet_ids : {
action = "Allow"
subnet_id = subnet.value
}
]
}
}

Some files were not shown because too many files have changed in this diff Show More