Update atst to atat

This commit is contained in:
leigh-mil
2020-02-28 16:01:45 -05:00
parent 6eb48239cf
commit c2814416fb
215 changed files with 735 additions and 746 deletions

0
atat/__init__.py Normal file
View File

366
atat/app.py Normal file
View File

@@ -0,0 +1,366 @@
import os
import re
from configparser import ConfigParser
import pendulum
from flask import Flask, request, g, session, url_for as flask_url_for
from flask_session import Session
import redis
from unipath import Path
from flask_wtf.csrf import CSRFProtect
from urllib.parse import urljoin
from atat.database import db
from atat.assets import environment as assets_environment
from atat.filters import register_filters
from atat.routes import bp
from atat.routes.portfolios import portfolios_bp as portfolio_routes
from atat.routes.task_orders import task_orders_bp
from atat.routes.applications import applications_bp
from atat.routes.dev import bp as dev_routes
from atat.routes.users import bp as user_routes
from atat.routes.errors import make_error_pages
from atat.routes.ccpo import bp as ccpo_routes
from atat.domain.authnid.crl import CRLCache, NoOpCRLCache
from atat.domain.auth import apply_authentication
from atat.domain.authz import Authorization
from atat.domain.csp import make_csp_provider
from atat.domain.portfolios import Portfolios
from atat.models.permissions import Permissions
from atat.queue import celery, update_celery
from atat.utils import mailer
from atat.utils.form_cache import FormCache
from atat.utils.json import CustomJSONEncoder, sqlalchemy_dumps
from atat.utils.notification_sender import NotificationSender
from atat.utils.session_limiter import SessionLimiter
from logging.config import dictConfig
from atat.utils.logging import JsonFormatter, RequestContextFilter
from atat.utils.context_processors import assign_resources
ENV = os.getenv("FLASK_ENV", "dev")
def make_app(config):
if ENV == "prod" or config.get("LOG_JSON"):
apply_json_logger()
parent_dir = Path().parent
app = Flask(
__name__,
template_folder=str(object=parent_dir.child("templates").absolute()),
static_folder=str(object=parent_dir.child("static").absolute()),
)
app.json_encoder = CustomJSONEncoder
make_redis(app, config)
csrf = CSRFProtect()
app.config.update(config)
app.config.update({"SESSION_REDIS": app.redis})
update_celery(celery, app)
make_flask_callbacks(app)
register_filters(app)
register_jinja_globals(app)
make_csp_provider(app, config.get("CSP", "mock"))
make_crl_validator(app)
make_mailer(app)
make_notification_sender(app)
db.init_app(app)
csrf.init_app(app)
Session(app)
make_session_limiter(app, session, config)
assets_environment.init_app(app)
make_error_pages(app)
app.register_blueprint(bp)
app.register_blueprint(portfolio_routes)
app.register_blueprint(task_orders_bp)
app.register_blueprint(applications_bp)
app.register_blueprint(user_routes)
app.register_blueprint(ccpo_routes)
if ENV != "prod":
app.register_blueprint(dev_routes)
app.form_cache = FormCache(app.redis)
apply_authentication(app)
set_default_headers(app)
@app.before_request
def _set_resources():
assign_resources(request.view_args)
return app
def make_flask_callbacks(app):
@app.before_request
def _set_globals():
g.current_user = None
g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
g.matchesPath = lambda href: re.search(href, request.full_path)
g.modal = request.args.get("modal", None)
g.Authorization = Authorization
g.Permissions = Permissions
@app.context_processor
def _portfolios():
if not g.current_user:
return {}
portfolios = Portfolios.for_user(g.current_user)
return {"portfolios": portfolios}
@app.after_request
def _cleanup(response):
g.current_user = None
g.portfolio = None
g.application = None
g.task_order = None
return response
def set_default_headers(app): # pragma: no cover
static_url = app.config.get("STATIC_URL")
blob_storage_url = app.config.get("BLOB_STORAGE_URL")
@app.after_request
def _set_security_headers(response):
response.headers[
"Strict-Transport-Security"
] = "max-age=31536000; includeSubDomains"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Access-Control-Allow-Origin"] = app.config.get("CDN_ORIGIN")
if ENV == "dev":
response.headers[
"Content-Security-Policy"
] = "default-src 'self' 'unsafe-eval' 'unsafe-inline'; connect-src *"
else:
response.headers[
"Content-Security-Policy"
] = f"default-src 'self' 'unsafe-eval' 'unsafe-inline' {blob_storage_url} {static_url}"
return response
def map_config(config):
return {
**config["default"],
"USE_AUDIT_LOG": config["default"].getboolean("USE_AUDIT_LOG"),
"ENV": config["default"]["ENVIRONMENT"],
"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"],
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
"SQLALCHEMY_ENGINE_OPTIONS": {
"json_serializer": sqlalchemy_dumps,
"connect_args": {
"sslmode": config["default"]["PGSSLMODE"],
"sslrootcert": config["default"]["PGSSLROOTCERT"],
},
},
"WTF_CSRF_ENABLED": config.getboolean("default", "WTF_CSRF_ENABLED"),
"PERMANENT_SESSION_LIFETIME": config.getint(
"default", "PERMANENT_SESSION_LIFETIME"
),
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
"LOG_JSON": config.getboolean("default", "LOG_JSON"),
"LIMIT_CONCURRENT_SESSIONS": config.getboolean(
"default", "LIMIT_CONCURRENT_SESSIONS"
),
# Store the celery task results in a database table (celery_taskmeta)
"CELERY_RESULT_BACKEND": "db+{}".format(config.get("default", "DATABASE_URI")),
# Do not automatically delete results (by default, Celery will do this
# with a Beat job once a day)
"CELERY_RESULT_EXPIRES": 0,
"CELERY_RESULT_EXTENDED": True,
"OFFICE_365_DOMAIN": "onmicrosoft.com",
"CONTRACT_START_DATE": pendulum.from_format(
config.get("default", "CONTRACT_START_DATE"), "YYYY-MM-DD"
).date(),
"CONTRACT_END_DATE": pendulum.from_format(
config.get("default", "CONTRACT_END_DATE"), "YYYY-MM-DD"
).date(),
"SESSION_COOKIE_SECURE": config.getboolean("default", "SESSION_COOKIE_SECURE"),
}
def make_config(direct_config=None):
BASE_CONFIG_FILENAME = os.path.join(os.path.dirname(__file__), "../config/base.ini")
ENV_CONFIG_FILENAME = os.path.join(
os.path.dirname(__file__), "../config/", "{}.ini".format(ENV.lower())
)
OVERRIDE_CONFIG_DIRECTORY = os.getenv("OVERRIDE_CONFIG_DIRECTORY")
config = ConfigParser(allow_no_value=True)
config.optionxform = str
config_files = [BASE_CONFIG_FILENAME, ENV_CONFIG_FILENAME]
# ENV_CONFIG will override values in BASE_CONFIG.
config.read(config_files)
if OVERRIDE_CONFIG_DIRECTORY:
apply_config_from_directory(OVERRIDE_CONFIG_DIRECTORY, config)
# Check for ENV variables as a final source of overrides
apply_config_from_environment(config)
# override if a dictionary of options has been given
if direct_config:
config.read_dict({"default": direct_config})
# Assemble DATABASE_URI value
database_uri = "postgresql://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
config.get("default", "PGUSER"),
config.get("default", "PGPASSWORD"),
config.get("default", "PGHOST"),
config.get("default", "PGPORT"),
config.get("default", "PGDATABASE"),
)
config.set("default", "DATABASE_URI", database_uri)
# Assemble REDIS_URI value
redis_use_tls = config["default"].getboolean("REDIS_TLS")
redis_uri = "redis{}://{}:{}@{}".format( # pragma: allowlist secret
("s" if redis_use_tls else ""),
(config.get("default", "REDIS_USER") or ""),
(config.get("default", "REDIS_PASSWORD") or ""),
config.get("default", "REDIS_HOST"),
)
celery_uri = redis_uri
if redis_use_tls:
tls_mode = config.get("default", "REDIS_SSLMODE")
tls_mode_str = tls_mode.lower() if tls_mode else "none"
redis_uri = f"{redis_uri}/?ssl_cert_reqs={tls_mode_str}"
# TODO: Kombu, one of Celery's dependencies, still requires
# that ssl_cert_reqs be passed as the string version of an
# option on the ssl module. We can clean this up and use
# the REDIS_URI for both when this PR to Kombu is released:
# https://github.com/celery/kombu/pull/1139
kombu_modes = {
"none": "CERT_NONE",
"required": "CERT_REQUIRED",
"optional": "CERT_OPTIONAL",
}
celery_tls_mode_str = kombu_modes[tls_mode_str]
celery_uri = f"{celery_uri}/?ssl_cert_reqs={celery_tls_mode_str}"
config.set("default", "REDIS_URI", redis_uri)
config.set("default", "BROKER_URL", celery_uri)
return map_config(config)
def apply_config_from_directory(config_dir, config, section="default"):
"""
Loop files in a directory, check if the names correspond to
known config values, and apply the file contents as the value
for that setting if they do.
"""
for confsetting in os.listdir(config_dir):
if confsetting in config.options(section):
full_path = os.path.join(config_dir, confsetting)
with open(full_path, "r") as conf_file:
config.set(section, confsetting, conf_file.read().strip())
return config
def apply_config_from_environment(config, section="default"):
"""
Loops all the configuration settins in a given section of a
config object and checks whether those settings also exist as
environment variables. If so, it applies the environment
variables value as the new configuration setting value.
"""
for confsetting in config.options(section):
env_override = os.getenv(confsetting.upper())
if env_override:
config.set(section, confsetting, env_override)
return config
def make_redis(app, config):
r = redis.Redis.from_url(config["REDIS_URI"])
app.redis = r
def make_crl_validator(app):
if app.config.get("DISABLE_CRL_CHECK"):
app.crl_cache = NoOpCRLCache(logger=app.logger)
else:
crl_dir = app.config["CRL_STORAGE_CONTAINER"]
if not os.path.isdir(crl_dir):
os.makedirs(crl_dir, exist_ok=True)
app.crl_cache = CRLCache(app.config["CA_CHAIN"], crl_dir, logger=app.logger,)
def make_mailer(app):
if app.config["DEBUG"] or app.config["DEBUG_MAILER"]:
mailer_connection = mailer.RedisConnection(app.redis)
else:
mailer_connection = mailer.SMTPConnection(
server=app.config.get("MAIL_SERVER"),
port=app.config.get("MAIL_PORT"),
username=app.config.get("MAIL_SENDER"),
password=app.config.get("MAIL_PASSWORD"),
use_tls=app.config.get("MAIL_TLS"),
)
sender = app.config.get("MAIL_SENDER")
app.mailer = mailer.Mailer(mailer_connection, sender)
def make_notification_sender(app):
app.notification_sender = NotificationSender()
def make_session_limiter(app, session, config):
app.session_limiter = SessionLimiter(config, session, app.redis)
def apply_json_logger():
dictConfig(
{
"version": 1,
"formatters": {"default": {"()": lambda *a, **k: JsonFormatter()}},
"filters": {"requests": {"()": lambda *a, **k: RequestContextFilter()}},
"handlers": {
"wsgi": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "default",
"filters": ["requests"],
}
},
"root": {"level": "INFO", "handlers": ["wsgi"]},
}
)
def register_jinja_globals(app):
static_url = app.config.get("STATIC_URL", "/static/")
def _url_for(endpoint, **values):
if endpoint == "static":
filename = values["filename"]
return urljoin(static_url, filename)
else:
return flask_url_for(endpoint, **values)
app.jinja_env.globals["url_for"] = _url_for

12
atat/assets.py Normal file
View File

@@ -0,0 +1,12 @@
from flask_assets import Environment, Bundle
environment = Environment()
css = Bundle(
"../static/assets/index.css", output="../static/assets/index.%(version)s.css"
)
environment.register("css", css)
js = Bundle("../static/assets/index.js", output="../static/assets/index.%(version)s.js")
environment.register("js_all", js)

3
atat/database.py Normal file
View File

@@ -0,0 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

25
atat/domain/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlalchemy.orm.exc import NoResultFound
from atat.database import db
from atat.domain.exceptions import NotFoundError
class BaseDomainClass(object):
model = None
resource_name = None
@classmethod
def get(cls, resource_id, **kwargs):
base_query = db.session.query(cls.model).filter(cls.model.id == resource_id)
if getattr(cls.model, "deleted", False):
base_query = base_query.filter(cls.model.deleted == False)
for col, val in kwargs.items():
base_query = base_query.filter(getattr(cls.model, col) == val)
try:
resource = base_query.one()
return resource
except NoResultFound:
raise NotFoundError(cls.resource_name)

View File

@@ -0,0 +1,142 @@
from itertools import groupby
from typing import List
from uuid import UUID
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy import func, and_, or_
from atat.database import db
from atat.domain.environment_roles import EnvironmentRoles
from atat.models import Application, ApplicationRole, ApplicationRoleStatus, Portfolio
from .permission_sets import PermissionSets
from .exceptions import NotFoundError
class ApplicationRoles(object):
@classmethod
def _permission_sets_for_names(cls, set_names):
set_names = set(set_names).union({PermissionSets.VIEW_APPLICATION})
return PermissionSets.get_many(set_names)
@classmethod
def create(cls, user, application, permission_set_names):
application_role = ApplicationRole(
user=user, application_id=application.id, application=application
)
application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
permission_set_names
)
db.session.add(application_role)
db.session.commit()
return application_role
@classmethod
def enable(cls, role, user):
role.status = ApplicationRoleStatus.ACTIVE
role.user = user
db.session.add(role)
db.session.commit()
@classmethod
def get(cls, user_id, application_id):
try:
app_role = (
db.session.query(ApplicationRole)
.filter_by(user_id=user_id, application_id=application_id)
.one()
)
except NoResultFound:
raise NotFoundError("application_role")
return app_role
@classmethod
def get_by_id(cls, id_):
try:
return (
db.session.query(ApplicationRole)
.filter(ApplicationRole.id == id_)
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
.one()
)
except NoResultFound:
raise NotFoundError("application_role")
@classmethod
def get_many(cls, ids):
return (
db.session.query(ApplicationRole)
.filter(ApplicationRole.id.in_(ids))
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
.all()
)
@classmethod
def update_permission_sets(cls, application_role, new_perm_sets_names):
application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
new_perm_sets_names
)
db.session.add(application_role)
db.session.commit()
return application_role
@classmethod
def _update_status(cls, application_role, new_status):
application_role.status = new_status
db.session.add(application_role)
db.session.commit()
return application_role
@classmethod
def disable(cls, application_role):
cls._update_status(application_role, ApplicationRoleStatus.DISABLED)
application_role.deleted = True
for env in application_role.application.environments:
EnvironmentRoles.delete(
application_role_id=application_role.id, environment_id=env.id
)
db.session.add(application_role)
db.session.commit()
@classmethod
def get_pending_creation(cls) -> List[List[UUID]]:
"""
Returns a list of lists of ApplicationRole IDs. The IDs
should be grouped by user and portfolio.
"""
results = (
db.session.query(ApplicationRole.id, ApplicationRole.user_id, Portfolio.id)
.join(Application, Application.id == ApplicationRole.application_id)
.join(Portfolio, Portfolio.id == Application.portfolio_id)
.filter(
and_(
Application.cloud_id.isnot(None),
ApplicationRole.deleted == False,
ApplicationRole.cloud_id.is_(None),
ApplicationRole.user_id.isnot(None),
ApplicationRole.status == ApplicationRoleStatus.ACTIVE,
or_(
ApplicationRole.claimed_until.is_(None),
ApplicationRole.claimed_until <= func.now(),
),
)
)
).all()
groups = []
keyfunc = lambda pair: (pair[1], pair[2])
sorted_results = sorted(results, key=keyfunc)
for _, g in groupby(sorted_results, keyfunc):
group = [pair[0] for pair in list(g)]
groups.append(group)
return groups

147
atat/domain/applications.py Normal file
View File

@@ -0,0 +1,147 @@
from flask import g
from sqlalchemy import func, or_, and_
from typing import List
from uuid import UUID
from . import BaseDomainClass
from atat.database import db
from atat.domain.application_roles import ApplicationRoles
from atat.domain.environments import Environments
from atat.domain.exceptions import NotFoundError
from atat.domain.invitations import ApplicationInvitations
from atat.models import (
Application,
ApplicationRole,
ApplicationRoleStatus,
EnvironmentRole,
Portfolio,
PortfolioStateMachine,
)
from atat.models.mixins.state_machines import FSMStates
from atat.utils import first_or_none, commit_or_raise_already_exists_error
class Applications(BaseDomainClass):
model = Application
resource_name = "application"
@classmethod
def create(cls, user, portfolio, name, description, environment_names=None):
application = Application(
portfolio=portfolio, name=name, description=description
)
db.session.add(application)
if environment_names:
Environments.create_many(user, application, environment_names)
commit_or_raise_already_exists_error(message="application")
return application
@classmethod
def for_user(self, user, portfolio):
return (
db.session.query(Application)
.join(ApplicationRole)
.filter(Application.portfolio_id == portfolio.id)
.filter(ApplicationRole.application_id == Application.id)
.filter(ApplicationRole.user_id == user.id)
.filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE)
.all()
)
@classmethod
def update(cls, application, new_data):
if "name" in new_data:
application.name = new_data["name"]
if "description" in new_data:
application.description = new_data["description"]
if "environment_names" in new_data:
Environments.create_many(
g.current_user, application, new_data["environment_names"]
)
db.session.add(application)
commit_or_raise_already_exists_error(message="application")
return application
@classmethod
def delete(cls, application):
for env in application.environments:
Environments.delete(env)
application.deleted = True
for role in application.roles:
role.deleted = True
role.status = ApplicationRoleStatus.DISABLED
db.session.add(role)
db.session.add(application)
db.session.commit()
@classmethod
def invite(
cls,
application,
inviter,
user_data,
permission_sets_names=None,
environment_roles_data=None,
):
permission_sets_names = permission_sets_names or []
permission_sets = ApplicationRoles._permission_sets_for_names(
permission_sets_names
)
app_role = ApplicationRole(
application=application, permission_sets=permission_sets
)
db.session.add(app_role)
for env_role_data in environment_roles_data:
env_role_name = env_role_data.get("role")
environment_id = env_role_data.get("environment_id")
if env_role_name is not None:
# pylint: disable=cell-var-from-loop
environment = first_or_none(
lambda e: str(e.id) == str(environment_id), application.environments
)
if environment is None:
raise NotFoundError("environment")
else:
env_role = EnvironmentRole(
application_role=app_role,
environment=environment,
role=env_role_name,
)
db.session.add(env_role)
invitation = ApplicationInvitations.create(
inviter=inviter, role=app_role, member_data=user_data
)
db.session.add(invitation)
db.session.commit()
return invitation
@classmethod
def get_applications_pending_creation(cls) -> List[UUID]:
results = (
db.session.query(Application.id)
.join(Portfolio)
.join(PortfolioStateMachine)
.filter(
and_(
PortfolioStateMachine.state == FSMStates.COMPLETED,
Application.deleted == False,
Application.cloud_id.is_(None),
or_(
Application.claimed_until.is_(None),
Application.claimed_until <= func.now(),
),
)
)
).all()
return [id_ for id_, in results]

78
atat/domain/audit_log.py Normal file
View File

@@ -0,0 +1,78 @@
from atat.database import db
from atat.domain.common import Query
from atat.models.audit_event import AuditEvent
class AuditEventQuery(Query):
model = AuditEvent
@classmethod
def get_all(cls, pagination_opts):
query = db.session.query(cls.model).order_by(cls.model.time_created.desc())
return cls.paginate(query, pagination_opts)
@classmethod
def get_portfolio_events(cls, portfolio_id, pagination_opts):
query = (
db.session.query(cls.model)
.filter(cls.model.portfolio_id == portfolio_id)
.order_by(cls.model.time_created.desc())
)
return cls.paginate(query, pagination_opts)
@classmethod
def get_application_events(cls, application_id, pagination_opts):
query = (
db.session.query(cls.model)
.filter(cls.model.application_id == application_id)
.order_by(cls.model.time_created.desc())
)
return cls.paginate(query, pagination_opts)
class AuditLog(object):
@classmethod
# TODO: see if this is being used anywhere and remove if not
def log_system_event(cls, resource, action, portfolio=None):
return cls._log(resource=resource, action=action, portfolio=portfolio)
@classmethod
def get_all_events(cls, pagination_opts=None):
return AuditEventQuery.get_all(pagination_opts)
@classmethod
def get_portfolio_events(cls, portfolio, pagination_opts=None):
return AuditEventQuery.get_portfolio_events(portfolio.id, pagination_opts)
@classmethod
def get_application_events(cls, application, pagination_opts=None):
return AuditEventQuery.get_application_events(application.id, pagination_opts)
@classmethod
def get_by_resource(cls, resource_id):
return (
db.session.query(AuditEvent)
.filter(AuditEvent.resource_id == resource_id)
.order_by(AuditEvent.time_created.desc())
.all()
)
@classmethod
def _resource_type(cls, resource):
return type(resource).__name__.lower()
@classmethod
# TODO: see if this is being used anywhere and remove if not
def _log(cls, user=None, portfolio=None, resource=None, action=None):
resource_id = resource.id if resource else None
resource_type = cls._resource_type(resource) if resource else None
portfolio_id = portfolio.id if portfolio else None
audit_event = AuditEventQuery.create(
user=user,
portfolio_id=portfolio_id,
resource_id=resource_id,
resource_type=resource_type,
action=action,
)
return AuditEventQuery.add_and_commit(audit_event)

92
atat/domain/auth.py Normal file
View File

@@ -0,0 +1,92 @@
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 atat.domain.users import Users
UNPROTECTED_ROUTES = [
"atat.root",
"dev.login_dev",
"dev.dev_new_user",
"atat.login_redirect",
"atat.logout",
"atat.unauthorized",
"static",
"atat.about",
]
def apply_authentication(app):
@app.before_request
# pylint: disable=unused-variable
def enforce_login():
user = get_current_user()
if user:
g.current_user = user
g.last_login = get_last_login()
if should_redirect_to_user_profile(request, user):
return redirect(url_for("users.user", next=request.path))
elif not _unprotected_route(request):
return redirect(url_for("atat.root", next=request.path))
def should_redirect_to_user_profile(request, user):
has_complete_profile = user.profile_complete
is_unprotected_route = _unprotected_route(request)
is_requesting_user_endpoint = request.endpoint in [
"users.user",
"users.update_user",
]
if has_complete_profile or is_unprotected_route or is_requesting_user_endpoint:
return False
return True
def get_current_user():
user_id = session.get("user_id")
if user_id:
return Users.get(user_id)
else:
return False
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():
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):
if request.endpoint in UNPROTECTED_ROUTES:
return True

View File

@@ -0,0 +1,59 @@
from atat.domain.exceptions import UnauthenticatedError, NotFoundError
from atat.domain.users import Users
from .utils import parse_sdn, email_from_certificate
from .crl import CRLRevocationException, CRLInvalidException
class AuthenticationContext:
def __init__(self, crl_cache, auth_status, sdn, cert):
if None in locals().values():
raise UnauthenticatedError(
"Missing required authentication context components"
)
self.crl_cache = crl_cache
self.auth_status = auth_status
self.sdn = sdn
self.cert = cert.encode()
self._parsed_sdn = None
def authenticate(self):
if not self.auth_status == "SUCCESS":
raise UnauthenticatedError("SSL/TLS client authentication failed")
self._crl_check()
return True
def get_user(self):
try:
return Users.get_by_dod_id(self.parsed_sdn["dod_id"])
except NotFoundError:
email = self._get_user_email()
return Users.create(permission_sets=[], email=email, **self.parsed_sdn)
def _get_user_email(self):
try:
return email_from_certificate(self.cert)
# this just means it is not an email certificate; we might choose to
# log in that case
except ValueError:
return None
def _crl_check(self):
try:
self.crl_cache.crl_check(self.cert)
except CRLRevocationException as exc:
raise UnauthenticatedError("CRL check failed. " + str(exc))
@property
def parsed_sdn(self):
if not self._parsed_sdn:
try:
self._parsed_sdn = parse_sdn(self.sdn)
except ValueError as exc:
raise UnauthenticatedError(str(exc))
return self._parsed_sdn

View File

@@ -0,0 +1,186 @@
import os
import re
import hashlib
import logging
from OpenSSL import crypto, SSL
from flask import current_app as app
from .util import load_crl_locations_cache, serialize_crl_locations_cache, CRL_LIST
# error codes from OpenSSL: https://github.com/openssl/openssl/blob/2c75f03b39de2fa7d006bc0f0d7c58235a54d9bb/include/openssl/x509_vfy.h#L111
CRL_EXPIRED_ERROR_CODE = 12
def get_common_name(x509_name_object):
for comp in x509_name_object.get_components():
if comp[0] == b"CN":
return comp[1].decode()
class CRLRevocationException(Exception):
pass
class CRLInvalidException(Exception):
# CRL expired
# CRL missing
pass
class CRLInterface:
def __init__(self, *args, logger=None, **kwargs):
self.logger = logger
def _log(self, message, level=logging.INFO):
if self.logger:
self.logger.log(level, message, extra={"tags": ["authorization", "crl"]})
def crl_check(self, cert):
raise NotImplementedError()
class NoOpCRLCache(CRLInterface):
def _get_cn(self, cert):
try:
parsed = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
return get_common_name(parsed.get_subject())
except crypto.Error:
pass
return "unknown"
def crl_check(self, cert):
cn = self._get_cn(cert)
self._log(
"Did not perform CRL validation for certificate with Common Name '{}'".format(
cn
)
)
return True
class CRLCache(CRLInterface):
_PEM_RE = re.compile(
b"-----BEGIN CERTIFICATE-----\r?.+?\r?-----END CERTIFICATE-----\r?\n?",
re.DOTALL,
)
def __init__(
self,
root_location,
crl_dir,
store_class=crypto.X509Store,
logger=None,
crl_list=CRL_LIST,
):
self._crl_dir = crl_dir
self.logger = logger
self.store_class = store_class
self.certificate_authorities = {}
self.crl_list = crl_list
self._load_roots(root_location)
self._build_crl_cache()
def _get_store(self, cert):
return self._build_store(cert.get_issuer())
def _load_roots(self, root_location):
with open(root_location, "rb") as f:
for raw_ca in self._parse_roots(f.read()):
ca = crypto.load_certificate(crypto.FILETYPE_PEM, raw_ca)
self.certificate_authorities[ca.get_subject().der()] = ca
def _parse_roots(self, root_str):
return [match.group(0) for match in self._PEM_RE.finditer(root_str)]
def _build_crl_cache(self):
try:
self.crl_cache = load_crl_locations_cache(self._crl_dir)
except FileNotFoundError:
self.crl_cache = serialize_crl_locations_cache(
self._crl_dir, crl_list=self.crl_list
)
def _load_crl(self, crl_location):
with open(crl_location, "rb") as crl_file:
try:
return crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
except crypto.Error:
self._log(
"Could not load CRL at location {}".format(crl_location),
level=logging.WARNING,
)
def _build_store(self, issuer):
store = self.store_class()
self._log("STORE ID: {}. Building store.".format(id(store)))
store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
crl_location = self.crl_cache.get(issuer.der())
issuer_name = get_common_name(issuer)
if not crl_location:
raise CRLInvalidException(
"Could not find matching CRL for issuer with Common Name {}".format(
issuer_name
)
)
crl = self._load_crl(crl_location)
store.add_crl(crl)
self._log(
"STORE ID: {}. Adding CRL with issuer Common Name {}".format(
id(store), issuer_name
)
)
store = self._add_certificate_chain_to_store(store, crl.get_issuer())
return store
# this _should_ happen just twice for the DoD PKI (intermediary, root) but
# theoretically it can build a longer certificate chain
def _add_certificate_chain_to_store(self, store, issuer):
ca = self.certificate_authorities.get(issuer.der())
store.add_cert(ca)
self._log(
"STORE ID: {}. Adding CA with subject {}".format(
id(store), ca.get_subject()
)
)
if issuer == ca.get_issuer():
# i.e., it is the root CA and we are at the end of the chain
return store
else:
return self._add_certificate_chain_to_store(store, ca.get_issuer())
def crl_check(self, cert):
parsed = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
store = self._get_store(parsed)
context = crypto.X509StoreContext(store, parsed)
try:
context.verify_certificate()
return True
except crypto.X509StoreContextError as err:
if err.args[0][0] == CRL_EXPIRED_ERROR_CODE:
if app.config.get("CRL_FAIL_OPEN"):
self._log(
"Encountered expired CRL for certificate with CN {} and issuer CN {}, failing open.".format(
parsed.get_subject().CN, parsed.get_issuer().CN
),
level=logging.WARNING,
)
return True
else:
raise CRLInvalidException("CRL expired. Args: {}".format(err.args))
raise CRLRevocationException(
"Certificate revoked or errored. Error: {}. Args: {}".format(
type(err), err.args
)
)

View File

@@ -0,0 +1,367 @@
import json
import os
import re
import pendulum
import requests
class CRLNotFoundError(Exception):
pass
class CRLParseError(Exception):
pass
MODIFIED_TIME_BUFFER = 15 * 60
CRL_LIST = [
(
"https://crl.gds.disa.mil/crl/DODROOTCA2.crl",
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412032", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODROOTCA3.crl",
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412033", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODROOTCA4.crl",
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412034", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODROOTCA5.crl",
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412035", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_33.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3333", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_34.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3334", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_35.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3335", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_36.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3336", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_37.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3337", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_38.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3338", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_39.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3339", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_40.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3430", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_41.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3431", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_42.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3432", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_43.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3433", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_44.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3434", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_45.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3435", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_46.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3436", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_47.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3437", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDSWCA_48.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3438", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_49.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3439", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_50.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3530", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_51.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3531", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_52.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3532", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODIDCA_59.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3539", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_53.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3533", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_54.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3534", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_55.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3535", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_56.crl",
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3536", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_57.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3537", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_58.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3538", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_60.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3630", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODSWCA_61.crl",
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3631", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_33.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3333", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_34.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3334", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_39.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3339", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_40.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3430", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_41.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3431", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_42.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3432", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_43.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3433", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_44.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3434", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_49.crl",
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3439", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_50.crl",
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3530", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_51.crl",
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3531", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_52.crl",
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3532", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODEMAILCA_59.crl",
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3539", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODINTEROPERABILITYROOTCA1.crl",
"306c310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312730250603550403131e446f4420496e7465726f7065726162696c69747920526f6f742043412031", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODINTEROPERABILITYROOTCA2.crl",
"306c310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312730250603550403131e446f4420496e7465726f7065726162696c69747920526f6f742043412032", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/USDODCCEBINTEROPERABILITYROOTCA1.crl",
"3074310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312f302d06035504031326555320446f44204343454220496e7465726f7065726162696c69747920526f6f742043412031", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/USDODCCEBINTEROPERABILITYROOTCA2.crl",
"3074310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312f302d06035504031326555320446f44204343454220496e7465726f7065726162696c69747920526f6f742043412032", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODNIPRINTERNALNPEROOTCA1.crl",
"3075310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f4431143012060355040b130b496e7465726e616c4e5045312830260603550403131f446f44204e49505220496e7465726e616c204e504520526f6f742043412031", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODNPEROOTCA1.crl",
"305f310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311a301806035504031311446f44204e504520526f6f742043412031", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DMDNSIGNINGCA_1.crl",
"305f310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311a301806035504031311444d444e205369676e696e672043412d31", # pragma: allowlist secret
),
(
"https://crl.gds.disa.mil/crl/DODWCFROOTCA1.crl",
"3063310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f443110300e060355040b130757434620504b49311a301806035504031311446f442057434620526f6f742043412031", # pragma: allowlist secret
),
]
JSON_CACHE = "crl_locations.json"
def _deserialize_cache_items(cache):
return {bytes.fromhex(der): data for (der, data) in cache.items()}
def load_crl_locations_cache(crl_dir):
json_location = "{}/{}".format(crl_dir, JSON_CACHE)
with open(json_location, "r") as json_file:
cache = json.load(json_file)
return _deserialize_cache_items(cache)
def serialize_crl_locations_cache(crl_dir, crl_list=CRL_LIST):
crl_cache = {}
for crl_uri, crl_issuer in crl_list:
crl_path = crl_local_path(crl_dir, crl_uri)
if os.path.isfile(crl_path):
crl_cache[crl_issuer] = crl_path
json_location = "{}/{}".format(crl_dir, JSON_CACHE)
with open(json_location, "w") as json_file:
json.dump(crl_cache, json_file)
return {bytes.fromhex(k): v for k, v in crl_cache.items()}
def crl_local_path(out_dir, crl_location):
name = re.split("/", crl_location)[-1]
crl = os.path.join(out_dir, name)
return crl
def existing_crl_modification_time(crl):
if os.path.exists(crl):
prev_time = os.path.getmtime(crl)
buffered = prev_time + MODIFIED_TIME_BUFFER
mod_time = prev_time if pendulum.now().timestamp() < buffered else buffered
dt = pendulum.from_timestamp(mod_time, tz="UTC")
return dt.format("ddd, DD MMM YYYY HH:mm:ss zz")
else:
return False
def write_crl(out_dir, target_dir, crl_location):
crl = crl_local_path(out_dir, crl_location)
existing = crl_local_path(target_dir, crl_location)
options = {"stream": True}
mod_time = existing_crl_modification_time(existing)
if mod_time:
options["headers"] = {"If-Modified-Since": mod_time}
with requests.get(crl_location, **options) as response:
if response.status_code > 399:
raise CRLNotFoundError()
if response.status_code == 304:
return (False, existing)
with open(crl, "wb") as crl_file:
for chunk in response.iter_content(chunk_size=1024):
if chunk:
crl_file.write(chunk)
return (True, existing)
def remove_bad_crl(out_dir, crl_location):
crl = crl_local_path(out_dir, crl_location)
os.remove(crl)
def log_error(logger, crl_location):
if logger:
logger.error(
"Error downloading {}, removing file and continuing anyway".format(
crl_location
)
)
def refresh_crl(out_dir, target_dir, crl_uri, logger):
logger.info("updating CRL from {}".format(crl_uri))
try:
was_updated, crl_path = write_crl(out_dir, target_dir, crl_uri)
if was_updated:
logger.info("successfully synced CRL from {}".format(crl_uri))
else:
logger.info("no updates for CRL from {}".format(crl_uri))
return crl_path
except requests.exceptions.ChunkedEncodingError:
log_error(logger, crl_uri)
remove_bad_crl(out_dir, crl_uri)
except CRLNotFoundError:
log_error(logger, crl_uri)
def sync_crls(tmp_location, final_location):
crl_cache = {}
for crl_uri, crl_issuer in CRL_LIST:
crl_path = refresh_crl(tmp_location, final_location, crl_uri, logger)
crl_cache[crl_issuer] = crl_path
json_location = "{}/{}".format(final_location, JSON_CACHE)
with open(json_location, "w") as json_file:
json.dump(crl_cache, json_file)
if __name__ == "__main__":
import sys
import logging
logging.basicConfig(
level=logging.INFO, format="[%(asctime)s]:%(levelname)s: %(message)s"
)
logger = logging.getLogger()
logger.info("Updating CRLs")
try:
tmp_location = sys.argv[1]
final_location = sys.argv[2]
sync_crls(tmp_location, final_location)
except Exception as err:
logger.exception("Fatal error encountered, stopping")
sys.exit(1)
logger.info("Finished updating CRLs")

View File

@@ -0,0 +1,39 @@
import re
import cryptography.x509 as x509
from cryptography.hazmat.backends import default_backend
def parse_sdn(sdn):
try:
parts = sdn.split(",")
cn_string = [piece for piece in parts if re.match("^CN=", piece)][0]
cn = cn_string.split("=")[-1]
info = cn.split(".")
return {"last_name": info[0], "first_name": info[1], "dod_id": info[-1]}
except (IndexError, AttributeError):
raise ValueError("'{}' is not a valid SDN".format(sdn))
def email_from_certificate(cert_file):
cert = x509.load_pem_x509_certificate(cert_file, default_backend())
try:
ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
email = ext.value.get_values_for_type(x509.RFC822Name)
if email:
return email[0]
else:
raise ValueError(
"No email available for certificate with serial {}".format(
cert.serial_number
)
)
except x509.extensions.ExtensionNotFound:
raise ValueError(
"No subjectAltName available for certificate with serial {}".format(
cert.serial_number
)
)

View File

@@ -0,0 +1,73 @@
from atat.utils import first_or_none
from atat.models.permissions import Permissions
from atat.domain.exceptions import UnauthorizedError
from atat.models.portfolio_role import Status as PortfolioRoleStatus
from atat.models.application_role import Status as ApplicationRoleStatus
class Authorization(object):
@classmethod
def has_atat_permission(cls, user, permission):
return permission in user.permissions
@classmethod
def has_portfolio_permission(cls, user, portfolio, permission):
if Authorization.has_atat_permission(user, permission):
return True
port_role = first_or_none(
lambda pr: pr.portfolio == portfolio, user.portfolio_roles
)
if port_role and port_role.status is not PortfolioRoleStatus.DISABLED:
return permission in port_role.permissions
else:
return False
@classmethod
def has_application_permission(cls, user, application, permission):
if Authorization.has_portfolio_permission(
user, application.portfolio, permission
):
return True
app_role = first_or_none(
lambda app_role: app_role.application == application, user.application_roles
)
if app_role and app_role.status is not ApplicationRoleStatus.DISABLED:
return permission in app_role.permissions
else:
return False
@classmethod
def check_atat_permission(cls, user, permission, message):
if not Authorization.has_atat_permission(user, permission):
raise UnauthorizedError(user, message)
return True
@classmethod
def check_portfolio_permission(cls, user, portfolio, permission, message):
if not Authorization.has_portfolio_permission(user, portfolio, permission):
raise UnauthorizedError(user, message)
return True
@classmethod
def check_application_permission(cls, user, portfolio, permission, message):
if not Authorization.has_application_permission(user, portfolio, permission):
raise UnauthorizedError(user, message)
return True
def user_can_access(user, permission, portfolio=None, application=None, message=None):
if application:
Authorization.check_application_permission(
user, application, permission, message
)
elif portfolio:
Authorization.check_portfolio_permission(user, portfolio, permission, message)
else:
Authorization.check_atat_permission(user, permission, message)
return True

View File

@@ -0,0 +1,50 @@
from functools import wraps
from flask import g, current_app as app, request
from . import user_can_access
from atat.domain.exceptions import UnauthorizedError
def check_access(permission, message, override, *args, **kwargs):
access_args = {
"message": message,
"portfolio": g.portfolio,
"application": g.application,
}
if override is not None and override(g.current_user, **access_args, **kwargs):
return True
user_can_access(g.current_user, permission, **access_args)
return True
def user_can_access_decorator(permission, message=None, override=None):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
try:
check_access(permission, message, override, *args, **kwargs)
app.logger.info(
"User {} accessed {} {}".format(
g.current_user.id, request.method, request.path
),
extra={"tags": ["access", "success"]},
)
return f(*args, **kwargs)
except UnauthorizedError as err:
app.logger.warning(
"User {} denied access {} {}".format(
g.current_user.id, request.method, request.path
),
extra={"tags": ["access", "failure"]},
)
raise (err)
return decorated_function
return decorator

View File

@@ -0,0 +1,2 @@
from .query import Query
from .query import Paginator

View File

@@ -0,0 +1,81 @@
from sqlalchemy.exc import DataError
from sqlalchemy.orm.exc import NoResultFound
from atat.domain.exceptions import NotFoundError
from atat.database import db
class Paginator(object):
"""
Uses the Flask-SQLAlchemy extension's pagination method to paginate
a query set.
Also acts as a proxy object so that the results of the query set can be iterated
over without needing to call `.items`.
"""
def __init__(self, query_set):
self.query_set = query_set
@classmethod
def get_pagination_opts(cls, request, default_page=1, default_per_page=100):
return {
"page": int(request.args.get("page", default_page)),
"per_page": int(request.args.get("perPage", default_per_page)),
}
@classmethod
def paginate(cls, query, pagination_opts=None):
if pagination_opts is not None:
return cls(
query.paginate(
page=pagination_opts["page"], per_page=pagination_opts["per_page"]
)
)
else:
return query.all()
def __getattr__(self, name):
return getattr(self.query_set, name)
def __iter__(self):
return self.items.__iter__()
def __len__(self):
return self.items.__len__()
class Query(object):
model = None
@property
def resource_name(cls):
return cls.model.__class__.lower()
@classmethod
def create(cls, **kwargs):
# pylint: disable=E1102
return cls.model(**kwargs)
@classmethod
def get(cls, id_):
try:
resource = db.session.query(cls.model).filter_by(id=id_).one()
return resource
except (NoResultFound, DataError):
raise NotFoundError(cls.resource_name)
@classmethod
def get_all(cls):
return db.session.query(cls.model).all()
@classmethod
def add_and_commit(cls, resource):
db.session.add(resource)
db.session.commit()
return resource
@classmethod
def paginate(cls, query, pagination_opts):
return Paginator.paginate(query, pagination_opts)

View File

@@ -0,0 +1,31 @@
from .cloud import MockCloudProvider
from .files import AzureFileService, MockFileService
from .reports import MockReportingProvider
class MockCSP:
def __init__(self, app, test_mode=False):
self.cloud = MockCloudProvider(
app.config,
with_delay=(not test_mode),
with_failure=(not test_mode),
with_authorization=(not test_mode),
)
self.files = MockFileService(app)
self.reports = MockReportingProvider()
class AzureCSP:
def __init__(self, app):
self.cloud = MockCloudProvider(app.config)
self.files = AzureFileService(app.config)
self.reports = MockReportingProvider()
def make_csp_provider(app, csp=None):
if csp == "azure":
app.csp = AzureCSP(app)
elif csp == "mock-test":
app.csp = MockCSP(app, test_mode=True)
else:
app.csp = MockCSP(app)

View File

@@ -0,0 +1,3 @@
from .azure_cloud_provider import AzureCloudProvider
from .cloud_provider_interface import CloudProviderInterface
from .mock_cloud_provider import MockCloudProvider

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
from typing import Dict
class CloudProviderInterface: # pragma: no cover
def set_secret(self, secret_key: str, secret_value: str):
raise NotImplementedError()
def get_secret(self, secret_key: str):
raise NotImplementedError()
def root_creds(self) -> Dict:
raise NotImplementedError()
def create_environment(self, payload):
"""Create a new environment in the CSP.
Arguments:
auth_credentials -- Object containing CSP account credentials
user -- ATAT user authorizing the environment creation
environment -- ATAT Environment model
Returns:
string: ID of created environment
Raises:
AuthenticationException: Problem with the credentials
AuthorizationException: Credentials not authorized for current action(s)
ConnectionException: Issue with the CSP API connection
UnknownServerException: Unknown issue on the CSP side
EnvironmentExistsException: Environment already exists and has been created
"""
raise NotImplementedError()
def create_or_update_user(
self, auth_credentials: Dict, user_info, csp_role_id: str
) -> str:
"""Creates a user or updates an existing user's role.
Arguments:
auth_credentials -- Object containing CSP account credentials
user_info -- instance of EnvironmentRole containing user data
if it has a csp_user_id it will try to update that user
csp_role_id -- The id of the role the user should be given in the CSP
Returns:
string: Returns the interal csp_user_id of the created/updated user account
Raises:
AuthenticationException: Problem with the credentials
AuthorizationException: Credentials not authorized for current action(s)
ConnectionException: Issue with the CSP API connection
UnknownServerException: Unknown issue on the CSP side
UserProvisioningException: User couldn't be created or modified
"""
raise NotImplementedError()
def disable_user(self, tenant_id: str, role_assignment_cloud_id: str) -> bool:
"""Revoke all privileges for a user. Used to prevent user access while a full
delete is being processed.
Arguments:
tenant_id -- CSP internal tenant identifier
role_assignment_cloud_id -- CSP name of the role assignment to delete.
Returns:
bool -- True on success
Raises:
AuthenticationException: Problem with the credentials
AuthorizationException: Credentials not authorized for current action(s)
ConnectionException: Issue with the CSP API connection
UnknownServerException: Unknown issue on the CSP side
UserRemovalException: User couldn't be suspended
"""
raise NotImplementedError()
def get_calculator_url(self) -> str:
"""Returns the calculator url for the CSP.
This will likely be a static property elsewhere once a CSP is chosen.
"""
raise NotImplementedError()
def get_environment_login_url(self, environment) -> str:
"""Returns the login url for a given environment
This may move to be a computed property on the Environment domain object
"""
raise NotImplementedError()

View File

@@ -0,0 +1,146 @@
class GeneralCSPException(Exception):
pass
class OperationInProgressException(GeneralCSPException):
"""Throw this for instances when the CSP reports that the current entity is already
being operated on/created/deleted/etc
"""
def __init__(self, operation_desc):
self.operation_desc = operation_desc
@property
def message(self):
return "An operation for this entity is already in progress: {}".format(
self.operation_desc
)
class AuthenticationException(GeneralCSPException):
"""Throw this for instances when there is a problem with the auth credentials:
* Missing credentials
* Incorrect credentials
* Other credential problems
"""
def __init__(self, auth_error):
self.auth_error = auth_error
@property
def message(self):
return "An error occurred with authentication: {}".format(self.auth_error)
class AuthorizationException(GeneralCSPException):
"""Throw this for instances when the current credentials are not authorized
for the current action.
"""
def __init__(self, auth_error):
self.auth_error = auth_error
@property
def message(self):
return "An error occurred with authorization: {}".format(self.auth_error)
class ConnectionException(GeneralCSPException):
"""A general problem with the connection, timeouts or unresolved endpoints
"""
def __init__(self, connection_error):
self.connection_error = connection_error
@property
def message(self):
return "Could not connect to cloud provider: {}".format(self.connection_error)
class UnknownServerException(GeneralCSPException):
"""An error occured on the CSP side (5xx) and we don't know why
"""
def __init__(self, status_code, server_error):
self.status_code = status_code
self.server_error = server_error
@property
def message(self):
return f"A server error with status code [{self.status_code}] occured: {self.server_error}"
class EnvironmentCreationException(GeneralCSPException):
"""If there was an error in creating the environment
"""
def __init__(self, env_identifier, reason):
self.env_identifier = env_identifier
self.reason = reason
@property
def message(self):
return "The envionment {} couldn't be created: {}".format(
self.env_identifier, self.reason
)
class UserProvisioningException(GeneralCSPException):
"""Failed to provision a user
"""
class UserRemovalException(GeneralCSPException):
"""Failed to remove a user
"""
def __init__(self, user_csp_id, reason):
self.user_csp_id = user_csp_id
self.reason = reason
@property
def message(self):
return "Failed to suspend or delete user {}: {}".format(
self.user_csp_id, self.reason
)
class BaselineProvisionException(GeneralCSPException):
"""If there's any issues standing up whatever is required
for an environment baseline
"""
def __init__(self, env_identifier, reason):
self.env_identifier = env_identifier
self.reason = reason
@property
def message(self):
return "Could not complete baseline provisioning for environment ({}): {}".format(
self.env_identifier, self.reason
)
class SecretException(GeneralCSPException):
"""A problem occurred with setting or getting secrets"""
def __init__(self, tenant_id, reason):
self.tenant_id = tenant_id
self.reason = reason
@property
def message(self):
return "Could not get or set secret for ({}): {}".format(
self.tenant_id, self.reason
)
class DomainNameException(GeneralCSPException):
"""A problem occured when generating the domain name for a tenant"""
def __init__(self, name):
self.name = name
@property
def message(self):
return f"Could not generate unique tenant name for {self.name}"

View File

@@ -0,0 +1,519 @@
from uuid import uuid4
import pendulum
from .cloud_provider_interface import CloudProviderInterface
from .exceptions import (
AuthenticationException,
AuthorizationException,
ConnectionException,
GeneralCSPException,
UnknownServerException,
UserProvisioningException,
UserRemovalException,
)
from .models import (
AZURE_MGMNT_PATH,
AdminRoleDefinitionCSPPayload,
AdminRoleDefinitionCSPResult,
ApplicationCSPPayload,
ApplicationCSPResult,
BillingInstructionCSPPayload,
BillingInstructionCSPResult,
BillingOwnerCSPPayload,
BillingOwnerCSPResult,
BillingProfileCreationCSPPayload,
BillingProfileCreationCSPResult,
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
InitialMgmtGroupCSPPayload,
InitialMgmtGroupCSPResult,
InitialMgmtGroupVerificationCSPPayload,
InitialMgmtGroupVerificationCSPResult,
CostManagementQueryCSPResult,
CostManagementQueryProperties,
ProductPurchaseCSPPayload,
ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload,
TaskOrderBillingVerificationCSPResult,
TenantAdminOwnershipCSPPayload,
TenantAdminOwnershipCSPResult,
TenantCSPPayload,
TenantCSPResult,
TenantPrincipalAppCSPPayload,
TenantPrincipalAppCSPResult,
TenantPrincipalCredentialCSPPayload,
TenantPrincipalCredentialCSPResult,
TenantPrincipalCSPPayload,
TenantPrincipalCSPResult,
TenantPrincipalOwnershipCSPPayload,
TenantPrincipalOwnershipCSPResult,
UserCSPPayload,
UserCSPResult,
)
class MockCloudProvider(CloudProviderInterface):
# TODO: All of these constants
AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.")
AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.")
NETWORK_EXCEPTION = ConnectionException("Network failure.")
SERVER_EXCEPTION = UnknownServerException(500, "Not our fault.")
SERVER_FAILURE_PCT = 1
NETWORK_FAILURE_PCT = 7
ENV_CREATE_FAILURE_PCT = 12
ATAT_ADMIN_CREATE_FAILURE_PCT = 12
UNAUTHORIZED_RATE = 2
def __init__(
self, config, with_delay=True, with_failure=True, with_authorization=True
):
from time import sleep
import random
self._with_delay = with_delay
self._with_failure = with_failure
self._with_authorization = with_authorization
self._sleep = sleep
self._random = random
def root_creds(self):
return self._auth_credentials
def set_secret(self, secret_key: str, secret_value: str):
pass
def get_secret(self, secret_key: str, default=dict()):
return default
def create_subscription(self, payload: SubscriptionCreationCSPPayload):
return self.create_subscription_creation(payload)
def create_subscription_creation(self, payload: SubscriptionCreationCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return SubscriptionCreationCSPResult(
subscription_verify_url="https://zombo.com", subscription_retry_after=10
)
def create_subscription_verification(
self, payload: SubscriptionVerificationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return SuscriptionVerificationCSPResult(
subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230"
)
def create_tenant(self, payload: TenantCSPPayload):
"""
payload is an instance of TenantCSPPayload data class
"""
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantCSPResult(
**{
"tenant_id": "",
"user_id": "",
"user_object_id": "",
"domain_name": "",
"tenant_admin_username": "test",
"tenant_admin_password": "test",
}
)
def create_billing_profile_creation(
self, payload: BillingProfileCreationCSPPayload
):
# response will be mostly the same as the body, but we only really care about the id
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return BillingProfileCreationCSPResult(
**dict(
billing_profile_verify_url="https://zombo.com",
billing_profile_retry_after=10,
)
)
def create_billing_profile_verification(
self, payload: BillingProfileVerificationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return BillingProfileVerificationCSPResult(
**{
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
"name": "KQWI-W2SU-BG7-TGB",
"properties": {
"address": {
"addressLine1": "123 S Broad Street, Suite 2400",
"city": "Philadelphia",
"companyName": "Promptworks",
"country": "US",
"postalCode": "19109",
"region": "PA",
},
"currency": "USD",
"displayName": "Test Billing Profile",
"enabledAzurePlans": [],
"hasReadAccess": True,
"invoiceDay": 5,
"invoiceEmailOptIn": False,
"invoiceSections": [
{
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB",
"name": "CHCO-BAAR-PJA-TGB",
"properties": {"displayName": "Test Billing Profile"},
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
}
],
},
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
}
)
def create_billing_profile_tenant_access(self, payload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return BillingProfileTenantAccessCSPResult(
**{
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
"name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
"properties": {
"createdOn": "2020-01-14T14:39:26.3342192+00:00",
"createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03",
"principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
"principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
"roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
"scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
},
"type": "Microsoft.Billing/billingRoleAssignments",
}
)
def create_task_order_billing_creation(
self, payload: TaskOrderBillingCreationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TaskOrderBillingCreationCSPResult(
**{"Location": "https://somelocation", "Retry-After": "10"}
)
def create_task_order_billing_verification(
self, payload: TaskOrderBillingVerificationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TaskOrderBillingVerificationCSPResult(
**{
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/XC36-GRNZ-BG7-TGB",
"name": "XC36-GRNZ-BG7-TGB",
"properties": {
"address": {
"addressLine1": "123 S Broad Street, Suite 2400",
"city": "Philadelphia",
"companyName": "Promptworks",
"country": "US",
"postalCode": "19109",
"region": "PA",
},
"currency": "USD",
"displayName": "First Portfolio Billing Profile",
"enabledAzurePlans": [
{
"productId": "DZH318Z0BPS6",
"skuId": "0001",
"skuDescription": "Microsoft Azure Plan",
}
],
"hasReadAccess": True,
"invoiceDay": 5,
"invoiceEmailOptIn": False,
},
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
}
)
def create_billing_instruction(self, payload: BillingInstructionCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return BillingInstructionCSPResult(
**{
"name": "TO1:CLIN001",
"properties": {
"amount": 1000.0,
"endDate": "2020-03-01T00:00:00+00:00",
"startDate": "2020-01-01T00:00:00+00:00",
},
"type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions",
}
)
def create_initial_mgmt_group(self, payload: InitialMgmtGroupCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return InitialMgmtGroupCSPResult(
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}",
)
def create_initial_mgmt_group_verification(
self, payload: InitialMgmtGroupVerificationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return InitialMgmtGroupVerificationCSPResult(
**dict(
id="Test Id"
# id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
)
)
def create_product_purchase(self, payload: ProductPurchaseCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return ProductPurchaseCSPResult(
**dict(
product_purchase_verify_url="https://zombo.com",
product_purchase_retry_after=10,
)
)
def create_product_purchase_verification(
self, payload: ProductPurchaseVerificationCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return ProductPurchaseVerificationCSPResult(
**dict(premium_purchase_date="2020-01-30T18:57:05.981Z")
)
def create_tenant_admin_ownership(self, payload: TenantAdminOwnershipCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantAdminOwnershipCSPResult(**dict(id="admin_owner_assignment_id"))
def create_tenant_principal_ownership(
self, payload: TenantPrincipalOwnershipCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantPrincipalOwnershipCSPResult(
**dict(id="principal_owner_assignment_id")
)
def create_tenant_principal_app(self, payload: TenantPrincipalAppCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantPrincipalAppCSPResult(
**dict(appId="principal_app_id", id="principal_app_object_id")
)
def create_tenant_principal(self, payload: TenantPrincipalCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantPrincipalCSPResult(**dict(id="principal_id"))
def create_tenant_principal_credential(
self, payload: TenantPrincipalCredentialCSPPayload
):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantPrincipalCredentialCSPResult(
**dict(
principal_client_id="principal_client_id",
principal_creds_established=True,
)
)
def create_admin_role_definition(self, payload: AdminRoleDefinitionCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return AdminRoleDefinitionCSPResult(
**dict(admin_role_def_id="admin_role_def_id")
)
def create_principal_admin_role(self, payload: PrincipalAdminRoleCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return PrincipalAdminRoleCSPResult(**dict(id="principal_assignment_id"))
def create_billing_owner(self, payload: BillingOwnerCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return BillingOwnerCSPResult(billing_owner_id="foo")
def create_or_update_user(self, auth_credentials, user_info, csp_role_id):
self._authorize(auth_credentials)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
UserProvisioningException(
user_info.environment.id,
user_info.application_role.user_id,
"Could not create user.",
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return self._id()
def disable_user(self, tenant_id, role_assignment_cloud_id):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
UserRemovalException(tenant_id, "Could not disable user."),
)
return self._maybe(12)
def get_calculator_url(self):
return "https://www.rackspace.com/en-us/calculator"
def get_environment_login_url(self, environment):
"""Returns the login url for a given environment
"""
return "https://www.mycloud.com/my-env-login"
def _id(self):
return uuid4().hex
def _delay(self, min_secs, max_secs):
if self._with_delay:
duration = self._random.randrange(min_secs, max_secs)
self._sleep(duration)
def _maybe(self, pct):
return not self._with_failure or self._random.randrange(0, 100) < pct
def _maybe_raise(self, pct, exc):
if self._with_failure and self._maybe(pct):
raise exc
@property
def _auth_credentials(self):
return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret
def _authorize(self, credentials):
self._delay(1, 5)
if self._with_authorization and credentials != self._auth_credentials:
raise self.AUTHENTICATION_EXCEPTION
def create_application(self, payload: ApplicationCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return ApplicationCSPResult(
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
)
def create_environment(self, payload: EnvironmentCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return EnvironmentCSPResult(
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
)
def create_user(self, payload: UserCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return UserCSPResult(id=str(uuid4()))
def get_credentials(self, scope="portfolio", tenant_id=None):
return self.root_creds()
def update_tenant_creds(self, tenant_id, secret):
return secret
def get_reporting_data(self, payload: ReportingCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
object_id = str(uuid4())
start_of_month = pendulum.today(tz="utc").start_of("month").replace(tzinfo=None)
this_month = start_of_month.to_atom_string()
last_month = start_of_month.subtract(months=1).to_atom_string()
two_months_ago = start_of_month.subtract(months=2).to_atom_string()
properties = CostManagementQueryProperties(
**dict(
columns=[
{"name": "PreTaxCost", "type": "Number"},
{"name": "BillingMonth", "type": "Datetime"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
rows=[
[1.0, two_months_ago, "", "USD"],
[500.0, two_months_ago, "e05009w9sf", "USD"],
[50.0, last_month, "", "USD"],
[1000.0, last_month, "e0500a4qhw", "USD"],
[500.0, this_month, "", "USD"],
],
)
)
return CostManagementQueryCSPResult(
**dict(name=object_id, properties=properties,)
)

View File

@@ -0,0 +1,621 @@
from enum import Enum
from secrets import token_urlsafe
from typing import Dict, List, Optional
from uuid import uuid4
import re
from pydantic import BaseModel, validator, root_validator
from .utils import (
generate_mail_nickname,
generate_user_principal_name,
)
from atat.utils import snake_to_camel
class AliasModel(BaseModel):
"""
This provides automatic camel <-> snake conversion for serializing to/from json
You can override the alias generation in subclasses by providing a Config that defines
a fields property with a dict mapping variables to their cast names, for cases like:
* some_url:someURL
* user_object_id:objectId
"""
class Config:
alias_generator = snake_to_camel
allow_population_by_field_name = True
class BaseCSPPayload(AliasModel):
tenant_id: str
class TenantCSPPayload(AliasModel):
user_id: str
password: Optional[str]
domain_name: str
first_name: str
last_name: str
country_code: str
password_recovery_email_address: str
class TenantCSPResult(AliasModel):
user_id: str
tenant_id: str
user_object_id: str
domain_name: str
tenant_admin_username: Optional[str]
tenant_admin_password: Optional[str]
class Config:
fields = {
"user_object_id": "objectId",
}
def dict(self, *args, **kwargs):
exclude = {"tenant_admin_username", "tenant_admin_password"}
if "exclude" not in kwargs:
kwargs["exclude"] = exclude
else:
kwargs["exclude"].update(exclude)
return super().dict(*args, **kwargs)
def get_creds(self):
return {
"tenant_admin_username": self.tenant_admin_username,
"tenant_admin_password": self.tenant_admin_password,
"tenant_id": self.tenant_id,
}
class BillingProfileAddress(AliasModel):
company_name: str
address_line_1: str
city: str
region: str
country: str
postal_code: str
class BillingProfileCLINBudget(AliasModel):
clin_budget: Dict
"""
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
"""
class BillingProfileCreationCSPPayload(BaseCSPPayload):
tenant_id: str
billing_profile_display_name: str
billing_account_name: str
enabled_azure_plans: Optional[List[str]]
address: BillingProfileAddress
@validator("enabled_azure_plans", pre=True, always=True)
def default_enabled_azure_plans(cls, v):
"""
Normally you'd implement this by setting the field with a value of:
dataclasses.field(default_factory=list)
but that prevents the object from being correctly pickled, so instead we need
to rely on a validator to ensure this has an empty value when not specified
"""
return v or []
class Config:
fields = {"billing_profile_display_name": "displayName"}
class BillingProfileCreationCSPResult(AliasModel):
billing_profile_verify_url: str
billing_profile_retry_after: int
class Config:
fields = {
"billing_profile_verify_url": "Location",
"billing_profile_retry_after": "Retry-After",
}
class BillingProfileVerificationCSPPayload(BaseCSPPayload):
billing_profile_verify_url: str
class BillingInvoiceSection(AliasModel):
invoice_section_id: str
invoice_section_name: str
class Config:
fields = {"invoice_section_id": "id", "invoice_section_name": "name"}
class BillingProfileProperties(AliasModel):
address: BillingProfileAddress
billing_profile_display_name: str
invoice_sections: List[BillingInvoiceSection]
class Config:
fields = {"billing_profile_display_name": "displayName"}
class BillingProfileVerificationCSPResult(AliasModel):
billing_profile_id: str
billing_profile_name: str
billing_profile_properties: BillingProfileProperties
class Config:
fields = {
"billing_profile_id": "id",
"billing_profile_name": "name",
"billing_profile_properties": "properties",
}
class BillingProfileTenantAccessCSPPayload(BaseCSPPayload):
tenant_id: str
user_object_id: str
billing_account_name: str
billing_profile_name: str
class BillingProfileTenantAccessCSPResult(AliasModel):
billing_role_assignment_id: str
billing_role_assignment_name: str
class Config:
fields = {
"billing_role_assignment_id": "id",
"billing_role_assignment_name": "name",
}
class TaskOrderBillingCreationCSPPayload(BaseCSPPayload):
billing_account_name: str
billing_profile_name: str
class TaskOrderBillingCreationCSPResult(AliasModel):
task_order_billing_verify_url: str
task_order_retry_after: int
class Config:
fields = {
"task_order_billing_verify_url": "Location",
"task_order_retry_after": "Retry-After",
}
class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload):
task_order_billing_verify_url: str
class BillingProfileEnabledPlanDetails(AliasModel):
enabled_azure_plans: List[Dict]
class TaskOrderBillingVerificationCSPResult(AliasModel):
billing_profile_id: str
billing_profile_name: str
billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails
class Config:
fields = {
"billing_profile_id": "id",
"billing_profile_name": "name",
"billing_profile_enabled_plan_details": "properties",
}
class BillingInstructionCSPPayload(BaseCSPPayload):
initial_clin_amount: float
initial_clin_start_date: str
initial_clin_end_date: str
initial_clin_type: str
initial_task_order_id: str
billing_account_name: str
billing_profile_name: str
class BillingInstructionCSPResult(AliasModel):
reported_clin_name: str
class Config:
fields = {
"reported_clin_name": "name",
}
class TenantAdminOwnershipCSPPayload(BaseCSPPayload):
user_object_id: str
class TenantAdminOwnershipCSPResult(AliasModel):
admin_owner_assignment_id: str
class Config:
fields = {"admin_owner_assignment_id": "id"}
class TenantPrincipalOwnershipCSPPayload(BaseCSPPayload):
principal_id: str
class TenantPrincipalOwnershipCSPResult(AliasModel):
principal_owner_assignment_id: str
class Config:
fields = {"principal_owner_assignment_id": "id"}
class TenantPrincipalAppCSPPayload(BaseCSPPayload):
pass
class TenantPrincipalAppCSPResult(AliasModel):
principal_app_id: str
principal_app_object_id: str
class Config:
fields = {"principal_app_id": "appId", "principal_app_object_id": "id"}
class TenantPrincipalCSPPayload(BaseCSPPayload):
principal_app_id: str
class TenantPrincipalCSPResult(AliasModel):
principal_id: str
class Config:
fields = {"principal_id": "id"}
class TenantPrincipalCredentialCSPPayload(BaseCSPPayload):
principal_app_id: str
principal_app_object_id: str
class TenantPrincipalCredentialCSPResult(AliasModel):
principal_client_id: str
principal_creds_established: bool
class AdminRoleDefinitionCSPPayload(BaseCSPPayload):
pass
class AdminRoleDefinitionCSPResult(AliasModel):
admin_role_def_id: str
class PrincipalAdminRoleCSPPayload(BaseCSPPayload):
principal_id: str
admin_role_def_id: str
class PrincipalAdminRoleCSPResult(AliasModel):
principal_assignment_id: str
class Config:
fields = {"principal_assignment_id": "id"}
AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/"
MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$"
class ManagementGroupCSPPayload(AliasModel):
"""
:param: management_group_name: Just pass a UUID for this.
:param: display_name: This can contain any character and
spaces, but should be 90 characters or fewer long.
:param: parent_id: This should be the fully qualified Azure ID,
i.e. /providers/Microsoft.Management/managementGroups/[management group ID]
"""
tenant_id: str
management_group_name: Optional[str]
display_name: str
parent_id: Optional[str]
@validator("management_group_name", pre=True, always=True)
def supply_management_group_name_default(cls, name):
if name:
if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None:
raise ValueError(
f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}"
)
return name[0:90]
else:
return str(uuid4())
@validator("display_name", pre=True, always=True)
def enforce_display_name_length(cls, name):
return name[0:90]
@validator("parent_id", pre=True, always=True)
def enforce_parent_id_pattern(cls, id_):
if id_:
if AZURE_MGMNT_PATH not in id_:
return f"{AZURE_MGMNT_PATH}{id_}"
else:
return id_
class ManagementGroupCSPResponse(AliasModel):
id: str
class ManagementGroupGetCSPPayload(BaseCSPPayload):
management_group_name: str
class ManagementGroupGetCSPResponse(AliasModel):
id: str
class ApplicationCSPPayload(ManagementGroupCSPPayload):
pass
class ApplicationCSPResult(ManagementGroupCSPResponse):
pass
class InitialMgmtGroupCSPPayload(ManagementGroupCSPPayload):
pass
class InitialMgmtGroupCSPResult(ManagementGroupCSPResponse):
pass
class InitialMgmtGroupVerificationCSPPayload(ManagementGroupGetCSPPayload):
pass
class InitialMgmtGroupVerificationCSPResult(ManagementGroupGetCSPResponse):
pass
class EnvironmentCSPPayload(ManagementGroupCSPPayload):
pass
class EnvironmentCSPResult(ManagementGroupCSPResponse):
pass
class KeyVaultCredentials(BaseModel):
root_sp_client_id: Optional[str]
root_sp_key: Optional[str]
root_tenant_id: Optional[str]
tenant_id: Optional[str]
tenant_admin_username: Optional[str]
tenant_admin_password: Optional[str]
tenant_sp_client_id: Optional[str]
tenant_sp_key: Optional[str]
@root_validator(pre=True)
def enforce_admin_creds(cls, values):
tenant_id = values.get("tenant_id")
username = values.get("tenant_admin_username")
password = values.get("tenant_admin_password")
if any([username, password]) and not all([tenant_id, username, password]):
raise ValueError(
"tenant_id, tenant_admin_username, and tenant_admin_password must all be set if any one is"
)
return values
@root_validator(pre=True)
def enforce_sp_creds(cls, values):
tenant_id = values.get("tenant_id")
client_id = values.get("tenant_sp_client_id")
key = values.get("tenant_sp_key")
if any([client_id, key]) and not all([tenant_id, client_id, key]):
raise ValueError(
"tenant_id, tenant_sp_client_id, and tenant_sp_key must all be set if any one is"
)
return values
@root_validator(pre=True)
def enforce_root_creds(cls, values):
sp_creds = [
values.get("root_tenant_id"),
values.get("root_sp_client_id"),
values.get("root_sp_key"),
]
if any(sp_creds) and not all(sp_creds):
raise ValueError(
"root_tenant_id, root_sp_client_id, and root_sp_key must all be set if any one is"
)
return values
def merge_credentials(
self, new_creds: "KeyVaultCredentials"
) -> "KeyVaultCredentials":
updated_creds = {k: v for k, v in new_creds.dict().items() if v}
old_creds = self.dict()
old_creds.update(updated_creds)
return KeyVaultCredentials(**old_creds)
class SubscriptionCreationCSPPayload(BaseCSPPayload):
display_name: str
parent_group_id: str
billing_account_name: str
billing_profile_name: str
invoice_section_name: str
class SubscriptionCreationCSPResult(AliasModel):
subscription_verify_url: str
subscription_retry_after: int
class Config:
fields = {
"subscription_verify_url": "Location",
"subscription_retry_after": "Retry-After",
}
class SubscriptionVerificationCSPPayload(BaseCSPPayload):
subscription_verify_url: str
SUBSCRIPTION_ID_REGEX = re.compile(
"\/?subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})",
re.I,
)
class SuscriptionVerificationCSPResult(AliasModel):
subscription_id: str
@validator("subscription_id", pre=True, always=True)
def enforce_display_name_length(cls, sub_id):
sub_id_match = SUBSCRIPTION_ID_REGEX.match(sub_id)
if sub_id_match:
return sub_id_match.group(1)
return False
class Config:
fields = {"subscription_id": "subscriptionLink"}
class ProductPurchaseCSPPayload(BaseCSPPayload):
billing_account_name: str
billing_profile_name: str
class ProductPurchaseCSPResult(AliasModel):
product_purchase_verify_url: str
product_purchase_retry_after: int
class Config:
fields = {
"product_purchase_verify_url": "Location",
"product_purchase_retry_after": "Retry-After",
}
class ProductPurchaseVerificationCSPPayload(BaseCSPPayload):
product_purchase_verify_url: str
class ProductPurchaseVerificationCSPResult(AliasModel):
premium_purchase_date: str
class UserMixin(BaseModel):
password: Optional[str]
@property
def user_principal_name(self):
return generate_user_principal_name(self.display_name, self.tenant_host_name)
@property
def mail_nickname(self):
return generate_mail_nickname(self.display_name)
@validator("password", pre=True, always=True)
def supply_password_default(cls, password):
return password or token_urlsafe(16)
class UserCSPPayload(BaseCSPPayload, UserMixin):
display_name: str
tenant_host_name: str
email: str
class UserCSPResult(AliasModel):
id: str
class UserRoleCSPPayload(BaseCSPPayload):
class Roles(str, Enum):
owner = "owner"
contributor = "contributor"
billing = "billing"
management_group_id: str
role: Roles
user_object_id: str
class UserRoleCSPResult(AliasModel):
id: str
class QueryColumn(AliasModel):
name: str
type: str
class CostManagementQueryProperties(AliasModel):
columns: List[QueryColumn]
rows: List[Optional[list]]
class CostManagementQueryCSPResult(AliasModel):
name: str
properties: CostManagementQueryProperties
class ReportingCSPPayload(BaseCSPPayload):
invoice_section_id: str
from_date: str
to_date: str
@root_validator(pre=True)
def extract_invoice_section(cls, values):
try:
values["invoice_section_id"] = values["billing_profile_properties"][
"invoice_sections"
][0]["invoice_section_id"]
return values
except (KeyError, IndexError):
raise ValueError("Invoice section ID not present in payload")
class BillingOwnerCSPPayload(BaseCSPPayload, UserMixin):
"""
This class needs to consume data in the shape it's in from the
top-level portfolio CSP data, but return it in the shape
needed for user provisioning.
"""
display_name = "billing_admin"
domain_name: str
password_recovery_email_address: str
@property
def tenant_host_name(self):
return self.domain_name
@property
def email(self):
return self.password_recovery_email_address
class BillingOwnerCSPResult(AliasModel):
billing_owner_id: str

View File

@@ -0,0 +1,47 @@
from glob import glob
import json
from dataclasses import dataclass
from os.path import join as path_join
class AzurePolicyManager:
def __init__(self, static_policy_location):
self._static_policy_location = static_policy_location
@property
def portfolio_definitions(self):
if getattr(self, "_portfolio_definitions", None) is None:
portfolio_files = self._glob_json("portfolios")
self._portfolio_definitions = self._load_policies(portfolio_files)
return self._portfolio_definitions
@property
def application_definitions(self):
pass
@property
def environment_definitions(self):
pass
def _glob_json(self, path):
return glob(path_join(self._static_policy_location, "portfolios", "*.json"))
def _load_policies(self, json_policies):
return [self._load_policy(pol) for pol in json_policies]
def _load_policy(self, policy_file):
with open(policy_file, "r") as file_:
doc = json.loads(file_.read())
return AzurePolicy(
definition_point=doc["definitionPoint"],
definition=doc["policyDefinition"],
parameters=doc["parameters"],
)
@dataclass
class AzurePolicy:
definition_point: str
definition: dict
parameters: dict

View File

@@ -0,0 +1,10 @@
from flask import current_app as app
def generate_user_principal_name(name, domain_name):
mail_name = generate_mail_nickname(name)
return f"{mail_name}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}"
def generate_mail_nickname(name):
return name.replace(" ", ".").lower()

107
atat/domain/csp/files.py Normal file
View File

@@ -0,0 +1,107 @@
from uuid import uuid4
import pendulum
class FileService:
def generate_token(self):
raise NotImplementedError()
def generate_download_link(self, object_name, filename) -> (dict, str):
raise NotImplementedError()
def object_name(self) -> str:
return str(uuid4())
def download_task_order(self, object_name):
raise NotImplementedError()
class MockFileService(FileService):
def __init__(self, config):
self.config = config
def get_token(self):
return ({}, self.object_name())
def generate_download_link(self, object_name, filename):
return ""
def download_task_order(self, object_name):
with open("tests/fixtures/sample.pdf", "rb") as some_bytes:
return {
"name": object_name,
"content": some_bytes,
}
class AzureFileService(FileService):
def __init__(self, config):
self.account_name = config["AZURE_ACCOUNT_NAME"]
self.storage_key = config["AZURE_STORAGE_KEY"]
self.container_name = config["AZURE_TO_BUCKET_NAME"]
self.timeout = config["PERMANENT_SESSION_LIFETIME"]
from azure.storage.common import CloudStorageAccount
from azure.storage.blob import BlobSasPermissions
from azure.storage.blob.models import BlobPermissions
from azure.storage.blob.blockblobservice import BlockBlobService
self.CloudStorageAccount = CloudStorageAccount
self.BlobSasPermissions = BlobSasPermissions
self.BlobPermissions = BlobPermissions
self.BlockBlobService = BlockBlobService
def get_token(self):
"""
Generates an Azure SAS token for pre-authorizing a file upload.
Returns a tuple in the following format: (token_dict, object_name), where
- token_dict has a `token` key which contains the SAS token as a string
- object_name is a string
"""
account = self.CloudStorageAccount(
account_name=self.account_name, account_key=self.storage_key
)
bbs = account.create_block_blob_service()
object_name = self.object_name()
sas_token = bbs.generate_blob_shared_access_signature(
self.container_name,
object_name,
permission=self.BlobSasPermissions(create=True),
expiry=pendulum.now(tz="utc").add(self.timeout),
protocol="https",
)
return ({"token": sas_token}, object_name)
def generate_download_link(self, object_name, filename):
block_blob_service = self.BlockBlobService(
account_name=self.account_name, account_key=self.storage_key
)
sas_token = block_blob_service.generate_blob_shared_access_signature(
container_name=self.container_name,
blob_name=object_name,
permission=self.BlobPermissions(read=True),
expiry=pendulum.now(tz="utc").add(self.timeout),
content_disposition=f"attachment; filename={filename}",
protocol="https",
)
return block_blob_service.make_blob_url(
container_name=self.container_name,
blob_name=object_name,
protocol="https",
sas_token=sas_token,
)
def download_task_order(self, object_name):
block_blob_service = self.BlockBlobService(
account_name=self.account_name, account_key=self.storage_key
)
# TODO: We should downloading errors more gracefully
# - what happens when we try to request a TO that doesn't exist?
b = block_blob_service.get_blob_to_bytes(
container_name=self.container_name, blob_name=object_name,
)
return {
"name": b.name,
"content": b.content,
}

View File

@@ -0,0 +1,35 @@
import json
from decimal import Decimal
import pendulum
def load_fixture_data():
with open("fixtures/fixture_spend_data.json") as json_file:
return json.load(json_file)
class MockReportingProvider:
FIXTURE_SPEND_DATA = load_fixture_data()
def prepare_azure_reporting_data(rows: list):
"""
Returns a dict representing invoiced and estimated funds for a portfolio given
a list of rows from CostManagementQueryCSPResult.properties.rows
{
invoiced: Decimal,
estimated: Decimal
}
"""
estimated = []
while rows:
if pendulum.parse(rows[-1][1]) >= pendulum.now(tz="utc").start_of("month"):
estimated.append(rows.pop())
else:
break
return dict(
invoiced=Decimal(sum([row[0] for row in rows])),
estimated=Decimal(sum([row[0] for row in estimated])),
)

View File

@@ -0,0 +1,154 @@
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy import func, and_, or_
from flask import current_app as app
from atat.database import db
from atat.models import (
Environment,
EnvironmentRole,
Application,
ApplicationRole,
ApplicationRoleStatus,
)
from atat.domain.exceptions import NotFoundError
from uuid import UUID
from typing import List
class EnvironmentRoles(object):
@classmethod
def create(cls, application_role, environment, role):
env_role = EnvironmentRole(
application_role=application_role, environment=environment, role=role
)
return env_role
@classmethod
def get(cls, application_role_id, environment_id):
existing_env_role = (
db.session.query(EnvironmentRole)
.filter(
EnvironmentRole.application_role_id == application_role_id,
EnvironmentRole.environment_id == environment_id,
EnvironmentRole.deleted == False,
EnvironmentRole.status != EnvironmentRole.Status.DISABLED,
)
.one_or_none()
)
return existing_env_role
@classmethod
def get_by_id(cls, id_) -> EnvironmentRole:
try:
return (
db.session.query(EnvironmentRole).filter(EnvironmentRole.id == id_)
).one()
except NoResultFound:
raise NotFoundError(cls.resource_name)
@classmethod
def get_by_user_and_environment(cls, user_id, environment_id):
existing_env_role = (
db.session.query(EnvironmentRole)
.join(ApplicationRole)
.filter(
ApplicationRole.user_id == user_id,
EnvironmentRole.environment_id == environment_id,
EnvironmentRole.deleted == False,
)
.one_or_none()
)
return existing_env_role
@classmethod
def _update_status(cls, environment_role, new_status):
environment_role.status = new_status
db.session.add(environment_role)
db.session.commit()
return environment_role
@classmethod
def delete(cls, application_role_id, environment_id):
existing_env_role = EnvironmentRoles.get(application_role_id, environment_id)
if existing_env_role:
# TODO: Implement suspension
existing_env_role.deleted = True
db.session.add(existing_env_role)
db.session.commit()
return True
else:
return False
@classmethod
def get_for_application_member(cls, application_role_id):
return (
db.session.query(EnvironmentRole)
.filter(EnvironmentRole.application_role_id == application_role_id)
.filter(EnvironmentRole.deleted != True)
.all()
)
@classmethod
def get_pending_creation(cls) -> List[UUID]:
results = (
db.session.query(EnvironmentRole.id)
.join(Environment)
.join(ApplicationRole)
.filter(
and_(
Environment.deleted == False,
EnvironmentRole.deleted == False,
ApplicationRole.deleted == False,
ApplicationRole.cloud_id != None,
ApplicationRole.status != ApplicationRoleStatus.DISABLED,
EnvironmentRole.status != EnvironmentRole.Status.DISABLED,
EnvironmentRole.cloud_id.is_(None),
or_(
EnvironmentRole.claimed_until.is_(None),
EnvironmentRole.claimed_until <= func.now(),
),
)
)
.all()
)
return [id_ for id_, in results]
@classmethod
def disable(cls, environment_role_id):
environment_role = EnvironmentRoles.get_by_id(environment_role_id)
if environment_role.cloud_id and not environment_role.environment.cloud_id:
tenant_id = environment_role.environment.portfolio.csp_data.get("tenant_id")
app.csp.cloud.disable_user(tenant_id, environment_role.csp_user_id)
environment_role.status = EnvironmentRole.Status.DISABLED
db.session.add(environment_role)
db.session.commit()
return environment_role
@classmethod
def get_for_update(cls, application_role_id, environment_id):
existing_env_role = (
db.session.query(EnvironmentRole)
.filter(
EnvironmentRole.application_role_id == application_role_id,
EnvironmentRole.environment_id == environment_id,
)
.one_or_none()
)
return existing_env_role
@classmethod
def for_user(cls, user_id, portfolio_id):
return (
db.session.query(EnvironmentRole)
.join(ApplicationRole)
.join(Application)
.filter(Application.portfolio_id == portfolio_id)
.filter(ApplicationRole.application_id == Application.id)
.filter(ApplicationRole.user_id == user_id)
.all()
)

140
atat/domain/environments.py Normal file
View File

@@ -0,0 +1,140 @@
from sqlalchemy import func, or_, and_
from sqlalchemy.orm.exc import NoResultFound
from typing import List
from uuid import UUID
from atat.database import db
from atat.models import (
Environment,
Application,
Portfolio,
TaskOrder,
CLIN,
)
from atat.domain.environment_roles import EnvironmentRoles
from atat.utils import commit_or_raise_already_exists_error
from .exceptions import NotFoundError, DisabledError
class Environments(object):
@classmethod
def create(cls, user, application, name):
environment = Environment(application=application, name=name, creator=user)
db.session.add(environment)
commit_or_raise_already_exists_error(message="environment")
return environment
@classmethod
def create_many(cls, user, application, names):
environments = []
for name in names:
environment = Environments.create(user, application, name)
environments.append(environment)
db.session.add_all(environments)
return environments
@classmethod
def update(cls, environment, name=None):
if name is not None:
environment.name = name
db.session.add(environment)
commit_or_raise_already_exists_error(message="environment")
return environment
@classmethod
def get(cls, environment_id):
try:
env = (
db.session.query(Environment)
.filter_by(id=environment_id, deleted=False)
.one()
)
except NoResultFound:
raise NotFoundError("environment")
return env
@classmethod
def update_env_role(cls, environment, application_role, new_role):
env_role = EnvironmentRoles.get_for_update(application_role.id, environment.id)
if env_role and new_role and (env_role.disabled or env_role.deleted):
raise DisabledError("environment_role", env_role.id)
if env_role and env_role.role != new_role and not env_role.disabled:
env_role.role = new_role
db.session.add(env_role)
elif not env_role and new_role:
env_role = EnvironmentRoles.create(
application_role=application_role,
environment=environment,
role=new_role,
)
db.session.add(env_role)
if env_role and not new_role and not env_role.disabled:
EnvironmentRoles.disable(env_role.id)
db.session.commit()
@classmethod
def revoke_access(cls, environment, target_user):
EnvironmentRoles.delete(environment.id, target_user.id)
@classmethod
def delete(cls, environment, commit=False):
environment.deleted = True
db.session.add(environment)
for role in environment.roles:
role.deleted = True
db.session.add(role)
if commit:
db.session.commit()
# TODO: How do we work around environment deletion being a largely manual process in the CSPs
return environment
@classmethod
def base_provision_query(cls, now):
return (
db.session.query(Environment.id)
.join(Application)
.join(Portfolio)
.join(TaskOrder)
.join(CLIN)
.filter(CLIN.start_date <= now)
.filter(CLIN.end_date > now)
.filter(Environment.deleted == False)
.filter(
or_(
Environment.claimed_until == None,
Environment.claimed_until <= func.now(),
)
)
)
@classmethod
def get_environments_pending_creation(cls, now) -> List[UUID]:
"""
Any environment with an active CLIN that doesn't yet have a `cloud_id`.
"""
results = (
cls.base_provision_query(now)
.filter(
and_(
Application.cloud_id != None,
Environment.cloud_id.is_(None),
or_(
Environment.claimed_until.is_(None),
Environment.claimed_until <= func.now(),
),
)
)
.all()
)
return [id_ for id_, in results]

65
atat/domain/exceptions.py Normal file
View File

@@ -0,0 +1,65 @@
class NotFoundError(Exception):
def __init__(self, resource_name, resource_id=None):
self.resource_name = resource_name
self.resource_id = resource_id
@property
def message(self):
return "No {} could be found.".format(self.resource_name)
class AlreadyExistsError(Exception):
def __init__(self, resource_name):
self.resource_name = resource_name
@property
def message(self):
return "{} already exists".format(self.resource_name)
class UnauthorizedError(Exception):
def __init__(self, user, action):
self.user = user
self.action = action
@property
def message(self):
return "User {} not authorized to {}".format(self.user.id, self.action)
class UnauthenticatedError(Exception):
@property
def message(self):
return str(self)
class UploadError(Exception):
pass
class NoAccessError(Exception):
def __init__(self, resource_name):
self.resource_name = resource_name
@property
def message(self):
return "Route for {} cannot be accessed".format(self.resource_name)
class ClaimFailedException(Exception):
def __init__(self, resource):
self.resource = resource
message = (
f"Could not acquire claim for {resource.__class__.__name__} {resource.id}."
)
super().__init__(message)
class DisabledError(Exception):
def __init__(self, resource_name, resource_id=None):
self.resource_name = resource_name
self.resource_id = resource_id
@property
def message(self):
return f"Cannot update disabled {self.resource_name} {self.resource_id}."

144
atat/domain/invitations.py Normal file
View File

@@ -0,0 +1,144 @@
from sqlalchemy.orm.exc import NoResultFound
import pendulum
from atat.database import db
from atat.models import ApplicationInvitation, InvitationStatus, PortfolioInvitation
from atat.domain.portfolio_roles import PortfolioRoles
from atat.domain.application_roles import ApplicationRoles
from .exceptions import NotFoundError
class WrongUserError(Exception):
def __init__(self, user, invite):
self.user = user
self.invite = invite
@property
def message(self):
return "User {} with DOD ID {} does not match expected DOD ID {} for invitation {}".format(
self.user.id, self.user.dod_id, self.invite.user.dod_id, self.invite.id
)
class ExpiredError(Exception):
def __init__(self, invite):
self.invite = invite
@property
def message(self):
return "Invitation {} has expired.".format(self.invite.id)
class InvitationError(Exception):
def __init__(self, invite):
self.invite = invite
@property
def message(self):
return "{} has a status of {}".format(self.invite.id, self.invite.status.value)
class BaseInvitations(object):
model = None
role_domain_class = None
# number of minutes a given invitation is considered valid
EXPIRATION_LIMIT_MINUTES = 360
@classmethod
def _get(cls, token):
try:
invite = db.session.query(cls.model).filter_by(token=token).one()
except NoResultFound:
raise NotFoundError(cls.model.__tablename__)
return invite
@classmethod
def create(cls, inviter, role, member_data, commit=False):
# pylint: disable=not-callable
invite = cls.model(
role=role,
inviter=inviter,
user=role.user,
status=InvitationStatus.PENDING,
expiration_time=cls.current_expiration_time(),
email=member_data.get("email"),
dod_id=member_data.get("dod_id"),
first_name=member_data.get("first_name"),
phone_number=member_data.get("phone_number"),
last_name=member_data.get("last_name"),
)
db.session.add(invite)
if commit:
db.session.commit()
return invite
@classmethod
def accept(cls, user, token):
invite = cls._get(token)
if invite.dod_id != user.dod_id:
if invite.is_pending:
cls._update_status(invite, InvitationStatus.REJECTED_WRONG_USER)
raise WrongUserError(user, invite)
elif invite.is_expired:
cls._update_status(invite, InvitationStatus.REJECTED_EXPIRED)
raise ExpiredError(invite)
elif invite.is_accepted or invite.is_revoked or invite.is_rejected:
raise InvitationError(invite)
elif invite.is_pending: # pragma: no branch
cls._update_status(invite, InvitationStatus.ACCEPTED)
cls.role_domain_class.enable(invite.role, user)
return invite
@classmethod
def current_expiration_time(cls):
return pendulum.now(tz="utc").add(minutes=cls.EXPIRATION_LIMIT_MINUTES)
@classmethod
def _update_status(cls, invite, new_status):
invite.status = new_status
db.session.add(invite)
db.session.commit()
return invite
@classmethod
def revoke(cls, token):
invite = cls._get(token)
invite = cls._update_status(invite, InvitationStatus.REVOKED)
cls.role_domain_class.disable(invite.role)
return invite
@classmethod
def resend(cls, inviter, token, user_info=None):
previous_invitation = cls._get(token)
cls._update_status(previous_invitation, InvitationStatus.REVOKED)
if not user_info:
user_info = {
"email": previous_invitation.email,
"dod_id": previous_invitation.dod_id,
"first_name": previous_invitation.first_name,
"last_name": previous_invitation.last_name,
"phone_number": previous_invitation.phone_number,
"phone_ext": previous_invitation.phone_ext,
}
return cls.create(inviter, previous_invitation.role, user_info, commit=True)
class PortfolioInvitations(BaseInvitations):
model = PortfolioInvitation
role_domain_class = PortfolioRoles
class ApplicationInvitations(BaseInvitations):
model = ApplicationInvitation
role_domain_class = ApplicationRoles

View File

@@ -0,0 +1,241 @@
from sqlalchemy.orm.exc import NoResultFound
from atat.database import db
from atat.models.permissions import Permissions
from atat.models.permission_set import PermissionSet
from .exceptions import NotFoundError
class PermissionSets(object):
VIEW_PORTFOLIO = "view_portfolio"
VIEW_PORTFOLIO_APPLICATION_MANAGEMENT = "view_portfolio_application_management"
VIEW_PORTFOLIO_FUNDING = "view_portfolio_funding"
VIEW_PORTFOLIO_REPORTS = "view_portfolio_reports"
VIEW_PORTFOLIO_ADMIN = "view_portfolio_admin"
EDIT_PORTFOLIO_APPLICATION_MANAGEMENT = "edit_portfolio_application_management"
EDIT_PORTFOLIO_FUNDING = "edit_portfolio_funding"
EDIT_PORTFOLIO_REPORTS = "edit_portfolio_reports"
EDIT_PORTFOLIO_ADMIN = "edit_portfolio_admin"
PORTFOLIO_POC = "portfolio_poc"
VIEW_AUDIT_LOG = "view_audit_log"
MANAGE_CCPO_USERS = "manage_ccpo_users"
VIEW_APPLICATION = "view_application"
EDIT_APPLICATION_ENVIRONMENTS = "edit_application_environments"
EDIT_APPLICATION_TEAM = "edit_application_team"
DELETE_APPLICATION_ENVIRONMENTS = "delete_application_environments"
@classmethod
def get(cls, perms_set_name):
try:
role = db.session.query(PermissionSet).filter_by(name=perms_set_name).one()
except NoResultFound:
raise NotFoundError("permission_set")
return role
@classmethod
def get_all(cls):
return db.session.query(PermissionSet).all()
@classmethod
def get_many(cls, perms_set_names):
permission_sets = (
db.session.query(PermissionSet)
.filter(PermissionSet.name.in_(perms_set_names))
.all()
)
if len(permission_sets) != len(perms_set_names):
raise NotFoundError("permission_set")
return permission_sets
ATAT_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_AUDIT_LOG,
"display_name": "View Audit Log",
"description": "",
"permissions": [Permissions.VIEW_AUDIT_LOG],
},
{
"name": PermissionSets.MANAGE_CCPO_USERS,
"display_name": "View Audit Log",
"description": "",
"permissions": [
Permissions.VIEW_CCPO_USER,
Permissions.CREATE_CCPO_USER,
Permissions.EDIT_CCPO_USER,
Permissions.DELETE_CCPO_USER,
],
},
]
_PORTFOLIO_BASIC_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_PORTFOLIO,
"description": "View basic portfolio info",
"display_name": "View Portfolio",
"permissions": [Permissions.VIEW_PORTFOLIO],
}
]
_PORTFOLIO_APP_MGMT_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"description": "View applications and related resources",
"display_name": "Application Management",
"permissions": [
Permissions.VIEW_APPLICATION,
Permissions.VIEW_APPLICATION_MEMBER,
Permissions.VIEW_ENVIRONMENT,
],
},
{
"name": PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
"description": "Edit applications and related resources",
"display_name": "Application Management",
"permissions": [
Permissions.EDIT_APPLICATION,
Permissions.CREATE_APPLICATION,
Permissions.DELETE_APPLICATION,
Permissions.EDIT_APPLICATION_MEMBER,
Permissions.DELETE_APPLICATION_MEMBER,
Permissions.CREATE_APPLICATION_MEMBER,
Permissions.EDIT_ENVIRONMENT,
Permissions.CREATE_ENVIRONMENT,
Permissions.DELETE_ENVIRONMENT,
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
],
},
]
_PORTFOLIO_FUNDING_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"description": "View a portfolio's task orders",
"display_name": "Funding",
"permissions": [
Permissions.VIEW_PORTFOLIO_FUNDING,
Permissions.VIEW_TASK_ORDER_DETAILS,
],
},
{
"name": PermissionSets.EDIT_PORTFOLIO_FUNDING,
"description": "Edit a portfolio's task orders and add new ones",
"display_name": "Funding",
"permissions": [
Permissions.CREATE_TASK_ORDER,
Permissions.EDIT_TASK_ORDER_DETAILS,
],
},
]
_PORTFOLIO_REPORTS_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"description": "View a portfolio's reports",
"display_name": "Reporting",
"permissions": [Permissions.VIEW_PORTFOLIO_REPORTS],
},
{
"name": PermissionSets.EDIT_PORTFOLIO_REPORTS,
"description": "Edit a portfolio's reports (no-op)",
"display_name": "Reporting",
"permissions": [],
},
]
_PORTFOLIO_ADMIN_PERMISSION_SETS = [
{
"name": PermissionSets.VIEW_PORTFOLIO_ADMIN,
"description": "View a portfolio's admin options",
"display_name": "Portfolio Administration",
"permissions": [
Permissions.VIEW_PORTFOLIO_ADMIN,
Permissions.VIEW_PORTFOLIO_NAME,
Permissions.VIEW_PORTFOLIO_USERS,
Permissions.VIEW_PORTFOLIO_ACTIVITY_LOG,
Permissions.VIEW_PORTFOLIO_POC,
],
},
{
"name": PermissionSets.EDIT_PORTFOLIO_ADMIN,
"description": "Edit a portfolio's admin options",
"display_name": "Portfolio Administration",
"permissions": [
Permissions.EDIT_PORTFOLIO_NAME,
Permissions.EDIT_PORTFOLIO_USERS,
Permissions.CREATE_PORTFOLIO_USERS,
],
},
]
_PORTFOLIO_POC_PERMISSION_SETS = [
{
"name": "portfolio_poc",
"description": "Permissions belonging to the Portfolio POC",
"display_name": "Portfolio Point of Contact",
"permissions": [Permissions.EDIT_PORTFOLIO_POC, Permissions.ARCHIVE_PORTFOLIO],
}
]
PORTFOLIO_PERMISSION_SETS = (
_PORTFOLIO_BASIC_PERMISSION_SETS
+ _PORTFOLIO_APP_MGMT_PERMISSION_SETS
+ _PORTFOLIO_FUNDING_PERMISSION_SETS
+ _PORTFOLIO_REPORTS_PERMISSION_SETS
+ _PORTFOLIO_ADMIN_PERMISSION_SETS
+ _PORTFOLIO_POC_PERMISSION_SETS
)
_APPLICATION_BASIC_PERMISSION_SET = {
"name": PermissionSets.VIEW_APPLICATION,
"description": "View application data",
"display_name": "View applications",
"permissions": [
Permissions.VIEW_APPLICATION,
Permissions.VIEW_APPLICATION_MEMBER,
Permissions.VIEW_ENVIRONMENT,
],
}
# need perm to assign and unassign users to environments
_APPLICATION_ENVIRONMENTS_PERMISSION_SET = {
"name": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS,
"description": "Manage environments for an application",
"display_name": "Manage environments",
"permissions": [
Permissions.EDIT_ENVIRONMENT,
Permissions.CREATE_ENVIRONMENT,
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
],
}
_APPLICATION_TEAM_PERMISSION_SET = {
"name": PermissionSets.EDIT_APPLICATION_TEAM,
"description": "Manage team members for an application",
"display_name": "Manage team",
"permissions": [
Permissions.EDIT_APPLICATION_MEMBER,
Permissions.DELETE_APPLICATION_MEMBER,
Permissions.CREATE_APPLICATION_MEMBER,
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
Permissions.VIEW_APPLICATION_ACTIVITY_LOG,
],
}
_APPLICATION_ENVIRONMENT_DELETE_PERMISSION_SET = {
"name": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS,
"description": "Delete environments within an application",
"display_name": "Delete environments",
"permissions": [Permissions.DELETE_ENVIRONMENT],
}
APPLICATION_PERMISSION_SETS = [
_APPLICATION_BASIC_PERMISSION_SET,
_APPLICATION_TEAM_PERMISSION_SET,
_APPLICATION_ENVIRONMENTS_PERMISSION_SET,
_APPLICATION_ENVIRONMENT_DELETE_PERMISSION_SET,
]

View File

@@ -0,0 +1,136 @@
from sqlalchemy.orm.exc import NoResultFound
from atat.database import db
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from atat.models.user import User
from .permission_sets import PermissionSets
from .exceptions import NotFoundError
class PortfolioRoles(object):
@classmethod
def get(cls, portfolio_id, user_id):
try:
portfolio_role = (
db.session.query(PortfolioRole)
.join(User)
.filter(User.id == user_id, PortfolioRole.portfolio_id == portfolio_id)
.one()
)
except NoResultFound:
raise NotFoundError("portfolio_role")
return portfolio_role
@classmethod
def get_by_id(cls, id_):
try:
return db.session.query(PortfolioRole).filter(PortfolioRole.id == id_).one()
except NoResultFound:
raise NotFoundError("portfolio_role")
@classmethod
def add(cls, user, portfolio_id, permission_sets=None):
new_portfolio_role = None
try:
existing_portfolio_role = (
db.session.query(PortfolioRole)
.filter(
PortfolioRole.user == user,
PortfolioRole.portfolio_id == portfolio_id,
)
.one()
)
new_portfolio_role = existing_portfolio_role
except NoResultFound:
new_portfolio_role = PortfolioRole(
user=user, portfolio_id=portfolio_id, status=PortfolioRoleStatus.PENDING
)
if permission_sets:
new_portfolio_role.permission_sets = PortfolioRoles._permission_sets_for_names(
permission_sets
)
user.portfolio_roles.append(new_portfolio_role)
db.session.add(user)
db.session.commit()
return new_portfolio_role
DEFAULT_PORTFOLIO_PERMISSION_SETS = {
PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
PermissionSets.VIEW_PORTFOLIO_FUNDING,
PermissionSets.VIEW_PORTFOLIO_REPORTS,
PermissionSets.VIEW_PORTFOLIO_ADMIN,
PermissionSets.VIEW_PORTFOLIO,
}
PORTFOLIO_PERMISSION_SETS = DEFAULT_PORTFOLIO_PERMISSION_SETS.union(
{
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
PermissionSets.EDIT_PORTFOLIO_FUNDING,
PermissionSets.EDIT_PORTFOLIO_REPORTS,
PermissionSets.EDIT_PORTFOLIO_ADMIN,
PermissionSets.PORTFOLIO_POC,
}
)
@classmethod
def _permission_sets_for_names(cls, set_names):
perms_set_names = PortfolioRoles.DEFAULT_PORTFOLIO_PERMISSION_SETS.union(
set(set_names)
)
return PermissionSets.get_many(perms_set_names)
@classmethod
def make_ppoc(cls, portfolio_role):
portfolio = portfolio_role.portfolio
original_owner_role = PortfolioRoles.get(
portfolio_id=portfolio.id, user_id=portfolio.owner.id
)
PortfolioRoles.revoke_ppoc_permissions(portfolio_role=original_owner_role)
PortfolioRoles.add(
user=portfolio_role.user,
portfolio_id=portfolio.id,
permission_sets=PortfolioRoles.PORTFOLIO_PERMISSION_SETS,
)
@classmethod
def revoke_ppoc_permissions(cls, portfolio_role):
permission_sets = [
permission_set.name
for permission_set in portfolio_role.permission_sets
if permission_set.name != PermissionSets.PORTFOLIO_POC
]
PortfolioRoles.update(portfolio_role=portfolio_role, set_names=permission_sets)
@classmethod
def disable(cls, portfolio_role, commit=True):
portfolio_role.status = PortfolioRoleStatus.DISABLED
db.session.add(portfolio_role)
if commit:
db.session.commit()
return portfolio_role
@classmethod
def update(cls, portfolio_role, set_names):
new_permission_sets = PortfolioRoles._permission_sets_for_names(set_names)
portfolio_role.permission_sets = new_permission_sets
db.session.add(portfolio_role)
db.session.commit()
return portfolio_role
@classmethod
def enable(cls, portfolio_role, user):
portfolio_role.status = PortfolioRoleStatus.ACTIVE
portfolio_role.user = user
db.session.add(portfolio_role)
db.session.commit()

View File

@@ -0,0 +1,6 @@
from .portfolios import (
Portfolios,
PortfolioError,
PortfolioDeletionApplicationsExistError,
PortfolioStateMachines,
)

View File

@@ -0,0 +1,172 @@
from sqlalchemy import or_
from typing import List
from uuid import UUID
from atat.database import db
from atat.domain.permission_sets import PermissionSets
from atat.domain.authz import Authorization
from atat.domain.portfolio_roles import PortfolioRoles
from atat.domain.invitations import PortfolioInvitations
from atat.models import (
Portfolio,
PortfolioStateMachine,
FSMStates,
Permissions,
PortfolioRole,
PortfolioRoleStatus,
TaskOrder,
CLIN,
)
from .query import PortfoliosQuery, PortfolioStateMachinesQuery
from .scopes import ScopedPortfolio
class PortfolioError(Exception):
pass
class PortfolioDeletionApplicationsExistError(Exception):
pass
class PortfolioStateMachines(object):
@classmethod
def create(cls, portfolio, **sm_attrs):
sm_attrs.update({"portfolio": portfolio})
sm = PortfolioStateMachinesQuery.create(**sm_attrs)
return sm
class Portfolios(object):
@classmethod
def get_or_create_state_machine(cls, portfolio):
"""
get or create Portfolio State Machine for a Portfolio
"""
return portfolio.state_machine or PortfolioStateMachines.create(portfolio)
@classmethod
def create(cls, user, portfolio_attrs):
portfolio = PortfoliosQuery.create(**portfolio_attrs)
perms_sets = PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS)
Portfolios._create_portfolio_role(
user,
portfolio,
status=PortfolioRoleStatus.ACTIVE,
permission_sets=perms_sets,
)
PortfoliosQuery.add_and_commit(portfolio)
return portfolio
@classmethod
def get(cls, user, portfolio_id):
portfolio = PortfoliosQuery.get(portfolio_id)
return ScopedPortfolio(user, portfolio)
@classmethod
def delete(cls, portfolio):
if len(portfolio.applications) != 0:
raise PortfolioDeletionApplicationsExistError()
for portfolio_role in portfolio.roles:
PortfolioRoles.disable(portfolio_role=portfolio_role, commit=False)
portfolio.deleted = True
db.session.add(portfolio)
db.session.commit()
return portfolio
@classmethod
def get_for_update(cls, portfolio_id):
portfolio = PortfoliosQuery.get(portfolio_id)
return portfolio
@classmethod
def for_user(cls, user):
if Authorization.has_atat_permission(user, Permissions.VIEW_PORTFOLIO):
portfolios = PortfoliosQuery.get_all()
else:
portfolios = PortfoliosQuery.get_for_user(user)
return portfolios
@classmethod
def add_member(cls, portfolio, member, permission_sets=None):
portfolio_role = PortfolioRoles.add(member, portfolio.id, permission_sets)
return portfolio_role
@classmethod
def invite(cls, portfolio, inviter, member_data):
permission_sets = PortfolioRoles._permission_sets_for_names(
member_data.get("permission_sets", [])
)
role = PortfolioRole(portfolio=portfolio, permission_sets=permission_sets)
invitation = PortfolioInvitations.create(
inviter=inviter, role=role, member_data=member_data["user_data"]
)
PortfoliosQuery.add_and_commit(role)
return invitation
@classmethod
def update_member(cls, member, permission_sets):
return PortfolioRoles.update(member, permission_sets)
@classmethod
def _create_portfolio_role(
cls, user, portfolio, status=PortfolioRoleStatus.PENDING, permission_sets=None
):
if permission_sets is None:
permission_sets = []
portfolio_role = PortfoliosQuery.create_portfolio_role(
user, portfolio, status=status, permission_sets=permission_sets
)
PortfoliosQuery.add_and_commit(portfolio_role)
return portfolio_role
@classmethod
def update(cls, portfolio, new_data):
if "name" in new_data:
portfolio.name = new_data["name"]
if "description" in new_data:
portfolio.description = new_data["description"]
PortfoliosQuery.add_and_commit(portfolio)
@classmethod
def base_provision_query(cls):
return db.session.query(Portfolio.id)
@classmethod
def get_portfolios_pending_provisioning(cls, now) -> List[UUID]:
"""
Any portfolio with a corresponding State Machine that is either:
not started yet,
failed in creating a tenant
failed
"""
results = (
db.session.query(Portfolio.id)
.join(PortfolioStateMachine)
.join(TaskOrder)
.join(CLIN)
.filter(Portfolio.deleted == False)
.filter(CLIN.start_date <= now)
.filter(CLIN.end_date > now)
.filter(
or_(
PortfolioStateMachine.state == FSMStates.UNSTARTED,
PortfolioStateMachine.state.like("%CREATED"),
)
)
)
return [id_ for id_, in results]

View File

@@ -0,0 +1,66 @@
from sqlalchemy import or_
from atat.database import db
from atat.domain.common import Query
from atat.models.portfolio import Portfolio
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from atat.models.application_role import (
ApplicationRole,
Status as ApplicationRoleStatus,
)
from atat.models.application import Application
from atat.models.portfolio_state_machine import PortfolioStateMachine
# from atat.models.application import Application
class PortfolioStateMachinesQuery(Query):
model = PortfolioStateMachine
class PortfoliosQuery(Query):
model = Portfolio
@classmethod
def get_for_user(cls, user):
return (
db.session.query(Portfolio)
.filter(
or_(
Portfolio.id.in_(
db.session.query(Portfolio.id)
.join(Application)
.filter(Portfolio.id == Application.portfolio_id)
.filter(
Application.id.in_(
db.session.query(Application.id)
.join(ApplicationRole)
.filter(
ApplicationRole.application_id == Application.id
)
.filter(ApplicationRole.user_id == user.id)
.filter(
ApplicationRole.status
== ApplicationRoleStatus.ACTIVE
)
.filter(ApplicationRole.deleted == False)
.subquery()
)
)
),
Portfolio.id.in_(
db.session.query(Portfolio.id)
.join(PortfolioRole)
.filter(PortfolioRole.user == user)
.filter(PortfolioRole.status == PortfolioRoleStatus.ACTIVE)
.subquery()
),
)
)
.filter(Portfolio.deleted == False)
.order_by(Portfolio.name.asc())
.all()
)
@classmethod
def create_portfolio_role(cls, user, portfolio, **kwargs):
return PortfolioRole(user=user, portfolio=portfolio, **kwargs)

View File

@@ -0,0 +1,39 @@
from atat.domain.authz import Authorization
from atat.models.permissions import Permissions
from atat.domain.applications import Applications
class ScopedResource(object):
"""
An abstract class that represents a resource that is restricted
in some way by the priveleges of the user viewing that resource.
"""
def __init__(self, user, resource):
self.user = user
self.resource = resource
def __getattr__(self, name):
return getattr(self.resource, name)
def __eq__(self, other):
return self.resource == other
class ScopedPortfolio(ScopedResource):
"""
An object that obeys the same API as a Portfolio, but with the added
functionality that it only returns sub-resources (applications and environments)
that the given user is allowed to see.
"""
@property
def applications(self):
can_view_all_applications = Authorization.has_portfolio_permission(
self.user, self.resource, Permissions.VIEW_APPLICATION
)
if can_view_all_applications:
return self.resource.applications
else:
return Applications.for_user(self.user, self.resource)

33
atat/domain/reports.py Normal file
View File

@@ -0,0 +1,33 @@
from flask import current_app
from atat.domain.csp.cloud.models import (
ReportingCSPPayload,
CostManagementQueryCSPResult,
)
from atat.domain.csp.reports import prepare_azure_reporting_data
import pendulum
class Reports:
@classmethod
def expired_task_orders(cls, portfolio):
return [
task_order for task_order in portfolio.task_orders if task_order.is_expired
]
@classmethod
def get_portfolio_spending(cls, portfolio):
# TODO: Extend this function to make from_date and to_date configurable
from_date = pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD")
to_date = pendulum.now().format("YYYY-MM-DD")
rows = []
if portfolio.csp_data:
payload = ReportingCSPPayload(
from_date=from_date, to_date=to_date, **portfolio.csp_data
)
response: CostManagementQueryCSPResult = current_app.csp.cloud.get_reporting_data(
payload
)
rows = response.properties.rows
return prepare_azure_reporting_data(rows)

102
atat/domain/task_orders.py Normal file
View File

@@ -0,0 +1,102 @@
from sqlalchemy import or_
import pendulum
from atat.database import db
from atat.models.clin import CLIN
from atat.models.task_order import TaskOrder, SORT_ORDERING
from . import BaseDomainClass
from atat.utils import commit_or_raise_already_exists_error
class TaskOrders(BaseDomainClass):
model = TaskOrder
resource_name = "task_order"
@classmethod
def create(cls, portfolio_id, number, clins, pdf):
task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf)
db.session.add(task_order)
commit_or_raise_already_exists_error(message="task_order")
TaskOrders.create_clins(task_order.id, clins)
return task_order
@classmethod
def update(cls, task_order_id, number, clins, pdf):
task_order = TaskOrders.get(task_order_id)
task_order.pdf = pdf
if len(clins) > 0:
for clin in task_order.clins:
db.session.delete(clin)
TaskOrders.create_clins(task_order_id, clins)
if number != task_order.number:
task_order.number = number
db.session.add(task_order)
commit_or_raise_already_exists_error(message="task_order")
return task_order
@classmethod
def sign(cls, task_order, signer_dod_id):
task_order.signer_dod_id = signer_dod_id
task_order.signed_at = pendulum.now(tz="utc")
db.session.add(task_order)
db.session.commit()
return task_order
@classmethod
def create_clins(cls, task_order_id, clin_list):
for clin_data in clin_list:
clin = CLIN(
task_order_id=task_order_id,
number=clin_data["number"],
start_date=clin_data["start_date"],
end_date=clin_data["end_date"],
total_amount=clin_data["total_amount"],
obligated_amount=clin_data["obligated_amount"],
jedi_clin_type=clin_data["jedi_clin_type"],
)
db.session.add(clin)
db.session.commit()
@classmethod
def sort_by_status(cls, task_orders):
by_status = {status.value: [] for status in SORT_ORDERING}
for task_order in task_orders:
by_status[task_order.display_status].append(task_order)
return by_status
@classmethod
def delete(cls, task_order_id):
task_order = TaskOrders.get(task_order_id)
db.session.delete(task_order)
db.session.commit()
@classmethod
def get_for_send_task_order_files(cls):
return (
db.session.query(TaskOrder)
.join(CLIN)
.filter(
or_(
TaskOrder.pdf_last_sent_at < CLIN.last_sent_at,
TaskOrder.pdf_last_sent_at.is_(None),
)
)
.all()
)
@classmethod
def get_clins_for_create_billing_instructions(cls):
return (
db.session.query(CLIN)
.filter(
CLIN.last_sent_at.is_(None), CLIN.start_date < pendulum.now(tz="UTC")
)
.all()
)

122
atat/domain/users.py Normal file
View File

@@ -0,0 +1,122 @@
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import IntegrityError
import pendulum
from atat.database import db
from atat.models import User
from .permission_sets import PermissionSets
from .exceptions import NotFoundError, AlreadyExistsError, UnauthorizedError
class Users(object):
@classmethod
def get(cls, user_id):
try:
user = db.session.query(User).filter_by(id=user_id).one()
except NoResultFound:
raise NotFoundError("user")
return user
@classmethod
def get_by_dod_id(cls, dod_id):
try:
user = db.session.query(User).filter_by(dod_id=dod_id).one()
except NoResultFound:
raise NotFoundError("user")
return user
@classmethod
def get_ccpo_users(cls):
return (
db.session.query(User)
.filter(User.permission_sets != None)
.order_by(User.last_name)
.all()
)
@classmethod
def create(cls, dod_id, permission_sets=None, **kwargs):
if permission_sets:
permission_sets = PermissionSets.get_many(permission_sets)
else:
permission_sets = []
try:
user = User(dod_id=dod_id, permission_sets=permission_sets, **kwargs)
db.session.add(user)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError("user")
return user
@classmethod
def get_or_create_by_dod_id(cls, dod_id, **kwargs):
try:
user = Users.get_by_dod_id(dod_id)
except NotFoundError:
user = Users.create(dod_id, **kwargs)
db.session.add(user)
db.session.commit()
return user
_UPDATEABLE_ATTRS = {
"first_name",
"last_name",
"email",
"phone_number",
"phone_ext",
"service_branch",
"citizenship",
"designation",
"date_latest_training",
}
@classmethod
def update(cls, user, user_delta):
delta_set = set(user_delta.keys())
if not set(delta_set).issubset(Users._UPDATEABLE_ATTRS):
unpermitted = delta_set - Users._UPDATEABLE_ATTRS
raise UnauthorizedError(user, "update {}".format(", ".join(unpermitted)))
for key, value in user_delta.items():
setattr(user, key, value)
db.session.add(user)
db.session.commit()
return user
@classmethod
def give_ccpo_perms(cls, user, commit=True):
user.permission_sets = PermissionSets.get_all()
db.session.add(user)
if commit:
db.session.commit()
return user
@classmethod
def revoke_ccpo_perms(cls, user):
user.permission_sets = []
db.session.add(user)
db.session.commit()
return user
@classmethod
def update_last_login(cls, user):
user.last_login = pendulum.now(tz="utc")
db.session.add(user)
db.session.commit()
@classmethod
def update_last_session_id(cls, user, session_id):
user.last_session_id = session_id
db.session.add(user)
db.session.commit()

91
atat/filters.py Normal file
View File

@@ -0,0 +1,91 @@
import re
from atat.utils.localization import translate
from flask import render_template
from jinja2 import contextfilter
from jinja2.exceptions import TemplateNotFound
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from decimal import DivisionByZero as DivisionByZeroException, InvalidOperation
def iconSvg(name):
with open("static/icons/" + name + ".svg") as contents:
return contents.read()
def dollars(value):
try:
numberValue = float(value)
except ValueError:
numberValue = 0
return "${:,.2f}".format(numberValue)
def with_extra_params(url, **params):
"""
Takes an existing url and safely appends additional query parms.
"""
parsed_url = urlparse(url)
parsed_params = parse_qs(parsed_url.query)
new_params = {**parsed_params, **params}
parsed_url = parsed_url._replace(query=urlencode(new_params))
return urlunparse(parsed_url)
def usPhone(number):
if not number:
return ""
phone = re.sub(r"\D", "", number)
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:])
def obligatedFundingGraphWidth(values):
numerator, denominator = values
try:
return (numerator / denominator) * 100
except (DivisionByZeroException, InvalidOperation):
return 0
def formattedDate(value, formatter="%m/%d/%Y"):
if value:
return value.strftime(formatter)
else:
return "-"
def pageWindow(pagination, size=2):
page = pagination.page
num_pages = pagination.pages
over = max(0, page + size - num_pages)
under = min(0, page - size - 1)
return (max(1, (page - size) - over), min(num_pages, (page + size) - under))
def renderAuditEvent(event):
template_name = "audit_log/events/{}.html".format(event.resource_type)
try:
return render_template(template_name, event=event)
except TemplateNotFound:
return render_template("audit_log/events/default.html", event=event)
def register_filters(app):
app.jinja_env.filters["iconSvg"] = iconSvg
app.jinja_env.filters["dollars"] = dollars
app.jinja_env.filters["usPhone"] = usPhone
app.jinja_env.filters["formattedDate"] = formattedDate
app.jinja_env.filters["pageWindow"] = pageWindow
app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent
app.jinja_env.filters["withExtraParams"] = with_extra_params
app.jinja_env.filters["obligatedFundingGraphWidth"] = obligatedFundingGraphWidth
@contextfilter
def translateWithoutCache(context, *kwargs):
return translate(*kwargs)
if app.config["DEBUG"]:
app.jinja_env.filters["translate"] = translateWithoutCache
else:
app.jinja_env.filters["translate"] = translate

48
atat/forms/application.py Normal file
View File

@@ -0,0 +1,48 @@
from .forms import BaseForm, remove_empty_string
from wtforms.fields import StringField, TextAreaField, FieldList
from wtforms.validators import Required, Optional, Length
from atat.forms.validators import ListItemRequired, ListItemsUnique, Name, AlphaNumeric
from atat.utils.localization import translate
class EditEnvironmentForm(BaseForm):
name = StringField(
label=translate("forms.environments.name_label"),
validators=[Required(), Name(), Length(max=100)],
filters=[remove_empty_string],
)
class NameAndDescriptionForm(BaseForm):
name = StringField(
label=translate("forms.application.name_label"),
validators=[Required(), Name(), Length(max=100)],
filters=[remove_empty_string],
)
description = TextAreaField(
label=translate("forms.application.description_label"),
validators=[Optional(), Length(max=1_000)],
filters=[remove_empty_string],
)
class EnvironmentsForm(BaseForm):
environment_names = FieldList(
StringField(
label=translate("forms.application.environment_names_label"),
filters=[remove_empty_string],
validators=[AlphaNumeric(), Length(max=100)],
),
validators=[
ListItemRequired(
message=translate(
"forms.application.environment_names_required_validation_message"
)
),
ListItemsUnique(
message=translate(
"forms.application.environment_names_unique_validation_message"
)
),
],
)

View File

@@ -0,0 +1,72 @@
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
from .data import ENV_ROLES, ENV_ROLE_NO_ACCESS as NO_ACCESS
from atat.forms.fields import SelectField
from atat.domain.permission_sets import PermissionSets
from atat.utils.localization import translate
from atat.forms.validators import AlphaNumeric
from wtforms.validators import Length
class EnvironmentForm(Form):
environment_id = HiddenField(validators=[UUID()])
environment_name = HiddenField(validators=[AlphaNumeric(), Length(max=100)])
role = SelectField(
environment_name,
choices=ENV_ROLES,
default=NO_ACCESS,
filters=[lambda x: NO_ACCESS if x == "None" else x],
)
disabled = BooleanField("Revoke Access", default=False)
@property
def data(self):
_data = super().data
if "role" in _data and _data["role"] == NO_ACCESS:
_data["role"] = None
return _data
class PermissionsForm(FlaskForm):
perms_env_mgmt = BooleanField(
translate("portfolios.applications.members.form.env_mgmt.label"),
default=False,
description=translate(
"portfolios.applications.members.form.env_mgmt.description"
),
)
perms_team_mgmt = BooleanField(
translate("portfolios.applications.members.form.team_mgmt.label"),
default=False,
description=translate(
"portfolios.applications.members.form.team_mgmt.description"
),
)
@property
def data(self):
_data = super().data
_data.pop("csrf_token", None)
perm_sets = []
if _data["perms_env_mgmt"]:
perm_sets.append(PermissionSets.EDIT_APPLICATION_ENVIRONMENTS)
if _data["perms_team_mgmt"]:
perm_sets.append(PermissionSets.EDIT_APPLICATION_TEAM)
_data["permission_sets"] = perm_sets
return _data
class NewForm(PermissionsForm):
user_data = FormField(BaseNewMemberForm)
environment_roles = FieldList(FormField(EnvironmentForm))
class UpdateMemberForm(PermissionsForm):
environment_roles = FieldList(FormField(EnvironmentForm))

13
atat/forms/ccpo_user.py Normal file
View File

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

30
atat/forms/data.py Normal file
View File

@@ -0,0 +1,30 @@
from atat.models import CSPRole
from atat.utils.localization import translate
SERVICE_BRANCHES = [
("air_force", translate("forms.portfolio.defense_component.choices.air_force")),
("army", translate("forms.portfolio.defense_component.choices.army")),
(
"marine_corps",
translate("forms.portfolio.defense_component.choices.marine_corps"),
),
("navy", translate("forms.portfolio.defense_component.choices.navy")),
("space_force", translate("forms.portfolio.defense_component.choices.space_force")),
("ccmd_js", translate("forms.portfolio.defense_component.choices.ccmd_js")),
("dafa", translate("forms.portfolio.defense_component.choices.dafa")),
("osd_psas", translate("forms.portfolio.defense_component.choices.osd_psas")),
("other", translate("forms.portfolio.defense_component.choices.other")),
]
ENV_ROLE_NO_ACCESS = "No Access"
ENV_ROLES = [(role.name, role.value) for role in CSPRole] + [
(ENV_ROLE_NO_ACCESS, ENV_ROLE_NO_ACCESS)
]
JEDI_CLIN_TYPES = [
("JEDI_CLIN_1", translate("JEDICLINType.JEDI_CLIN_1")),
("JEDI_CLIN_2", translate("JEDICLINType.JEDI_CLIN_2")),
("JEDI_CLIN_3", translate("JEDICLINType.JEDI_CLIN_3")),
("JEDI_CLIN_4", translate("JEDICLINType.JEDI_CLIN_4")),
]

93
atat/forms/edit_user.py Normal file
View File

@@ -0,0 +1,93 @@
import pendulum
from copy import deepcopy
from wtforms.fields.html5 import DateField, EmailField, TelField
from wtforms.fields import RadioField, StringField
from wtforms.validators import Email, DataRequired, Optional
from .fields import SelectField
from .forms import BaseForm
from .data import SERVICE_BRANCHES
from atat.models.user import User
from atat.utils.localization import translate
from wtforms.validators import Length
from atat.forms.validators import Number
from .validators import Name, DateRange, PhoneNumber
USER_FIELDS = {
"first_name": StringField(
translate("forms.edit_user.first_name_label"),
validators=[Name(), Length(max=100)],
),
"last_name": StringField(
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", validators=[Number(), Length(max=10)]),
"service_branch": SelectField(
translate("forms.edit_user.service_branch_label"), choices=SERVICE_BRANCHES
),
"citizenship": RadioField(
choices=[
("United States", "United States"),
("Foreign National", "Foreign National"),
("Other", "Other"),
]
),
"designation": RadioField(
translate("forms.edit_user.designation_label"),
choices=[
("military", "Military"),
("civilian", "Civilian"),
("contractor", "Contractor"),
],
),
"date_latest_training": DateField(
translate("forms.edit_user.date_latest_training_label"),
description=translate("forms.edit_user.date_latest_training_description"),
validators=[
DateRange(
lower_bound=pendulum.duration(years=1),
upper_bound=pendulum.duration(days=0),
message="Must be a date within the last year.",
)
],
format="%m/%d/%Y",
),
}
def inherit_field(unbound_field, required=True):
kwargs = deepcopy(unbound_field.kwargs)
if not "validators" in kwargs:
kwargs["validators"] = []
if required:
kwargs["validators"].append(DataRequired())
else:
kwargs["validators"].append(Optional())
return unbound_field.field_class(*unbound_field.args, **kwargs)
def inherit_user_field(field_name):
required = field_name in User.REQUIRED_FIELDS
return inherit_field(USER_FIELDS[field_name], required=required)
class EditUserForm(BaseForm):
first_name = inherit_user_field("first_name")
last_name = inherit_user_field("last_name")
email = inherit_user_field("email")
phone_number = inherit_user_field("phone_number")
phone_ext = inherit_user_field("phone_ext")
service_branch = inherit_user_field("service_branch")
citizenship = inherit_user_field("citizenship")
designation = inherit_user_field("designation")
date_latest_training = inherit_user_field("date_latest_training")

8
atat/forms/fields.py Normal file
View File

@@ -0,0 +1,8 @@
from wtforms.fields import SelectField as SelectField_
class SelectField(SelectField_):
def __init__(self, *args, **kwargs):
render_kw = kwargs.get("render_kw", {})
kwargs["render_kw"] = {**render_kw, "required": False}
super().__init__(*args, **kwargs)

46
atat/forms/forms.py Normal file
View File

@@ -0,0 +1,46 @@
from flask_wtf import FlaskForm
from flask import current_app, request as http_request
import re
from atat.utils.flash import formatted_flash as flash
EMPTY_LIST_FIELD = ["", None]
def remove_empty_string(value):
# only return strings that contain non whitespace characters
if value and re.search(r"\S", value):
return value.strip()
else:
return None
class BaseForm(FlaskForm):
def __init__(self, formdata=None, **kwargs):
# initialize the form with data from the cache
formdata = formdata or {}
cached_data = current_app.form_cache.from_request(http_request)
cached_data.update(formdata)
super().__init__(cached_data, **kwargs)
@property
def data(self):
# remove 'csrf_token' key/value pair
# remove empty strings and None from list fields
# prevent values that are not an option in a RadioField from being saved to the DB
_data = super().data
_data.pop("csrf_token", None)
for field in _data:
if _data[field].__class__.__name__ == "list":
_data[field] = [el for el in _data[field] if el not in EMPTY_LIST_FIELD]
if self[field].__class__.__name__ == "RadioField":
choices = [el[0] for el in self[field].choices]
if _data[field] not in choices:
_data[field] = None
return _data
def validate(self, *args, flash_invalid=True, **kwargs):
valid = super().validate(*args, **kwargs)
if not valid and flash_invalid:
flash("form_errors")
return valid

30
atat/forms/member.py Normal file
View File

@@ -0,0 +1,30 @@
from flask_wtf import FlaskForm
from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length, Optional
from wtforms.fields import StringField
from atat.forms.validators import Number, PhoneNumber, Name
from atat.utils.localization import translate
class NewForm(FlaskForm):
first_name = StringField(
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(), Name(), Length(max=100)],
)
email = EmailField(
translate("forms.new_member.email_label"), validators=[Required(), Email()]
)
phone_number = TelField(
translate("forms.new_member.phone_number_label"),
validators=[Optional(), PhoneNumber()],
)
phone_ext = StringField("Extension", validators=[Number(), Length(max=10)])
dod_id = StringField(
translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10), Number()],
)

47
atat/forms/portfolio.py Normal file
View File

@@ -0,0 +1,47 @@
from wtforms.fields import (
SelectMultipleField,
StringField,
TextAreaField,
)
from wtforms.validators import Length, InputRequired
from atat.forms.validators import Name
from wtforms.widgets import ListWidget, CheckboxInput
from .forms import BaseForm
from atat.utils.localization import translate
from .data import SERVICE_BRANCHES
class PortfolioForm(BaseForm):
name = StringField(
translate("forms.portfolio.name.label"),
validators=[
Length(
min=4,
max=100,
message=translate("forms.portfolio.name.length_validation_message"),
),
Name(),
],
)
description = TextAreaField(
translate("forms.portfolio.description.label"), validators=[Length(max=1_000)]
)
class PortfolioCreationForm(PortfolioForm):
defense_component = SelectMultipleField(
translate("forms.portfolio.defense_component.title"),
description=translate("forms.portfolio.defense_component.help_text"),
choices=SERVICE_BRANCHES,
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),
validators=[
InputRequired(
message=translate(
"forms.portfolio.defense_component.validation_message"
)
)
],
)

View File

@@ -0,0 +1,64 @@
from wtforms.validators import Required
from wtforms.fields import BooleanField, FormField
from .forms import BaseForm
from .member import NewForm as BaseNewMemberForm
from atat.domain.permission_sets import PermissionSets
from atat.forms.fields import SelectField
from atat.utils.localization import translate
class PermissionsForm(BaseForm):
perms_app_mgmt = BooleanField(
translate("forms.new_member.app_mgmt.label"),
default=False,
description=translate("forms.new_member.app_mgmt.description"),
)
perms_funding = BooleanField(
translate("forms.new_member.funding.label"),
default=False,
description=translate("forms.new_member.funding.description"),
)
perms_reporting = BooleanField(
translate("forms.new_member.reporting.label"),
default=False,
description=translate("forms.new_member.reporting.description"),
)
perms_portfolio_mgmt = BooleanField(
translate("forms.new_member.portfolio_mgmt.label"),
default=False,
description=translate("forms.new_member.portfolio_mgmt.description"),
)
@property
def data(self):
_data = super().data
_data.pop("csrf_token", None)
perm_sets = []
if _data["perms_app_mgmt"]:
perm_sets.append(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT)
if _data["perms_funding"]:
perm_sets.append(PermissionSets.EDIT_PORTFOLIO_FUNDING)
if _data["perms_reporting"]:
perm_sets.append(PermissionSets.EDIT_PORTFOLIO_REPORTS)
if _data["perms_portfolio_mgmt"]:
perm_sets.append(PermissionSets.EDIT_PORTFOLIO_ADMIN)
_data["permission_sets"] = perm_sets
return _data
class NewForm(PermissionsForm):
user_data = FormField(BaseNewMemberForm)
class AssignPPOCForm(PermissionsForm):
role_id = SelectField(
label=translate("forms.assign_ppoc.dod_id"),
validators=[Required()],
choices=[("", "- Select -")],
)

176
atat/forms/task_order.py Normal file
View File

@@ -0,0 +1,176 @@
from wtforms.fields import (
BooleanField,
DecimalField,
FieldList,
FormField,
StringField,
HiddenField,
)
from wtforms.fields.html5 import DateField
from wtforms.validators import (
Required,
Length,
Optional,
NumberRange,
ValidationError,
)
from flask_wtf import FlaskForm
import numbers
from atat.forms.validators import Number, AlphaNumeric
from .data import JEDI_CLIN_TYPES
from .fields import SelectField
from .forms import BaseForm, remove_empty_string
from atat.utils.localization import translate
from flask import current_app as app
MAX_CLIN_AMOUNT = 1_000_000_000
def coerce_enum(enum_inst):
if getattr(enum_inst, "value", None):
return enum_inst.value
else:
return enum_inst
def validate_funding(form, field):
if (
isinstance(form.total_amount.data, numbers.Number)
and isinstance(field.data, numbers.Number)
and form.total_amount.data < field.data
):
raise ValidationError(
translate("forms.task_order.clin_funding_errors.obligated_amount_error")
)
def validate_date_in_range(form, field):
contract_start = app.config.get("CONTRACT_START_DATE")
contract_end = app.config.get("CONTRACT_END_DATE")
if field.data and (field.data < contract_start or field.data > contract_end):
raise ValidationError(
translate(
"forms.task_order.pop_errors.range",
{
"start": contract_start.strftime("%b %d, %Y"),
"end": contract_end.strftime("%b %d, %Y"),
},
)
)
def remove_dashes(value):
return value.replace("-", "") if value else None
def coerce_upper(value):
return value.upper() if value else None
class CLINForm(FlaskForm):
jedi_clin_type = SelectField(
translate("task_orders.form.clin_type_label"),
choices=JEDI_CLIN_TYPES,
coerce=coerce_enum,
)
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"),
format="%m/%d/%Y",
validators=[validate_date_in_range],
)
end_date = DateField(
translate("task_orders.form.pop_end"),
description=translate("task_orders.form.pop_example"),
format="%m/%d/%Y",
validators=[validate_date_in_range],
)
total_amount = DecimalField(
label=translate("task_orders.form.total_funds_label"),
validators=[
NumberRange(
0,
MAX_CLIN_AMOUNT,
translate("forms.task_order.clin_funding_errors.funding_range_error"),
)
],
)
obligated_amount = DecimalField(
label=translate("task_orders.form.obligated_funds_label"),
validators=[
validate_funding,
NumberRange(
0,
MAX_CLIN_AMOUNT,
translate("forms.task_order.clin_funding_errors.funding_range_error"),
),
],
)
def validate(self, *args, **kwargs):
valid = super().validate(*args, **kwargs)
if (
self.start_date.data
and self.end_date.data
and self.start_date.data > self.end_date.data
):
self.start_date.errors.append(
translate("forms.task_order.pop_errors.date_order")
)
valid = False
return valid
class AttachmentForm(BaseForm):
filename = HiddenField(
id="attachment_filename",
validators=[
Length(
max=100, message=translate("forms.attachment.filename.length_error")
),
AlphaNumeric(),
],
)
object_name = HiddenField(
id="attachment_object_name",
validators=[
Length(
max=40, message=translate("forms.attachment.object_name.length_error")
),
AlphaNumeric(),
],
)
accept = ".pdf,application/pdf"
def validate(self, *args, **kwargs):
return super().validate(*args, **{**kwargs, "flash_invalid": False})
class TaskOrderForm(BaseForm):
number = StringField(
label=translate("forms.task_order.number_description"),
filters=[remove_empty_string, remove_dashes, coerce_upper],
validators=[AlphaNumeric(), Length(min=13, max=17), Optional()],
)
pdf = FormField(AttachmentForm)
clins = FieldList(FormField(CLINForm))
class SignatureForm(BaseForm):
signature = BooleanField(
translate("task_orders.sign.digital_signature_description"),
validators=[Required()],
)
confirm = BooleanField(
translate("task_orders.sign.confirmation_description"), validators=[Required()],
)

104
atat/forms/validators.py Normal file
View File

@@ -0,0 +1,104 @@
from datetime import datetime
import re
from werkzeug.datastructures import FileStorage
from wtforms.validators import ValidationError, Regexp
import pendulum
from atat.utils.localization import translate
def DateRange(lower_bound=None, upper_bound=None, message=None):
def _date_range(form, field):
if field.data is None:
return
now = pendulum.now().date()
if isinstance(field.data, str):
date = datetime.strptime(field.data, field.format)
else:
date = field.data
if lower_bound is not None:
if (now - lower_bound) > date:
raise ValidationError(message)
if upper_bound is not None:
if (now + upper_bound) < date:
raise ValidationError(message)
return _date_range
def Number(message=translate("forms.validators.is_number_message")):
def _is_number(form, field):
if field.data:
try:
int(field.data)
except (ValueError, TypeError):
raise ValidationError(message)
return _is_number
def PhoneNumber(message=translate("forms.validators.phone_number_message")):
def _is_phone_number(form, field):
digits = re.sub(r"\D", "", field.data)
if len(digits) not in [5, 10]:
raise ValidationError(message)
match = re.match(r"[\d\-\(\) ]+", field.data)
if not match or match.group() != field.data:
raise ValidationError(message)
return _is_phone_number
def Name(message=translate("forms.validators.name_message")):
def _name(form, field):
match = re.match(r"[\w \,\.\'\-]+", field.data)
if not match or match.group() != field.data:
raise ValidationError(message)
return _name
def ListItemRequired(
message=translate("forms.validators.list_item_required_message"),
empty_values=[None],
):
def _list_item_required(form, field):
non_empty_values = [
v for v in field.data if (v not in empty_values and re.search(r"\S", v))
]
if len(non_empty_values) == 0:
raise ValidationError(message)
return _list_item_required
def ListItemsUnique(message=translate("forms.validators.list_items_unique_message")):
def _list_items_unique(form, field):
if len(field.data) > len(set(field.data)):
raise ValidationError(message)
return _list_items_unique
def FileLength(max_length=50000000, message=None):
def _file_length(_form, field):
if field.data is None or not isinstance(field.data, FileStorage):
return True
content = field.data.read()
if len(content) > max_length:
raise ValidationError(message)
else:
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)

363
atat/jobs.py Normal file
View File

@@ -0,0 +1,363 @@
import pendulum
from flask import current_app as app
from smtplib import SMTPException
from azure.core.exceptions import AzureError
from atat.database import db
from atat.domain.application_roles import ApplicationRoles
from atat.domain.applications import Applications
from atat.domain.csp.cloud import CloudProviderInterface
from atat.domain.csp.cloud.utils import generate_user_principal_name
from atat.domain.csp.cloud.exceptions import GeneralCSPException
from atat.domain.csp.cloud.models import (
ApplicationCSPPayload,
BillingInstructionCSPPayload,
EnvironmentCSPPayload,
UserCSPPayload,
UserRoleCSPPayload,
)
from atat.domain.environments import Environments
from atat.domain.environment_roles import EnvironmentRoles
from atat.domain.portfolios import Portfolios
from atat.models import CSPRole, JobFailure
from atat.models.mixins.state_machines import FSMStates
from atat.domain.task_orders import TaskOrders
from atat.models.utils import claim_for_update, claim_many_for_update
from atat.queue import celery
from atat.utils.localization import translate
class RecordFailure(celery.Task):
_ENTITIES = [
"portfolio_id",
"application_id",
"environment_id",
"environment_role_id",
]
def _derive_entity_info(self, kwargs):
matches = [e for e in self._ENTITIES if e in kwargs.keys()]
if matches:
match = matches[0]
return {"entity": match.replace("_id", ""), "entity_id": kwargs[match]}
else:
return None
def on_failure(self, exc, task_id, args, kwargs, einfo):
info = self._derive_entity_info(kwargs)
if info:
failure = JobFailure(**info, task_id=task_id)
db.session.add(failure)
db.session.commit()
@celery.task(ignore_result=True)
def send_mail(recipients, subject, body, attachments=[]):
app.mailer.send(recipients, subject, body, attachments)
@celery.task(ignore_result=True)
def send_notification_mail(recipients, subject, body):
app.logger.info(
"Sending a notification to these recipients: {}\n\nSubject: {}\n\n{}".format(
recipients, subject, body
)
)
app.mailer.send(recipients, subject, body)
def do_create_application(csp: CloudProviderInterface, application_id=None):
application = Applications.get(application_id)
with claim_for_update(application) as application:
if application.cloud_id:
return
csp_details = application.portfolio.csp_data
parent_id = csp_details.get("root_management_group_id")
tenant_id = csp_details.get("tenant_id")
payload = ApplicationCSPPayload(
tenant_id=tenant_id, display_name=application.name, parent_id=parent_id
)
app_result = csp.create_application(payload)
application.cloud_id = app_result.id
db.session.add(application)
db.session.commit()
def do_create_user(csp: CloudProviderInterface, application_role_ids=None):
if not application_role_ids:
return
app_roles = ApplicationRoles.get_many(application_role_ids)
with claim_many_for_update(app_roles) as app_roles:
if any([ar.cloud_id for ar in app_roles]):
return
csp_details = app_roles[0].application.portfolio.csp_data
user = app_roles[0].user
payload = UserCSPPayload(
tenant_id=csp_details.get("tenant_id"),
tenant_host_name=csp_details.get("domain_name"),
display_name=user.full_name,
email=user.email,
)
result = csp.create_user(payload)
for app_role in app_roles:
app_role.cloud_id = result.id
db.session.add(app_role)
db.session.commit()
username = payload.user_principal_name
send_mail(
recipients=[user.email],
subject=translate("email.app_role_created.subject"),
body=translate(
"email.app_role_created.body",
{"url": app.config.get("AZURE_LOGIN_URL"), "username": username},
),
)
app.logger.info(
f"Application role created notification email sent. User id: {user.id}"
)
def do_create_environment(csp: CloudProviderInterface, environment_id=None):
environment = Environments.get(environment_id)
with claim_for_update(environment) as environment:
if environment.cloud_id is not None:
return
csp_details = environment.portfolio.csp_data
parent_id = environment.application.cloud_id
tenant_id = csp_details.get("tenant_id")
payload = EnvironmentCSPPayload(
tenant_id=tenant_id, display_name=environment.name, parent_id=parent_id
)
env_result = csp.create_environment(payload)
environment.cloud_id = env_result.id
db.session.add(environment)
db.session.commit()
def do_create_environment_role(csp: CloudProviderInterface, environment_role_id=None):
env_role = EnvironmentRoles.get_by_id(environment_role_id)
with claim_for_update(env_role) as env_role:
if env_role.cloud_id is not None:
return
env = env_role.environment
csp_details = env.portfolio.csp_data
app_role = env_role.application_role
role = None
if env_role.role == CSPRole.ADMIN:
role = UserRoleCSPPayload.Roles.owner
elif env_role.role == CSPRole.BILLING_READ:
role = UserRoleCSPPayload.Roles.billing
elif env_role.role == CSPRole.CONTRIBUTOR:
role = UserRoleCSPPayload.Roles.contributor
payload = UserRoleCSPPayload(
tenant_id=csp_details.get("tenant_id"),
management_group_id=env.cloud_id,
user_object_id=app_role.cloud_id,
role=role,
)
result = csp.create_user_role(payload)
env_role.cloud_id = result.id
db.session.add(env_role)
db.session.commit()
user = env_role.application_role.user
domain_name = csp_details.get("domain_name")
username = generate_user_principal_name(user.full_name, domain_name,)
send_mail(
recipients=[user.email],
subject=translate("email.azure_account_update.subject"),
body=translate(
"email.azure_account_update.body",
{"url": app.config.get("AZURE_LOGIN_URL"), "username": username},
),
)
app.logger.info(
f"Notification email sent for environment role creation. User id: {user.id}"
)
def render_email(template_path, context):
return app.jinja_env.get_template(template_path).render(context)
def do_work(fn, task, csp, **kwargs):
try:
fn(csp, **kwargs)
except GeneralCSPException as e:
raise task.retry(exc=e)
def send_PPOC_email(portfolio_dict):
ppoc_email = portfolio_dict.get("password_recovery_email_address")
user_id = portfolio_dict.get("user_id")
domain_name = portfolio_dict.get("domain_name")
username = generate_user_principal_name(user_id, domain_name)
send_mail(
recipients=[ppoc_email],
subject=translate("email.portfolio_ready.subject"),
body=translate(
"email.portfolio_ready.body",
{
"password_reset_address": app.config.get("AZURE_LOGIN_URL"),
"username": username,
},
),
)
def make_initial_csp_data(portfolio):
return {
**portfolio.to_dictionary(),
"billing_account_name": app.config.get("AZURE_BILLING_ACCOUNT_NAME"),
}
def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None):
portfolio = Portfolios.get_for_update(portfolio_id)
fsm = Portfolios.get_or_create_state_machine(portfolio)
fsm.trigger_next_transition(csp_data=make_initial_csp_data(portfolio))
if fsm.current_state == FSMStates.COMPLETED:
send_PPOC_email(portfolio.to_dictionary())
@celery.task(bind=True, base=RecordFailure)
def provision_portfolio(self, portfolio_id=None):
do_work(do_provision_portfolio, self, app.csp.cloud, portfolio_id=portfolio_id)
@celery.task(bind=True, base=RecordFailure)
def create_application(self, application_id=None):
do_work(do_create_application, self, app.csp.cloud, application_id=application_id)
@celery.task(bind=True, base=RecordFailure)
def create_user(self, application_role_ids=None):
do_work(
do_create_user, self, app.csp.cloud, application_role_ids=application_role_ids
)
@celery.task(bind=True, base=RecordFailure)
def create_environment_role(self, environment_role_id=None):
do_work(
do_create_environment_role,
self,
app.csp.cloud,
environment_role_id=environment_role_id,
)
@celery.task(bind=True, base=RecordFailure)
def create_environment(self, environment_id=None):
do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id)
@celery.task(bind=True)
def dispatch_provision_portfolio(self):
"""
Iterate over portfolios with a corresponding State Machine that have not completed.
"""
for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()):
provision_portfolio.delay(portfolio_id=portfolio_id)
@celery.task(bind=True)
def dispatch_create_application(self):
for application_id in Applications.get_applications_pending_creation():
create_application.delay(application_id=application_id)
@celery.task(bind=True)
def dispatch_create_user(self):
for application_role_ids in ApplicationRoles.get_pending_creation():
create_user.delay(application_role_ids=application_role_ids)
@celery.task(bind=True)
def dispatch_create_environment_role(self):
for environment_role_id in EnvironmentRoles.get_pending_creation():
create_environment_role.delay(environment_role_id=environment_role_id)
@celery.task(bind=True)
def dispatch_create_environment(self):
for environment_id in Environments.get_environments_pending_creation(
pendulum.now()
):
create_environment.delay(environment_id=environment_id)
@celery.task(bind=True)
def send_task_order_files(self):
task_orders = TaskOrders.get_for_send_task_order_files()
recipients = [app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")]
for task_order in task_orders:
subject = translate(
"email.task_order_sent.subject", {"to_number": task_order.number}
)
body = translate("email.task_order_sent.body", {"to_number": task_order.number})
try:
file = app.csp.files.download_task_order(task_order.pdf.object_name)
file["maintype"] = "application"
file["subtype"] = "pdf"
send_mail(
recipients=recipients, subject=subject, body=body, attachments=[file]
)
except (AzureError, SMTPException) as err:
app.logger.exception(err)
continue
task_order.pdf_last_sent_at = pendulum.now(tz="UTC")
db.session.add(task_order)
db.session.commit()
@celery.task(bind=True)
def create_billing_instruction(self):
clins = TaskOrders.get_clins_for_create_billing_instructions()
for clin in clins:
portfolio = clin.task_order.portfolio
payload = BillingInstructionCSPPayload(
tenant_id=portfolio.csp_data.get("tenant_id"),
billing_account_name=portfolio.csp_data.get("billing_account_name"),
billing_profile_name=portfolio.csp_data.get("billing_profile_name"),
initial_clin_amount=clin.obligated_amount,
initial_clin_start_date=str(clin.start_date),
initial_clin_end_date=str(clin.end_date),
initial_clin_type=clin.jedi_clin_number,
initial_task_order_id=str(clin.task_order_id),
)
try:
app.csp.cloud.create_billing_instruction(payload)
except (AzureError) as err:
app.logger.exception(err)
continue
clin.last_sent_at = pendulum.now(tz="UTC")
db.session.add(clin)
db.session.commit()

21
atat/models/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
from .base import Base
from .application import Application
from .application_invitation import ApplicationInvitation
from .application_role import ApplicationRole, Status as ApplicationRoleStatus
from .attachment import Attachment
from .audit_event import AuditEvent
from .clin import CLIN, JEDICLINType
from .environment import Environment
from .environment_role import EnvironmentRole, CSPRole
from .job_failure import JobFailure
from .notification_recipient import NotificationRecipient
from .permissions import Permissions
from .permission_set import PermissionSet
from .portfolio import Portfolio
from .portfolio_state_machine import PortfolioStateMachine, FSMStates
from .portfolio_invitation import PortfolioInvitation
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .task_order import TaskOrder
from .user import User
from .mixins.invites import Status as InvitationStatus

View File

@@ -0,0 +1,68 @@
from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship, synonym
from atat.models.base import Base
from atat.models.application_role import ApplicationRole
from atat.models.environment import Environment
from atat.models import mixins
from atat.models.types import Id
class Application(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "applications"
id = Id()
name = Column(String, nullable=False)
description = Column(String)
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio")
environments = relationship(
"Environment",
back_populates="application",
primaryjoin=and_(
Environment.application_id == id, Environment.deleted == False
),
order_by="Environment.name",
)
roles = relationship(
"ApplicationRole",
primaryjoin=and_(
ApplicationRole.application_id == id, ApplicationRole.deleted == False
),
)
members = synonym("roles")
__table_args__ = (
UniqueConstraint(
"name", "portfolio_id", name="applications_name_portfolio_id_key"
),
)
cloud_id = Column(String)
@property
def users(self):
return set(role.user for role in self.members)
@property
def displayname(self):
return self.name
@property
def application_id(self):
return self.id
def __repr__(self): # pragma: no cover
return "<Application(name='{}', description='{}', portfolio='{}', id='{}')>".format(
self.name, self.description, self.portfolio.name, self.id
)
@property
def history(self):
return self.get_changes()

View File

@@ -0,0 +1,50 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atat.models.base import Base
import atat.models.mixins as mixins
class ApplicationInvitation(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.InvitesMixin
):
__tablename__ = "application_invitations"
application_role_id = Column(
UUID(as_uuid=True),
ForeignKey("application_roles.id"),
index=True,
nullable=False,
)
role = relationship(
"ApplicationRole",
backref=backref("invitations", order_by="ApplicationInvitation.time_created"),
)
@property
def application(self):
if self.role: # pragma: no branch
return self.role.application
@property
def application_id(self):
return self.role.application_id
@property
def portfolio_id(self):
return self.role.portfolio_id
@property
def event_details(self):
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@@ -0,0 +1,153 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.event import listen
from atat.utils import first_or_none
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
from atat.models.mixins.auditable import record_permission_sets_updates
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
application_roles_permission_sets = Table(
"application_roles_permission_sets",
Base.metadata,
Column(
"application_role_id", UUID(as_uuid=True), ForeignKey("application_roles.id")
),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class ApplicationRole(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.PermissionsMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "application_roles"
id = types.Id()
application_id = Column(
UUID(as_uuid=True), ForeignKey("applications.id"), index=True, nullable=False
)
application = relationship("Application", back_populates="roles")
user_id = Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
)
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
permission_sets = relationship(
"PermissionSet", secondary=application_roles_permission_sets
)
environment_roles = relationship(
"EnvironmentRole",
primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)",
)
cloud_id = Column(String)
@property
def latest_invitation(self):
if self.invitations:
return self.invitations[-1]
@property
def user_name(self):
if self.user:
return self.user.full_name
elif self.latest_invitation:
return self.latest_invitation.user_name
def __repr__(self):
return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format(
self.application.name, self.user_id, self.id, self.permissions
)
@property
def history(self):
previous_state = self.get_changes()
change_set = {}
if "status" in previous_state:
from_status = previous_state["status"][0].value
to_status = self.status.value
change_set["status"] = [from_status, to_status]
return change_set
def has_permission_set(self, perm_set_name):
return first_or_none(
lambda prms: prms.name == perm_set_name, self.permission_sets
)
@property
def portfolio_id(self):
return self.application.portfolio_id
@property
def event_details(self):
return {
"updated_user_name": self.user_name,
"updated_user_id": str(self.user_id),
"application": self.application.name,
"portfolio": self.application.portfolio.name,
}
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def display_status(self):
if (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_expired
):
return "invite_expired"
elif (
self.is_pending
and self.latest_invitation
and self.latest_invitation.is_pending
):
return "invite_pending"
elif self.is_active and any(
env_role.is_pending for env_role in self.environment_roles
):
return "changes_pending"
return None
Index(
"application_role_user_application",
ApplicationRole.user_id,
ApplicationRole.application_id,
unique=True,
)
listen(
ApplicationRole.permission_sets,
"bulk_replace",
record_permission_sets_updates,
raw=True,
)

65
atat/models/attachment.py Normal file
View File

@@ -0,0 +1,65 @@
from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm.exc import NoResultFound
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
from atat.database import db
from atat.domain.exceptions import NotFoundError
class AttachmentError(Exception):
pass
class Attachment(Base, mixins.TimestampsMixin):
__tablename__ = "attachments"
id = types.Id()
filename = Column(String, nullable=False)
object_name = Column(String, unique=True, nullable=False)
resource = Column(String)
resource_id = Column(UUID(as_uuid=True), index=True)
@classmethod
def get_or_create(cls, object_name, params):
try:
return db.session.query(Attachment).filter_by(object_name=object_name).one()
except NoResultFound:
new_attachment = cls(**params)
db.session.add(new_attachment)
db.session.commit()
return new_attachment
@classmethod
def get(cls, id_):
try:
return db.session.query(Attachment).filter_by(id=id_).one()
except NoResultFound:
raise NotFoundError("attachment")
@classmethod
def get_for_resource(cls, resource, resource_id):
try:
return (
db.session.query(Attachment)
.filter_by(resource=resource, resource_id=resource_id)
.one()
)
except NoResultFound:
raise NotFoundError("attachment")
@classmethod
def delete_for_resource(cls, resource, resource_id):
try:
return (
db.session.query(Attachment)
.filter_by(resource=resource, resource_id=resource_id)
.update({"resource_id": None})
)
except NoResultFound:
raise NotFoundError("attachment")
def __repr__(self):
return "<Attachment(name='{}', id='{}')>".format(self.filename, self.id)

View File

@@ -0,0 +1,56 @@
from sqlalchemy import String, Column, ForeignKey, inspect
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from atat.models.base import Base
import atat.models.types as types
from atat.models.mixins.timestamps import TimestampsMixin
class AuditEvent(Base, TimestampsMixin):
__tablename__ = "audit_events"
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="audit_events")
portfolio_id = Column(UUID(as_uuid=True), ForeignKey("portfolios.id"), index=True)
portfolio = relationship("Portfolio", backref="audit_events")
application_id = Column(
UUID(as_uuid=True), ForeignKey("applications.id"), index=True
)
application = relationship("Application", backref="audit_events")
changed_state = Column(JSONB())
event_details = Column(JSONB())
resource_type = Column(String(), nullable=False)
resource_id = Column(UUID(as_uuid=True), index=True, nullable=False)
display_name = Column(String())
action = Column(String(), nullable=False)
@property
def log(self):
return {
"portfolio_id": str(self.portfolio_id),
"application_id": str(self.application_id),
"changed_state": self.changed_state,
"event_details": self.event_details,
"resource_type": self.resource_type,
"resource_id": str(self.resource_id),
"display_name": self.display_name,
"action": self.action,
}
def save(self, connection):
attrs = inspect(self).dict
connection.execute(self.__table__.insert(), **attrs)
def __repr__(self): # pragma: no cover
return "<AuditEvent(name='{}', action='{}', id='{}')>".format(
self.display_name, self.action, self.id
)

3
atat/models/base.py Normal file
View File

@@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

85
atat/models/clin.py Normal file
View File

@@ -0,0 +1,85 @@
from enum import Enum
from sqlalchemy import (
Column,
Date,
DateTime,
Enum as SQLAEnum,
ForeignKey,
Numeric,
String,
)
from sqlalchemy.orm import relationship
import pendulum
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class JEDICLINType(Enum):
JEDI_CLIN_1 = "JEDI_CLIN_1"
JEDI_CLIN_2 = "JEDI_CLIN_2"
JEDI_CLIN_3 = "JEDI_CLIN_3"
JEDI_CLIN_4 = "JEDI_CLIN_4"
class CLIN(Base, mixins.TimestampsMixin):
__tablename__ = "clins"
id = types.Id()
task_order_id = Column(ForeignKey("task_orders.id"), nullable=False)
task_order = relationship("TaskOrder")
number = Column(String, nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
total_amount = Column(Numeric(scale=2), nullable=False)
obligated_amount = Column(Numeric(scale=2), nullable=False)
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False)
last_sent_at = Column(DateTime)
#
# NOTE: For now obligated CLINS are CLIN 1 + CLIN 3
#
def is_obligated(self):
return self.jedi_clin_type in [
JEDICLINType.JEDI_CLIN_1,
JEDICLINType.JEDI_CLIN_3,
]
@property
def type(self):
return "Base" if self.number[0] == "0" else "Option"
@property
def is_completed(self):
return all(
[
self.number,
self.start_date,
self.end_date,
self.total_amount,
self.obligated_amount,
self.jedi_clin_type,
]
)
@property
def jedi_clin_number(self):
return self.jedi_clin_type.value[-1]
def to_dictionary(self):
data = {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
}
return data
@property
def is_active(self):
return (
self.start_date <= pendulum.today().date() <= self.end_date
) and self.task_order.signed_at

View File

@@ -0,0 +1,79 @@
from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship
import atat.models.mixins as mixins
import atat.models.types as types
from atat.models.base import Base
class Environment(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "environments"
id = types.Id()
name = Column(String, nullable=False)
application_id = Column(ForeignKey("applications.id"), nullable=False)
application = relationship("Application")
# User user.id as the foreign key here beacuse the Environment creator may
# not have an application role. We may need to revisit this if we receive any
# requirements around tracking an environment's custodian.
creator_id = Column(ForeignKey("users.id"), nullable=False)
creator = relationship("User")
cloud_id = Column(String)
roles = relationship(
"EnvironmentRole",
back_populates="environment",
primaryjoin="and_(EnvironmentRole.environment_id == Environment.id, EnvironmentRole.deleted == False)",
)
__table_args__ = (
UniqueConstraint(
"name", "application_id", name="environments_name_application_id_key"
),
)
@property
def users(self):
return {r.application_role.user for r in self.roles}
@property
def num_users(self):
return len(self.users)
@property
def displayname(self):
return self.name
@property
def portfolio(self):
return self.application.portfolio
@property
def portfolio_id(self):
return self.application.portfolio_id
@property
def is_pending(self):
return self.cloud_id is None
def __repr__(self):
return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format(
self.name,
self.num_users,
self.application.name,
self.portfolio.name,
self.id,
)
@property
def history(self):
return self.get_changes()

View File

@@ -0,0 +1,99 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, String, Enum as SQLAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class CSPRole(Enum):
ADMIN = "Admin"
BILLING_READ = "Billing Read-only"
CONTRIBUTOR = "Contributor"
class EnvironmentRole(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
):
__tablename__ = "environment_roles"
id = types.Id()
environment_id = Column(
UUID(as_uuid=True), ForeignKey("environments.id"), nullable=False
)
environment = relationship("Environment")
role = Column(SQLAEnum(CSPRole, native_enum=False), nullable=True)
application_role_id = Column(
UUID(as_uuid=True), ForeignKey("application_roles.id"), nullable=False
)
application_role = relationship("ApplicationRole")
cloud_id = Column(String())
class Status(Enum):
PENDING = "pending"
COMPLETED = "completed"
DISABLED = "disabled"
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
def __repr__(self):
return "<EnvironmentRole(role='{}', user='{}', environment='{}', id='{}')>".format(
self.role, self.application_role.user_name, self.environment.name, self.id
)
@property
def history(self):
return self.get_changes()
@property
def portfolio_id(self):
return self.environment.portfolio_id
@property
def application_id(self):
return self.environment.application_id
@property
def displayname(self):
return self.role
@property
def disabled(self):
return self.status == EnvironmentRole.Status.DISABLED
@property
def is_pending(self):
return self.status == EnvironmentRole.Status.PENDING
@property
def event_details(self):
return {
"updated_user_name": self.application_role.user_name,
"updated_application_role_id": str(self.application_role_id),
"role": self.role,
"environment": self.environment.displayname,
"environment_id": str(self.environment_id),
"application": self.environment.application.name,
"application_id": str(self.environment.application_id),
"portfolio": self.environment.portfolio.name,
"portfolio_id": str(self.environment.portfolio.id),
}
Index(
"environments_role_user_environment",
EnvironmentRole.application_role_id,
EnvironmentRole.environment_id,
unique=True,
)

View File

@@ -0,0 +1,21 @@
from celery.result import AsyncResult
from sqlalchemy import Column, String, Integer
from atat.models.base import Base
import atat.models.mixins as mixins
class JobFailure(Base, mixins.TimestampsMixin):
__tablename__ = "job_failures"
id = Column(Integer(), primary_key=True)
task_id = Column(String(), nullable=False)
entity = Column(String(), nullable=False)
entity_id = Column(String(), nullable=False)
@property
def task(self):
if not hasattr(self, "_task"):
self._task = AsyncResult(self.task_id)
return self._task

View File

@@ -0,0 +1,7 @@
from .timestamps import TimestampsMixin
from .auditable import AuditableMixin
from .permissions import PermissionsMixin
from .deletable import DeletableMixin
from .invites import InvitesMixin
from .state_machines import FSMMixin
from .claimable import ClaimableMixin

View File

@@ -0,0 +1,121 @@
from sqlalchemy import event, inspect
from flask import g, current_app as app
from atat.models.audit_event import AuditEvent
from atat.utils import camel_to_snake, getattr_path
ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_DELETE = "delete"
class AuditableMixin(object):
@staticmethod
def create_audit_event(connection, resource, action, changed_state=None):
user_id = getattr_path(g, "current_user.id")
if changed_state is None:
changed_state = resource.history if action == ACTION_UPDATE else None
log_data = {
"user_id": user_id,
"portfolio_id": resource.portfolio_id,
"application_id": resource.application_id,
"resource_type": resource.resource_type,
"resource_id": resource.id,
"display_name": resource.displayname,
"action": action,
"changed_state": changed_state,
"event_details": resource.event_details,
}
app.logger.info(
"Audit Event {}".format(action),
extra={
"audit_event": {key: str(value) for key, value in log_data.items()},
"tags": ["audit_event", action],
},
)
if app.config.get("USE_AUDIT_LOG", False):
audit_event = AuditEvent(**log_data)
audit_event.save(connection)
@classmethod
def __declare_last__(cls):
event.listen(cls, "after_insert", cls.audit_insert)
event.listen(cls, "after_delete", cls.audit_delete)
event.listen(cls, "after_update", cls.audit_update)
@staticmethod
def audit_insert(mapper, connection, target):
"""Listen for the `after_insert` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_CREATE)
@staticmethod
def audit_delete(mapper, connection, target):
"""Listen for the `after_delete` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_DELETE)
@staticmethod
def audit_update(mapper, connection, target):
if AuditableMixin.get_changes(target):
target.create_audit_event(connection, target, ACTION_UPDATE)
def get_changes(self):
"""
This function returns a dictionary of the form {item: [from_value, to_value]},
where 'item' is the attribute on the target that has been updated,
'from_value' is the value of the attribute before it was updated,
and 'to_value' is the current value of the attribute.
There may be more than one item in the dictionary, but that is not expected.
"""
previous_state = {}
attrs = inspect(self).mapper.column_attrs
for attr in attrs:
history = getattr(inspect(self).attrs, attr.key).history
if history.has_changes():
deleted = history.deleted.pop() if history.deleted else None
added = history.added.pop() if history.added else None
previous_state[attr.key] = [deleted, added]
return previous_state
@property
def history(self):
return None
@property
def event_details(self):
return None
@property
def resource_type(self):
return camel_to_snake(type(self).__name__)
@property
def portfolio_id(self):
raise NotImplementedError()
@property
def application_id(self):
raise NotImplementedError()
@property
def displayname(self):
return None
def record_permission_sets_updates(instance_state, permission_sets, initiator):
old_perm_sets = instance_state.attrs.get("permission_sets").value
if instance_state.persistent and old_perm_sets != permission_sets:
connection = instance_state.session.connection()
old_state = [p.name for p in old_perm_sets]
new_state = [p.name for p in permission_sets]
changed_state = {"permission_sets": (old_state, new_state)}
instance_state.object.create_audit_event(
connection,
instance_state.object,
ACTION_UPDATE,
changed_state=changed_state,
)

View File

@@ -0,0 +1,5 @@
from sqlalchemy import Column, TIMESTAMP
class ClaimableMixin(object):
claimed_until = Column(TIMESTAMP(timezone=True))

View File

@@ -0,0 +1,6 @@
from sqlalchemy import Column, Boolean
from sqlalchemy.sql import expression
class DeletableMixin(object):
deleted = Column(Boolean, nullable=False, server_default=expression.false())

View File

@@ -0,0 +1,139 @@
import pendulum
from enum import Enum
import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atat.models import types
class Status(Enum):
ACCEPTED = "accepted"
REVOKED = "revoked"
PENDING = "pending"
REJECTED_WRONG_USER = "rejected_wrong_user"
REJECTED_EXPIRED = "rejected_expired"
class InvitesMixin(object):
id = types.Id()
@declared_attr
def user_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
@declared_attr
def user(cls):
return relationship("User", foreign_keys=[cls.user_id])
@declared_attr
def inviter_id(cls):
return Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
)
@declared_attr
def inviter(cls):
return relationship("User", foreign_keys=[cls.inviter_id])
status = Column(
SQLAEnum(Status, native_enum=False, default=Status.PENDING, nullable=False)
)
expiration_time = Column(TIMESTAMP(timezone=True), nullable=False)
token = Column(
String, index=True, default=lambda: secrets.token_urlsafe(), nullable=False
)
email = Column(String, nullable=False)
dod_id = Column(String, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone_number = Column(String)
phone_ext = Column(String)
def __repr__(self):
role_id = self.role.id if self.role else None
return "<{}(user='{}', role='{}', id='{}', email='{}')>".format(
self.__class__.__name__, self.user_id, role_id, self.id, self.email
)
@property
def is_accepted(self):
return self.status == Status.ACCEPTED
@property
def is_revoked(self):
return self.status == Status.REVOKED
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_rejected(self):
return self.status in [Status.REJECTED_WRONG_USER, Status.REJECTED_EXPIRED]
@property
def is_rejected_expired(self):
return self.status == Status.REJECTED_EXPIRED
@property
def is_rejected_wrong_user(self):
return self.status == Status.REJECTED_WRONG_USER
@property
def is_expired(self):
return (
pendulum.now(tz=self.expiration_time.tzinfo) > self.expiration_time
and not self.status == Status.ACCEPTED
)
@property
def is_inactive(self):
return self.is_expired or self.status in [
Status.REJECTED_WRONG_USER,
Status.REJECTED_EXPIRED,
Status.REVOKED,
]
@property
def user_name(self):
return "{} {}".format(self.first_name, self.last_name)
@property
def is_revokable(self):
return self.is_pending and not self.is_expired
@property
def can_resend(self):
return self.is_pending or self.is_expired
@property
def user_dod_id(self):
return self.user.dod_id if self.user is not None else None
@property
def event_details(self):
"""Overrides the same property in AuditableMixin.
Provides the event details for an invite that are required for the audit log
"""
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
"""Overrides the same property in AuditableMixin
Determines whether or not invite status has been updated
"""
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@@ -0,0 +1,6 @@
class PermissionsMixin(object):
@property
def permissions(self):
return [
perm for permset in self.permission_sets for perm in permset.permissions
]

View File

@@ -0,0 +1,158 @@
from enum import Enum
from flask import current_app as app
class StageStates(Enum):
CREATED = "created"
IN_PROGRESS = "in progress"
FAILED = "failed"
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE_CREATION = "billing profile creation"
BILLING_PROFILE_VERIFICATION = "billing profile verification"
BILLING_PROFILE_TENANT_ACCESS = "billing profile tenant access"
TASK_ORDER_BILLING_CREATION = "task order billing creation"
TASK_ORDER_BILLING_VERIFICATION = "task order billing verification"
BILLING_INSTRUCTION = "billing instruction"
PRODUCT_PURCHASE = "purchase aad premium product"
PRODUCT_PURCHASE_VERIFICATION = "purchase aad premium product verification"
TENANT_PRINCIPAL_APP = "tenant principal application"
TENANT_PRINCIPAL = "tenant principal"
TENANT_PRINCIPAL_CREDENTIAL = "tenant principal credential"
ADMIN_ROLE_DEFINITION = "admin role definition"
PRINCIPAL_ADMIN_ROLE = "tenant principal admin"
INITIAL_MGMT_GROUP = "initial management group"
INITIAL_MGMT_GROUP_VERIFICATION = "initial management group verification"
TENANT_ADMIN_OWNERSHIP = "tenant admin ownership"
TENANT_PRINCIPAL_OWNERSHIP = "tenant principial ownership"
BILLING_OWNER = "billing owner"
def _build_csp_states(csp_stages):
states = {
"UNSTARTED": "unstarted",
"STARTING": "starting",
"STARTED": "started",
"COMPLETED": "completed",
"FAILED": "failed",
}
for csp_stage in csp_stages:
for state in StageStates:
states[csp_stage.name + "_" + state.name] = (
csp_stage.value + " " + state.value
)
return states
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
def _build_transitions(csp_stages):
transitions = []
states = []
for stage_i, csp_stage in enumerate(csp_stages):
# the last CREATED stage has a transition to COMPLETED
if stage_i == len(csp_stages) - 1:
transitions.append(
dict(
trigger="complete",
source=compose_state(
list(csp_stages)[stage_i], StageStates.CREATED
),
dest=FSMStates.COMPLETED,
)
)
for state in StageStates:
states.append(
dict(
name=compose_state(csp_stage, state),
tags=[csp_stage.name, state.name],
)
)
if state == StageStates.CREATED:
if stage_i > 0:
src = compose_state(
list(csp_stages)[stage_i - 1], StageStates.CREATED
)
else:
src = FSMStates.STARTED
transitions.append(
dict(
trigger="create_" + csp_stage.name.lower(),
source=src,
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
after="after_in_progress_callback",
)
)
if state == StageStates.IN_PROGRESS:
transitions.append(
dict(
trigger="finish_" + csp_stage.name.lower(),
source=compose_state(csp_stage, state),
dest=compose_state(csp_stage, StageStates.CREATED),
conditions=["is_csp_data_valid"],
)
)
if state == StageStates.FAILED:
transitions.append(
dict(
trigger="fail_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.IN_PROGRESS),
dest=compose_state(csp_stage, StageStates.FAILED),
)
)
transitions.append(
dict(
trigger="resume_progress_" + csp_stage.name.lower(),
source=compose_state(csp_stage, StageStates.FAILED),
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
conditions=["is_ready_resume_progress"],
)
)
return states, transitions
class FSMMixin:
system_states = [
{"name": FSMStates.UNSTARTED.name, "tags": ["system"]},
{"name": FSMStates.STARTING.name, "tags": ["system"]},
{"name": FSMStates.STARTED.name, "tags": ["system"]},
{"name": FSMStates.FAILED.name, "tags": ["system"]},
{"name": FSMStates.COMPLETED.name, "tags": ["system"]},
]
system_transitions = [
{"trigger": "init", "source": FSMStates.UNSTARTED, "dest": FSMStates.STARTING},
{"trigger": "start", "source": FSMStates.STARTING, "dest": FSMStates.STARTED},
{"trigger": "reset", "source": "*", "dest": FSMStates.UNSTARTED},
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
]
def fail_stage(self, stage):
fail_trigger = f"fail_{stage}"
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
app.logger.info(
f"calling fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
else:
app.logger.info(
f"could not locate fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
def finish_stage(self, stage):
finish_trigger = f"finish_{stage}"
if finish_trigger in self.machine.get_triggers(self.current_state.name):
app.logger.info(
f"calling finish trigger '{finish_trigger}' for '{self.__repr__()}'"
)
self.trigger(finish_trigger)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, func, TIMESTAMP
class TimestampsMixin(object):
time_created = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=func.now()
)
time_updated = Column(
TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.current_timestamp(),
)

View File

@@ -0,0 +1,12 @@
from sqlalchemy import String, Column
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
class NotificationRecipient(Base, mixins.TimestampsMixin):
__tablename__ = "notification_recipients"
id = types.Id()
email = Column(String, nullable=False)

View File

@@ -0,0 +1,21 @@
from sqlalchemy import String, Column
from sqlalchemy.dialects.postgresql import ARRAY
from atat.models.base import Base
import atat.models.mixins as mixins
import atat.models.types as types
class PermissionSet(Base, mixins.TimestampsMixin):
__tablename__ = "permission_sets"
id = types.Id()
name = Column(String, index=True, unique=True, nullable=False)
display_name = Column(String, nullable=False)
description = Column(String, nullable=False)
permissions = Column(ARRAY(String), index=True, server_default="{}", nullable=False)
def __repr__(self):
return "<PermissionSet(name='{}', description='{}', permissions='{}', id='{}')>".format(
self.name, self.description, self.permissions, self.id
)

View File

@@ -0,0 +1,51 @@
class Permissions(object):
# ccpo permissions
VIEW_AUDIT_LOG = "view_audit_log"
VIEW_CCPO_USER = "view_ccpo_user"
CREATE_CCPO_USER = "create_ccpo_user"
EDIT_CCPO_USER = "edit_ccpo_user"
DELETE_CCPO_USER = "delete_ccpo_user"
# base portfolio perms
VIEW_PORTFOLIO = "view_portfolio"
# application management
VIEW_APPLICATION = "view_application"
EDIT_APPLICATION = "edit_application"
CREATE_APPLICATION = "create_application"
DELETE_APPLICATION = "delete_application"
VIEW_APPLICATION_MEMBER = "view_application_member"
EDIT_APPLICATION_MEMBER = "edit_application_member"
DELETE_APPLICATION_MEMBER = "delete_application_member"
CREATE_APPLICATION_MEMBER = "create_application_member"
VIEW_ENVIRONMENT = "view_environment"
EDIT_ENVIRONMENT = "edit_environment"
CREATE_ENVIRONMENT = "create_environment"
DELETE_ENVIRONMENT = "delete_environment"
ASSIGN_ENVIRONMENT_MEMBER = "assign_environment_member"
VIEW_APPLICATION_ACTIVITY_LOG = "view_application_activity_log"
# funding
VIEW_PORTFOLIO_FUNDING = "view_portfolio_funding" # TO summary page
CREATE_TASK_ORDER = "create_task_order" # create a new TO
VIEW_TASK_ORDER_DETAILS = "view_task_order_details" # individual TO page
EDIT_TASK_ORDER_DETAILS = (
"edit_task_order_details" # edit TO that has not been finalized
)
# reporting
VIEW_PORTFOLIO_REPORTS = "view_portfolio_reports"
# portfolio admin
VIEW_PORTFOLIO_ADMIN = "view_portfolio_admin"
VIEW_PORTFOLIO_NAME = "view_portfolio_name"
EDIT_PORTFOLIO_NAME = "edit_portfolio_name"
VIEW_PORTFOLIO_USERS = "view_portfolio_users"
EDIT_PORTFOLIO_USERS = "edit_portfolio_users"
CREATE_PORTFOLIO_USERS = "create_portfolio_users"
VIEW_PORTFOLIO_ACTIVITY_LOG = "view_portfolio_activity_log"
VIEW_PORTFOLIO_POC = "view_portfolio_poc"
# portfolio POC
EDIT_PORTFOLIO_POC = "edit_portfolio_poc"
ARCHIVE_PORTFOLIO = "archive_portfolio"

228
atat/models/portfolio.py Normal file
View File

@@ -0,0 +1,228 @@
import re
from itertools import chain
from typing import Dict
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from sqlalchemy_json import NestedMutableJson
from atat.database import db
import atat.models.mixins as mixins
import atat.models.types as types
from atat.domain.csp.cloud.utils import generate_mail_nickname
from atat.domain.permission_sets import PermissionSets
from atat.models.base import Base
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from atat.utils import first_or_none
class Portfolio(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "portfolios"
id = types.Id()
name = Column(String, nullable=False)
defense_component = Column(
ARRAY(String), nullable=False
) # Department of Defense Component
app_migration = Column(String) # App Migration
complexity = Column(ARRAY(String)) # Application Complexity
complexity_other = Column(String)
description = Column(String)
dev_team = Column(ARRAY(String)) # Development Team
dev_team_other = Column(String)
native_apps = Column(String) # Native Apps
team_experience = Column(String) # Team Experience
csp_data = Column(NestedMutableJson, nullable=True)
applications = relationship(
"Application",
back_populates="portfolio",
primaryjoin="and_(Application.portfolio_id == Portfolio.id, Application.deleted == False)",
)
state_machine = relationship(
"PortfolioStateMachine", uselist=False, back_populates="portfolio"
)
roles = relationship("PortfolioRole")
task_orders = relationship("TaskOrder")
clins = relationship("CLIN", secondary="task_orders")
@property
def owner_role(self):
def _is_portfolio_owner(portfolio_role):
return PermissionSets.PORTFOLIO_POC in [
perms_set.name for perms_set in portfolio_role.permission_sets
]
return first_or_none(_is_portfolio_owner, self.roles)
@property
def owner(self):
owner_role = self.owner_role
return owner_role.user if owner_role else None
@property
def users(self):
return set(role.user for role in self.roles)
@property
def user_count(self):
return len(self.members)
@property
def num_task_orders(self):
return len(self.task_orders)
@property
def initial_clin_dict(self) -> Dict:
initial_clin = min(
(
clin
for clin in self.clins
if (clin.is_active and clin.task_order.is_signed)
),
key=lambda clin: clin.start_date,
default=None,
)
if initial_clin:
return {
"initial_task_order_id": initial_clin.task_order.number,
"initial_clin_number": initial_clin.number,
"initial_clin_type": initial_clin.jedi_clin_number,
"initial_clin_amount": initial_clin.obligated_amount,
"initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"),
"initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"),
}
else:
return {}
@property
def active_task_orders(self):
return [task_order for task_order in self.task_orders if task_order.is_active]
@property
def total_obligated_funds(self):
return sum(
(task_order.total_obligated_funds for task_order in self.active_task_orders)
)
@property
def upcoming_obligated_funds(self):
return sum(
(
task_order.total_obligated_funds
for task_order in self.task_orders
if task_order.is_upcoming
)
)
@property
def funding_duration(self):
"""
Return the earliest period of performance start date and latest period
of performance end date for all active task orders in a portfolio.
@return: (datetime.date or None, datetime.date or None)
"""
start_dates = (
task_order.start_date
for task_order in self.task_orders
if task_order.is_active
)
end_dates = (
task_order.end_date
for task_order in self.task_orders
if task_order.is_active
)
earliest_pop_start_date = min(start_dates, default=None)
latest_pop_end_date = max(end_dates, default=None)
return (earliest_pop_start_date, latest_pop_end_date)
@property
def days_to_funding_expiration(self):
"""
Returns the number of days between today and the lastest period performance
end date of all active Task Orders
"""
return max(
(
task_order.days_to_expiration
for task_order in self.task_orders
if task_order.is_active
),
default=0,
)
@property
def members(self):
return (
db.session.query(PortfolioRole)
.filter(PortfolioRole.portfolio_id == self.id)
.filter(PortfolioRole.status != PortfolioRoleStatus.DISABLED)
.all()
)
@property
def displayname(self):
return self.name
@property
def all_environments(self):
return list(chain.from_iterable(p.environments for p in self.applications))
@property
def portfolio_id(self):
return self.id
@property
def domain_name(self):
"""
CSP domain name associated with portfolio.
If a domain name is not set, generate one.
"""
domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower()
if self.csp_data:
return self.csp_data.get("domain_name", domain_name)
else:
return domain_name
@property
def application_id(self):
return None
def to_dictionary(self):
return {
"user_id": generate_mail_nickname(
f"{self.owner.first_name[0]}{self.owner.last_name}"
),
"password": "",
"domain_name": self.domain_name,
"first_name": self.owner.first_name,
"last_name": self.owner.last_name,
"country_code": "US",
"password_recovery_email_address": self.owner.email,
"address": { # TODO: TBD if we're sourcing this from data or config
"company_name": "",
"address_line_1": "",
"city": "",
"region": "",
"country": "",
"postal_code": "",
},
"billing_profile_display_name": "ATAT Billing Profile",
**self.initial_clin_dict,
}
def __repr__(self):
return "<Portfolio(name='{}', user_count='{}', id='{}')>".format(
self.name, self.user_count, self.id
)

View File

@@ -0,0 +1,33 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atat.models.base import Base
import atat.models.mixins as mixins
class PortfolioInvitation(
Base, mixins.TimestampsMixin, mixins.InvitesMixin, mixins.AuditableMixin
):
__tablename__ = "portfolio_invitations"
portfolio_role_id = Column(
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True, nullable=False
)
role = relationship(
"PortfolioRole",
backref=backref("invitations", order_by="PortfolioInvitation.time_created"),
)
@property
def portfolio(self):
if self.role: # pragma: no branch
return self.role.portfolio
@property
def portfolio_id(self):
return self.role.portfolio_id
@property
def application_id(self):
return None

View File

@@ -0,0 +1,148 @@
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.event import listen
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.utils import first_or_none
from atat.models.mixins.auditable import record_permission_sets_updates
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
portfolio_roles_permission_sets = Table(
"portfolio_roles_permission_sets",
Base.metadata,
Column("portfolio_role_id", UUID(as_uuid=True), ForeignKey("portfolio_roles.id")),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class PortfolioRole(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin
):
__tablename__ = "portfolio_roles"
id = types.Id()
portfolio_id = Column(
UUID(as_uuid=True), ForeignKey("portfolios.id"), index=True, nullable=False
)
portfolio = relationship("Portfolio", back_populates="roles")
user_id = Column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
)
status = Column(
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
)
permission_sets = relationship(
"PermissionSet", secondary=portfolio_roles_permission_sets
)
def __repr__(self):
return "<PortfolioRole(portfolio='{}', user_id='{}', id='{}', permissions={})>".format(
self.portfolio.name, self.user_id, self.id, self.permissions
)
@property
def history(self):
previous_state = self.get_changes()
change_set = {}
if "status" in previous_state:
from_status = previous_state["status"][0].value
to_status = self.status.value
change_set["status"] = [from_status, to_status]
return change_set
@property
def event_details(self):
return {
"updated_user_name": self.user_name,
"updated_user_id": str(self.user_id),
}
@property
def latest_invitation(self):
if self.invitations:
return self.invitations[-1]
@property
def display_status(self):
if self.status == Status.ACTIVE:
return "active"
elif self.status == Status.DISABLED:
return "disabled"
elif self.latest_invitation:
if self.latest_invitation.is_revoked:
return "invite_revoked"
elif self.latest_invitation.is_rejected_wrong_user:
return "invite_error"
elif (
self.latest_invitation.is_rejected_expired
or self.latest_invitation.is_expired
):
return "invite_expired"
else:
return "invite_pending"
else:
return "unknown"
def has_permission_set(self, perm_set_name):
return first_or_none(
lambda prms: prms.name == perm_set_name, self.permission_sets
)
@property
def has_dod_id_error(self):
return self.latest_invitation and self.latest_invitation.is_rejected_wrong_user
@property
def user_name(self):
if self.user:
return self.user.full_name
else:
return self.latest_invitation.user_name
@property
def full_name(self):
return self.user_name
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def can_resend_invitation(self):
return not self.is_active and (
self.latest_invitation and self.latest_invitation.is_inactive
)
@property
def application_id(self):
return None
Index(
"portfolio_role_user_portfolio",
PortfolioRole.user_id,
PortfolioRole.portfolio_id,
unique=True,
)
listen(
PortfolioRole.permission_sets,
"bulk_replace",
record_permission_sets_updates,
raw=True,
)

View File

@@ -0,0 +1,261 @@
import importlib
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
from sqlalchemy.orm import relationship, reconstructor
from sqlalchemy.dialects.postgresql import UUID
from pydantic import ValidationError as PydanticValidationError
from transitions import Machine
from transitions.extensions.states import add_state_features, Tags
from flask import current_app as app
from atat.domain.csp.cloud.exceptions import ConnectionException, UnknownServerException
from atat.database import db
from atat.models.types import Id
from atat.models.base import Base
import atat.models.mixins as mixins
from atat.models.mixins.state_machines import (
FSMStates,
AzureStages,
StageStates,
_build_transitions,
)
class StateMachineMisconfiguredError(Exception):
def __init__(self, class_details):
self.class_details = class_details
@property
def message(self):
return self.class_details
def _stage_to_classname(stage):
return "".join(map(lambda word: word.capitalize(), stage.split("_")))
def _stage_state_to_stage_name(state, stage_state):
return state.name.split(f"_{stage_state.name}")[0].lower()
def get_stage_csp_class(stage, class_type):
"""
given a stage name and class_type return the class
class_type is either 'payload' or 'result'
"""
cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}"
try:
return getattr(
importlib.import_module("atat.domain.csp.cloud.models"), cls_name
)
except AttributeError:
raise StateMachineMisconfiguredError(
f"could not import CSP Payload/Result class {cls_name}"
)
@add_state_features(Tags)
class StateMachineWithTags(Machine):
pass
class PortfolioStateMachine(
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.FSMMixin,
):
__tablename__ = "portfolio_state_machines"
id = Id()
portfolio_id = Column(UUID(as_uuid=True), ForeignKey("portfolios.id"),)
portfolio = relationship("Portfolio", back_populates="state_machine")
state = Column(
SQLAEnum(FSMStates, native_enum=False, create_constraint=False),
default=FSMStates.UNSTARTED,
nullable=False,
)
def __init__(self, portfolio, csp=None, **kwargs):
self.portfolio = portfolio
self.attach_machine()
def after_state_change(self, event):
db.session.add(self)
db.session.commit()
def __repr__(self):
return f"<PortfolioStateMachine(state='{self.current_state.name}', portfolio='{self.portfolio.name}'"
@reconstructor
def attach_machine(self, stages=AzureStages):
"""
This is called as a result of a sqlalchemy query.
Attach a machine depending on the current state.
"""
self.machine = StateMachineWithTags(
model=self,
send_event=True,
initial=self.current_state if self.state else FSMStates.UNSTARTED,
auto_transitions=False,
after_state_change="after_state_change",
)
states, transitions = _build_transitions(stages)
self.machine.add_states(self.system_states + states)
self.machine.add_transitions(self.system_transitions + transitions)
@property
def current_state(self):
if isinstance(self.state, str):
return getattr(FSMStates, self.state)
return self.state
def trigger_next_transition(self, **kwargs):
state_obj = self.machine.get_state(self.state)
kwargs["csp_data"] = kwargs.get("csp_data", {})
if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
self.trigger(trigger_name, **kwargs)
elif self.current_state == FSMStates.STARTED:
# get the first trigger that starts with 'create_'
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(FSMStates.STARTED.name),
),
None,
)
if create_trigger:
self.trigger(create_trigger, **kwargs)
else:
app.logger.info(
f"could not locate 'create trigger' for {self.__repr__()}"
)
self.trigger("fail")
elif self.current_state == FSMStates.FAILED:
# get the first trigger that starts with 'resume_progress_'
resume_progress_trigger = next(
filter(
lambda trigger: trigger.startswith("resume_progress_"),
self.machine.get_triggers(FSMStates.FAILED.name),
),
None,
)
if resume_progress_trigger:
self.trigger(resume_progress_trigger, **kwargs)
else:
app.logger.info(
f"could not locate 'resume progress trigger' for {self.__repr__()}"
)
elif state_obj.is_CREATED:
# if last CREATED state then transition to COMPLETED
if list(AzureStages)[-1].name == state_obj.name.split("_CREATED")[
0
] and "complete" in self.machine.get_triggers(state_obj.name):
app.logger.info(
"last stage completed. transitioning to COMPLETED state"
)
self.trigger("complete", **kwargs)
# the create trigger for the next stage should be in the available
# triggers for the current state
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(self.state.name),
),
None,
)
if create_trigger is not None:
self.trigger(create_trigger, **kwargs)
def after_in_progress_callback(self, event):
# Accumulate payload w/ creds
payload = event.kwargs.get("csp_data")
current_stage = _stage_state_to_stage_name(
self.current_state, StageStates.IN_PROGRESS
)
payload_data_cls = get_stage_csp_class(current_stage, "payload")
if not payload_data_cls:
app.logger.info(
f"could not resolve payload data class for stage {current_stage}"
)
self.fail_stage(current_stage)
try:
payload_data = payload_data_cls(**payload)
except PydanticValidationError as exc:
app.logger.error(
f"Payload Validation Error in {self.__repr__()}:", exc_info=1
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload)
self.fail_stage(current_stage)
# TODO: Determine best place to do this, maybe @reconstructor
self.csp = app.csp.cloud
try:
func_name = f"create_{current_stage}"
response = getattr(self.csp, func_name)(payload_data)
if self.portfolio.csp_data is None:
self.portfolio.csp_data = {}
self.portfolio.csp_data.update(response.dict())
db.session.add(self.portfolio)
db.session.commit()
except PydanticValidationError as exc:
app.logger.error(
f"Failed to cast response to valid result class {self.__repr__()}:",
exc_info=1,
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload_data)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(current_stage)
# TODO: catch and handle general CSP exception here
except (ConnectionException, UnknownServerException) as exc:
app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(current_stage)
self.finish_stage(current_stage)
def is_csp_data_valid(self, event):
"""
This function guards advancing states from *_IN_PROGRESS to *_COMPLETED.
"""
if self.portfolio.csp_data is None or not isinstance(
self.portfolio.csp_data, dict
):
print("no csp data")
return False
return True
def is_ready_resume_progress(self, event):
"""
This function guards advancing states from FAILED to *_IN_PROGRESS.
"""
return True
@property
def application_id(self):
return None

166
atat/models/task_order.py Normal file
View File

@@ -0,0 +1,166 @@
from enum import Enum
from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from atat.models.clin import CLIN
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.models.attachment import Attachment
from pendulum import today
from sqlalchemy import func
class Status(Enum):
DRAFT = "Draft"
ACTIVE = "Active"
UPCOMING = "Upcoming"
EXPIRED = "Expired"
UNSIGNED = "Unsigned"
SORT_ORDERING = [
Status.ACTIVE,
Status.DRAFT,
Status.UPCOMING,
Status.EXPIRED,
]
class TaskOrder(Base, mixins.TimestampsMixin):
__tablename__ = "task_orders"
id = types.Id()
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio")
pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
pdf_last_sent_at = Column(DateTime)
number = Column(String, unique=True,) # Task Order Number
signer_dod_id = Column(String)
signed_at = Column(DateTime)
clins = relationship(
"CLIN",
back_populates="task_order",
cascade="all, delete-orphan",
order_by=lambda: [func.substr(CLIN.number, 2), func.substr(CLIN.number, 1, 2)],
)
@hybrid_property
def pdf(self):
return self._pdf
@pdf.setter
def pdf(self, new_pdf):
self._pdf = self._set_attachment(new_pdf, "_pdf")
def _set_attachment(self, new_attachment, attribute):
if isinstance(new_attachment, Attachment):
return new_attachment
elif isinstance(new_attachment, dict):
if new_attachment["filename"] and new_attachment["object_name"]:
attachment = Attachment.get_or_create(
new_attachment["object_name"], new_attachment
)
return attachment
else:
return None
elif not new_attachment and hasattr(self, attribute):
return None
else:
raise TypeError("Could not set attachment with invalid type")
@property
def is_draft(self):
return self.status == Status.DRAFT or self.status == Status.UNSIGNED
@property
def is_active(self):
return self.status == Status.ACTIVE
@property
def is_expired(self):
return self.status == Status.EXPIRED
@property
def is_upcoming(self):
return self.status == Status.UPCOMING
@property
def clins_are_completed(self):
return all([len(self.clins), (clin.is_completed for clin in self.clins)])
@property
def is_completed(self):
return all([self.pdf, self.number, self.clins_are_completed])
@property
def is_signed(self):
return self.signed_at is not None
@property
def status(self):
todays_date = today(tz="UTC").date()
if not self.is_completed and not self.is_signed:
return Status.DRAFT
elif self.is_completed and not self.is_signed:
return Status.UNSIGNED
elif todays_date < self.start_date:
return Status.UPCOMING
elif todays_date > self.end_date:
return Status.EXPIRED
elif self.start_date <= todays_date <= self.end_date:
return Status.ACTIVE
@property
def start_date(self):
return min((c.start_date for c in self.clins), default=None)
@property
def end_date(self):
return max((c.end_date for c in self.clins), default=None)
@property
def days_to_expiration(self):
if self.end_date:
return (self.end_date - today(tz="UTC").date()).days
@property
def total_obligated_funds(self):
return sum(
(clin.obligated_amount for clin in self.clins if clin.obligated_amount)
)
@property
def total_contract_amount(self):
return sum((clin.total_amount for clin in self.clins if clin.total_amount))
@property
def display_status(self):
if self.status == Status.UNSIGNED:
return Status.DRAFT.value
else:
return self.status.value
@property
def portfolio_name(self):
return self.portfolio.name
def to_dictionary(self):
return {
"portfolio_name": self.portfolio_name,
"pdf": self.pdf,
"clins": [clin.to_dictionary() for clin in self.clins],
**{
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
},
}
def __repr__(self):
return "<TaskOrder(number='{}', id='{}')>".format(self.number, self.id)

11
atat/models/types.py Normal file
View File

@@ -0,0 +1,11 @@
import sqlalchemy
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import UUID
def Id():
return Column(
UUID(as_uuid=True),
primary_key=True,
server_default=sqlalchemy.text("uuid_generate_v4()"),
)

128
atat/models/user.py Normal file
View File

@@ -0,0 +1,128 @@
from sqlalchemy import String, ForeignKey, Column, Date, Table, TIMESTAMP
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.event import listen
from atat.models.base import Base
import atat.models.types as types
import atat.models.mixins as mixins
from atat.models.portfolio_invitation import PortfolioInvitation
from atat.models.application_invitation import ApplicationInvitation
from atat.models.mixins.auditable import (
AuditableMixin,
ACTION_UPDATE,
record_permission_sets_updates,
)
users_permission_sets = Table(
"users_permission_sets",
Base.metadata,
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id")),
Column("permission_set_id", UUID(as_uuid=True), ForeignKey("permission_sets.id")),
)
class User(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin
):
__tablename__ = "users"
id = types.Id()
username = Column(String)
permission_sets = relationship("PermissionSet", secondary=users_permission_sets)
portfolio_roles = relationship("PortfolioRole", backref="user")
application_roles = relationship(
"ApplicationRole",
backref="user",
primaryjoin="and_(ApplicationRole.user_id == User.id, ApplicationRole.deleted == False)",
)
portfolio_invitations = relationship(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.user_id
)
sent_portfolio_invitations = relationship(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.inviter_id
)
application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.user_id
)
sent_application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.inviter_id
)
email = Column(String)
dod_id = Column(String, unique=True, nullable=False)
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
phone_number = Column(String)
phone_ext = Column(String)
service_branch = Column(String)
citizenship = Column(String)
designation = Column(String)
date_latest_training = Column(Date)
last_login = Column(TIMESTAMP(timezone=True), nullable=True)
last_session_id = Column(UUID(as_uuid=True), nullable=True)
cloud_id = Column(String)
REQUIRED_FIELDS = [
"email",
"dod_id",
"first_name",
"last_name",
"phone_number",
"service_branch",
"citizenship",
"designation",
"date_latest_training",
]
@property
def profile_complete(self):
return all(
[
getattr(self, field_name) is not None
for field_name in self.REQUIRED_FIELDS
]
)
@property
def full_name(self):
return "{} {}".format(self.first_name, self.last_name)
@property
def displayname(self):
return self.full_name
@property
def portfolio_id(self):
return None
@property
def application_id(self):
return None
def __repr__(self):
return "<User(name='{}', dod_id='{}', email='{}', id='{}')>".format(
self.full_name, self.dod_id, self.email, self.id
)
def to_dictionary(self):
return {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in ["id"]
}
@staticmethod
def audit_update(mapper, connection, target):
changes = AuditableMixin.get_changes(target)
if changes and not "last_login" in changes:
target.create_audit_event(connection, target, ACTION_UPDATE)
listen(User.permission_sets, "bulk_replace", record_permission_sets_updates, raw=True)

100
atat/models/utils.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import List
from sqlalchemy import func, sql, Interval, and_, or_
from contextlib import contextmanager
from atat.database import db
from atat.domain.exceptions import ClaimFailedException
@contextmanager
def claim_for_update(resource, minutes=30):
"""
Claim a mutually exclusive expiring hold on a resource.
Uses the database as a central source of time in case the server clocks have drifted.
Args:
resource: A SQLAlchemy model instance with a `claimed_until` attribute.
minutes: The maximum amount of time, in minutes, to hold the claim.
"""
Model = resource.__class__
claim_until = func.now() + func.cast(
sql.functions.concat(minutes, " MINUTES"), Interval
)
# Optimistically query for and update the resource in question. If it's
# already claimed, `rows_updated` will be 0 and we can give up.
rows_updated = (
db.session.query(Model)
.filter(
and_(
Model.id == resource.id,
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
)
)
.update({"claimed_until": claim_until}, synchronize_session="fetch")
)
if rows_updated < 1:
raise ClaimFailedException(resource)
# Fetch the claimed resource
claimed = db.session.query(Model).filter_by(id=resource.id).one()
try:
# Give the resource to the caller.
yield claimed
finally:
# Release the claim.
db.session.query(Model).filter(Model.id == resource.id).filter(
Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit()
@contextmanager
def claim_many_for_update(resources: List, minutes=30):
"""
Claim a mutually exclusive expiring hold on a group of resources.
Uses the database as a central source of time in case the server clocks have drifted.
Args:
resources: A list of SQLAlchemy model instances with a `claimed_until` attribute.
minutes: The maximum amount of time, in minutes, to hold the claim.
"""
Model = resources[0].__class__
claim_until = func.now() + func.cast(
sql.functions.concat(minutes, " MINUTES"), Interval
)
ids = tuple(r.id for r in resources)
# Optimistically query for and update the resources in question. If they're
# already claimed, `rows_updated` will be 0 and we can give up.
rows_updated = (
db.session.query(Model)
.filter(
and_(
Model.id.in_(ids),
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
)
)
.update({"claimed_until": claim_until}, synchronize_session="fetch")
)
if rows_updated < 1:
# TODO: Generalize this exception class so it can take multiple resources
raise ClaimFailedException(resources[0])
# Fetch the claimed resources
claimed = db.session.query(Model).filter(Model.id.in_(ids)).all()
try:
# Give the resource to the caller.
yield claimed
finally:
# Release the claim.
db.session.query(Model).filter(Model.id.in_(ids)).filter(
Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit()

46
atat/queue.py Normal file
View File

@@ -0,0 +1,46 @@
from celery import Celery
celery = Celery(__name__)
def update_celery(celery, app):
celery.conf.update(app.config)
celery.conf.CELERYBEAT_SCHEDULE = {
"beat-dispatch_provision_portfolio": {
"task": "atat.jobs.dispatch_provision_portfolio",
"schedule": 60,
},
"beat-dispatch_create_application": {
"task": "atat.jobs.dispatch_create_application",
"schedule": 60,
},
"beat-dispatch_create_environment": {
"task": "atat.jobs.dispatch_create_environment",
"schedule": 60,
},
"beat-dispatch_create_user": {
"task": "atat.jobs.dispatch_create_user",
"schedule": 60,
},
"beat-dispatch_create_environment_role": {
"task": "atat.jobs.dispatch_create_environment_role",
"schedule": 60,
},
"beat-send_task_order_files": {
"task": "atat.jobs.send_task_order_files",
"schedule": 60,
},
"beat-create_billing_instruction": {
"task": "atat.jobs.create_billing_instruction",
"schedule": 60,
},
}
class ContextTask(celery.Task):
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
return celery

132
atat/routes/__init__.py Normal file
View File

@@ -0,0 +1,132 @@
import urllib.parse as url
from flask import (
Blueprint,
render_template,
g,
redirect,
session,
url_for,
request,
make_response,
current_app as app,
)
from jinja2.exceptions import TemplateNotFound
import pendulum
import os
from werkzeug.exceptions import NotFound, MethodNotAllowed
from werkzeug.routing import RequestRedirect
from atat.domain.users import Users
from atat.domain.authnid import AuthenticationContext
from atat.domain.auth import logout as _logout
from atat.domain.exceptions import UnauthenticatedError
from atat.utils.flash import formatted_flash as flash
bp = Blueprint("atat", __name__)
@bp.route("/")
def root():
if g.current_user:
return redirect(url_for(".home"))
redirect_url = app.config.get("CAC_URL")
if request.args.get("next"):
redirect_url = url.urljoin(
redirect_url,
"?{}".format(url.urlencode({"next": request.args.get("next")})),
)
flash("login_next")
return render_template("login.html", redirect_url=redirect_url)
@bp.route("/home")
def home():
return render_template("home.html")
def _client_s_dn():
return request.environ.get("HTTP_X_SSL_CLIENT_S_DN")
def _make_authentication_context():
return AuthenticationContext(
crl_cache=app.crl_cache,
auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"),
sdn=_client_s_dn(),
cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT"),
)
def redirect_after_login_url():
returl = request.args.get("next")
if match_url_pattern(returl):
param_name = request.args.get(app.form_cache.PARAM_NAME)
if param_name:
returl += "?" + url.urlencode({app.form_cache.PARAM_NAME: param_name})
return returl
else:
return url_for("atat.home")
def match_url_pattern(url, method="GET"):
"""Ensure a url matches a url pattern in the flask app
inspired by https://stackoverflow.com/questions/38488134/get-the-flask-view-function-that-matches-a-url/38488506#38488506
"""
server_name = app.config.get("SERVER_NAME") or "localhost"
adapter = app.url_map.bind(server_name=server_name)
try:
match = adapter.match(url, method=method)
except RequestRedirect as e:
# recursively match redirects
return match_url_pattern(e.new_url, method)
except (MethodNotAllowed, NotFound):
# no match
return None
if match[0] in app.view_functions:
return url
def current_user_setup(user):
session["user_id"] = user.id
session["last_login"] = user.last_login
app.session_limiter.on_login(user)
app.logger.info(f"authentication succeeded for user with EDIPI {user.dod_id}")
Users.update_last_login(user)
@bp.route("/login-redirect")
def login_redirect():
try:
auth_context = _make_authentication_context()
auth_context.authenticate()
user = auth_context.get_user()
current_user_setup(user)
except UnauthenticatedError as err:
app.logger.info(
f"authentication failed for subject distinguished name {_client_s_dn()}"
)
raise err
return redirect(redirect_after_login_url())
@bp.route("/logout")
def logout():
_logout()
response = make_response(redirect(url_for(".root")))
response.set_cookie("expandSidenav", "", expires=0)
flash("logged_out")
return response
@bp.route("/about")
def about():
return render_template("about.html")

View File

@@ -0,0 +1,25 @@
from flask import current_app as app, g, redirect, url_for
from . import index
from . import new
from . import settings
from . import invitations
from .blueprint import applications_bp
from atat.domain.environment_roles import EnvironmentRoles
from atat.domain.exceptions import UnauthorizedError
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.models.permissions import Permissions
def wrap_environment_role_lookup(user, environment_id=None, **kwargs):
env_role = EnvironmentRoles.get_by_user_and_environment(user.id, environment_id)
if not env_role:
raise UnauthorizedError(user, "access environment {}".format(environment_id))
return True
@applications_bp.route("/environments/<environment_id>/access")
@user_can(None, override=wrap_environment_role_lookup, message="access environment")
def access_environment(environment_id):
return redirect("https://portal.azure.com")

View File

@@ -0,0 +1,6 @@
from flask import Blueprint
from atat.utils.context_processors import portfolio as portfolio_context_processor
applications_bp = Blueprint("applications", __name__)
applications_bp.context_processor(portfolio_context_processor)

View File

@@ -0,0 +1,34 @@
from flask import render_template, g
from .blueprint import applications_bp
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.domain.environment_roles import EnvironmentRoles
from atat.models.permissions import Permissions
def has_portfolio_applications(_user, portfolio=None, **_kwargs):
"""
If the portfolio exists and the user has access to applications
within the scoped portfolio, the user has access to the
portfolio landing page.
"""
if portfolio and portfolio.applications:
return True
@applications_bp.route("/portfolios/<portfolio_id>")
@applications_bp.route("/portfolios/<portfolio_id>/applications")
@user_can(
Permissions.VIEW_APPLICATION,
override=has_portfolio_applications,
message="view portfolio applications",
)
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.value for env_role in user_env_roles
}
return render_template(
"applications/index.html", environment_access=environment_access
)

View File

@@ -0,0 +1,16 @@
from flask import redirect, url_for, g
from .blueprint import applications_bp
from atat.domain.invitations import ApplicationInvitations
@applications_bp.route("/applications/invitations/<token>", methods=["GET"])
def accept_invitation(token):
invite = ApplicationInvitations.accept(g.current_user, token)
return redirect(
url_for(
"applications.portfolio_applications",
portfolio_id=invite.application.portfolio_id,
)
)

View File

@@ -0,0 +1,167 @@
from flask import redirect, render_template, request as http_request, url_for
from .blueprint import applications_bp
from atat.domain.applications import Applications
from atat.forms.application import NameAndDescriptionForm, EnvironmentsForm
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.models.permissions import Permissions
from atat.utils.flash import formatted_flash as flash
from atat.routes.applications.settings import (
get_members_data,
get_new_member_form,
handle_create_member,
handle_update_member,
handle_update_application,
)
def get_new_application_form(form_data, form_class, application_id=None):
if application_id:
application = Applications.get(application_id)
return form_class(form_data, obj=application)
else:
return form_class(form_data)
def render_new_application_form(
template, form_class, portfolio_id=None, application_id=None, form=None
):
render_args = {"application_id": application_id}
if application_id:
application = Applications.get(application_id)
render_args["form"] = form or form_class(obj=application)
render_args["application"] = application
else:
render_args["form"] = form or form_class()
return render_template(template, **render_args)
@applications_bp.route("/portfolios/<portfolio_id>/applications/new")
@applications_bp.route("/applications/<application_id>/new/step_1")
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def view_new_application_step_1(portfolio_id=None, application_id=None):
return render_new_application_form(
"applications/new/step_1.html",
NameAndDescriptionForm,
portfolio_id=portfolio_id,
application_id=application_id,
)
@applications_bp.route(
"/portfolios/<portfolio_id>/applications/new",
endpoint="create_new_application_step_1",
methods=["POST"],
)
@applications_bp.route(
"/applications/<application_id>/new/step_1",
endpoint="update_new_application_step_1",
methods=["POST"],
)
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def create_or_update_new_application_step_1(portfolio_id=None, application_id=None):
form = get_new_application_form(
{**http_request.form}, NameAndDescriptionForm, application_id
)
application = handle_update_application(form, application_id, portfolio_id)
if application:
return redirect(
url_for(
"applications.update_new_application_step_2",
application_id=application.id,
)
)
else:
return (
render_new_application_form(
"applications/new/step_1.html",
NameAndDescriptionForm,
portfolio_id,
application_id,
form,
),
400,
)
@applications_bp.route("/applications/<application_id>/new/step_2")
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def view_new_application_step_2(application_id):
application = Applications.get(application_id)
render_args = {
"form": EnvironmentsForm(
data={
"environment_names": [
environment.name for environment in application.environments
]
}
),
"application": application,
}
return render_template("applications/new/step_2.html", **render_args)
@applications_bp.route("/applications/<application_id>/new/step_2", methods=["POST"])
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def update_new_application_step_2(application_id):
form = get_new_application_form(
{**http_request.form}, EnvironmentsForm, application_id
)
if form.validate():
application = Applications.get(application_id)
application = Applications.update(application, form.data)
flash("application_environments_updated")
return redirect(
url_for(
"applications.update_new_application_step_3",
application_id=application_id,
)
)
else:
return (
render_new_application_form(
"applications/new/step_2.html",
EnvironmentsForm,
application_id=application_id,
form=form,
),
400,
)
@applications_bp.route("/applications/<application_id>/new/step_3")
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def view_new_application_step_3(application_id):
application = Applications.get(application_id)
members = get_members_data(application)
new_member_form = get_new_member_form(application)
return render_template(
"applications/new/step_3.html",
application_id=application_id,
application=application,
members=members,
new_member_form=new_member_form,
)
@applications_bp.route("/applications/<application_id>/new/step_3", methods=["POST"])
@applications_bp.route(
"/applications/<application_id>/new/step_3/member/<application_role_id>",
methods=["POST"],
)
@user_can(Permissions.CREATE_APPLICATION, message="view create new application form")
def update_new_application_step_3(application_id, application_role_id=None):
if application_role_id:
handle_update_member(application_id, application_role_id, http_request.form)
else:
handle_create_member(application_id, http_request.form)
return redirect(
url_for(
"applications.view_new_application_step_3", application_id=application_id
)
)

View File

@@ -0,0 +1,579 @@
from flask import (
current_app as app,
g,
redirect,
render_template,
request as http_request,
url_for,
)
from secrets import token_urlsafe
from .blueprint import applications_bp
from atat.domain.exceptions import AlreadyExistsError
from atat.domain.environments import Environments
from atat.domain.applications import Applications
from atat.domain.application_roles import ApplicationRoles
from atat.domain.audit_log import AuditLog
from atat.domain.csp.cloud.exceptions import GeneralCSPException
from atat.domain.csp.cloud.models import SubscriptionCreationCSPPayload
from atat.domain.common import Paginator
from atat.domain.environment_roles import EnvironmentRoles
from atat.domain.invitations import ApplicationInvitations
from atat.domain.portfolios import Portfolios
from atat.forms.application_member import NewForm as NewMemberForm, UpdateMemberForm
from atat.forms.application import NameAndDescriptionForm, EditEnvironmentForm
from atat.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS
from atat.forms.member import NewForm as MemberForm
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.models.permissions import Permissions
from atat.domain.permission_sets import PermissionSets
from atat.utils.flash import formatted_flash as flash
from atat.utils.localization import translate
from atat.jobs import send_mail
from atat.routes.errors import log_error
def get_environments_obj_for_app(application):
return sorted(
[
{
"id": env.id,
"name": env.name,
"pending": env.is_pending,
"edit_form": EditEnvironmentForm(obj=env),
"member_count": len(env.roles),
"members": sorted(
[
{
"user_name": env_role.application_role.user_name,
"status": env_role.status.value,
}
for env_role in env.roles
],
key=lambda env_role: env_role["user_name"],
),
}
for env in application.environments
],
key=lambda env: env["name"],
)
def filter_perm_sets_data(member):
perm_sets_data = {
"perms_team_mgmt": bool(
member.has_permission_set(PermissionSets.EDIT_APPLICATION_TEAM)
),
"perms_env_mgmt": bool(
member.has_permission_set(PermissionSets.EDIT_APPLICATION_ENVIRONMENTS)
),
}
return perm_sets_data
def filter_env_roles_data(roles):
return sorted(
[
{
"environment_id": str(role.environment.id),
"environment_name": role.environment.name,
"role": (role.role.value if role.role else "None"),
}
for role in roles
],
key=lambda env: env["environment_name"],
)
def filter_env_roles_form_data(member, environments):
env_roles_form_data = []
for env in environments:
env_data = {
"environment_id": str(env.id),
"environment_name": env.name,
"role": NO_ACCESS,
"disabled": False,
}
env_roles_set = set(env.roles).intersection(set(member.environment_roles))
if len(env_roles_set) == 1:
(env_role,) = env_roles_set
env_data["disabled"] = env_role.disabled
if env_role.role:
env_data["role"] = env_role.role.name
env_roles_form_data.append(env_data)
return env_roles_form_data
def get_members_data(application):
members_data = []
for member in application.members:
permission_sets = filter_perm_sets_data(member)
roles = EnvironmentRoles.get_for_application_member(member.id)
environment_roles = filter_env_roles_data(roles)
env_roles_form_data = filter_env_roles_form_data(
member, application.environments
)
form = UpdateMemberForm(
environment_roles=env_roles_form_data, **permission_sets
)
update_invite_form = (
MemberForm(obj=member.latest_invitation)
if member.latest_invitation and member.latest_invitation.can_resend
else MemberForm()
)
members_data.append(
{
"role_id": member.id,
"user_name": member.user_name,
"permission_sets": permission_sets,
"environment_roles": environment_roles,
"role_status": member.display_status,
"form": form,
"update_invite_form": update_invite_form,
}
)
return sorted(members_data, key=lambda member: member["user_name"])
def get_new_member_form(application):
env_roles = sorted(
[
{"environment_id": e.id, "environment_name": e.name}
for e in application.environments
],
key=lambda role: role["environment_name"],
)
return NewMemberForm(data={"environment_roles": env_roles})
def render_settings_page(application, **kwargs):
environments_obj = get_environments_obj_for_app(application=application)
new_env_form = EditEnvironmentForm()
pagination_opts = Paginator.get_pagination_opts(http_request)
audit_events = AuditLog.get_application_events(application, pagination_opts)
new_member_form = get_new_member_form(application)
members = get_members_data(application)
if "application_form" not in kwargs:
kwargs["application_form"] = NameAndDescriptionForm(
name=application.name, description=application.description
)
return render_template(
"applications/settings.html",
application=application,
environments_obj=environments_obj,
new_env_form=new_env_form,
audit_events=audit_events,
new_member_form=new_member_form,
members=members,
**kwargs,
)
def send_application_invitation(invitee_email, inviter_name, token):
body = render_template(
"emails/application/invitation.txt", owner=inviter_name, token=token
)
send_mail.delay(
[invitee_email],
translate("email.application_invite", {"inviter_name": inviter_name}),
body,
)
def handle_create_member(application_id, form_data):
application = Applications.get(application_id)
form = NewMemberForm(form_data)
if form.validate():
try:
invite = Applications.invite(
application=application,
inviter=g.current_user,
user_data=form.user_data.data,
permission_sets_names=form.data["permission_sets"],
environment_roles_data=form.environment_roles.data,
)
send_application_invitation(
invitee_email=invite.email,
inviter_name=g.current_user.full_name,
token=invite.token,
)
flash("new_application_member", user_name=invite.first_name)
except AlreadyExistsError:
return render_template(
"error.html", message="There was an error processing your request."
)
else:
pass
# TODO: flash error message
def handle_update_member(application_id, application_role_id, form_data):
app_role = ApplicationRoles.get_by_id(application_role_id)
application = Applications.get(application_id)
existing_env_roles_data = filter_env_roles_form_data(
app_role, application.environments
)
form = UpdateMemberForm(
formdata=form_data, environment_roles=existing_env_roles_data
)
if form.validate():
try:
ApplicationRoles.update_permission_sets(
app_role, form.data["permission_sets"]
)
for env_role in form.environment_roles:
environment = Environments.get(env_role.environment_id.data)
new_role = None if env_role.disabled.data else env_role.data["role"]
Environments.update_env_role(environment, app_role, new_role)
flash("application_member_updated", user_name=app_role.user_name)
except GeneralCSPException as exc:
log_error(exc)
flash(
"application_member_update_error", user_name=app_role.user_name,
)
else:
pass
# TODO: flash error message
def handle_update_environment(form, application=None, environment=None):
if form.validate():
try:
if environment:
environment = Environments.update(
environment=environment, name=form.name.data
)
flash("application_environments_updated")
else:
environment = Environments.create(
g.current_user, application=application, name=form.name.data
)
flash("environment_added", environment_name=form.name.data)
return environment
except AlreadyExistsError:
flash("application_environments_name_error", name=form.name.data)
return False
else:
return False
def handle_update_application(form, application_id=None, portfolio_id=None):
if form.validate():
application = None
try:
if application_id:
application = Applications.get(application_id)
application = Applications.update(application, form.data)
flash("application_updated", application_name=application.name)
else:
portfolio = Portfolios.get_for_update(portfolio_id)
application = Applications.create(
g.current_user, portfolio, **form.data
)
flash("application_created", application_name=application.name)
return application
except AlreadyExistsError:
flash("application_name_error", name=form.data["name"])
return False
@applications_bp.route("/applications/<application_id>/settings")
@user_can(Permissions.VIEW_APPLICATION, message="view application edit form")
def settings(application_id):
application = Applications.get(application_id)
return render_settings_page(application=application,)
@applications_bp.route("/environments/<environment_id>/edit", methods=["POST"])
@user_can(Permissions.EDIT_ENVIRONMENT, message="edit application environments")
def update_environment(environment_id):
environment = Environments.get(environment_id)
application = environment.application
env_form = EditEnvironmentForm(obj=environment, formdata=http_request.form)
updated_environment = handle_update_environment(
form=env_form, application=application, environment=environment
)
if updated_environment:
return redirect(
url_for(
"applications.settings",
application_id=application.id,
fragment="application-environments",
_anchor="application-environments",
)
)
else:
return (render_settings_page(application=application, show_flash=True), 400)
@applications_bp.route(
"/applications/<application_id>/environments/new", methods=["POST"]
)
@user_can(Permissions.CREATE_ENVIRONMENT, message="create application environment")
def new_environment(application_id):
application = Applications.get(application_id)
env_form = EditEnvironmentForm(formdata=http_request.form)
environment = handle_update_environment(form=env_form, application=application)
if environment:
return redirect(
url_for(
"applications.settings",
application_id=application.id,
fragment="application-environments",
_anchor="application-environments",
)
)
else:
return (render_settings_page(application=application, show_flash=True), 400)
@applications_bp.route("/applications/<application_id>/edit", methods=["POST"])
@user_can(Permissions.EDIT_APPLICATION, message="update application")
def update(application_id):
application = Applications.get(application_id)
form = NameAndDescriptionForm(http_request.form)
updated_application = handle_update_application(form, application_id)
if updated_application:
return redirect(
url_for(
"applications.portfolio_applications",
portfolio_id=application.portfolio_id,
)
)
else:
return (
render_settings_page(application=application, show_flash=True),
400,
)
@applications_bp.route("/environments/<environment_id>/delete", methods=["POST"])
@user_can(Permissions.DELETE_ENVIRONMENT, message="delete environment")
def delete_environment(environment_id):
environment = Environments.get(environment_id)
Environments.delete(environment=environment, commit=True)
flash("environment_deleted", environment_name=environment.name)
return redirect(
url_for(
"applications.settings",
application_id=environment.application_id,
_anchor="application-environments",
fragment="application-environments",
)
)
@applications_bp.route("/application/<application_id>/members/new", methods=["POST"])
@user_can(
Permissions.CREATE_APPLICATION_MEMBER, message="create new application member"
)
def create_member(application_id):
handle_create_member(application_id, http_request.form)
return redirect(
url_for(
"applications.settings",
application_id=application_id,
fragment="application-members",
_anchor="application-members",
)
)
@applications_bp.route(
"/applications/<application_id>/members/<application_role_id>/delete",
methods=["POST"],
)
@user_can(Permissions.DELETE_APPLICATION_MEMBER, message="remove application member")
def remove_member(application_id, application_role_id):
application_role = ApplicationRoles.get_by_id(application_role_id)
ApplicationRoles.disable(application_role)
flash(
"application_member_removed",
user_name=application_role.user_name,
application_name=g.application.name,
)
return redirect(
url_for(
"applications.settings",
_anchor="application-members",
application_id=g.application.id,
fragment="application-members",
)
)
@applications_bp.route(
"/applications/<application_id>/members/<application_role_id>/update",
methods=["POST"],
)
@user_can(Permissions.EDIT_APPLICATION_MEMBER, message="update application member")
def update_member(application_id, application_role_id):
handle_update_member(application_id, application_role_id, http_request.form)
return redirect(
url_for(
"applications.settings",
application_id=application_id,
fragment="application-members",
_anchor="application-members",
)
)
@applications_bp.route(
"/applications/<application_id>/members/<application_role_id>/revoke_invite",
methods=["POST"],
)
@user_can(
Permissions.DELETE_APPLICATION_MEMBER, message="revoke application invitation"
)
def revoke_invite(application_id, application_role_id):
app_role = ApplicationRoles.get_by_id(application_role_id)
invite = app_role.latest_invitation
if invite.is_pending:
ApplicationInvitations.revoke(invite.token)
flash(
"invite_revoked",
resource="Application",
user_name=app_role.user_name,
resource_name=g.application.name,
)
else:
flash(
"application_invite_error",
user_name=app_role.user_name,
application_name=g.application.name,
)
return redirect(
url_for(
"applications.settings",
application_id=application_id,
fragment="application-members",
_anchor="application-members",
)
)
@applications_bp.route(
"/applications/<application_id>/members/<application_role_id>/resend_invite",
methods=["POST"],
)
@user_can(Permissions.EDIT_APPLICATION_MEMBER, message="resend application invitation")
def resend_invite(application_id, application_role_id):
app_role = ApplicationRoles.get_by_id(application_role_id)
invite = app_role.latest_invitation
form = MemberForm(http_request.form)
if form.validate():
new_invite = ApplicationInvitations.resend(
g.current_user, invite.token, form.data
)
send_application_invitation(
invitee_email=new_invite.email,
inviter_name=g.current_user.full_name,
token=new_invite.token,
)
flash("application_invite_resent", email=new_invite.email)
else:
flash(
"application_invite_error",
user_name=app_role.user_name,
application_name=g.application.name,
)
return redirect(
url_for(
"applications.settings",
application_id=application_id,
fragment="application-members",
_anchor="application-members",
)
)
def build_subscription_payload(environment) -> SubscriptionCreationCSPPayload:
csp_data = environment.portfolio.csp_data
parent_group_id = environment.cloud_id
invoice_section_name = csp_data["billing_profile_properties"]["invoice_sections"][
0
]["invoice_section_name"]
display_name = (
f"{environment.application.name}-{environment.name}-{token_urlsafe(6)}"
)
return SubscriptionCreationCSPPayload(
tenant_id=csp_data.get("tenant_id"),
display_name=display_name,
parent_group_id=parent_group_id,
billing_account_name=csp_data.get("billing_account_name"),
billing_profile_name=csp_data.get("billing_profile_name"),
invoice_section_name=invoice_section_name,
)
@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:
payload = build_subscription_payload(environment)
app.csp.cloud.create_subscription(payload)
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",
)
)

78
atat/routes/ccpo.py Normal file
View File

@@ -0,0 +1,78 @@
from flask import (
Blueprint,
render_template,
redirect,
url_for,
request,
current_app as app,
)
from atat.domain.users import Users
from atat.domain.audit_log import AuditLog
from atat.domain.common import Paginator
from atat.domain.exceptions import NotFoundError
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.forms.ccpo_user import CCPOUserForm
from atat.models.permissions import Permissions
from atat.utils.context_processors import atat as atat_context_processor
from atat.utils.flash import formatted_flash as flash
bp = Blueprint("ccpo", __name__)
bp.context_processor(atat_context_processor)
@bp.route("/activity-history")
@user_can(Permissions.VIEW_AUDIT_LOG, message="view activity log")
def activity_history():
if app.config.get("USE_AUDIT_LOG", False):
pagination_opts = Paginator.get_pagination_opts(request)
audit_events = AuditLog.get_all_events(pagination_opts)
return render_template("audit_log/audit_log.html", audit_events=audit_events)
else:
return redirect("/")
@bp.route("/ccpo-users")
@user_can(Permissions.VIEW_CCPO_USER, message="view ccpo users")
def users():
users = Users.get_ccpo_users()
users_info = [(user, CCPOUserForm(obj=user)) for user in users]
return render_template("ccpo/users.html", users_info=users_info)
@bp.route("/ccpo-users/new")
@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user")
def add_new_user():
form = CCPOUserForm()
return render_template("ccpo/add_user.html", form=form)
@bp.route("/ccpo-users/new", methods=["POST"])
@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user")
def submit_new_user():
try:
new_user = Users.get_by_dod_id(request.form["dod_id"])
form = CCPOUserForm(obj=new_user)
except NotFoundError:
flash("ccpo_user_not_found")
return redirect(url_for("ccpo.users"))
return render_template("ccpo/confirm_user.html", new_user=new_user, form=form)
@bp.route("/ccpo-users/confirm-new", methods=["POST"])
@user_can(Permissions.CREATE_CCPO_USER, message="create ccpo user")
def confirm_new_user():
user = Users.get_by_dod_id(request.form["dod_id"])
Users.give_ccpo_perms(user)
flash("ccpo_user_added", user_name=user.full_name)
return redirect(url_for("ccpo.users"))
@bp.route("/ccpo-users/remove-access/<user_id>", methods=["POST"])
@user_can(Permissions.DELETE_CCPO_USER, message="remove ccpo user")
def remove_access(user_id):
user = Users.get(user_id)
Users.revoke_ccpo_perms(user)
flash("ccpo_user_removed", user_name=user.full_name)
return redirect(url_for("ccpo.users"))

186
atat/routes/dev.py Normal file
View File

@@ -0,0 +1,186 @@
import random
from flask import (
Blueprint,
request,
redirect,
render_template,
url_for,
current_app as app,
)
import pendulum
from . import redirect_after_login_url, current_user_setup
from atat.domain.exceptions import AlreadyExistsError, NotFoundError
from atat.domain.users import Users
from atat.domain.permission_sets import PermissionSets
from atat.forms.data import SERVICE_BRANCHES
from atat.jobs import send_mail
from atat.utils import pick
bp = Blueprint("dev", __name__)
_ALL_PERMS = [
PermissionSets.VIEW_PORTFOLIO,
PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
PermissionSets.VIEW_PORTFOLIO_FUNDING,
PermissionSets.VIEW_PORTFOLIO_REPORTS,
PermissionSets.VIEW_PORTFOLIO_ADMIN,
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
PermissionSets.EDIT_PORTFOLIO_FUNDING,
PermissionSets.EDIT_PORTFOLIO_REPORTS,
PermissionSets.EDIT_PORTFOLIO_ADMIN,
PermissionSets.PORTFOLIO_POC,
PermissionSets.VIEW_AUDIT_LOG,
PermissionSets.MANAGE_CCPO_USERS,
]
def random_service_branch():
return random.choice([k for k, v in SERVICE_BRANCHES if k]) # nosec
_DEV_USERS = {
"sam": {
"dod_id": "6346349876",
"first_name": "Sam",
"last_name": "Stevenson",
"permission_sets": _ALL_PERMS,
"email": "sam@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
"amanda": {
"dod_id": "2345678901",
"first_name": "Amanda",
"last_name": "Adamson",
"email": "amanda@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
"brandon": {
"dod_id": "3456789012",
"first_name": "Brandon",
"last_name": "Buchannan",
"email": "brandon@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
"christina": {
"dod_id": "4567890123",
"first_name": "Christina",
"last_name": "Collins",
"email": "christina@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
"dominick": {
"dod_id": "5678901234",
"first_name": "Dominick",
"last_name": "Domingo",
"email": "dominick@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
"erica": {
"dod_id": "6789012345",
"first_name": "Erica",
"last_name": "Eichner",
"email": "erica@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
},
}
class IncompleteInfoError(Exception):
@property
def message(self):
return "You must provide each of: first_name, last_name and dod_id"
@bp.route("/login-dev")
def login_dev():
dod_id = request.args.get("dod_id", None)
if dod_id is not None:
user = Users.get_by_dod_id(dod_id)
else:
role = request.args.get("username", "amanda")
user_data = _DEV_USERS[role]
user = Users.get_or_create_by_dod_id(
user_data["dod_id"],
**pick(
[
"permission_sets",
"first_name",
"last_name",
"email",
"service_branch",
"phone_number",
"citizenship",
"designation",
"date_latest_training",
],
user_data,
),
)
current_user_setup(user)
return redirect(redirect_after_login_url())
@bp.route("/dev-new-user")
def dev_new_user():
first_name = request.args.get("first_name", None)
last_name = request.args.get("last_name", None)
dod_id = request.args.get("dod_id", None)
if None in [first_name, last_name, dod_id]:
raise IncompleteInfoError()
try:
Users.get_by_dod_id(dod_id)
raise AlreadyExistsError("User with dod_id {}".format(dod_id))
except NotFoundError:
pass
new_user = {"first_name": first_name, "last_name": last_name}
created_user = Users.create(dod_id, **new_user)
current_user_setup(created_user)
return redirect(redirect_after_login_url())
@bp.route("/test-email")
def test_email():
send_mail.delay(
[request.args.get("to")], request.args.get("subject"), request.args.get("body")
)
return redirect(url_for("dev.messages"))
@bp.route("/messages")
def messages():
return render_template("dev/emails.html", messages=app.mailer.messages)

85
atat/routes/errors.py Normal file
View File

@@ -0,0 +1,85 @@
from flask import render_template, current_app, url_for, redirect, request
from flask_wtf.csrf import CSRFError
import werkzeug.exceptions as werkzeug_exceptions
import atat.domain.exceptions as exceptions
from atat.domain.invitations import (
InvitationError,
ExpiredError as InvitationExpiredError,
WrongUserError as InvitationWrongUserError,
)
from atat.domain.authnid.crl import CRLInvalidException
from atat.domain.portfolios import PortfolioError
from atat.utils.flash import formatted_flash as flash
from atat.utils.localization import translate
NO_NOTIFY_STATUS_CODES = set([404, 401])
def log_error(e):
error_message = e.message if hasattr(e, "message") else str(e)
current_app.logger.exception(error_message)
def notify(e, message, code):
if code not in NO_NOTIFY_STATUS_CODES:
current_app.notification_sender.send(message)
def handle_error(e, message=translate("errors.not_found"), code=404):
log_error(e)
notify(e, message, code)
return (render_template("error.html", message=message, code=code), code)
def make_error_pages(app):
@app.errorhandler(werkzeug_exceptions.NotFound)
@app.errorhandler(exceptions.NotFoundError)
@app.errorhandler(exceptions.UnauthorizedError)
@app.errorhandler(PortfolioError)
@app.errorhandler(exceptions.NoAccessError)
# pylint: disable=unused-variable
def not_found(e):
return handle_error(e)
@app.errorhandler(CRLInvalidException)
# pylint: disable=unused-variable
def missing_crl(e):
return handle_error(e, message="Error Code 008", code=401)
@app.errorhandler(exceptions.UnauthenticatedError)
# pylint: disable=unused-variable
def unauthorized(e):
return handle_error(e, message="Log in Failed", code=401)
@app.errorhandler(CSRFError)
# pylint: disable=unused-variable
def session_expired(e):
log_error(e)
url_args = {"next": request.path}
flash("session_expired")
if request.method == "POST":
url_args[app.form_cache.PARAM_NAME] = app.form_cache.write(request.form)
return redirect(url_for("atat.root", **url_args))
@app.errorhandler(Exception)
# pylint: disable=unused-variable
def exception(e):
if current_app.debug:
raise e
return handle_error(e, message="An Unexpected Error Occurred", code=500)
@app.errorhandler(InvitationError)
@app.errorhandler(InvitationWrongUserError)
# pylint: disable=unused-variable
def invalid_invitation(e):
return handle_error(e, message="The link you followed is invalid.", code=404)
@app.errorhandler(InvitationExpiredError)
# pylint: disable=unused-variable
def invalid_invitation(e):
return handle_error(
e, message="The invitation you followed has expired.", code=404
)
return app

View File

@@ -0,0 +1,7 @@
from flask import request as http_request, g, render_template
from operator import attrgetter
from . import index
from . import invitations
from . import admin
from .blueprint import portfolios_bp

View File

@@ -0,0 +1,212 @@
from flask import render_template, request as http_request, g, redirect, url_for
from .blueprint import portfolios_bp
from atat.domain.portfolios import Portfolios
from atat.domain.portfolio_roles import PortfolioRoles
from atat.models.portfolio_role import Status as PortfolioRoleStatus
from atat.domain.invitations import PortfolioInvitations
from atat.domain.permission_sets import PermissionSets
from atat.domain.audit_log import AuditLog
from atat.domain.common import Paginator
from atat.forms.portfolio import PortfolioForm
import atat.forms.portfolio_member as member_forms
from atat.models.permissions import Permissions
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.utils import first_or_none
from atat.utils.flash import formatted_flash as flash
from atat.domain.exceptions import UnauthorizedError
def filter_perm_sets_data(member):
perm_sets_data = {
"perms_app_mgmt": bool(
member.has_permission_set(
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT
)
),
"perms_funding": bool(
member.has_permission_set(PermissionSets.EDIT_PORTFOLIO_FUNDING)
),
"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):
members_data = []
for member in members_list:
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"])
def render_admin_page(portfolio, form=None):
pagination_opts = Paginator.get_pagination_opts(http_request)
audit_events = AuditLog.get_portfolio_events(portfolio, pagination_opts)
portfolio_form = PortfolioForm(obj=portfolio)
member_list = portfolio.members
assign_ppoc_form = member_forms.AssignPPOCForm()
for pf_role in portfolio.roles:
if pf_role.user != portfolio.owner and pf_role.is_active:
assign_ppoc_form.role_id.choices += [(pf_role.id, pf_role.full_name)]
current_member = first_or_none(
lambda m: m.user_id == g.current_user.id, portfolio.members
)
current_member_id = current_member.id if current_member else None
return render_template(
"portfolios/admin.html",
form=form,
portfolio_form=portfolio_form,
members=filter_members_data(member_list),
new_manager_form=member_forms.NewForm(),
assign_ppoc_form=assign_ppoc_form,
portfolio=portfolio,
audit_events=audit_events,
user=g.current_user,
current_member_id=current_member_id,
applications_count=len(portfolio.applications),
)
@portfolios_bp.route("/portfolios/<portfolio_id>/admin")
@user_can(Permissions.VIEW_PORTFOLIO_ADMIN, message="view portfolio admin page")
def admin(portfolio_id):
portfolio = Portfolios.get_for_update(portfolio_id)
return render_admin_page(portfolio)
# 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"])
@user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio")
def edit(portfolio_id):
portfolio = Portfolios.get_for_update(portfolio_id)
form = PortfolioForm(http_request.form)
if form.validate():
Portfolios.update(portfolio, form.data)
return redirect(
url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
)
else:
# rerender portfolio admin page
return render_admin_page(portfolio, form)
@portfolios_bp.route(
"/portfolios/<portfolio_id>/members/<portfolio_role_id>/delete", methods=["POST"]
)
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="update portfolio members")
def remove_member(portfolio_id, portfolio_role_id):
portfolio_role = PortfolioRoles.get_by_id(portfolio_role_id)
if g.current_user.id == portfolio_role.user_id:
raise UnauthorizedError(
g.current_user, "you cant remove yourself from the portfolio"
)
portfolio = Portfolios.get(user=g.current_user, portfolio_id=portfolio_id)
if portfolio_role.user_id == portfolio.owner.id:
raise UnauthorizedError(
g.current_user, "you can't delete the portfolios PPoC from the portfolio"
)
if (
portfolio_role.latest_invitation
and portfolio_role.status == PortfolioRoleStatus.PENDING
):
PortfolioInvitations.revoke(portfolio_role.latest_invitation.token)
else:
PortfolioRoles.disable(portfolio_role=portfolio_role)
flash("portfolio_member_removed", member_name=portfolio_role.full_name)
return redirect(
url_for(
"portfolios.admin",
portfolio_id=portfolio_id,
_anchor="portfolio-members",
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

@@ -0,0 +1,5 @@
from flask import Blueprint
from atat.utils.context_processors import portfolio as portfolio_context_processor
portfolios_bp = Blueprint("portfolios", __name__)
portfolios_bp.context_processor(portfolio_context_processor)

View File

@@ -0,0 +1,57 @@
import pendulum
from flask import redirect, render_template, url_for, request as http_request, g
from .blueprint import portfolios_bp
from atat.forms.portfolio import PortfolioCreationForm
from atat.domain.reports import Reports
from atat.domain.portfolios import Portfolios
from atat.models.permissions import Permissions
from atat.domain.authz.decorator import user_can_access_decorator as user_can
from atat.utils.flash import formatted_flash as flash
@portfolios_bp.route("/portfolios/new")
def new_portfolio_step_1():
form = PortfolioCreationForm()
return render_template("portfolios/new/step_1.html", form=form)
@portfolios_bp.route("/portfolios", methods=["POST"])
def create_portfolio():
form = PortfolioCreationForm(http_request.form)
if form.validate():
portfolio = Portfolios.create(user=g.current_user, portfolio_attrs=form.data)
return redirect(
url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
)
else:
return render_template("portfolios/new/step_1.html", form=form), 400
@portfolios_bp.route("/portfolios/<portfolio_id>/reports")
@user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports")
def reports(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id)
spending = Reports.get_portfolio_spending(portfolio)
obligated = portfolio.total_obligated_funds
remaining = obligated - (spending["invoiced"] + spending["estimated"])
current_obligated_funds = {
**spending,
"obligated": obligated,
"remaining": remaining,
}
if current_obligated_funds["remaining"] < 0:
flash("insufficient_funds")
return render_template(
"portfolios/reports/index.html",
portfolio=portfolio,
# wrapped in str() because this sum returns a Decimal object
total_portfolio_value=str(portfolio.total_obligated_funds),
current_obligated_funds=current_obligated_funds,
expired_task_orders=Reports.expired_task_orders(portfolio),
retrieved=pendulum.now(), # mocked datetime of reporting data retrival
)

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