Update atst to atat

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,92 @@
from flask import (
g,
redirect,
url_for,
session,
request,
current_app as app,
_request_ctx_stack as request_ctx_stack,
)
from werkzeug.datastructures import ImmutableTypeConversionDict
from atat.domain.users import Users
UNPROTECTED_ROUTES = [
"atat.root",
"dev.login_dev",
"dev.dev_new_user",
"atat.login_redirect",
"atat.logout",
"atat.unauthorized",
"static",
"atat.about",
]
def apply_authentication(app):
@app.before_request
# pylint: disable=unused-variable
def enforce_login():
user = get_current_user()
if user:
g.current_user = user
g.last_login = get_last_login()
if should_redirect_to_user_profile(request, user):
return redirect(url_for("users.user", next=request.path))
elif not _unprotected_route(request):
return redirect(url_for("atat.root", next=request.path))
def should_redirect_to_user_profile(request, user):
has_complete_profile = user.profile_complete
is_unprotected_route = _unprotected_route(request)
is_requesting_user_endpoint = request.endpoint in [
"users.user",
"users.update_user",
]
if has_complete_profile or is_unprotected_route or is_requesting_user_endpoint:
return False
return True
def get_current_user():
user_id = session.get("user_id")
if user_id:
return Users.get(user_id)
else:
return False
def get_last_login():
return session.get("user_id") and session.get("last_login")
def _nullify_session(session):
session_key = f"{app.config.get('SESSION_KEY_PREFIX')}{session.sid}"
app.redis.delete(session_key)
request.cookies = ImmutableTypeConversionDict()
request_ctx_stack.top.session = app.session_interface.open_session(app, request)
def _current_dod_id():
return g.current_user.dod_id if session.get("user_id") else None
def logout():
dod_id = _current_dod_id()
_nullify_session(session)
if dod_id:
app.logger.info(f"user with EDIPI {dod_id} has logged out")
else:
app.logger.info("unauthenticated user has logged out")
def _unprotected_route(request):
if request.endpoint in UNPROTECTED_ROUTES:
return True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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