Update atst to atat
This commit is contained in:
0
atat/__init__.py
Normal file
0
atat/__init__.py
Normal file
366
atat/app.py
Normal file
366
atat/app.py
Normal 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
12
atat/assets.py
Normal 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
3
atat/database.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
25
atat/domain/__init__.py
Normal file
25
atat/domain/__init__.py
Normal 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)
|
142
atat/domain/application_roles.py
Normal file
142
atat/domain/application_roles.py
Normal 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
147
atat/domain/applications.py
Normal 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
78
atat/domain/audit_log.py
Normal 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
92
atat/domain/auth.py
Normal 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
|
59
atat/domain/authnid/__init__.py
Normal file
59
atat/domain/authnid/__init__.py
Normal 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
|
186
atat/domain/authnid/crl/__init__.py
Normal file
186
atat/domain/authnid/crl/__init__.py
Normal 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
|
||||
)
|
||||
)
|
367
atat/domain/authnid/crl/util.py
Normal file
367
atat/domain/authnid/crl/util.py
Normal 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")
|
39
atat/domain/authnid/utils.py
Normal file
39
atat/domain/authnid/utils.py
Normal 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
|
||||
)
|
||||
)
|
73
atat/domain/authz/__init__.py
Normal file
73
atat/domain/authz/__init__.py
Normal 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
|
50
atat/domain/authz/decorator.py
Normal file
50
atat/domain/authz/decorator.py
Normal 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
|
2
atat/domain/common/__init__.py
Normal file
2
atat/domain/common/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .query import Query
|
||||
from .query import Paginator
|
81
atat/domain/common/query.py
Normal file
81
atat/domain/common/query.py
Normal 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)
|
31
atat/domain/csp/__init__.py
Normal file
31
atat/domain/csp/__init__.py
Normal 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)
|
3
atat/domain/csp/cloud/__init__.py
Normal file
3
atat/domain/csp/cloud/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .azure_cloud_provider import AzureCloudProvider
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .mock_cloud_provider import MockCloudProvider
|
1841
atat/domain/csp/cloud/azure_cloud_provider.py
Normal file
1841
atat/domain/csp/cloud/azure_cloud_provider.py
Normal file
File diff suppressed because it is too large
Load Diff
87
atat/domain/csp/cloud/cloud_provider_interface.py
Normal file
87
atat/domain/csp/cloud/cloud_provider_interface.py
Normal 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()
|
146
atat/domain/csp/cloud/exceptions.py
Normal file
146
atat/domain/csp/cloud/exceptions.py
Normal 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}"
|
519
atat/domain/csp/cloud/mock_cloud_provider.py
Normal file
519
atat/domain/csp/cloud/mock_cloud_provider.py
Normal 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,)
|
||||
)
|
621
atat/domain/csp/cloud/models.py
Normal file
621
atat/domain/csp/cloud/models.py
Normal 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
|
47
atat/domain/csp/cloud/policy.py
Normal file
47
atat/domain/csp/cloud/policy.py
Normal 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
|
10
atat/domain/csp/cloud/utils.py
Normal file
10
atat/domain/csp/cloud/utils.py
Normal 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
107
atat/domain/csp/files.py
Normal 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,
|
||||
}
|
35
atat/domain/csp/reports.py
Normal file
35
atat/domain/csp/reports.py
Normal 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])),
|
||||
)
|
154
atat/domain/environment_roles.py
Normal file
154
atat/domain/environment_roles.py
Normal 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
140
atat/domain/environments.py
Normal 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
65
atat/domain/exceptions.py
Normal 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
144
atat/domain/invitations.py
Normal 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
|
241
atat/domain/permission_sets.py
Normal file
241
atat/domain/permission_sets.py
Normal 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,
|
||||
]
|
136
atat/domain/portfolio_roles.py
Normal file
136
atat/domain/portfolio_roles.py
Normal 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()
|
6
atat/domain/portfolios/__init__.py
Normal file
6
atat/domain/portfolios/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .portfolios import (
|
||||
Portfolios,
|
||||
PortfolioError,
|
||||
PortfolioDeletionApplicationsExistError,
|
||||
PortfolioStateMachines,
|
||||
)
|
172
atat/domain/portfolios/portfolios.py
Normal file
172
atat/domain/portfolios/portfolios.py
Normal 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]
|
66
atat/domain/portfolios/query.py
Normal file
66
atat/domain/portfolios/query.py
Normal 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)
|
39
atat/domain/portfolios/scopes.py
Normal file
39
atat/domain/portfolios/scopes.py
Normal 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
33
atat/domain/reports.py
Normal 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
102
atat/domain/task_orders.py
Normal 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
122
atat/domain/users.py
Normal 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
91
atat/filters.py
Normal 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
48
atat/forms/application.py
Normal 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"
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
72
atat/forms/application_member.py
Normal file
72
atat/forms/application_member.py
Normal 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
13
atat/forms/ccpo_user.py
Normal 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
30
atat/forms/data.py
Normal 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
93
atat/forms/edit_user.py
Normal 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
8
atat/forms/fields.py
Normal 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
46
atat/forms/forms.py
Normal 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
30
atat/forms/member.py
Normal 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
47
atat/forms/portfolio.py
Normal 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"
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
64
atat/forms/portfolio_member.py
Normal file
64
atat/forms/portfolio_member.py
Normal 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
176
atat/forms/task_order.py
Normal 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
104
atat/forms/validators.py
Normal 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
363
atat/jobs.py
Normal 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
21
atat/models/__init__.py
Normal 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
|
68
atat/models/application.py
Normal file
68
atat/models/application.py
Normal 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()
|
50
atat/models/application_invitation.py
Normal file
50
atat/models/application_invitation.py
Normal 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
|
153
atat/models/application_role.py
Normal file
153
atat/models/application_role.py
Normal 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
65
atat/models/attachment.py
Normal 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)
|
56
atat/models/audit_event.py
Normal file
56
atat/models/audit_event.py
Normal 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
3
atat/models/base.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
85
atat/models/clin.py
Normal file
85
atat/models/clin.py
Normal 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
|
79
atat/models/environment.py
Normal file
79
atat/models/environment.py
Normal 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()
|
99
atat/models/environment_role.py
Normal file
99
atat/models/environment_role.py
Normal 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,
|
||||
)
|
21
atat/models/job_failure.py
Normal file
21
atat/models/job_failure.py
Normal 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
|
7
atat/models/mixins/__init__.py
Normal file
7
atat/models/mixins/__init__.py
Normal 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
|
121
atat/models/mixins/auditable.py
Normal file
121
atat/models/mixins/auditable.py
Normal 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,
|
||||
)
|
5
atat/models/mixins/claimable.py
Normal file
5
atat/models/mixins/claimable.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy import Column, TIMESTAMP
|
||||
|
||||
|
||||
class ClaimableMixin(object):
|
||||
claimed_until = Column(TIMESTAMP(timezone=True))
|
6
atat/models/mixins/deletable.py
Normal file
6
atat/models/mixins/deletable.py
Normal 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())
|
139
atat/models/mixins/invites.py
Normal file
139
atat/models/mixins/invites.py
Normal 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
|
6
atat/models/mixins/permissions.py
Normal file
6
atat/models/mixins/permissions.py
Normal 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
|
||||
]
|
158
atat/models/mixins/state_machines.py
Normal file
158
atat/models/mixins/state_machines.py
Normal 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)
|
13
atat/models/mixins/timestamps.py
Normal file
13
atat/models/mixins/timestamps.py
Normal 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(),
|
||||
)
|
12
atat/models/notification_recipient.py
Normal file
12
atat/models/notification_recipient.py
Normal 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)
|
21
atat/models/permission_set.py
Normal file
21
atat/models/permission_set.py
Normal 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
|
||||
)
|
51
atat/models/permissions.py
Normal file
51
atat/models/permissions.py
Normal 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
228
atat/models/portfolio.py
Normal 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
|
||||
)
|
33
atat/models/portfolio_invitation.py
Normal file
33
atat/models/portfolio_invitation.py
Normal 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
|
148
atat/models/portfolio_role.py
Normal file
148
atat/models/portfolio_role.py
Normal 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,
|
||||
)
|
261
atat/models/portfolio_state_machine.py
Normal file
261
atat/models/portfolio_state_machine.py
Normal 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
166
atat/models/task_order.py
Normal 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
11
atat/models/types.py
Normal 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
128
atat/models/user.py
Normal 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
100
atat/models/utils.py
Normal 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
46
atat/queue.py
Normal 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
132
atat/routes/__init__.py
Normal 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")
|
25
atat/routes/applications/__init__.py
Normal file
25
atat/routes/applications/__init__.py
Normal 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")
|
6
atat/routes/applications/blueprint.py
Normal file
6
atat/routes/applications/blueprint.py
Normal 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)
|
34
atat/routes/applications/index.py
Normal file
34
atat/routes/applications/index.py
Normal 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
|
||||
)
|
16
atat/routes/applications/invitations.py
Normal file
16
atat/routes/applications/invitations.py
Normal 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,
|
||||
)
|
||||
)
|
167
atat/routes/applications/new.py
Normal file
167
atat/routes/applications/new.py
Normal 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
|
||||
)
|
||||
)
|
579
atat/routes/applications/settings.py
Normal file
579
atat/routes/applications/settings.py
Normal 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
78
atat/routes/ccpo.py
Normal 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
186
atat/routes/dev.py
Normal 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
85
atat/routes/errors.py
Normal 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
|
7
atat/routes/portfolios/__init__.py
Normal file
7
atat/routes/portfolios/__init__.py
Normal 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
|
212
atat/routes/portfolios/admin.py
Normal file
212
atat/routes/portfolios/admin.py
Normal 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)
|
5
atat/routes/portfolios/blueprint.py
Normal file
5
atat/routes/portfolios/blueprint.py
Normal 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)
|
57
atat/routes/portfolios/index.py
Normal file
57
atat/routes/portfolios/index.py
Normal 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
Reference in New Issue
Block a user