Update atst to atat
This commit is contained in:
25
atat/domain/__init__.py
Normal file
25
atat/domain/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from atat.database import db
|
||||
from atat.domain.exceptions import NotFoundError
|
||||
|
||||
|
||||
class BaseDomainClass(object):
|
||||
model = None
|
||||
resource_name = None
|
||||
|
||||
@classmethod
|
||||
def get(cls, resource_id, **kwargs):
|
||||
base_query = db.session.query(cls.model).filter(cls.model.id == resource_id)
|
||||
if getattr(cls.model, "deleted", False):
|
||||
base_query = base_query.filter(cls.model.deleted == False)
|
||||
|
||||
for col, val in kwargs.items():
|
||||
base_query = base_query.filter(getattr(cls.model, col) == val)
|
||||
|
||||
try:
|
||||
resource = base_query.one()
|
||||
|
||||
return resource
|
||||
except NoResultFound:
|
||||
raise NotFoundError(cls.resource_name)
|
142
atat/domain/application_roles.py
Normal file
142
atat/domain/application_roles.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from itertools import groupby
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy import func, and_, or_
|
||||
|
||||
from atat.database import db
|
||||
from atat.domain.environment_roles import EnvironmentRoles
|
||||
from atat.models import Application, ApplicationRole, ApplicationRoleStatus, Portfolio
|
||||
from .permission_sets import PermissionSets
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
|
||||
class ApplicationRoles(object):
|
||||
@classmethod
|
||||
def _permission_sets_for_names(cls, set_names):
|
||||
set_names = set(set_names).union({PermissionSets.VIEW_APPLICATION})
|
||||
return PermissionSets.get_many(set_names)
|
||||
|
||||
@classmethod
|
||||
def create(cls, user, application, permission_set_names):
|
||||
application_role = ApplicationRole(
|
||||
user=user, application_id=application.id, application=application
|
||||
)
|
||||
|
||||
application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
|
||||
permission_set_names
|
||||
)
|
||||
|
||||
db.session.add(application_role)
|
||||
db.session.commit()
|
||||
|
||||
return application_role
|
||||
|
||||
@classmethod
|
||||
def enable(cls, role, user):
|
||||
role.status = ApplicationRoleStatus.ACTIVE
|
||||
role.user = user
|
||||
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get(cls, user_id, application_id):
|
||||
try:
|
||||
app_role = (
|
||||
db.session.query(ApplicationRole)
|
||||
.filter_by(user_id=user_id, application_id=application_id)
|
||||
.one()
|
||||
)
|
||||
except NoResultFound:
|
||||
raise NotFoundError("application_role")
|
||||
|
||||
return app_role
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id_):
|
||||
try:
|
||||
return (
|
||||
db.session.query(ApplicationRole)
|
||||
.filter(ApplicationRole.id == id_)
|
||||
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
|
||||
.one()
|
||||
)
|
||||
except NoResultFound:
|
||||
raise NotFoundError("application_role")
|
||||
|
||||
@classmethod
|
||||
def get_many(cls, ids):
|
||||
return (
|
||||
db.session.query(ApplicationRole)
|
||||
.filter(ApplicationRole.id.in_(ids))
|
||||
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_permission_sets(cls, application_role, new_perm_sets_names):
|
||||
application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
|
||||
new_perm_sets_names
|
||||
)
|
||||
|
||||
db.session.add(application_role)
|
||||
db.session.commit()
|
||||
|
||||
return application_role
|
||||
|
||||
@classmethod
|
||||
def _update_status(cls, application_role, new_status):
|
||||
application_role.status = new_status
|
||||
db.session.add(application_role)
|
||||
db.session.commit()
|
||||
|
||||
return application_role
|
||||
|
||||
@classmethod
|
||||
def disable(cls, application_role):
|
||||
cls._update_status(application_role, ApplicationRoleStatus.DISABLED)
|
||||
application_role.deleted = True
|
||||
|
||||
for env in application_role.application.environments:
|
||||
EnvironmentRoles.delete(
|
||||
application_role_id=application_role.id, environment_id=env.id
|
||||
)
|
||||
|
||||
db.session.add(application_role)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_pending_creation(cls) -> List[List[UUID]]:
|
||||
"""
|
||||
Returns a list of lists of ApplicationRole IDs. The IDs
|
||||
should be grouped by user and portfolio.
|
||||
"""
|
||||
results = (
|
||||
db.session.query(ApplicationRole.id, ApplicationRole.user_id, Portfolio.id)
|
||||
.join(Application, Application.id == ApplicationRole.application_id)
|
||||
.join(Portfolio, Portfolio.id == Application.portfolio_id)
|
||||
.filter(
|
||||
and_(
|
||||
Application.cloud_id.isnot(None),
|
||||
ApplicationRole.deleted == False,
|
||||
ApplicationRole.cloud_id.is_(None),
|
||||
ApplicationRole.user_id.isnot(None),
|
||||
ApplicationRole.status == ApplicationRoleStatus.ACTIVE,
|
||||
or_(
|
||||
ApplicationRole.claimed_until.is_(None),
|
||||
ApplicationRole.claimed_until <= func.now(),
|
||||
),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
groups = []
|
||||
keyfunc = lambda pair: (pair[1], pair[2])
|
||||
sorted_results = sorted(results, key=keyfunc)
|
||||
for _, g in groupby(sorted_results, keyfunc):
|
||||
group = [pair[0] for pair in list(g)]
|
||||
groups.append(group)
|
||||
|
||||
return groups
|
147
atat/domain/applications.py
Normal file
147
atat/domain/applications.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from flask import g
|
||||
from sqlalchemy import func, or_, and_
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from . import BaseDomainClass
|
||||
from atat.database import db
|
||||
from atat.domain.application_roles import ApplicationRoles
|
||||
from atat.domain.environments import Environments
|
||||
from atat.domain.exceptions import NotFoundError
|
||||
from atat.domain.invitations import ApplicationInvitations
|
||||
from atat.models import (
|
||||
Application,
|
||||
ApplicationRole,
|
||||
ApplicationRoleStatus,
|
||||
EnvironmentRole,
|
||||
Portfolio,
|
||||
PortfolioStateMachine,
|
||||
)
|
||||
from atat.models.mixins.state_machines import FSMStates
|
||||
from atat.utils import first_or_none, commit_or_raise_already_exists_error
|
||||
|
||||
|
||||
class Applications(BaseDomainClass):
|
||||
model = Application
|
||||
resource_name = "application"
|
||||
|
||||
@classmethod
|
||||
def create(cls, user, portfolio, name, description, environment_names=None):
|
||||
application = Application(
|
||||
portfolio=portfolio, name=name, description=description
|
||||
)
|
||||
db.session.add(application)
|
||||
|
||||
if environment_names:
|
||||
Environments.create_many(user, application, environment_names)
|
||||
|
||||
commit_or_raise_already_exists_error(message="application")
|
||||
return application
|
||||
|
||||
@classmethod
|
||||
def for_user(self, user, portfolio):
|
||||
return (
|
||||
db.session.query(Application)
|
||||
.join(ApplicationRole)
|
||||
.filter(Application.portfolio_id == portfolio.id)
|
||||
.filter(ApplicationRole.application_id == Application.id)
|
||||
.filter(ApplicationRole.user_id == user.id)
|
||||
.filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update(cls, application, new_data):
|
||||
if "name" in new_data:
|
||||
application.name = new_data["name"]
|
||||
if "description" in new_data:
|
||||
application.description = new_data["description"]
|
||||
if "environment_names" in new_data:
|
||||
Environments.create_many(
|
||||
g.current_user, application, new_data["environment_names"]
|
||||
)
|
||||
|
||||
db.session.add(application)
|
||||
commit_or_raise_already_exists_error(message="application")
|
||||
return application
|
||||
|
||||
@classmethod
|
||||
def delete(cls, application):
|
||||
for env in application.environments:
|
||||
Environments.delete(env)
|
||||
|
||||
application.deleted = True
|
||||
|
||||
for role in application.roles:
|
||||
role.deleted = True
|
||||
role.status = ApplicationRoleStatus.DISABLED
|
||||
db.session.add(role)
|
||||
|
||||
db.session.add(application)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def invite(
|
||||
cls,
|
||||
application,
|
||||
inviter,
|
||||
user_data,
|
||||
permission_sets_names=None,
|
||||
environment_roles_data=None,
|
||||
):
|
||||
permission_sets_names = permission_sets_names or []
|
||||
permission_sets = ApplicationRoles._permission_sets_for_names(
|
||||
permission_sets_names
|
||||
)
|
||||
app_role = ApplicationRole(
|
||||
application=application, permission_sets=permission_sets
|
||||
)
|
||||
|
||||
db.session.add(app_role)
|
||||
|
||||
for env_role_data in environment_roles_data:
|
||||
env_role_name = env_role_data.get("role")
|
||||
environment_id = env_role_data.get("environment_id")
|
||||
if env_role_name is not None:
|
||||
# pylint: disable=cell-var-from-loop
|
||||
environment = first_or_none(
|
||||
lambda e: str(e.id) == str(environment_id), application.environments
|
||||
)
|
||||
if environment is None:
|
||||
raise NotFoundError("environment")
|
||||
else:
|
||||
env_role = EnvironmentRole(
|
||||
application_role=app_role,
|
||||
environment=environment,
|
||||
role=env_role_name,
|
||||
)
|
||||
db.session.add(env_role)
|
||||
|
||||
invitation = ApplicationInvitations.create(
|
||||
inviter=inviter, role=app_role, member_data=user_data
|
||||
)
|
||||
db.session.add(invitation)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return invitation
|
||||
|
||||
@classmethod
|
||||
def get_applications_pending_creation(cls) -> List[UUID]:
|
||||
results = (
|
||||
db.session.query(Application.id)
|
||||
.join(Portfolio)
|
||||
.join(PortfolioStateMachine)
|
||||
.filter(
|
||||
and_(
|
||||
PortfolioStateMachine.state == FSMStates.COMPLETED,
|
||||
Application.deleted == False,
|
||||
Application.cloud_id.is_(None),
|
||||
or_(
|
||||
Application.claimed_until.is_(None),
|
||||
Application.claimed_until <= func.now(),
|
||||
),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return [id_ for id_, in results]
|
78
atat/domain/audit_log.py
Normal file
78
atat/domain/audit_log.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from atat.database import db
|
||||
from atat.domain.common import Query
|
||||
from atat.models.audit_event import AuditEvent
|
||||
|
||||
|
||||
class AuditEventQuery(Query):
|
||||
model = AuditEvent
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, pagination_opts):
|
||||
query = db.session.query(cls.model).order_by(cls.model.time_created.desc())
|
||||
return cls.paginate(query, pagination_opts)
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_events(cls, portfolio_id, pagination_opts):
|
||||
query = (
|
||||
db.session.query(cls.model)
|
||||
.filter(cls.model.portfolio_id == portfolio_id)
|
||||
.order_by(cls.model.time_created.desc())
|
||||
)
|
||||
return cls.paginate(query, pagination_opts)
|
||||
|
||||
@classmethod
|
||||
def get_application_events(cls, application_id, pagination_opts):
|
||||
query = (
|
||||
db.session.query(cls.model)
|
||||
.filter(cls.model.application_id == application_id)
|
||||
.order_by(cls.model.time_created.desc())
|
||||
)
|
||||
return cls.paginate(query, pagination_opts)
|
||||
|
||||
|
||||
class AuditLog(object):
|
||||
@classmethod
|
||||
# TODO: see if this is being used anywhere and remove if not
|
||||
def log_system_event(cls, resource, action, portfolio=None):
|
||||
return cls._log(resource=resource, action=action, portfolio=portfolio)
|
||||
|
||||
@classmethod
|
||||
def get_all_events(cls, pagination_opts=None):
|
||||
return AuditEventQuery.get_all(pagination_opts)
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_events(cls, portfolio, pagination_opts=None):
|
||||
return AuditEventQuery.get_portfolio_events(portfolio.id, pagination_opts)
|
||||
|
||||
@classmethod
|
||||
def get_application_events(cls, application, pagination_opts=None):
|
||||
return AuditEventQuery.get_application_events(application.id, pagination_opts)
|
||||
|
||||
@classmethod
|
||||
def get_by_resource(cls, resource_id):
|
||||
return (
|
||||
db.session.query(AuditEvent)
|
||||
.filter(AuditEvent.resource_id == resource_id)
|
||||
.order_by(AuditEvent.time_created.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _resource_type(cls, resource):
|
||||
return type(resource).__name__.lower()
|
||||
|
||||
@classmethod
|
||||
# TODO: see if this is being used anywhere and remove if not
|
||||
def _log(cls, user=None, portfolio=None, resource=None, action=None):
|
||||
resource_id = resource.id if resource else None
|
||||
resource_type = cls._resource_type(resource) if resource else None
|
||||
portfolio_id = portfolio.id if portfolio else None
|
||||
|
||||
audit_event = AuditEventQuery.create(
|
||||
user=user,
|
||||
portfolio_id=portfolio_id,
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type,
|
||||
action=action,
|
||||
)
|
||||
return AuditEventQuery.add_and_commit(audit_event)
|
92
atat/domain/auth.py
Normal file
92
atat/domain/auth.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from flask import (
|
||||
g,
|
||||
redirect,
|
||||
url_for,
|
||||
session,
|
||||
request,
|
||||
current_app as app,
|
||||
_request_ctx_stack as request_ctx_stack,
|
||||
)
|
||||
from werkzeug.datastructures import ImmutableTypeConversionDict
|
||||
|
||||
from atat.domain.users import Users
|
||||
|
||||
|
||||
UNPROTECTED_ROUTES = [
|
||||
"atat.root",
|
||||
"dev.login_dev",
|
||||
"dev.dev_new_user",
|
||||
"atat.login_redirect",
|
||||
"atat.logout",
|
||||
"atat.unauthorized",
|
||||
"static",
|
||||
"atat.about",
|
||||
]
|
||||
|
||||
|
||||
def apply_authentication(app):
|
||||
@app.before_request
|
||||
# pylint: disable=unused-variable
|
||||
def enforce_login():
|
||||
user = get_current_user()
|
||||
if user:
|
||||
g.current_user = user
|
||||
g.last_login = get_last_login()
|
||||
|
||||
if should_redirect_to_user_profile(request, user):
|
||||
return redirect(url_for("users.user", next=request.path))
|
||||
elif not _unprotected_route(request):
|
||||
return redirect(url_for("atat.root", next=request.path))
|
||||
|
||||
|
||||
def should_redirect_to_user_profile(request, user):
|
||||
has_complete_profile = user.profile_complete
|
||||
is_unprotected_route = _unprotected_route(request)
|
||||
is_requesting_user_endpoint = request.endpoint in [
|
||||
"users.user",
|
||||
"users.update_user",
|
||||
]
|
||||
|
||||
if has_complete_profile or is_unprotected_route or is_requesting_user_endpoint:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_current_user():
|
||||
user_id = session.get("user_id")
|
||||
if user_id:
|
||||
return Users.get(user_id)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def get_last_login():
|
||||
return session.get("user_id") and session.get("last_login")
|
||||
|
||||
|
||||
def _nullify_session(session):
|
||||
session_key = f"{app.config.get('SESSION_KEY_PREFIX')}{session.sid}"
|
||||
app.redis.delete(session_key)
|
||||
request.cookies = ImmutableTypeConversionDict()
|
||||
request_ctx_stack.top.session = app.session_interface.open_session(app, request)
|
||||
|
||||
|
||||
def _current_dod_id():
|
||||
return g.current_user.dod_id if session.get("user_id") else None
|
||||
|
||||
|
||||
def logout():
|
||||
dod_id = _current_dod_id()
|
||||
|
||||
_nullify_session(session)
|
||||
|
||||
if dod_id:
|
||||
app.logger.info(f"user with EDIPI {dod_id} has logged out")
|
||||
else:
|
||||
app.logger.info("unauthenticated user has logged out")
|
||||
|
||||
|
||||
def _unprotected_route(request):
|
||||
if request.endpoint in UNPROTECTED_ROUTES:
|
||||
return True
|
59
atat/domain/authnid/__init__.py
Normal file
59
atat/domain/authnid/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from atat.domain.exceptions import UnauthenticatedError, NotFoundError
|
||||
from atat.domain.users import Users
|
||||
from .utils import parse_sdn, email_from_certificate
|
||||
from .crl import CRLRevocationException, CRLInvalidException
|
||||
|
||||
|
||||
class AuthenticationContext:
|
||||
def __init__(self, crl_cache, auth_status, sdn, cert):
|
||||
if None in locals().values():
|
||||
raise UnauthenticatedError(
|
||||
"Missing required authentication context components"
|
||||
)
|
||||
|
||||
self.crl_cache = crl_cache
|
||||
self.auth_status = auth_status
|
||||
self.sdn = sdn
|
||||
self.cert = cert.encode()
|
||||
self._parsed_sdn = None
|
||||
|
||||
def authenticate(self):
|
||||
if not self.auth_status == "SUCCESS":
|
||||
raise UnauthenticatedError("SSL/TLS client authentication failed")
|
||||
|
||||
self._crl_check()
|
||||
|
||||
return True
|
||||
|
||||
def get_user(self):
|
||||
try:
|
||||
return Users.get_by_dod_id(self.parsed_sdn["dod_id"])
|
||||
|
||||
except NotFoundError:
|
||||
email = self._get_user_email()
|
||||
return Users.create(permission_sets=[], email=email, **self.parsed_sdn)
|
||||
|
||||
def _get_user_email(self):
|
||||
try:
|
||||
return email_from_certificate(self.cert)
|
||||
|
||||
# this just means it is not an email certificate; we might choose to
|
||||
# log in that case
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _crl_check(self):
|
||||
try:
|
||||
self.crl_cache.crl_check(self.cert)
|
||||
except CRLRevocationException as exc:
|
||||
raise UnauthenticatedError("CRL check failed. " + str(exc))
|
||||
|
||||
@property
|
||||
def parsed_sdn(self):
|
||||
if not self._parsed_sdn:
|
||||
try:
|
||||
self._parsed_sdn = parse_sdn(self.sdn)
|
||||
except ValueError as exc:
|
||||
raise UnauthenticatedError(str(exc))
|
||||
|
||||
return self._parsed_sdn
|
186
atat/domain/authnid/crl/__init__.py
Normal file
186
atat/domain/authnid/crl/__init__.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from OpenSSL import crypto, SSL
|
||||
from flask import current_app as app
|
||||
|
||||
from .util import load_crl_locations_cache, serialize_crl_locations_cache, CRL_LIST
|
||||
|
||||
# error codes from OpenSSL: https://github.com/openssl/openssl/blob/2c75f03b39de2fa7d006bc0f0d7c58235a54d9bb/include/openssl/x509_vfy.h#L111
|
||||
CRL_EXPIRED_ERROR_CODE = 12
|
||||
|
||||
|
||||
def get_common_name(x509_name_object):
|
||||
for comp in x509_name_object.get_components():
|
||||
if comp[0] == b"CN":
|
||||
return comp[1].decode()
|
||||
|
||||
|
||||
class CRLRevocationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CRLInvalidException(Exception):
|
||||
# CRL expired
|
||||
# CRL missing
|
||||
pass
|
||||
|
||||
|
||||
class CRLInterface:
|
||||
def __init__(self, *args, logger=None, **kwargs):
|
||||
self.logger = logger
|
||||
|
||||
def _log(self, message, level=logging.INFO):
|
||||
if self.logger:
|
||||
self.logger.log(level, message, extra={"tags": ["authorization", "crl"]})
|
||||
|
||||
def crl_check(self, cert):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class NoOpCRLCache(CRLInterface):
|
||||
def _get_cn(self, cert):
|
||||
try:
|
||||
parsed = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
|
||||
return get_common_name(parsed.get_subject())
|
||||
except crypto.Error:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
def crl_check(self, cert):
|
||||
cn = self._get_cn(cert)
|
||||
self._log(
|
||||
"Did not perform CRL validation for certificate with Common Name '{}'".format(
|
||||
cn
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class CRLCache(CRLInterface):
|
||||
|
||||
_PEM_RE = re.compile(
|
||||
b"-----BEGIN CERTIFICATE-----\r?.+?\r?-----END CERTIFICATE-----\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_location,
|
||||
crl_dir,
|
||||
store_class=crypto.X509Store,
|
||||
logger=None,
|
||||
crl_list=CRL_LIST,
|
||||
):
|
||||
self._crl_dir = crl_dir
|
||||
self.logger = logger
|
||||
self.store_class = store_class
|
||||
self.certificate_authorities = {}
|
||||
self.crl_list = crl_list
|
||||
self._load_roots(root_location)
|
||||
self._build_crl_cache()
|
||||
|
||||
def _get_store(self, cert):
|
||||
return self._build_store(cert.get_issuer())
|
||||
|
||||
def _load_roots(self, root_location):
|
||||
with open(root_location, "rb") as f:
|
||||
for raw_ca in self._parse_roots(f.read()):
|
||||
ca = crypto.load_certificate(crypto.FILETYPE_PEM, raw_ca)
|
||||
self.certificate_authorities[ca.get_subject().der()] = ca
|
||||
|
||||
def _parse_roots(self, root_str):
|
||||
return [match.group(0) for match in self._PEM_RE.finditer(root_str)]
|
||||
|
||||
def _build_crl_cache(self):
|
||||
try:
|
||||
self.crl_cache = load_crl_locations_cache(self._crl_dir)
|
||||
except FileNotFoundError:
|
||||
self.crl_cache = serialize_crl_locations_cache(
|
||||
self._crl_dir, crl_list=self.crl_list
|
||||
)
|
||||
|
||||
def _load_crl(self, crl_location):
|
||||
with open(crl_location, "rb") as crl_file:
|
||||
try:
|
||||
return crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
|
||||
except crypto.Error:
|
||||
self._log(
|
||||
"Could not load CRL at location {}".format(crl_location),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
|
||||
def _build_store(self, issuer):
|
||||
store = self.store_class()
|
||||
self._log("STORE ID: {}. Building store.".format(id(store)))
|
||||
store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
|
||||
crl_location = self.crl_cache.get(issuer.der())
|
||||
issuer_name = get_common_name(issuer)
|
||||
|
||||
if not crl_location:
|
||||
raise CRLInvalidException(
|
||||
"Could not find matching CRL for issuer with Common Name {}".format(
|
||||
issuer_name
|
||||
)
|
||||
)
|
||||
|
||||
crl = self._load_crl(crl_location)
|
||||
store.add_crl(crl)
|
||||
|
||||
self._log(
|
||||
"STORE ID: {}. Adding CRL with issuer Common Name {}".format(
|
||||
id(store), issuer_name
|
||||
)
|
||||
)
|
||||
|
||||
store = self._add_certificate_chain_to_store(store, crl.get_issuer())
|
||||
return store
|
||||
|
||||
# this _should_ happen just twice for the DoD PKI (intermediary, root) but
|
||||
# theoretically it can build a longer certificate chain
|
||||
|
||||
def _add_certificate_chain_to_store(self, store, issuer):
|
||||
ca = self.certificate_authorities.get(issuer.der())
|
||||
store.add_cert(ca)
|
||||
self._log(
|
||||
"STORE ID: {}. Adding CA with subject {}".format(
|
||||
id(store), ca.get_subject()
|
||||
)
|
||||
)
|
||||
|
||||
if issuer == ca.get_issuer():
|
||||
# i.e., it is the root CA and we are at the end of the chain
|
||||
return store
|
||||
|
||||
else:
|
||||
return self._add_certificate_chain_to_store(store, ca.get_issuer())
|
||||
|
||||
def crl_check(self, cert):
|
||||
parsed = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
|
||||
store = self._get_store(parsed)
|
||||
context = crypto.X509StoreContext(store, parsed)
|
||||
try:
|
||||
context.verify_certificate()
|
||||
return True
|
||||
|
||||
except crypto.X509StoreContextError as err:
|
||||
if err.args[0][0] == CRL_EXPIRED_ERROR_CODE:
|
||||
if app.config.get("CRL_FAIL_OPEN"):
|
||||
self._log(
|
||||
"Encountered expired CRL for certificate with CN {} and issuer CN {}, failing open.".format(
|
||||
parsed.get_subject().CN, parsed.get_issuer().CN
|
||||
),
|
||||
level=logging.WARNING,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
raise CRLInvalidException("CRL expired. Args: {}".format(err.args))
|
||||
raise CRLRevocationException(
|
||||
"Certificate revoked or errored. Error: {}. Args: {}".format(
|
||||
type(err), err.args
|
||||
)
|
||||
)
|
367
atat/domain/authnid/crl/util.py
Normal file
367
atat/domain/authnid/crl/util.py
Normal file
@@ -0,0 +1,367 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import pendulum
|
||||
import requests
|
||||
|
||||
|
||||
class CRLNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CRLParseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
MODIFIED_TIME_BUFFER = 15 * 60
|
||||
|
||||
|
||||
CRL_LIST = [
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODROOTCA2.crl",
|
||||
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412032", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODROOTCA3.crl",
|
||||
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412033", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODROOTCA4.crl",
|
||||
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412034", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODROOTCA5.crl",
|
||||
"305b310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311630140603550403130d446f4420526f6f742043412035", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_33.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3333", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_34.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3334", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_35.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3335", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_36.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3336", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_37.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3337", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_38.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3338", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_39.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3339", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_40.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3430", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_41.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3431", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_42.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3432", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_43.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3433", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_44.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3434", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_45.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3435", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_46.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3436", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_47.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3437", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDSWCA_48.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f442049442053572043412d3438", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_49.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3439", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_50.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3530", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_51.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3531", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_52.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442049442043412d3532", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODIDCA_59.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442049442043412d3539", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_53.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3533", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_54.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3534", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_55.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3535", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_56.crl",
|
||||
"305a310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493115301306035504030c0c444f442053572043412d3536", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_57.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3537", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_58.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3538", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_60.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3630", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODSWCA_61.crl",
|
||||
"305a310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311530130603550403130c444f442053572043412d3631", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_33.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3333", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_34.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3334", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_39.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3339", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_40.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3430", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_41.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3431", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_42.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3432", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_43.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3433", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_44.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3434", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_49.crl",
|
||||
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3439", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_50.crl",
|
||||
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3530", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_51.crl",
|
||||
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3531", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_52.crl",
|
||||
"305d310b300906035504061302555331183016060355040a0c0f552e532e20476f7665726e6d656e74310c300a060355040b0c03446f44310c300a060355040b0c03504b493118301606035504030c0f444f4420454d41494c2043412d3532", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODEMAILCA_59.crl",
|
||||
"305d310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311830160603550403130f444f4420454d41494c2043412d3539", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODINTEROPERABILITYROOTCA1.crl",
|
||||
"306c310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312730250603550403131e446f4420496e7465726f7065726162696c69747920526f6f742043412031", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODINTEROPERABILITYROOTCA2.crl",
|
||||
"306c310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312730250603550403131e446f4420496e7465726f7065726162696c69747920526f6f742043412032", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/USDODCCEBINTEROPERABILITYROOTCA1.crl",
|
||||
"3074310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312f302d06035504031326555320446f44204343454220496e7465726f7065726162696c69747920526f6f742043412031", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/USDODCCEBINTEROPERABILITYROOTCA2.crl",
|
||||
"3074310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49312f302d06035504031326555320446f44204343454220496e7465726f7065726162696c69747920526f6f742043412032", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODNIPRINTERNALNPEROOTCA1.crl",
|
||||
"3075310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f4431143012060355040b130b496e7465726e616c4e5045312830260603550403131f446f44204e49505220496e7465726e616c204e504520526f6f742043412031", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODNPEROOTCA1.crl",
|
||||
"305f310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311a301806035504031311446f44204e504520526f6f742043412031", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DMDNSIGNINGCA_1.crl",
|
||||
"305f310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f44310c300a060355040b1303504b49311a301806035504031311444d444e205369676e696e672043412d31", # pragma: allowlist secret
|
||||
),
|
||||
(
|
||||
"https://crl.gds.disa.mil/crl/DODWCFROOTCA1.crl",
|
||||
"3063310b300906035504061302555331183016060355040a130f552e532e20476f7665726e6d656e74310c300a060355040b1303446f443110300e060355040b130757434620504b49311a301806035504031311446f442057434620526f6f742043412031", # pragma: allowlist secret
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
JSON_CACHE = "crl_locations.json"
|
||||
|
||||
|
||||
def _deserialize_cache_items(cache):
|
||||
return {bytes.fromhex(der): data for (der, data) in cache.items()}
|
||||
|
||||
|
||||
def load_crl_locations_cache(crl_dir):
|
||||
json_location = "{}/{}".format(crl_dir, JSON_CACHE)
|
||||
with open(json_location, "r") as json_file:
|
||||
cache = json.load(json_file)
|
||||
return _deserialize_cache_items(cache)
|
||||
|
||||
|
||||
def serialize_crl_locations_cache(crl_dir, crl_list=CRL_LIST):
|
||||
crl_cache = {}
|
||||
for crl_uri, crl_issuer in crl_list:
|
||||
crl_path = crl_local_path(crl_dir, crl_uri)
|
||||
if os.path.isfile(crl_path):
|
||||
crl_cache[crl_issuer] = crl_path
|
||||
|
||||
json_location = "{}/{}".format(crl_dir, JSON_CACHE)
|
||||
with open(json_location, "w") as json_file:
|
||||
json.dump(crl_cache, json_file)
|
||||
|
||||
return {bytes.fromhex(k): v for k, v in crl_cache.items()}
|
||||
|
||||
|
||||
def crl_local_path(out_dir, crl_location):
|
||||
name = re.split("/", crl_location)[-1]
|
||||
crl = os.path.join(out_dir, name)
|
||||
return crl
|
||||
|
||||
|
||||
def existing_crl_modification_time(crl):
|
||||
if os.path.exists(crl):
|
||||
prev_time = os.path.getmtime(crl)
|
||||
buffered = prev_time + MODIFIED_TIME_BUFFER
|
||||
mod_time = prev_time if pendulum.now().timestamp() < buffered else buffered
|
||||
dt = pendulum.from_timestamp(mod_time, tz="UTC")
|
||||
return dt.format("ddd, DD MMM YYYY HH:mm:ss zz")
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def write_crl(out_dir, target_dir, crl_location):
|
||||
crl = crl_local_path(out_dir, crl_location)
|
||||
existing = crl_local_path(target_dir, crl_location)
|
||||
options = {"stream": True}
|
||||
mod_time = existing_crl_modification_time(existing)
|
||||
if mod_time:
|
||||
options["headers"] = {"If-Modified-Since": mod_time}
|
||||
|
||||
with requests.get(crl_location, **options) as response:
|
||||
if response.status_code > 399:
|
||||
raise CRLNotFoundError()
|
||||
|
||||
if response.status_code == 304:
|
||||
return (False, existing)
|
||||
|
||||
with open(crl, "wb") as crl_file:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
crl_file.write(chunk)
|
||||
|
||||
return (True, existing)
|
||||
|
||||
|
||||
def remove_bad_crl(out_dir, crl_location):
|
||||
crl = crl_local_path(out_dir, crl_location)
|
||||
os.remove(crl)
|
||||
|
||||
|
||||
def log_error(logger, crl_location):
|
||||
if logger:
|
||||
logger.error(
|
||||
"Error downloading {}, removing file and continuing anyway".format(
|
||||
crl_location
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def refresh_crl(out_dir, target_dir, crl_uri, logger):
|
||||
logger.info("updating CRL from {}".format(crl_uri))
|
||||
try:
|
||||
was_updated, crl_path = write_crl(out_dir, target_dir, crl_uri)
|
||||
if was_updated:
|
||||
logger.info("successfully synced CRL from {}".format(crl_uri))
|
||||
else:
|
||||
logger.info("no updates for CRL from {}".format(crl_uri))
|
||||
|
||||
return crl_path
|
||||
except requests.exceptions.ChunkedEncodingError:
|
||||
log_error(logger, crl_uri)
|
||||
remove_bad_crl(out_dir, crl_uri)
|
||||
except CRLNotFoundError:
|
||||
log_error(logger, crl_uri)
|
||||
|
||||
|
||||
def sync_crls(tmp_location, final_location):
|
||||
crl_cache = {}
|
||||
for crl_uri, crl_issuer in CRL_LIST:
|
||||
crl_path = refresh_crl(tmp_location, final_location, crl_uri, logger)
|
||||
crl_cache[crl_issuer] = crl_path
|
||||
|
||||
json_location = "{}/{}".format(final_location, JSON_CACHE)
|
||||
with open(json_location, "w") as json_file:
|
||||
json.dump(crl_cache, json_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="[%(asctime)s]:%(levelname)s: %(message)s"
|
||||
)
|
||||
logger = logging.getLogger()
|
||||
logger.info("Updating CRLs")
|
||||
try:
|
||||
tmp_location = sys.argv[1]
|
||||
final_location = sys.argv[2]
|
||||
sync_crls(tmp_location, final_location)
|
||||
except Exception as err:
|
||||
logger.exception("Fatal error encountered, stopping")
|
||||
sys.exit(1)
|
||||
logger.info("Finished updating CRLs")
|
39
atat/domain/authnid/utils.py
Normal file
39
atat/domain/authnid/utils.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import re
|
||||
|
||||
import cryptography.x509 as x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
def parse_sdn(sdn):
|
||||
try:
|
||||
parts = sdn.split(",")
|
||||
cn_string = [piece for piece in parts if re.match("^CN=", piece)][0]
|
||||
cn = cn_string.split("=")[-1]
|
||||
info = cn.split(".")
|
||||
return {"last_name": info[0], "first_name": info[1], "dod_id": info[-1]}
|
||||
|
||||
except (IndexError, AttributeError):
|
||||
raise ValueError("'{}' is not a valid SDN".format(sdn))
|
||||
|
||||
|
||||
def email_from_certificate(cert_file):
|
||||
cert = x509.load_pem_x509_certificate(cert_file, default_backend())
|
||||
try:
|
||||
ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
email = ext.value.get_values_for_type(x509.RFC822Name)
|
||||
if email:
|
||||
return email[0]
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
"No email available for certificate with serial {}".format(
|
||||
cert.serial_number
|
||||
)
|
||||
)
|
||||
|
||||
except x509.extensions.ExtensionNotFound:
|
||||
raise ValueError(
|
||||
"No subjectAltName available for certificate with serial {}".format(
|
||||
cert.serial_number
|
||||
)
|
||||
)
|
73
atat/domain/authz/__init__.py
Normal file
73
atat/domain/authz/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from atat.utils import first_or_none
|
||||
from atat.models.permissions import Permissions
|
||||
from atat.domain.exceptions import UnauthorizedError
|
||||
from atat.models.portfolio_role import Status as PortfolioRoleStatus
|
||||
from atat.models.application_role import Status as ApplicationRoleStatus
|
||||
|
||||
|
||||
class Authorization(object):
|
||||
@classmethod
|
||||
def has_atat_permission(cls, user, permission):
|
||||
return permission in user.permissions
|
||||
|
||||
@classmethod
|
||||
def has_portfolio_permission(cls, user, portfolio, permission):
|
||||
if Authorization.has_atat_permission(user, permission):
|
||||
return True
|
||||
|
||||
port_role = first_or_none(
|
||||
lambda pr: pr.portfolio == portfolio, user.portfolio_roles
|
||||
)
|
||||
if port_role and port_role.status is not PortfolioRoleStatus.DISABLED:
|
||||
return permission in port_role.permissions
|
||||
else:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_application_permission(cls, user, application, permission):
|
||||
if Authorization.has_portfolio_permission(
|
||||
user, application.portfolio, permission
|
||||
):
|
||||
return True
|
||||
|
||||
app_role = first_or_none(
|
||||
lambda app_role: app_role.application == application, user.application_roles
|
||||
)
|
||||
if app_role and app_role.status is not ApplicationRoleStatus.DISABLED:
|
||||
return permission in app_role.permissions
|
||||
else:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def check_atat_permission(cls, user, permission, message):
|
||||
if not Authorization.has_atat_permission(user, permission):
|
||||
raise UnauthorizedError(user, message)
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_portfolio_permission(cls, user, portfolio, permission, message):
|
||||
if not Authorization.has_portfolio_permission(user, portfolio, permission):
|
||||
raise UnauthorizedError(user, message)
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_application_permission(cls, user, portfolio, permission, message):
|
||||
if not Authorization.has_application_permission(user, portfolio, permission):
|
||||
raise UnauthorizedError(user, message)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def user_can_access(user, permission, portfolio=None, application=None, message=None):
|
||||
if application:
|
||||
Authorization.check_application_permission(
|
||||
user, application, permission, message
|
||||
)
|
||||
elif portfolio:
|
||||
Authorization.check_portfolio_permission(user, portfolio, permission, message)
|
||||
else:
|
||||
Authorization.check_atat_permission(user, permission, message)
|
||||
|
||||
return True
|
50
atat/domain/authz/decorator.py
Normal file
50
atat/domain/authz/decorator.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from functools import wraps
|
||||
|
||||
from flask import g, current_app as app, request
|
||||
|
||||
from . import user_can_access
|
||||
from atat.domain.exceptions import UnauthorizedError
|
||||
|
||||
|
||||
def check_access(permission, message, override, *args, **kwargs):
|
||||
access_args = {
|
||||
"message": message,
|
||||
"portfolio": g.portfolio,
|
||||
"application": g.application,
|
||||
}
|
||||
|
||||
if override is not None and override(g.current_user, **access_args, **kwargs):
|
||||
return True
|
||||
|
||||
user_can_access(g.current_user, permission, **access_args)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def user_can_access_decorator(permission, message=None, override=None):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
try:
|
||||
check_access(permission, message, override, *args, **kwargs)
|
||||
app.logger.info(
|
||||
"User {} accessed {} {}".format(
|
||||
g.current_user.id, request.method, request.path
|
||||
),
|
||||
extra={"tags": ["access", "success"]},
|
||||
)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
except UnauthorizedError as err:
|
||||
app.logger.warning(
|
||||
"User {} denied access {} {}".format(
|
||||
g.current_user.id, request.method, request.path
|
||||
),
|
||||
extra={"tags": ["access", "failure"]},
|
||||
)
|
||||
|
||||
raise (err)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
2
atat/domain/common/__init__.py
Normal file
2
atat/domain/common/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .query import Query
|
||||
from .query import Paginator
|
81
atat/domain/common/query.py
Normal file
81
atat/domain/common/query.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from sqlalchemy.exc import DataError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from atat.domain.exceptions import NotFoundError
|
||||
from atat.database import db
|
||||
|
||||
|
||||
class Paginator(object):
|
||||
"""
|
||||
Uses the Flask-SQLAlchemy extension's pagination method to paginate
|
||||
a query set.
|
||||
|
||||
Also acts as a proxy object so that the results of the query set can be iterated
|
||||
over without needing to call `.items`.
|
||||
"""
|
||||
|
||||
def __init__(self, query_set):
|
||||
self.query_set = query_set
|
||||
|
||||
@classmethod
|
||||
def get_pagination_opts(cls, request, default_page=1, default_per_page=100):
|
||||
return {
|
||||
"page": int(request.args.get("page", default_page)),
|
||||
"per_page": int(request.args.get("perPage", default_per_page)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def paginate(cls, query, pagination_opts=None):
|
||||
if pagination_opts is not None:
|
||||
return cls(
|
||||
query.paginate(
|
||||
page=pagination_opts["page"], per_page=pagination_opts["per_page"]
|
||||
)
|
||||
)
|
||||
else:
|
||||
return query.all()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.query_set, name)
|
||||
|
||||
def __iter__(self):
|
||||
return self.items.__iter__()
|
||||
|
||||
def __len__(self):
|
||||
return self.items.__len__()
|
||||
|
||||
|
||||
class Query(object):
|
||||
|
||||
model = None
|
||||
|
||||
@property
|
||||
def resource_name(cls):
|
||||
return cls.model.__class__.lower()
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
# pylint: disable=E1102
|
||||
return cls.model(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def get(cls, id_):
|
||||
try:
|
||||
resource = db.session.query(cls.model).filter_by(id=id_).one()
|
||||
return resource
|
||||
except (NoResultFound, DataError):
|
||||
raise NotFoundError(cls.resource_name)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
return db.session.query(cls.model).all()
|
||||
|
||||
@classmethod
|
||||
def add_and_commit(cls, resource):
|
||||
db.session.add(resource)
|
||||
db.session.commit()
|
||||
return resource
|
||||
|
||||
@classmethod
|
||||
def paginate(cls, query, pagination_opts):
|
||||
return Paginator.paginate(query, pagination_opts)
|
31
atat/domain/csp/__init__.py
Normal file
31
atat/domain/csp/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from .cloud import MockCloudProvider
|
||||
from .files import AzureFileService, MockFileService
|
||||
from .reports import MockReportingProvider
|
||||
|
||||
|
||||
class MockCSP:
|
||||
def __init__(self, app, test_mode=False):
|
||||
self.cloud = MockCloudProvider(
|
||||
app.config,
|
||||
with_delay=(not test_mode),
|
||||
with_failure=(not test_mode),
|
||||
with_authorization=(not test_mode),
|
||||
)
|
||||
self.files = MockFileService(app)
|
||||
self.reports = MockReportingProvider()
|
||||
|
||||
|
||||
class AzureCSP:
|
||||
def __init__(self, app):
|
||||
self.cloud = MockCloudProvider(app.config)
|
||||
self.files = AzureFileService(app.config)
|
||||
self.reports = MockReportingProvider()
|
||||
|
||||
|
||||
def make_csp_provider(app, csp=None):
|
||||
if csp == "azure":
|
||||
app.csp = AzureCSP(app)
|
||||
elif csp == "mock-test":
|
||||
app.csp = MockCSP(app, test_mode=True)
|
||||
else:
|
||||
app.csp = MockCSP(app)
|
3
atat/domain/csp/cloud/__init__.py
Normal file
3
atat/domain/csp/cloud/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .azure_cloud_provider import AzureCloudProvider
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .mock_cloud_provider import MockCloudProvider
|
1841
atat/domain/csp/cloud/azure_cloud_provider.py
Normal file
1841
atat/domain/csp/cloud/azure_cloud_provider.py
Normal file
File diff suppressed because it is too large
Load Diff
87
atat/domain/csp/cloud/cloud_provider_interface.py
Normal file
87
atat/domain/csp/cloud/cloud_provider_interface.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class CloudProviderInterface: # pragma: no cover
|
||||
def set_secret(self, secret_key: str, secret_value: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_secret(self, secret_key: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def root_creds(self) -> Dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_environment(self, payload):
|
||||
"""Create a new environment in the CSP.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user -- ATAT user authorizing the environment creation
|
||||
environment -- ATAT Environment model
|
||||
|
||||
Returns:
|
||||
string: ID of created environment
|
||||
|
||||
Raises:
|
||||
AuthenticationException: Problem with the credentials
|
||||
AuthorizationException: Credentials not authorized for current action(s)
|
||||
ConnectionException: Issue with the CSP API connection
|
||||
UnknownServerException: Unknown issue on the CSP side
|
||||
EnvironmentExistsException: Environment already exists and has been created
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_or_update_user(
|
||||
self, auth_credentials: Dict, user_info, csp_role_id: str
|
||||
) -> str:
|
||||
"""Creates a user or updates an existing user's role.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user_info -- instance of EnvironmentRole containing user data
|
||||
if it has a csp_user_id it will try to update that user
|
||||
csp_role_id -- The id of the role the user should be given in the CSP
|
||||
|
||||
Returns:
|
||||
string: Returns the interal csp_user_id of the created/updated user account
|
||||
|
||||
Raises:
|
||||
AuthenticationException: Problem with the credentials
|
||||
AuthorizationException: Credentials not authorized for current action(s)
|
||||
ConnectionException: Issue with the CSP API connection
|
||||
UnknownServerException: Unknown issue on the CSP side
|
||||
UserProvisioningException: User couldn't be created or modified
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def disable_user(self, tenant_id: str, role_assignment_cloud_id: str) -> bool:
|
||||
"""Revoke all privileges for a user. Used to prevent user access while a full
|
||||
delete is being processed.
|
||||
|
||||
Arguments:
|
||||
tenant_id -- CSP internal tenant identifier
|
||||
role_assignment_cloud_id -- CSP name of the role assignment to delete.
|
||||
|
||||
Returns:
|
||||
bool -- True on success
|
||||
|
||||
Raises:
|
||||
AuthenticationException: Problem with the credentials
|
||||
AuthorizationException: Credentials not authorized for current action(s)
|
||||
ConnectionException: Issue with the CSP API connection
|
||||
UnknownServerException: Unknown issue on the CSP side
|
||||
UserRemovalException: User couldn't be suspended
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_calculator_url(self) -> str:
|
||||
"""Returns the calculator url for the CSP.
|
||||
This will likely be a static property elsewhere once a CSP is chosen.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_environment_login_url(self, environment) -> str:
|
||||
"""Returns the login url for a given environment
|
||||
This may move to be a computed property on the Environment domain object
|
||||
"""
|
||||
raise NotImplementedError()
|
146
atat/domain/csp/cloud/exceptions.py
Normal file
146
atat/domain/csp/cloud/exceptions.py
Normal file
@@ -0,0 +1,146 @@
|
||||
class GeneralCSPException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OperationInProgressException(GeneralCSPException):
|
||||
"""Throw this for instances when the CSP reports that the current entity is already
|
||||
being operated on/created/deleted/etc
|
||||
"""
|
||||
|
||||
def __init__(self, operation_desc):
|
||||
self.operation_desc = operation_desc
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An operation for this entity is already in progress: {}".format(
|
||||
self.operation_desc
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationException(GeneralCSPException):
|
||||
"""Throw this for instances when there is a problem with the auth credentials:
|
||||
* Missing credentials
|
||||
* Incorrect credentials
|
||||
* Other credential problems
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authentication: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class AuthorizationException(GeneralCSPException):
|
||||
"""Throw this for instances when the current credentials are not authorized
|
||||
for the current action.
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authorization: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class ConnectionException(GeneralCSPException):
|
||||
"""A general problem with the connection, timeouts or unresolved endpoints
|
||||
"""
|
||||
|
||||
def __init__(self, connection_error):
|
||||
self.connection_error = connection_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not connect to cloud provider: {}".format(self.connection_error)
|
||||
|
||||
|
||||
class UnknownServerException(GeneralCSPException):
|
||||
"""An error occured on the CSP side (5xx) and we don't know why
|
||||
"""
|
||||
|
||||
def __init__(self, status_code, server_error):
|
||||
self.status_code = status_code
|
||||
self.server_error = server_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return f"A server error with status code [{self.status_code}] occured: {self.server_error}"
|
||||
|
||||
|
||||
class EnvironmentCreationException(GeneralCSPException):
|
||||
"""If there was an error in creating the environment
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "The envionment {} couldn't be created: {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class UserProvisioningException(GeneralCSPException):
|
||||
"""Failed to provision a user
|
||||
"""
|
||||
|
||||
|
||||
class UserRemovalException(GeneralCSPException):
|
||||
"""Failed to remove a user
|
||||
"""
|
||||
|
||||
def __init__(self, user_csp_id, reason):
|
||||
self.user_csp_id = user_csp_id
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Failed to suspend or delete user {}: {}".format(
|
||||
self.user_csp_id, self.reason
|
||||
)
|
||||
|
||||
|
||||
class BaselineProvisionException(GeneralCSPException):
|
||||
"""If there's any issues standing up whatever is required
|
||||
for an environment baseline
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not complete baseline provisioning for environment ({}): {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class SecretException(GeneralCSPException):
|
||||
"""A problem occurred with setting or getting secrets"""
|
||||
|
||||
def __init__(self, tenant_id, reason):
|
||||
self.tenant_id = tenant_id
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not get or set secret for ({}): {}".format(
|
||||
self.tenant_id, self.reason
|
||||
)
|
||||
|
||||
|
||||
class DomainNameException(GeneralCSPException):
|
||||
"""A problem occured when generating the domain name for a tenant"""
|
||||
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return f"Could not generate unique tenant name for {self.name}"
|
519
atat/domain/csp/cloud/mock_cloud_provider.py
Normal file
519
atat/domain/csp/cloud/mock_cloud_provider.py
Normal file
@@ -0,0 +1,519 @@
|
||||
from uuid import uuid4
|
||||
import pendulum
|
||||
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .exceptions import (
|
||||
AuthenticationException,
|
||||
AuthorizationException,
|
||||
ConnectionException,
|
||||
GeneralCSPException,
|
||||
UnknownServerException,
|
||||
UserProvisioningException,
|
||||
UserRemovalException,
|
||||
)
|
||||
from .models import (
|
||||
AZURE_MGMNT_PATH,
|
||||
AdminRoleDefinitionCSPPayload,
|
||||
AdminRoleDefinitionCSPResult,
|
||||
ApplicationCSPPayload,
|
||||
ApplicationCSPResult,
|
||||
BillingInstructionCSPPayload,
|
||||
BillingInstructionCSPResult,
|
||||
BillingOwnerCSPPayload,
|
||||
BillingOwnerCSPResult,
|
||||
BillingProfileCreationCSPPayload,
|
||||
BillingProfileCreationCSPResult,
|
||||
BillingProfileTenantAccessCSPResult,
|
||||
BillingProfileVerificationCSPPayload,
|
||||
BillingProfileVerificationCSPResult,
|
||||
InitialMgmtGroupCSPPayload,
|
||||
InitialMgmtGroupCSPResult,
|
||||
InitialMgmtGroupVerificationCSPPayload,
|
||||
InitialMgmtGroupVerificationCSPResult,
|
||||
CostManagementQueryCSPResult,
|
||||
CostManagementQueryProperties,
|
||||
ProductPurchaseCSPPayload,
|
||||
ProductPurchaseCSPResult,
|
||||
ProductPurchaseVerificationCSPPayload,
|
||||
ProductPurchaseVerificationCSPResult,
|
||||
PrincipalAdminRoleCSPPayload,
|
||||
PrincipalAdminRoleCSPResult,
|
||||
ReportingCSPPayload,
|
||||
SubscriptionCreationCSPPayload,
|
||||
SubscriptionCreationCSPResult,
|
||||
SubscriptionVerificationCSPPayload,
|
||||
SuscriptionVerificationCSPResult,
|
||||
EnvironmentCSPPayload,
|
||||
EnvironmentCSPResult,
|
||||
TaskOrderBillingCreationCSPPayload,
|
||||
TaskOrderBillingCreationCSPResult,
|
||||
TaskOrderBillingVerificationCSPPayload,
|
||||
TaskOrderBillingVerificationCSPResult,
|
||||
TenantAdminOwnershipCSPPayload,
|
||||
TenantAdminOwnershipCSPResult,
|
||||
TenantCSPPayload,
|
||||
TenantCSPResult,
|
||||
TenantPrincipalAppCSPPayload,
|
||||
TenantPrincipalAppCSPResult,
|
||||
TenantPrincipalCredentialCSPPayload,
|
||||
TenantPrincipalCredentialCSPResult,
|
||||
TenantPrincipalCSPPayload,
|
||||
TenantPrincipalCSPResult,
|
||||
TenantPrincipalOwnershipCSPPayload,
|
||||
TenantPrincipalOwnershipCSPResult,
|
||||
UserCSPPayload,
|
||||
UserCSPResult,
|
||||
)
|
||||
|
||||
|
||||
class MockCloudProvider(CloudProviderInterface):
|
||||
|
||||
# TODO: All of these constants
|
||||
AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.")
|
||||
AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.")
|
||||
NETWORK_EXCEPTION = ConnectionException("Network failure.")
|
||||
SERVER_EXCEPTION = UnknownServerException(500, "Not our fault.")
|
||||
|
||||
SERVER_FAILURE_PCT = 1
|
||||
NETWORK_FAILURE_PCT = 7
|
||||
ENV_CREATE_FAILURE_PCT = 12
|
||||
ATAT_ADMIN_CREATE_FAILURE_PCT = 12
|
||||
UNAUTHORIZED_RATE = 2
|
||||
|
||||
def __init__(
|
||||
self, config, with_delay=True, with_failure=True, with_authorization=True
|
||||
):
|
||||
from time import sleep
|
||||
import random
|
||||
|
||||
self._with_delay = with_delay
|
||||
self._with_failure = with_failure
|
||||
self._with_authorization = with_authorization
|
||||
self._sleep = sleep
|
||||
self._random = random
|
||||
|
||||
def root_creds(self):
|
||||
return self._auth_credentials
|
||||
|
||||
def set_secret(self, secret_key: str, secret_value: str):
|
||||
pass
|
||||
|
||||
def get_secret(self, secret_key: str, default=dict()):
|
||||
return default
|
||||
|
||||
def create_subscription(self, payload: SubscriptionCreationCSPPayload):
|
||||
return self.create_subscription_creation(payload)
|
||||
|
||||
def create_subscription_creation(self, payload: SubscriptionCreationCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return SubscriptionCreationCSPResult(
|
||||
subscription_verify_url="https://zombo.com", subscription_retry_after=10
|
||||
)
|
||||
|
||||
def create_subscription_verification(
|
||||
self, payload: SubscriptionVerificationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return SuscriptionVerificationCSPResult(
|
||||
subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230"
|
||||
)
|
||||
|
||||
def create_tenant(self, payload: TenantCSPPayload):
|
||||
"""
|
||||
payload is an instance of TenantCSPPayload data class
|
||||
"""
|
||||
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantCSPResult(
|
||||
**{
|
||||
"tenant_id": "",
|
||||
"user_id": "",
|
||||
"user_object_id": "",
|
||||
"domain_name": "",
|
||||
"tenant_admin_username": "test",
|
||||
"tenant_admin_password": "test",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_profile_creation(
|
||||
self, payload: BillingProfileCreationCSPPayload
|
||||
):
|
||||
# response will be mostly the same as the body, but we only really care about the id
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return BillingProfileCreationCSPResult(
|
||||
**dict(
|
||||
billing_profile_verify_url="https://zombo.com",
|
||||
billing_profile_retry_after=10,
|
||||
)
|
||||
)
|
||||
|
||||
def create_billing_profile_verification(
|
||||
self, payload: BillingProfileVerificationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
return BillingProfileVerificationCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
"name": "KQWI-W2SU-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "Test Billing Profile",
|
||||
"enabledAzurePlans": [],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
"invoiceSections": [
|
||||
{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB",
|
||||
"name": "CHCO-BAAR-PJA-TGB",
|
||||
"properties": {"displayName": "Test Billing Profile"},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
|
||||
}
|
||||
],
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_profile_tenant_access(self, payload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return BillingProfileTenantAccessCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"properties": {
|
||||
"createdOn": "2020-01-14T14:39:26.3342192+00:00",
|
||||
"createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03",
|
||||
"principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
"roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
|
||||
"scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingRoleAssignments",
|
||||
}
|
||||
)
|
||||
|
||||
def create_task_order_billing_creation(
|
||||
self, payload: TaskOrderBillingCreationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TaskOrderBillingCreationCSPResult(
|
||||
**{"Location": "https://somelocation", "Retry-After": "10"}
|
||||
)
|
||||
|
||||
def create_task_order_billing_verification(
|
||||
self, payload: TaskOrderBillingVerificationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TaskOrderBillingVerificationCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/XC36-GRNZ-BG7-TGB",
|
||||
"name": "XC36-GRNZ-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "First Portfolio Billing Profile",
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"productId": "DZH318Z0BPS6",
|
||||
"skuId": "0001",
|
||||
"skuDescription": "Microsoft Azure Plan",
|
||||
}
|
||||
],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_instruction(self, payload: BillingInstructionCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return BillingInstructionCSPResult(
|
||||
**{
|
||||
"name": "TO1:CLIN001",
|
||||
"properties": {
|
||||
"amount": 1000.0,
|
||||
"endDate": "2020-03-01T00:00:00+00:00",
|
||||
"startDate": "2020-01-01T00:00:00+00:00",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions",
|
||||
}
|
||||
)
|
||||
|
||||
def create_initial_mgmt_group(self, payload: InitialMgmtGroupCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return InitialMgmtGroupCSPResult(
|
||||
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}",
|
||||
)
|
||||
|
||||
def create_initial_mgmt_group_verification(
|
||||
self, payload: InitialMgmtGroupVerificationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return InitialMgmtGroupVerificationCSPResult(
|
||||
**dict(
|
||||
id="Test Id"
|
||||
# id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
|
||||
)
|
||||
)
|
||||
|
||||
def create_product_purchase(self, payload: ProductPurchaseCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return ProductPurchaseCSPResult(
|
||||
**dict(
|
||||
product_purchase_verify_url="https://zombo.com",
|
||||
product_purchase_retry_after=10,
|
||||
)
|
||||
)
|
||||
|
||||
def create_product_purchase_verification(
|
||||
self, payload: ProductPurchaseVerificationCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return ProductPurchaseVerificationCSPResult(
|
||||
**dict(premium_purchase_date="2020-01-30T18:57:05.981Z")
|
||||
)
|
||||
|
||||
def create_tenant_admin_ownership(self, payload: TenantAdminOwnershipCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
return TenantAdminOwnershipCSPResult(**dict(id="admin_owner_assignment_id"))
|
||||
|
||||
def create_tenant_principal_ownership(
|
||||
self, payload: TenantPrincipalOwnershipCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantPrincipalOwnershipCSPResult(
|
||||
**dict(id="principal_owner_assignment_id")
|
||||
)
|
||||
|
||||
def create_tenant_principal_app(self, payload: TenantPrincipalAppCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantPrincipalAppCSPResult(
|
||||
**dict(appId="principal_app_id", id="principal_app_object_id")
|
||||
)
|
||||
|
||||
def create_tenant_principal(self, payload: TenantPrincipalCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantPrincipalCSPResult(**dict(id="principal_id"))
|
||||
|
||||
def create_tenant_principal_credential(
|
||||
self, payload: TenantPrincipalCredentialCSPPayload
|
||||
):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantPrincipalCredentialCSPResult(
|
||||
**dict(
|
||||
principal_client_id="principal_client_id",
|
||||
principal_creds_established=True,
|
||||
)
|
||||
)
|
||||
|
||||
def create_admin_role_definition(self, payload: AdminRoleDefinitionCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return AdminRoleDefinitionCSPResult(
|
||||
**dict(admin_role_def_id="admin_role_def_id")
|
||||
)
|
||||
|
||||
def create_principal_admin_role(self, payload: PrincipalAdminRoleCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return PrincipalAdminRoleCSPResult(**dict(id="principal_assignment_id"))
|
||||
|
||||
def create_billing_owner(self, payload: BillingOwnerCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return BillingOwnerCSPResult(billing_owner_id="foo")
|
||||
|
||||
def create_or_update_user(self, auth_credentials, user_info, csp_role_id):
|
||||
self._authorize(auth_credentials)
|
||||
|
||||
self._delay(1, 5)
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(
|
||||
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
|
||||
UserProvisioningException(
|
||||
user_info.environment.id,
|
||||
user_info.application_role.user_id,
|
||||
"Could not create user.",
|
||||
),
|
||||
)
|
||||
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
return self._id()
|
||||
|
||||
def disable_user(self, tenant_id, role_assignment_cloud_id):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
|
||||
self._maybe_raise(
|
||||
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
|
||||
UserRemovalException(tenant_id, "Could not disable user."),
|
||||
)
|
||||
|
||||
return self._maybe(12)
|
||||
|
||||
def get_calculator_url(self):
|
||||
return "https://www.rackspace.com/en-us/calculator"
|
||||
|
||||
def get_environment_login_url(self, environment):
|
||||
"""Returns the login url for a given environment
|
||||
"""
|
||||
return "https://www.mycloud.com/my-env-login"
|
||||
|
||||
def _id(self):
|
||||
return uuid4().hex
|
||||
|
||||
def _delay(self, min_secs, max_secs):
|
||||
if self._with_delay:
|
||||
duration = self._random.randrange(min_secs, max_secs)
|
||||
self._sleep(duration)
|
||||
|
||||
def _maybe(self, pct):
|
||||
return not self._with_failure or self._random.randrange(0, 100) < pct
|
||||
|
||||
def _maybe_raise(self, pct, exc):
|
||||
if self._with_failure and self._maybe(pct):
|
||||
raise exc
|
||||
|
||||
@property
|
||||
def _auth_credentials(self):
|
||||
return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret
|
||||
|
||||
def _authorize(self, credentials):
|
||||
self._delay(1, 5)
|
||||
if self._with_authorization and credentials != self._auth_credentials:
|
||||
raise self.AUTHENTICATION_EXCEPTION
|
||||
|
||||
def create_application(self, payload: ApplicationCSPPayload):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return ApplicationCSPResult(
|
||||
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
|
||||
)
|
||||
|
||||
def create_environment(self, payload: EnvironmentCSPPayload):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return EnvironmentCSPResult(
|
||||
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
|
||||
)
|
||||
|
||||
def create_user(self, payload: UserCSPPayload):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return UserCSPResult(id=str(uuid4()))
|
||||
|
||||
def get_credentials(self, scope="portfolio", tenant_id=None):
|
||||
return self.root_creds()
|
||||
|
||||
def update_tenant_creds(self, tenant_id, secret):
|
||||
return secret
|
||||
|
||||
def get_reporting_data(self, payload: ReportingCSPPayload):
|
||||
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
|
||||
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
object_id = str(uuid4())
|
||||
|
||||
start_of_month = pendulum.today(tz="utc").start_of("month").replace(tzinfo=None)
|
||||
this_month = start_of_month.to_atom_string()
|
||||
last_month = start_of_month.subtract(months=1).to_atom_string()
|
||||
two_months_ago = start_of_month.subtract(months=2).to_atom_string()
|
||||
|
||||
properties = CostManagementQueryProperties(
|
||||
**dict(
|
||||
columns=[
|
||||
{"name": "PreTaxCost", "type": "Number"},
|
||||
{"name": "BillingMonth", "type": "Datetime"},
|
||||
{"name": "InvoiceId", "type": "String"},
|
||||
{"name": "Currency", "type": "String"},
|
||||
],
|
||||
rows=[
|
||||
[1.0, two_months_ago, "", "USD"],
|
||||
[500.0, two_months_ago, "e05009w9sf", "USD"],
|
||||
[50.0, last_month, "", "USD"],
|
||||
[1000.0, last_month, "e0500a4qhw", "USD"],
|
||||
[500.0, this_month, "", "USD"],
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return CostManagementQueryCSPResult(
|
||||
**dict(name=object_id, properties=properties,)
|
||||
)
|
621
atat/domain/csp/cloud/models.py
Normal file
621
atat/domain/csp/cloud/models.py
Normal file
@@ -0,0 +1,621 @@
|
||||
from enum import Enum
|
||||
from secrets import token_urlsafe
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, validator, root_validator
|
||||
|
||||
from .utils import (
|
||||
generate_mail_nickname,
|
||||
generate_user_principal_name,
|
||||
)
|
||||
from atat.utils import snake_to_camel
|
||||
|
||||
|
||||
class AliasModel(BaseModel):
|
||||
"""
|
||||
This provides automatic camel <-> snake conversion for serializing to/from json
|
||||
You can override the alias generation in subclasses by providing a Config that defines
|
||||
a fields property with a dict mapping variables to their cast names, for cases like:
|
||||
* some_url:someURL
|
||||
* user_object_id:objectId
|
||||
"""
|
||||
|
||||
class Config:
|
||||
alias_generator = snake_to_camel
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class BaseCSPPayload(AliasModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class TenantCSPPayload(AliasModel):
|
||||
user_id: str
|
||||
password: Optional[str]
|
||||
domain_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
country_code: str
|
||||
password_recovery_email_address: str
|
||||
|
||||
|
||||
class TenantCSPResult(AliasModel):
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
domain_name: str
|
||||
|
||||
tenant_admin_username: Optional[str]
|
||||
tenant_admin_password: Optional[str]
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"user_object_id": "objectId",
|
||||
}
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
exclude = {"tenant_admin_username", "tenant_admin_password"}
|
||||
if "exclude" not in kwargs:
|
||||
kwargs["exclude"] = exclude
|
||||
else:
|
||||
kwargs["exclude"].update(exclude)
|
||||
|
||||
return super().dict(*args, **kwargs)
|
||||
|
||||
def get_creds(self):
|
||||
return {
|
||||
"tenant_admin_username": self.tenant_admin_username,
|
||||
"tenant_admin_password": self.tenant_admin_password,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileAddress(AliasModel):
|
||||
company_name: str
|
||||
address_line_1: str
|
||||
city: str
|
||||
region: str
|
||||
country: str
|
||||
postal_code: str
|
||||
|
||||
|
||||
class BillingProfileCLINBudget(AliasModel):
|
||||
clin_budget: Dict
|
||||
"""
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class BillingProfileCreationCSPPayload(BaseCSPPayload):
|
||||
tenant_id: str
|
||||
billing_profile_display_name: str
|
||||
billing_account_name: str
|
||||
enabled_azure_plans: Optional[List[str]]
|
||||
address: BillingProfileAddress
|
||||
|
||||
@validator("enabled_azure_plans", pre=True, always=True)
|
||||
def default_enabled_azure_plans(cls, v):
|
||||
"""
|
||||
Normally you'd implement this by setting the field with a value of:
|
||||
dataclasses.field(default_factory=list)
|
||||
but that prevents the object from being correctly pickled, so instead we need
|
||||
to rely on a validator to ensure this has an empty value when not specified
|
||||
"""
|
||||
return v or []
|
||||
|
||||
class Config:
|
||||
fields = {"billing_profile_display_name": "displayName"}
|
||||
|
||||
|
||||
class BillingProfileCreationCSPResult(AliasModel):
|
||||
billing_profile_verify_url: str
|
||||
billing_profile_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_verify_url": "Location",
|
||||
"billing_profile_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileVerificationCSPPayload(BaseCSPPayload):
|
||||
billing_profile_verify_url: str
|
||||
|
||||
|
||||
class BillingInvoiceSection(AliasModel):
|
||||
invoice_section_id: str
|
||||
invoice_section_name: str
|
||||
|
||||
class Config:
|
||||
fields = {"invoice_section_id": "id", "invoice_section_name": "name"}
|
||||
|
||||
|
||||
class BillingProfileProperties(AliasModel):
|
||||
address: BillingProfileAddress
|
||||
billing_profile_display_name: str
|
||||
invoice_sections: List[BillingInvoiceSection]
|
||||
|
||||
class Config:
|
||||
fields = {"billing_profile_display_name": "displayName"}
|
||||
|
||||
|
||||
class BillingProfileVerificationCSPResult(AliasModel):
|
||||
billing_profile_id: str
|
||||
billing_profile_name: str
|
||||
billing_profile_properties: BillingProfileProperties
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_id": "id",
|
||||
"billing_profile_name": "name",
|
||||
"billing_profile_properties": "properties",
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileTenantAccessCSPPayload(BaseCSPPayload):
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class BillingProfileTenantAccessCSPResult(AliasModel):
|
||||
billing_role_assignment_id: str
|
||||
billing_role_assignment_name: str
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_role_assignment_id": "id",
|
||||
"billing_role_assignment_name": "name",
|
||||
}
|
||||
|
||||
|
||||
class TaskOrderBillingCreationCSPPayload(BaseCSPPayload):
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class TaskOrderBillingCreationCSPResult(AliasModel):
|
||||
task_order_billing_verify_url: str
|
||||
task_order_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"task_order_billing_verify_url": "Location",
|
||||
"task_order_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload):
|
||||
task_order_billing_verify_url: str
|
||||
|
||||
|
||||
class BillingProfileEnabledPlanDetails(AliasModel):
|
||||
enabled_azure_plans: List[Dict]
|
||||
|
||||
|
||||
class TaskOrderBillingVerificationCSPResult(AliasModel):
|
||||
billing_profile_id: str
|
||||
billing_profile_name: str
|
||||
billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_id": "id",
|
||||
"billing_profile_name": "name",
|
||||
"billing_profile_enabled_plan_details": "properties",
|
||||
}
|
||||
|
||||
|
||||
class BillingInstructionCSPPayload(BaseCSPPayload):
|
||||
initial_clin_amount: float
|
||||
initial_clin_start_date: str
|
||||
initial_clin_end_date: str
|
||||
initial_clin_type: str
|
||||
initial_task_order_id: str
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class BillingInstructionCSPResult(AliasModel):
|
||||
reported_clin_name: str
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"reported_clin_name": "name",
|
||||
}
|
||||
|
||||
|
||||
class TenantAdminOwnershipCSPPayload(BaseCSPPayload):
|
||||
user_object_id: str
|
||||
|
||||
|
||||
class TenantAdminOwnershipCSPResult(AliasModel):
|
||||
admin_owner_assignment_id: str
|
||||
|
||||
class Config:
|
||||
fields = {"admin_owner_assignment_id": "id"}
|
||||
|
||||
|
||||
class TenantPrincipalOwnershipCSPPayload(BaseCSPPayload):
|
||||
principal_id: str
|
||||
|
||||
|
||||
class TenantPrincipalOwnershipCSPResult(AliasModel):
|
||||
principal_owner_assignment_id: str
|
||||
|
||||
class Config:
|
||||
fields = {"principal_owner_assignment_id": "id"}
|
||||
|
||||
|
||||
class TenantPrincipalAppCSPPayload(BaseCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class TenantPrincipalAppCSPResult(AliasModel):
|
||||
principal_app_id: str
|
||||
principal_app_object_id: str
|
||||
|
||||
class Config:
|
||||
fields = {"principal_app_id": "appId", "principal_app_object_id": "id"}
|
||||
|
||||
|
||||
class TenantPrincipalCSPPayload(BaseCSPPayload):
|
||||
principal_app_id: str
|
||||
|
||||
|
||||
class TenantPrincipalCSPResult(AliasModel):
|
||||
principal_id: str
|
||||
|
||||
class Config:
|
||||
fields = {"principal_id": "id"}
|
||||
|
||||
|
||||
class TenantPrincipalCredentialCSPPayload(BaseCSPPayload):
|
||||
principal_app_id: str
|
||||
principal_app_object_id: str
|
||||
|
||||
|
||||
class TenantPrincipalCredentialCSPResult(AliasModel):
|
||||
principal_client_id: str
|
||||
principal_creds_established: bool
|
||||
|
||||
|
||||
class AdminRoleDefinitionCSPPayload(BaseCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class AdminRoleDefinitionCSPResult(AliasModel):
|
||||
admin_role_def_id: str
|
||||
|
||||
|
||||
class PrincipalAdminRoleCSPPayload(BaseCSPPayload):
|
||||
principal_id: str
|
||||
admin_role_def_id: str
|
||||
|
||||
|
||||
class PrincipalAdminRoleCSPResult(AliasModel):
|
||||
principal_assignment_id: str
|
||||
|
||||
class Config:
|
||||
fields = {"principal_assignment_id": "id"}
|
||||
|
||||
|
||||
AZURE_MGMNT_PATH = "/providers/Microsoft.Management/managementGroups/"
|
||||
|
||||
MANAGEMENT_GROUP_NAME_REGEX = "^[a-zA-Z0-9\-_\(\)\.]+$"
|
||||
|
||||
|
||||
class ManagementGroupCSPPayload(AliasModel):
|
||||
"""
|
||||
:param: management_group_name: Just pass a UUID for this.
|
||||
:param: display_name: This can contain any character and
|
||||
spaces, but should be 90 characters or fewer long.
|
||||
:param: parent_id: This should be the fully qualified Azure ID,
|
||||
i.e. /providers/Microsoft.Management/managementGroups/[management group ID]
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
management_group_name: Optional[str]
|
||||
display_name: str
|
||||
parent_id: Optional[str]
|
||||
|
||||
@validator("management_group_name", pre=True, always=True)
|
||||
def supply_management_group_name_default(cls, name):
|
||||
if name:
|
||||
if re.match(MANAGEMENT_GROUP_NAME_REGEX, name) is None:
|
||||
raise ValueError(
|
||||
f"Management group name must match {MANAGEMENT_GROUP_NAME_REGEX}"
|
||||
)
|
||||
|
||||
return name[0:90]
|
||||
else:
|
||||
return str(uuid4())
|
||||
|
||||
@validator("display_name", pre=True, always=True)
|
||||
def enforce_display_name_length(cls, name):
|
||||
return name[0:90]
|
||||
|
||||
@validator("parent_id", pre=True, always=True)
|
||||
def enforce_parent_id_pattern(cls, id_):
|
||||
if id_:
|
||||
if AZURE_MGMNT_PATH not in id_:
|
||||
return f"{AZURE_MGMNT_PATH}{id_}"
|
||||
else:
|
||||
return id_
|
||||
|
||||
|
||||
class ManagementGroupCSPResponse(AliasModel):
|
||||
id: str
|
||||
|
||||
|
||||
class ManagementGroupGetCSPPayload(BaseCSPPayload):
|
||||
management_group_name: str
|
||||
|
||||
|
||||
class ManagementGroupGetCSPResponse(AliasModel):
|
||||
id: str
|
||||
|
||||
|
||||
class ApplicationCSPPayload(ManagementGroupCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class ApplicationCSPResult(ManagementGroupCSPResponse):
|
||||
pass
|
||||
|
||||
|
||||
class InitialMgmtGroupCSPPayload(ManagementGroupCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InitialMgmtGroupCSPResult(ManagementGroupCSPResponse):
|
||||
pass
|
||||
|
||||
|
||||
class InitialMgmtGroupVerificationCSPPayload(ManagementGroupGetCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class InitialMgmtGroupVerificationCSPResult(ManagementGroupGetCSPResponse):
|
||||
pass
|
||||
|
||||
|
||||
class EnvironmentCSPPayload(ManagementGroupCSPPayload):
|
||||
pass
|
||||
|
||||
|
||||
class EnvironmentCSPResult(ManagementGroupCSPResponse):
|
||||
pass
|
||||
|
||||
|
||||
class KeyVaultCredentials(BaseModel):
|
||||
root_sp_client_id: Optional[str]
|
||||
root_sp_key: Optional[str]
|
||||
root_tenant_id: Optional[str]
|
||||
|
||||
tenant_id: Optional[str]
|
||||
|
||||
tenant_admin_username: Optional[str]
|
||||
tenant_admin_password: Optional[str]
|
||||
|
||||
tenant_sp_client_id: Optional[str]
|
||||
tenant_sp_key: Optional[str]
|
||||
|
||||
@root_validator(pre=True)
|
||||
def enforce_admin_creds(cls, values):
|
||||
tenant_id = values.get("tenant_id")
|
||||
username = values.get("tenant_admin_username")
|
||||
password = values.get("tenant_admin_password")
|
||||
if any([username, password]) and not all([tenant_id, username, password]):
|
||||
raise ValueError(
|
||||
"tenant_id, tenant_admin_username, and tenant_admin_password must all be set if any one is"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
@root_validator(pre=True)
|
||||
def enforce_sp_creds(cls, values):
|
||||
tenant_id = values.get("tenant_id")
|
||||
client_id = values.get("tenant_sp_client_id")
|
||||
key = values.get("tenant_sp_key")
|
||||
if any([client_id, key]) and not all([tenant_id, client_id, key]):
|
||||
raise ValueError(
|
||||
"tenant_id, tenant_sp_client_id, and tenant_sp_key must all be set if any one is"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
@root_validator(pre=True)
|
||||
def enforce_root_creds(cls, values):
|
||||
sp_creds = [
|
||||
values.get("root_tenant_id"),
|
||||
values.get("root_sp_client_id"),
|
||||
values.get("root_sp_key"),
|
||||
]
|
||||
if any(sp_creds) and not all(sp_creds):
|
||||
raise ValueError(
|
||||
"root_tenant_id, root_sp_client_id, and root_sp_key must all be set if any one is"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
def merge_credentials(
|
||||
self, new_creds: "KeyVaultCredentials"
|
||||
) -> "KeyVaultCredentials":
|
||||
updated_creds = {k: v for k, v in new_creds.dict().items() if v}
|
||||
old_creds = self.dict()
|
||||
old_creds.update(updated_creds)
|
||||
|
||||
return KeyVaultCredentials(**old_creds)
|
||||
|
||||
|
||||
class SubscriptionCreationCSPPayload(BaseCSPPayload):
|
||||
display_name: str
|
||||
parent_group_id: str
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
invoice_section_name: str
|
||||
|
||||
|
||||
class SubscriptionCreationCSPResult(AliasModel):
|
||||
subscription_verify_url: str
|
||||
subscription_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"subscription_verify_url": "Location",
|
||||
"subscription_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class SubscriptionVerificationCSPPayload(BaseCSPPayload):
|
||||
subscription_verify_url: str
|
||||
|
||||
|
||||
SUBSCRIPTION_ID_REGEX = re.compile(
|
||||
"\/?subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
class SuscriptionVerificationCSPResult(AliasModel):
|
||||
subscription_id: str
|
||||
|
||||
@validator("subscription_id", pre=True, always=True)
|
||||
def enforce_display_name_length(cls, sub_id):
|
||||
sub_id_match = SUBSCRIPTION_ID_REGEX.match(sub_id)
|
||||
if sub_id_match:
|
||||
return sub_id_match.group(1)
|
||||
|
||||
return False
|
||||
|
||||
class Config:
|
||||
fields = {"subscription_id": "subscriptionLink"}
|
||||
|
||||
|
||||
class ProductPurchaseCSPPayload(BaseCSPPayload):
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class ProductPurchaseCSPResult(AliasModel):
|
||||
product_purchase_verify_url: str
|
||||
product_purchase_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"product_purchase_verify_url": "Location",
|
||||
"product_purchase_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class ProductPurchaseVerificationCSPPayload(BaseCSPPayload):
|
||||
product_purchase_verify_url: str
|
||||
|
||||
|
||||
class ProductPurchaseVerificationCSPResult(AliasModel):
|
||||
premium_purchase_date: str
|
||||
|
||||
|
||||
class UserMixin(BaseModel):
|
||||
password: Optional[str]
|
||||
|
||||
@property
|
||||
def user_principal_name(self):
|
||||
return generate_user_principal_name(self.display_name, self.tenant_host_name)
|
||||
|
||||
@property
|
||||
def mail_nickname(self):
|
||||
return generate_mail_nickname(self.display_name)
|
||||
|
||||
@validator("password", pre=True, always=True)
|
||||
def supply_password_default(cls, password):
|
||||
return password or token_urlsafe(16)
|
||||
|
||||
|
||||
class UserCSPPayload(BaseCSPPayload, UserMixin):
|
||||
display_name: str
|
||||
tenant_host_name: str
|
||||
email: str
|
||||
|
||||
|
||||
class UserCSPResult(AliasModel):
|
||||
id: str
|
||||
|
||||
|
||||
class UserRoleCSPPayload(BaseCSPPayload):
|
||||
class Roles(str, Enum):
|
||||
owner = "owner"
|
||||
contributor = "contributor"
|
||||
billing = "billing"
|
||||
|
||||
management_group_id: str
|
||||
role: Roles
|
||||
user_object_id: str
|
||||
|
||||
|
||||
class UserRoleCSPResult(AliasModel):
|
||||
id: str
|
||||
|
||||
|
||||
class QueryColumn(AliasModel):
|
||||
name: str
|
||||
type: str
|
||||
|
||||
|
||||
class CostManagementQueryProperties(AliasModel):
|
||||
columns: List[QueryColumn]
|
||||
rows: List[Optional[list]]
|
||||
|
||||
|
||||
class CostManagementQueryCSPResult(AliasModel):
|
||||
name: str
|
||||
properties: CostManagementQueryProperties
|
||||
|
||||
|
||||
class ReportingCSPPayload(BaseCSPPayload):
|
||||
invoice_section_id: str
|
||||
from_date: str
|
||||
to_date: str
|
||||
|
||||
@root_validator(pre=True)
|
||||
def extract_invoice_section(cls, values):
|
||||
try:
|
||||
values["invoice_section_id"] = values["billing_profile_properties"][
|
||||
"invoice_sections"
|
||||
][0]["invoice_section_id"]
|
||||
return values
|
||||
except (KeyError, IndexError):
|
||||
raise ValueError("Invoice section ID not present in payload")
|
||||
|
||||
|
||||
class BillingOwnerCSPPayload(BaseCSPPayload, UserMixin):
|
||||
"""
|
||||
This class needs to consume data in the shape it's in from the
|
||||
top-level portfolio CSP data, but return it in the shape
|
||||
needed for user provisioning.
|
||||
"""
|
||||
|
||||
display_name = "billing_admin"
|
||||
domain_name: str
|
||||
password_recovery_email_address: str
|
||||
|
||||
@property
|
||||
def tenant_host_name(self):
|
||||
return self.domain_name
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
return self.password_recovery_email_address
|
||||
|
||||
|
||||
class BillingOwnerCSPResult(AliasModel):
|
||||
billing_owner_id: str
|
47
atat/domain/csp/cloud/policy.py
Normal file
47
atat/domain/csp/cloud/policy.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from glob import glob
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from os.path import join as path_join
|
||||
|
||||
|
||||
class AzurePolicyManager:
|
||||
def __init__(self, static_policy_location):
|
||||
self._static_policy_location = static_policy_location
|
||||
|
||||
@property
|
||||
def portfolio_definitions(self):
|
||||
if getattr(self, "_portfolio_definitions", None) is None:
|
||||
portfolio_files = self._glob_json("portfolios")
|
||||
self._portfolio_definitions = self._load_policies(portfolio_files)
|
||||
|
||||
return self._portfolio_definitions
|
||||
|
||||
@property
|
||||
def application_definitions(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def environment_definitions(self):
|
||||
pass
|
||||
|
||||
def _glob_json(self, path):
|
||||
return glob(path_join(self._static_policy_location, "portfolios", "*.json"))
|
||||
|
||||
def _load_policies(self, json_policies):
|
||||
return [self._load_policy(pol) for pol in json_policies]
|
||||
|
||||
def _load_policy(self, policy_file):
|
||||
with open(policy_file, "r") as file_:
|
||||
doc = json.loads(file_.read())
|
||||
return AzurePolicy(
|
||||
definition_point=doc["definitionPoint"],
|
||||
definition=doc["policyDefinition"],
|
||||
parameters=doc["parameters"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AzurePolicy:
|
||||
definition_point: str
|
||||
definition: dict
|
||||
parameters: dict
|
10
atat/domain/csp/cloud/utils.py
Normal file
10
atat/domain/csp/cloud/utils.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from flask import current_app as app
|
||||
|
||||
|
||||
def generate_user_principal_name(name, domain_name):
|
||||
mail_name = generate_mail_nickname(name)
|
||||
return f"{mail_name}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}"
|
||||
|
||||
|
||||
def generate_mail_nickname(name):
|
||||
return name.replace(" ", ".").lower()
|
107
atat/domain/csp/files.py
Normal file
107
atat/domain/csp/files.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from uuid import uuid4
|
||||
import pendulum
|
||||
|
||||
|
||||
class FileService:
|
||||
def generate_token(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_download_link(self, object_name, filename) -> (dict, str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def object_name(self) -> str:
|
||||
return str(uuid4())
|
||||
|
||||
def download_task_order(self, object_name):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class MockFileService(FileService):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def get_token(self):
|
||||
return ({}, self.object_name())
|
||||
|
||||
def generate_download_link(self, object_name, filename):
|
||||
return ""
|
||||
|
||||
def download_task_order(self, object_name):
|
||||
with open("tests/fixtures/sample.pdf", "rb") as some_bytes:
|
||||
return {
|
||||
"name": object_name,
|
||||
"content": some_bytes,
|
||||
}
|
||||
|
||||
|
||||
class AzureFileService(FileService):
|
||||
def __init__(self, config):
|
||||
self.account_name = config["AZURE_ACCOUNT_NAME"]
|
||||
self.storage_key = config["AZURE_STORAGE_KEY"]
|
||||
self.container_name = config["AZURE_TO_BUCKET_NAME"]
|
||||
self.timeout = config["PERMANENT_SESSION_LIFETIME"]
|
||||
|
||||
from azure.storage.common import CloudStorageAccount
|
||||
from azure.storage.blob import BlobSasPermissions
|
||||
from azure.storage.blob.models import BlobPermissions
|
||||
from azure.storage.blob.blockblobservice import BlockBlobService
|
||||
|
||||
self.CloudStorageAccount = CloudStorageAccount
|
||||
self.BlobSasPermissions = BlobSasPermissions
|
||||
self.BlobPermissions = BlobPermissions
|
||||
self.BlockBlobService = BlockBlobService
|
||||
|
||||
def get_token(self):
|
||||
"""
|
||||
Generates an Azure SAS token for pre-authorizing a file upload.
|
||||
|
||||
Returns a tuple in the following format: (token_dict, object_name), where
|
||||
- token_dict has a `token` key which contains the SAS token as a string
|
||||
- object_name is a string
|
||||
"""
|
||||
account = self.CloudStorageAccount(
|
||||
account_name=self.account_name, account_key=self.storage_key
|
||||
)
|
||||
bbs = account.create_block_blob_service()
|
||||
object_name = self.object_name()
|
||||
sas_token = bbs.generate_blob_shared_access_signature(
|
||||
self.container_name,
|
||||
object_name,
|
||||
permission=self.BlobSasPermissions(create=True),
|
||||
expiry=pendulum.now(tz="utc").add(self.timeout),
|
||||
protocol="https",
|
||||
)
|
||||
return ({"token": sas_token}, object_name)
|
||||
|
||||
def generate_download_link(self, object_name, filename):
|
||||
block_blob_service = self.BlockBlobService(
|
||||
account_name=self.account_name, account_key=self.storage_key
|
||||
)
|
||||
sas_token = block_blob_service.generate_blob_shared_access_signature(
|
||||
container_name=self.container_name,
|
||||
blob_name=object_name,
|
||||
permission=self.BlobPermissions(read=True),
|
||||
expiry=pendulum.now(tz="utc").add(self.timeout),
|
||||
content_disposition=f"attachment; filename={filename}",
|
||||
protocol="https",
|
||||
)
|
||||
return block_blob_service.make_blob_url(
|
||||
container_name=self.container_name,
|
||||
blob_name=object_name,
|
||||
protocol="https",
|
||||
sas_token=sas_token,
|
||||
)
|
||||
|
||||
def download_task_order(self, object_name):
|
||||
block_blob_service = self.BlockBlobService(
|
||||
account_name=self.account_name, account_key=self.storage_key
|
||||
)
|
||||
# TODO: We should downloading errors more gracefully
|
||||
# - what happens when we try to request a TO that doesn't exist?
|
||||
b = block_blob_service.get_blob_to_bytes(
|
||||
container_name=self.container_name, blob_name=object_name,
|
||||
)
|
||||
return {
|
||||
"name": b.name,
|
||||
"content": b.content,
|
||||
}
|
35
atat/domain/csp/reports.py
Normal file
35
atat/domain/csp/reports.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
from decimal import Decimal
|
||||
import pendulum
|
||||
|
||||
|
||||
def load_fixture_data():
|
||||
with open("fixtures/fixture_spend_data.json") as json_file:
|
||||
return json.load(json_file)
|
||||
|
||||
|
||||
class MockReportingProvider:
|
||||
FIXTURE_SPEND_DATA = load_fixture_data()
|
||||
|
||||
|
||||
def prepare_azure_reporting_data(rows: list):
|
||||
"""
|
||||
Returns a dict representing invoiced and estimated funds for a portfolio given
|
||||
a list of rows from CostManagementQueryCSPResult.properties.rows
|
||||
{
|
||||
invoiced: Decimal,
|
||||
estimated: Decimal
|
||||
}
|
||||
"""
|
||||
|
||||
estimated = []
|
||||
while rows:
|
||||
if pendulum.parse(rows[-1][1]) >= pendulum.now(tz="utc").start_of("month"):
|
||||
estimated.append(rows.pop())
|
||||
else:
|
||||
break
|
||||
|
||||
return dict(
|
||||
invoiced=Decimal(sum([row[0] for row in rows])),
|
||||
estimated=Decimal(sum([row[0] for row in estimated])),
|
||||
)
|
154
atat/domain/environment_roles.py
Normal file
154
atat/domain/environment_roles.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy import func, and_, or_
|
||||
from flask import current_app as app
|
||||
|
||||
from atat.database import db
|
||||
from atat.models import (
|
||||
Environment,
|
||||
EnvironmentRole,
|
||||
Application,
|
||||
ApplicationRole,
|
||||
ApplicationRoleStatus,
|
||||
)
|
||||
from atat.domain.exceptions import NotFoundError
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
|
||||
class EnvironmentRoles(object):
|
||||
@classmethod
|
||||
def create(cls, application_role, environment, role):
|
||||
env_role = EnvironmentRole(
|
||||
application_role=application_role, environment=environment, role=role
|
||||
)
|
||||
return env_role
|
||||
|
||||
@classmethod
|
||||
def get(cls, application_role_id, environment_id):
|
||||
existing_env_role = (
|
||||
db.session.query(EnvironmentRole)
|
||||
.filter(
|
||||
EnvironmentRole.application_role_id == application_role_id,
|
||||
EnvironmentRole.environment_id == environment_id,
|
||||
EnvironmentRole.deleted == False,
|
||||
EnvironmentRole.status != EnvironmentRole.Status.DISABLED,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
return existing_env_role
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id_) -> EnvironmentRole:
|
||||
try:
|
||||
return (
|
||||
db.session.query(EnvironmentRole).filter(EnvironmentRole.id == id_)
|
||||
).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError(cls.resource_name)
|
||||
|
||||
@classmethod
|
||||
def get_by_user_and_environment(cls, user_id, environment_id):
|
||||
existing_env_role = (
|
||||
db.session.query(EnvironmentRole)
|
||||
.join(ApplicationRole)
|
||||
.filter(
|
||||
ApplicationRole.user_id == user_id,
|
||||
EnvironmentRole.environment_id == environment_id,
|
||||
EnvironmentRole.deleted == False,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
return existing_env_role
|
||||
|
||||
@classmethod
|
||||
def _update_status(cls, environment_role, new_status):
|
||||
environment_role.status = new_status
|
||||
db.session.add(environment_role)
|
||||
db.session.commit()
|
||||
|
||||
return environment_role
|
||||
|
||||
@classmethod
|
||||
def delete(cls, application_role_id, environment_id):
|
||||
existing_env_role = EnvironmentRoles.get(application_role_id, environment_id)
|
||||
|
||||
if existing_env_role:
|
||||
# TODO: Implement suspension
|
||||
existing_env_role.deleted = True
|
||||
db.session.add(existing_env_role)
|
||||
db.session.commit()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_for_application_member(cls, application_role_id):
|
||||
return (
|
||||
db.session.query(EnvironmentRole)
|
||||
.filter(EnvironmentRole.application_role_id == application_role_id)
|
||||
.filter(EnvironmentRole.deleted != True)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_pending_creation(cls) -> List[UUID]:
|
||||
results = (
|
||||
db.session.query(EnvironmentRole.id)
|
||||
.join(Environment)
|
||||
.join(ApplicationRole)
|
||||
.filter(
|
||||
and_(
|
||||
Environment.deleted == False,
|
||||
EnvironmentRole.deleted == False,
|
||||
ApplicationRole.deleted == False,
|
||||
ApplicationRole.cloud_id != None,
|
||||
ApplicationRole.status != ApplicationRoleStatus.DISABLED,
|
||||
EnvironmentRole.status != EnvironmentRole.Status.DISABLED,
|
||||
EnvironmentRole.cloud_id.is_(None),
|
||||
or_(
|
||||
EnvironmentRole.claimed_until.is_(None),
|
||||
EnvironmentRole.claimed_until <= func.now(),
|
||||
),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [id_ for id_, in results]
|
||||
|
||||
@classmethod
|
||||
def disable(cls, environment_role_id):
|
||||
environment_role = EnvironmentRoles.get_by_id(environment_role_id)
|
||||
|
||||
if environment_role.cloud_id and not environment_role.environment.cloud_id:
|
||||
tenant_id = environment_role.environment.portfolio.csp_data.get("tenant_id")
|
||||
app.csp.cloud.disable_user(tenant_id, environment_role.csp_user_id)
|
||||
|
||||
environment_role.status = EnvironmentRole.Status.DISABLED
|
||||
db.session.add(environment_role)
|
||||
db.session.commit()
|
||||
|
||||
return environment_role
|
||||
|
||||
@classmethod
|
||||
def get_for_update(cls, application_role_id, environment_id):
|
||||
existing_env_role = (
|
||||
db.session.query(EnvironmentRole)
|
||||
.filter(
|
||||
EnvironmentRole.application_role_id == application_role_id,
|
||||
EnvironmentRole.environment_id == environment_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
return existing_env_role
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, user_id, portfolio_id):
|
||||
return (
|
||||
db.session.query(EnvironmentRole)
|
||||
.join(ApplicationRole)
|
||||
.join(Application)
|
||||
.filter(Application.portfolio_id == portfolio_id)
|
||||
.filter(ApplicationRole.application_id == Application.id)
|
||||
.filter(ApplicationRole.user_id == user_id)
|
||||
.all()
|
||||
)
|
140
atat/domain/environments.py
Normal file
140
atat/domain/environments.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from sqlalchemy import func, or_, and_
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from atat.database import db
|
||||
from atat.models import (
|
||||
Environment,
|
||||
Application,
|
||||
Portfolio,
|
||||
TaskOrder,
|
||||
CLIN,
|
||||
)
|
||||
from atat.domain.environment_roles import EnvironmentRoles
|
||||
from atat.utils import commit_or_raise_already_exists_error
|
||||
|
||||
from .exceptions import NotFoundError, DisabledError
|
||||
|
||||
|
||||
class Environments(object):
|
||||
@classmethod
|
||||
def create(cls, user, application, name):
|
||||
environment = Environment(application=application, name=name, creator=user)
|
||||
db.session.add(environment)
|
||||
commit_or_raise_already_exists_error(message="environment")
|
||||
return environment
|
||||
|
||||
@classmethod
|
||||
def create_many(cls, user, application, names):
|
||||
environments = []
|
||||
for name in names:
|
||||
environment = Environments.create(user, application, name)
|
||||
environments.append(environment)
|
||||
|
||||
db.session.add_all(environments)
|
||||
return environments
|
||||
|
||||
@classmethod
|
||||
def update(cls, environment, name=None):
|
||||
if name is not None:
|
||||
environment.name = name
|
||||
db.session.add(environment)
|
||||
commit_or_raise_already_exists_error(message="environment")
|
||||
return environment
|
||||
|
||||
@classmethod
|
||||
def get(cls, environment_id):
|
||||
try:
|
||||
env = (
|
||||
db.session.query(Environment)
|
||||
.filter_by(id=environment_id, deleted=False)
|
||||
.one()
|
||||
)
|
||||
except NoResultFound:
|
||||
raise NotFoundError("environment")
|
||||
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def update_env_role(cls, environment, application_role, new_role):
|
||||
env_role = EnvironmentRoles.get_for_update(application_role.id, environment.id)
|
||||
|
||||
if env_role and new_role and (env_role.disabled or env_role.deleted):
|
||||
raise DisabledError("environment_role", env_role.id)
|
||||
|
||||
if env_role and env_role.role != new_role and not env_role.disabled:
|
||||
env_role.role = new_role
|
||||
db.session.add(env_role)
|
||||
elif not env_role and new_role:
|
||||
env_role = EnvironmentRoles.create(
|
||||
application_role=application_role,
|
||||
environment=environment,
|
||||
role=new_role,
|
||||
)
|
||||
db.session.add(env_role)
|
||||
|
||||
if env_role and not new_role and not env_role.disabled:
|
||||
EnvironmentRoles.disable(env_role.id)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def revoke_access(cls, environment, target_user):
|
||||
EnvironmentRoles.delete(environment.id, target_user.id)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, environment, commit=False):
|
||||
environment.deleted = True
|
||||
db.session.add(environment)
|
||||
|
||||
for role in environment.roles:
|
||||
role.deleted = True
|
||||
db.session.add(role)
|
||||
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
# TODO: How do we work around environment deletion being a largely manual process in the CSPs
|
||||
|
||||
return environment
|
||||
|
||||
@classmethod
|
||||
def base_provision_query(cls, now):
|
||||
return (
|
||||
db.session.query(Environment.id)
|
||||
.join(Application)
|
||||
.join(Portfolio)
|
||||
.join(TaskOrder)
|
||||
.join(CLIN)
|
||||
.filter(CLIN.start_date <= now)
|
||||
.filter(CLIN.end_date > now)
|
||||
.filter(Environment.deleted == False)
|
||||
.filter(
|
||||
or_(
|
||||
Environment.claimed_until == None,
|
||||
Environment.claimed_until <= func.now(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_environments_pending_creation(cls, now) -> List[UUID]:
|
||||
"""
|
||||
Any environment with an active CLIN that doesn't yet have a `cloud_id`.
|
||||
"""
|
||||
results = (
|
||||
cls.base_provision_query(now)
|
||||
.filter(
|
||||
and_(
|
||||
Application.cloud_id != None,
|
||||
Environment.cloud_id.is_(None),
|
||||
or_(
|
||||
Environment.claimed_until.is_(None),
|
||||
Environment.claimed_until <= func.now(),
|
||||
),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [id_ for id_, in results]
|
65
atat/domain/exceptions.py
Normal file
65
atat/domain/exceptions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
class NotFoundError(Exception):
|
||||
def __init__(self, resource_name, resource_id=None):
|
||||
self.resource_name = resource_name
|
||||
self.resource_id = resource_id
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "No {} could be found.".format(self.resource_name)
|
||||
|
||||
|
||||
class AlreadyExistsError(Exception):
|
||||
def __init__(self, resource_name):
|
||||
self.resource_name = resource_name
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "{} already exists".format(self.resource_name)
|
||||
|
||||
|
||||
class UnauthorizedError(Exception):
|
||||
def __init__(self, user, action):
|
||||
self.user = user
|
||||
self.action = action
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "User {} not authorized to {}".format(self.user.id, self.action)
|
||||
|
||||
|
||||
class UnauthenticatedError(Exception):
|
||||
@property
|
||||
def message(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoAccessError(Exception):
|
||||
def __init__(self, resource_name):
|
||||
self.resource_name = resource_name
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Route for {} cannot be accessed".format(self.resource_name)
|
||||
|
||||
|
||||
class ClaimFailedException(Exception):
|
||||
def __init__(self, resource):
|
||||
self.resource = resource
|
||||
message = (
|
||||
f"Could not acquire claim for {resource.__class__.__name__} {resource.id}."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DisabledError(Exception):
|
||||
def __init__(self, resource_name, resource_id=None):
|
||||
self.resource_name = resource_name
|
||||
self.resource_id = resource_id
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return f"Cannot update disabled {self.resource_name} {self.resource_id}."
|
144
atat/domain/invitations.py
Normal file
144
atat/domain/invitations.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
import pendulum
|
||||
|
||||
from atat.database import db
|
||||
from atat.models import ApplicationInvitation, InvitationStatus, PortfolioInvitation
|
||||
from atat.domain.portfolio_roles import PortfolioRoles
|
||||
from atat.domain.application_roles import ApplicationRoles
|
||||
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
|
||||
class WrongUserError(Exception):
|
||||
def __init__(self, user, invite):
|
||||
self.user = user
|
||||
self.invite = invite
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "User {} with DOD ID {} does not match expected DOD ID {} for invitation {}".format(
|
||||
self.user.id, self.user.dod_id, self.invite.user.dod_id, self.invite.id
|
||||
)
|
||||
|
||||
|
||||
class ExpiredError(Exception):
|
||||
def __init__(self, invite):
|
||||
self.invite = invite
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Invitation {} has expired.".format(self.invite.id)
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
def __init__(self, invite):
|
||||
self.invite = invite
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "{} has a status of {}".format(self.invite.id, self.invite.status.value)
|
||||
|
||||
|
||||
class BaseInvitations(object):
|
||||
model = None
|
||||
role_domain_class = None
|
||||
# number of minutes a given invitation is considered valid
|
||||
EXPIRATION_LIMIT_MINUTES = 360
|
||||
|
||||
@classmethod
|
||||
def _get(cls, token):
|
||||
try:
|
||||
invite = db.session.query(cls.model).filter_by(token=token).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError(cls.model.__tablename__)
|
||||
|
||||
return invite
|
||||
|
||||
@classmethod
|
||||
def create(cls, inviter, role, member_data, commit=False):
|
||||
# pylint: disable=not-callable
|
||||
invite = cls.model(
|
||||
role=role,
|
||||
inviter=inviter,
|
||||
user=role.user,
|
||||
status=InvitationStatus.PENDING,
|
||||
expiration_time=cls.current_expiration_time(),
|
||||
email=member_data.get("email"),
|
||||
dod_id=member_data.get("dod_id"),
|
||||
first_name=member_data.get("first_name"),
|
||||
phone_number=member_data.get("phone_number"),
|
||||
last_name=member_data.get("last_name"),
|
||||
)
|
||||
db.session.add(invite)
|
||||
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
return invite
|
||||
|
||||
@classmethod
|
||||
def accept(cls, user, token):
|
||||
invite = cls._get(token)
|
||||
|
||||
if invite.dod_id != user.dod_id:
|
||||
if invite.is_pending:
|
||||
cls._update_status(invite, InvitationStatus.REJECTED_WRONG_USER)
|
||||
raise WrongUserError(user, invite)
|
||||
|
||||
elif invite.is_expired:
|
||||
cls._update_status(invite, InvitationStatus.REJECTED_EXPIRED)
|
||||
raise ExpiredError(invite)
|
||||
|
||||
elif invite.is_accepted or invite.is_revoked or invite.is_rejected:
|
||||
raise InvitationError(invite)
|
||||
|
||||
elif invite.is_pending: # pragma: no branch
|
||||
cls._update_status(invite, InvitationStatus.ACCEPTED)
|
||||
cls.role_domain_class.enable(invite.role, user)
|
||||
return invite
|
||||
|
||||
@classmethod
|
||||
def current_expiration_time(cls):
|
||||
return pendulum.now(tz="utc").add(minutes=cls.EXPIRATION_LIMIT_MINUTES)
|
||||
|
||||
@classmethod
|
||||
def _update_status(cls, invite, new_status):
|
||||
invite.status = new_status
|
||||
db.session.add(invite)
|
||||
db.session.commit()
|
||||
|
||||
return invite
|
||||
|
||||
@classmethod
|
||||
def revoke(cls, token):
|
||||
invite = cls._get(token)
|
||||
invite = cls._update_status(invite, InvitationStatus.REVOKED)
|
||||
cls.role_domain_class.disable(invite.role)
|
||||
return invite
|
||||
|
||||
@classmethod
|
||||
def resend(cls, inviter, token, user_info=None):
|
||||
previous_invitation = cls._get(token)
|
||||
cls._update_status(previous_invitation, InvitationStatus.REVOKED)
|
||||
|
||||
if not user_info:
|
||||
user_info = {
|
||||
"email": previous_invitation.email,
|
||||
"dod_id": previous_invitation.dod_id,
|
||||
"first_name": previous_invitation.first_name,
|
||||
"last_name": previous_invitation.last_name,
|
||||
"phone_number": previous_invitation.phone_number,
|
||||
"phone_ext": previous_invitation.phone_ext,
|
||||
}
|
||||
|
||||
return cls.create(inviter, previous_invitation.role, user_info, commit=True)
|
||||
|
||||
|
||||
class PortfolioInvitations(BaseInvitations):
|
||||
model = PortfolioInvitation
|
||||
role_domain_class = PortfolioRoles
|
||||
|
||||
|
||||
class ApplicationInvitations(BaseInvitations):
|
||||
model = ApplicationInvitation
|
||||
role_domain_class = ApplicationRoles
|
241
atat/domain/permission_sets.py
Normal file
241
atat/domain/permission_sets.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from atat.database import db
|
||||
from atat.models.permissions import Permissions
|
||||
from atat.models.permission_set import PermissionSet
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
|
||||
class PermissionSets(object):
|
||||
VIEW_PORTFOLIO = "view_portfolio"
|
||||
VIEW_PORTFOLIO_APPLICATION_MANAGEMENT = "view_portfolio_application_management"
|
||||
VIEW_PORTFOLIO_FUNDING = "view_portfolio_funding"
|
||||
VIEW_PORTFOLIO_REPORTS = "view_portfolio_reports"
|
||||
VIEW_PORTFOLIO_ADMIN = "view_portfolio_admin"
|
||||
EDIT_PORTFOLIO_APPLICATION_MANAGEMENT = "edit_portfolio_application_management"
|
||||
EDIT_PORTFOLIO_FUNDING = "edit_portfolio_funding"
|
||||
EDIT_PORTFOLIO_REPORTS = "edit_portfolio_reports"
|
||||
EDIT_PORTFOLIO_ADMIN = "edit_portfolio_admin"
|
||||
PORTFOLIO_POC = "portfolio_poc"
|
||||
VIEW_AUDIT_LOG = "view_audit_log"
|
||||
MANAGE_CCPO_USERS = "manage_ccpo_users"
|
||||
|
||||
VIEW_APPLICATION = "view_application"
|
||||
EDIT_APPLICATION_ENVIRONMENTS = "edit_application_environments"
|
||||
EDIT_APPLICATION_TEAM = "edit_application_team"
|
||||
DELETE_APPLICATION_ENVIRONMENTS = "delete_application_environments"
|
||||
|
||||
@classmethod
|
||||
def get(cls, perms_set_name):
|
||||
try:
|
||||
role = db.session.query(PermissionSet).filter_by(name=perms_set_name).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError("permission_set")
|
||||
|
||||
return role
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
return db.session.query(PermissionSet).all()
|
||||
|
||||
@classmethod
|
||||
def get_many(cls, perms_set_names):
|
||||
permission_sets = (
|
||||
db.session.query(PermissionSet)
|
||||
.filter(PermissionSet.name.in_(perms_set_names))
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(permission_sets) != len(perms_set_names):
|
||||
raise NotFoundError("permission_set")
|
||||
|
||||
return permission_sets
|
||||
|
||||
|
||||
ATAT_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_AUDIT_LOG,
|
||||
"display_name": "View Audit Log",
|
||||
"description": "",
|
||||
"permissions": [Permissions.VIEW_AUDIT_LOG],
|
||||
},
|
||||
{
|
||||
"name": PermissionSets.MANAGE_CCPO_USERS,
|
||||
"display_name": "View Audit Log",
|
||||
"description": "",
|
||||
"permissions": [
|
||||
Permissions.VIEW_CCPO_USER,
|
||||
Permissions.CREATE_CCPO_USER,
|
||||
Permissions.EDIT_CCPO_USER,
|
||||
Permissions.DELETE_CCPO_USER,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PORTFOLIO_BASIC_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_PORTFOLIO,
|
||||
"description": "View basic portfolio info",
|
||||
"display_name": "View Portfolio",
|
||||
"permissions": [Permissions.VIEW_PORTFOLIO],
|
||||
}
|
||||
]
|
||||
|
||||
_PORTFOLIO_APP_MGMT_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
|
||||
"description": "View applications and related resources",
|
||||
"display_name": "Application Management",
|
||||
"permissions": [
|
||||
Permissions.VIEW_APPLICATION,
|
||||
Permissions.VIEW_APPLICATION_MEMBER,
|
||||
Permissions.VIEW_ENVIRONMENT,
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
|
||||
"description": "Edit applications and related resources",
|
||||
"display_name": "Application Management",
|
||||
"permissions": [
|
||||
Permissions.EDIT_APPLICATION,
|
||||
Permissions.CREATE_APPLICATION,
|
||||
Permissions.DELETE_APPLICATION,
|
||||
Permissions.EDIT_APPLICATION_MEMBER,
|
||||
Permissions.DELETE_APPLICATION_MEMBER,
|
||||
Permissions.CREATE_APPLICATION_MEMBER,
|
||||
Permissions.EDIT_ENVIRONMENT,
|
||||
Permissions.CREATE_ENVIRONMENT,
|
||||
Permissions.DELETE_ENVIRONMENT,
|
||||
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PORTFOLIO_FUNDING_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_PORTFOLIO_FUNDING,
|
||||
"description": "View a portfolio's task orders",
|
||||
"display_name": "Funding",
|
||||
"permissions": [
|
||||
Permissions.VIEW_PORTFOLIO_FUNDING,
|
||||
Permissions.VIEW_TASK_ORDER_DETAILS,
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": PermissionSets.EDIT_PORTFOLIO_FUNDING,
|
||||
"description": "Edit a portfolio's task orders and add new ones",
|
||||
"display_name": "Funding",
|
||||
"permissions": [
|
||||
Permissions.CREATE_TASK_ORDER,
|
||||
Permissions.EDIT_TASK_ORDER_DETAILS,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PORTFOLIO_REPORTS_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_PORTFOLIO_REPORTS,
|
||||
"description": "View a portfolio's reports",
|
||||
"display_name": "Reporting",
|
||||
"permissions": [Permissions.VIEW_PORTFOLIO_REPORTS],
|
||||
},
|
||||
{
|
||||
"name": PermissionSets.EDIT_PORTFOLIO_REPORTS,
|
||||
"description": "Edit a portfolio's reports (no-op)",
|
||||
"display_name": "Reporting",
|
||||
"permissions": [],
|
||||
},
|
||||
]
|
||||
|
||||
_PORTFOLIO_ADMIN_PERMISSION_SETS = [
|
||||
{
|
||||
"name": PermissionSets.VIEW_PORTFOLIO_ADMIN,
|
||||
"description": "View a portfolio's admin options",
|
||||
"display_name": "Portfolio Administration",
|
||||
"permissions": [
|
||||
Permissions.VIEW_PORTFOLIO_ADMIN,
|
||||
Permissions.VIEW_PORTFOLIO_NAME,
|
||||
Permissions.VIEW_PORTFOLIO_USERS,
|
||||
Permissions.VIEW_PORTFOLIO_ACTIVITY_LOG,
|
||||
Permissions.VIEW_PORTFOLIO_POC,
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": PermissionSets.EDIT_PORTFOLIO_ADMIN,
|
||||
"description": "Edit a portfolio's admin options",
|
||||
"display_name": "Portfolio Administration",
|
||||
"permissions": [
|
||||
Permissions.EDIT_PORTFOLIO_NAME,
|
||||
Permissions.EDIT_PORTFOLIO_USERS,
|
||||
Permissions.CREATE_PORTFOLIO_USERS,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PORTFOLIO_POC_PERMISSION_SETS = [
|
||||
{
|
||||
"name": "portfolio_poc",
|
||||
"description": "Permissions belonging to the Portfolio POC",
|
||||
"display_name": "Portfolio Point of Contact",
|
||||
"permissions": [Permissions.EDIT_PORTFOLIO_POC, Permissions.ARCHIVE_PORTFOLIO],
|
||||
}
|
||||
]
|
||||
|
||||
PORTFOLIO_PERMISSION_SETS = (
|
||||
_PORTFOLIO_BASIC_PERMISSION_SETS
|
||||
+ _PORTFOLIO_APP_MGMT_PERMISSION_SETS
|
||||
+ _PORTFOLIO_FUNDING_PERMISSION_SETS
|
||||
+ _PORTFOLIO_REPORTS_PERMISSION_SETS
|
||||
+ _PORTFOLIO_ADMIN_PERMISSION_SETS
|
||||
+ _PORTFOLIO_POC_PERMISSION_SETS
|
||||
)
|
||||
|
||||
_APPLICATION_BASIC_PERMISSION_SET = {
|
||||
"name": PermissionSets.VIEW_APPLICATION,
|
||||
"description": "View application data",
|
||||
"display_name": "View applications",
|
||||
"permissions": [
|
||||
Permissions.VIEW_APPLICATION,
|
||||
Permissions.VIEW_APPLICATION_MEMBER,
|
||||
Permissions.VIEW_ENVIRONMENT,
|
||||
],
|
||||
}
|
||||
|
||||
# need perm to assign and unassign users to environments
|
||||
_APPLICATION_ENVIRONMENTS_PERMISSION_SET = {
|
||||
"name": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS,
|
||||
"description": "Manage environments for an application",
|
||||
"display_name": "Manage environments",
|
||||
"permissions": [
|
||||
Permissions.EDIT_ENVIRONMENT,
|
||||
Permissions.CREATE_ENVIRONMENT,
|
||||
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
|
||||
],
|
||||
}
|
||||
|
||||
_APPLICATION_TEAM_PERMISSION_SET = {
|
||||
"name": PermissionSets.EDIT_APPLICATION_TEAM,
|
||||
"description": "Manage team members for an application",
|
||||
"display_name": "Manage team",
|
||||
"permissions": [
|
||||
Permissions.EDIT_APPLICATION_MEMBER,
|
||||
Permissions.DELETE_APPLICATION_MEMBER,
|
||||
Permissions.CREATE_APPLICATION_MEMBER,
|
||||
Permissions.ASSIGN_ENVIRONMENT_MEMBER,
|
||||
Permissions.VIEW_APPLICATION_ACTIVITY_LOG,
|
||||
],
|
||||
}
|
||||
|
||||
_APPLICATION_ENVIRONMENT_DELETE_PERMISSION_SET = {
|
||||
"name": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS,
|
||||
"description": "Delete environments within an application",
|
||||
"display_name": "Delete environments",
|
||||
"permissions": [Permissions.DELETE_ENVIRONMENT],
|
||||
}
|
||||
|
||||
APPLICATION_PERMISSION_SETS = [
|
||||
_APPLICATION_BASIC_PERMISSION_SET,
|
||||
_APPLICATION_TEAM_PERMISSION_SET,
|
||||
_APPLICATION_ENVIRONMENTS_PERMISSION_SET,
|
||||
_APPLICATION_ENVIRONMENT_DELETE_PERMISSION_SET,
|
||||
]
|
136
atat/domain/portfolio_roles.py
Normal file
136
atat/domain/portfolio_roles.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
|
||||
from atat.database import db
|
||||
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
|
||||
from atat.models.user import User
|
||||
|
||||
from .permission_sets import PermissionSets
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
|
||||
class PortfolioRoles(object):
|
||||
@classmethod
|
||||
def get(cls, portfolio_id, user_id):
|
||||
try:
|
||||
portfolio_role = (
|
||||
db.session.query(PortfolioRole)
|
||||
.join(User)
|
||||
.filter(User.id == user_id, PortfolioRole.portfolio_id == portfolio_id)
|
||||
.one()
|
||||
)
|
||||
except NoResultFound:
|
||||
raise NotFoundError("portfolio_role")
|
||||
|
||||
return portfolio_role
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, id_):
|
||||
try:
|
||||
return db.session.query(PortfolioRole).filter(PortfolioRole.id == id_).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError("portfolio_role")
|
||||
|
||||
@classmethod
|
||||
def add(cls, user, portfolio_id, permission_sets=None):
|
||||
new_portfolio_role = None
|
||||
try:
|
||||
existing_portfolio_role = (
|
||||
db.session.query(PortfolioRole)
|
||||
.filter(
|
||||
PortfolioRole.user == user,
|
||||
PortfolioRole.portfolio_id == portfolio_id,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
new_portfolio_role = existing_portfolio_role
|
||||
except NoResultFound:
|
||||
new_portfolio_role = PortfolioRole(
|
||||
user=user, portfolio_id=portfolio_id, status=PortfolioRoleStatus.PENDING
|
||||
)
|
||||
|
||||
if permission_sets:
|
||||
new_portfolio_role.permission_sets = PortfolioRoles._permission_sets_for_names(
|
||||
permission_sets
|
||||
)
|
||||
|
||||
user.portfolio_roles.append(new_portfolio_role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return new_portfolio_role
|
||||
|
||||
DEFAULT_PORTFOLIO_PERMISSION_SETS = {
|
||||
PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
|
||||
PermissionSets.VIEW_PORTFOLIO_FUNDING,
|
||||
PermissionSets.VIEW_PORTFOLIO_REPORTS,
|
||||
PermissionSets.VIEW_PORTFOLIO_ADMIN,
|
||||
PermissionSets.VIEW_PORTFOLIO,
|
||||
}
|
||||
|
||||
PORTFOLIO_PERMISSION_SETS = DEFAULT_PORTFOLIO_PERMISSION_SETS.union(
|
||||
{
|
||||
PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT,
|
||||
PermissionSets.EDIT_PORTFOLIO_FUNDING,
|
||||
PermissionSets.EDIT_PORTFOLIO_REPORTS,
|
||||
PermissionSets.EDIT_PORTFOLIO_ADMIN,
|
||||
PermissionSets.PORTFOLIO_POC,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _permission_sets_for_names(cls, set_names):
|
||||
perms_set_names = PortfolioRoles.DEFAULT_PORTFOLIO_PERMISSION_SETS.union(
|
||||
set(set_names)
|
||||
)
|
||||
return PermissionSets.get_many(perms_set_names)
|
||||
|
||||
@classmethod
|
||||
def make_ppoc(cls, portfolio_role):
|
||||
portfolio = portfolio_role.portfolio
|
||||
original_owner_role = PortfolioRoles.get(
|
||||
portfolio_id=portfolio.id, user_id=portfolio.owner.id
|
||||
)
|
||||
PortfolioRoles.revoke_ppoc_permissions(portfolio_role=original_owner_role)
|
||||
PortfolioRoles.add(
|
||||
user=portfolio_role.user,
|
||||
portfolio_id=portfolio.id,
|
||||
permission_sets=PortfolioRoles.PORTFOLIO_PERMISSION_SETS,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def revoke_ppoc_permissions(cls, portfolio_role):
|
||||
permission_sets = [
|
||||
permission_set.name
|
||||
for permission_set in portfolio_role.permission_sets
|
||||
if permission_set.name != PermissionSets.PORTFOLIO_POC
|
||||
]
|
||||
PortfolioRoles.update(portfolio_role=portfolio_role, set_names=permission_sets)
|
||||
|
||||
@classmethod
|
||||
def disable(cls, portfolio_role, commit=True):
|
||||
portfolio_role.status = PortfolioRoleStatus.DISABLED
|
||||
|
||||
db.session.add(portfolio_role)
|
||||
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
return portfolio_role
|
||||
|
||||
@classmethod
|
||||
def update(cls, portfolio_role, set_names):
|
||||
new_permission_sets = PortfolioRoles._permission_sets_for_names(set_names)
|
||||
portfolio_role.permission_sets = new_permission_sets
|
||||
|
||||
db.session.add(portfolio_role)
|
||||
db.session.commit()
|
||||
|
||||
return portfolio_role
|
||||
|
||||
@classmethod
|
||||
def enable(cls, portfolio_role, user):
|
||||
portfolio_role.status = PortfolioRoleStatus.ACTIVE
|
||||
portfolio_role.user = user
|
||||
|
||||
db.session.add(portfolio_role)
|
||||
db.session.commit()
|
6
atat/domain/portfolios/__init__.py
Normal file
6
atat/domain/portfolios/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .portfolios import (
|
||||
Portfolios,
|
||||
PortfolioError,
|
||||
PortfolioDeletionApplicationsExistError,
|
||||
PortfolioStateMachines,
|
||||
)
|
172
atat/domain/portfolios/portfolios.py
Normal file
172
atat/domain/portfolios/portfolios.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from sqlalchemy import or_
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from atat.database import db
|
||||
from atat.domain.permission_sets import PermissionSets
|
||||
from atat.domain.authz import Authorization
|
||||
from atat.domain.portfolio_roles import PortfolioRoles
|
||||
|
||||
from atat.domain.invitations import PortfolioInvitations
|
||||
from atat.models import (
|
||||
Portfolio,
|
||||
PortfolioStateMachine,
|
||||
FSMStates,
|
||||
Permissions,
|
||||
PortfolioRole,
|
||||
PortfolioRoleStatus,
|
||||
TaskOrder,
|
||||
CLIN,
|
||||
)
|
||||
|
||||
from .query import PortfoliosQuery, PortfolioStateMachinesQuery
|
||||
from .scopes import ScopedPortfolio
|
||||
|
||||
|
||||
class PortfolioError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PortfolioDeletionApplicationsExistError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PortfolioStateMachines(object):
|
||||
@classmethod
|
||||
def create(cls, portfolio, **sm_attrs):
|
||||
sm_attrs.update({"portfolio": portfolio})
|
||||
sm = PortfolioStateMachinesQuery.create(**sm_attrs)
|
||||
return sm
|
||||
|
||||
|
||||
class Portfolios(object):
|
||||
@classmethod
|
||||
def get_or_create_state_machine(cls, portfolio):
|
||||
"""
|
||||
get or create Portfolio State Machine for a Portfolio
|
||||
"""
|
||||
return portfolio.state_machine or PortfolioStateMachines.create(portfolio)
|
||||
|
||||
@classmethod
|
||||
def create(cls, user, portfolio_attrs):
|
||||
portfolio = PortfoliosQuery.create(**portfolio_attrs)
|
||||
perms_sets = PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS)
|
||||
Portfolios._create_portfolio_role(
|
||||
user,
|
||||
portfolio,
|
||||
status=PortfolioRoleStatus.ACTIVE,
|
||||
permission_sets=perms_sets,
|
||||
)
|
||||
PortfoliosQuery.add_and_commit(portfolio)
|
||||
return portfolio
|
||||
|
||||
@classmethod
|
||||
def get(cls, user, portfolio_id):
|
||||
portfolio = PortfoliosQuery.get(portfolio_id)
|
||||
return ScopedPortfolio(user, portfolio)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, portfolio):
|
||||
if len(portfolio.applications) != 0:
|
||||
raise PortfolioDeletionApplicationsExistError()
|
||||
|
||||
for portfolio_role in portfolio.roles:
|
||||
PortfolioRoles.disable(portfolio_role=portfolio_role, commit=False)
|
||||
|
||||
portfolio.deleted = True
|
||||
|
||||
db.session.add(portfolio)
|
||||
db.session.commit()
|
||||
|
||||
return portfolio
|
||||
|
||||
@classmethod
|
||||
def get_for_update(cls, portfolio_id):
|
||||
portfolio = PortfoliosQuery.get(portfolio_id)
|
||||
|
||||
return portfolio
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, user):
|
||||
if Authorization.has_atat_permission(user, Permissions.VIEW_PORTFOLIO):
|
||||
portfolios = PortfoliosQuery.get_all()
|
||||
else:
|
||||
portfolios = PortfoliosQuery.get_for_user(user)
|
||||
return portfolios
|
||||
|
||||
@classmethod
|
||||
def add_member(cls, portfolio, member, permission_sets=None):
|
||||
portfolio_role = PortfolioRoles.add(member, portfolio.id, permission_sets)
|
||||
return portfolio_role
|
||||
|
||||
@classmethod
|
||||
def invite(cls, portfolio, inviter, member_data):
|
||||
permission_sets = PortfolioRoles._permission_sets_for_names(
|
||||
member_data.get("permission_sets", [])
|
||||
)
|
||||
role = PortfolioRole(portfolio=portfolio, permission_sets=permission_sets)
|
||||
|
||||
invitation = PortfolioInvitations.create(
|
||||
inviter=inviter, role=role, member_data=member_data["user_data"]
|
||||
)
|
||||
|
||||
PortfoliosQuery.add_and_commit(role)
|
||||
|
||||
return invitation
|
||||
|
||||
@classmethod
|
||||
def update_member(cls, member, permission_sets):
|
||||
return PortfolioRoles.update(member, permission_sets)
|
||||
|
||||
@classmethod
|
||||
def _create_portfolio_role(
|
||||
cls, user, portfolio, status=PortfolioRoleStatus.PENDING, permission_sets=None
|
||||
):
|
||||
if permission_sets is None:
|
||||
permission_sets = []
|
||||
|
||||
portfolio_role = PortfoliosQuery.create_portfolio_role(
|
||||
user, portfolio, status=status, permission_sets=permission_sets
|
||||
)
|
||||
PortfoliosQuery.add_and_commit(portfolio_role)
|
||||
return portfolio_role
|
||||
|
||||
@classmethod
|
||||
def update(cls, portfolio, new_data):
|
||||
if "name" in new_data:
|
||||
portfolio.name = new_data["name"]
|
||||
|
||||
if "description" in new_data:
|
||||
portfolio.description = new_data["description"]
|
||||
|
||||
PortfoliosQuery.add_and_commit(portfolio)
|
||||
|
||||
@classmethod
|
||||
def base_provision_query(cls):
|
||||
return db.session.query(Portfolio.id)
|
||||
|
||||
@classmethod
|
||||
def get_portfolios_pending_provisioning(cls, now) -> List[UUID]:
|
||||
"""
|
||||
Any portfolio with a corresponding State Machine that is either:
|
||||
not started yet,
|
||||
failed in creating a tenant
|
||||
failed
|
||||
"""
|
||||
|
||||
results = (
|
||||
db.session.query(Portfolio.id)
|
||||
.join(PortfolioStateMachine)
|
||||
.join(TaskOrder)
|
||||
.join(CLIN)
|
||||
.filter(Portfolio.deleted == False)
|
||||
.filter(CLIN.start_date <= now)
|
||||
.filter(CLIN.end_date > now)
|
||||
.filter(
|
||||
or_(
|
||||
PortfolioStateMachine.state == FSMStates.UNSTARTED,
|
||||
PortfolioStateMachine.state.like("%CREATED"),
|
||||
)
|
||||
)
|
||||
)
|
||||
return [id_ for id_, in results]
|
66
atat/domain/portfolios/query.py
Normal file
66
atat/domain/portfolios/query.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy import or_
|
||||
from atat.database import db
|
||||
from atat.domain.common import Query
|
||||
from atat.models.portfolio import Portfolio
|
||||
from atat.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
|
||||
from atat.models.application_role import (
|
||||
ApplicationRole,
|
||||
Status as ApplicationRoleStatus,
|
||||
)
|
||||
from atat.models.application import Application
|
||||
from atat.models.portfolio_state_machine import PortfolioStateMachine
|
||||
|
||||
# from atat.models.application import Application
|
||||
|
||||
|
||||
class PortfolioStateMachinesQuery(Query):
|
||||
model = PortfolioStateMachine
|
||||
|
||||
|
||||
class PortfoliosQuery(Query):
|
||||
model = Portfolio
|
||||
|
||||
@classmethod
|
||||
def get_for_user(cls, user):
|
||||
return (
|
||||
db.session.query(Portfolio)
|
||||
.filter(
|
||||
or_(
|
||||
Portfolio.id.in_(
|
||||
db.session.query(Portfolio.id)
|
||||
.join(Application)
|
||||
.filter(Portfolio.id == Application.portfolio_id)
|
||||
.filter(
|
||||
Application.id.in_(
|
||||
db.session.query(Application.id)
|
||||
.join(ApplicationRole)
|
||||
.filter(
|
||||
ApplicationRole.application_id == Application.id
|
||||
)
|
||||
.filter(ApplicationRole.user_id == user.id)
|
||||
.filter(
|
||||
ApplicationRole.status
|
||||
== ApplicationRoleStatus.ACTIVE
|
||||
)
|
||||
.filter(ApplicationRole.deleted == False)
|
||||
.subquery()
|
||||
)
|
||||
)
|
||||
),
|
||||
Portfolio.id.in_(
|
||||
db.session.query(Portfolio.id)
|
||||
.join(PortfolioRole)
|
||||
.filter(PortfolioRole.user == user)
|
||||
.filter(PortfolioRole.status == PortfolioRoleStatus.ACTIVE)
|
||||
.subquery()
|
||||
),
|
||||
)
|
||||
)
|
||||
.filter(Portfolio.deleted == False)
|
||||
.order_by(Portfolio.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_portfolio_role(cls, user, portfolio, **kwargs):
|
||||
return PortfolioRole(user=user, portfolio=portfolio, **kwargs)
|
39
atat/domain/portfolios/scopes.py
Normal file
39
atat/domain/portfolios/scopes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from atat.domain.authz import Authorization
|
||||
from atat.models.permissions import Permissions
|
||||
from atat.domain.applications import Applications
|
||||
|
||||
|
||||
class ScopedResource(object):
|
||||
"""
|
||||
An abstract class that represents a resource that is restricted
|
||||
in some way by the priveleges of the user viewing that resource.
|
||||
"""
|
||||
|
||||
def __init__(self, user, resource):
|
||||
self.user = user
|
||||
self.resource = resource
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.resource, name)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.resource == other
|
||||
|
||||
|
||||
class ScopedPortfolio(ScopedResource):
|
||||
"""
|
||||
An object that obeys the same API as a Portfolio, but with the added
|
||||
functionality that it only returns sub-resources (applications and environments)
|
||||
that the given user is allowed to see.
|
||||
"""
|
||||
|
||||
@property
|
||||
def applications(self):
|
||||
can_view_all_applications = Authorization.has_portfolio_permission(
|
||||
self.user, self.resource, Permissions.VIEW_APPLICATION
|
||||
)
|
||||
|
||||
if can_view_all_applications:
|
||||
return self.resource.applications
|
||||
else:
|
||||
return Applications.for_user(self.user, self.resource)
|
33
atat/domain/reports.py
Normal file
33
atat/domain/reports.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from flask import current_app
|
||||
from atat.domain.csp.cloud.models import (
|
||||
ReportingCSPPayload,
|
||||
CostManagementQueryCSPResult,
|
||||
)
|
||||
from atat.domain.csp.reports import prepare_azure_reporting_data
|
||||
import pendulum
|
||||
|
||||
|
||||
class Reports:
|
||||
@classmethod
|
||||
def expired_task_orders(cls, portfolio):
|
||||
return [
|
||||
task_order for task_order in portfolio.task_orders if task_order.is_expired
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_spending(cls, portfolio):
|
||||
# TODO: Extend this function to make from_date and to_date configurable
|
||||
from_date = pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD")
|
||||
to_date = pendulum.now().format("YYYY-MM-DD")
|
||||
rows = []
|
||||
|
||||
if portfolio.csp_data:
|
||||
payload = ReportingCSPPayload(
|
||||
from_date=from_date, to_date=to_date, **portfolio.csp_data
|
||||
)
|
||||
response: CostManagementQueryCSPResult = current_app.csp.cloud.get_reporting_data(
|
||||
payload
|
||||
)
|
||||
rows = response.properties.rows
|
||||
|
||||
return prepare_azure_reporting_data(rows)
|
102
atat/domain/task_orders.py
Normal file
102
atat/domain/task_orders.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from sqlalchemy import or_
|
||||
import pendulum
|
||||
|
||||
from atat.database import db
|
||||
from atat.models.clin import CLIN
|
||||
from atat.models.task_order import TaskOrder, SORT_ORDERING
|
||||
from . import BaseDomainClass
|
||||
from atat.utils import commit_or_raise_already_exists_error
|
||||
|
||||
|
||||
class TaskOrders(BaseDomainClass):
|
||||
model = TaskOrder
|
||||
resource_name = "task_order"
|
||||
|
||||
@classmethod
|
||||
def create(cls, portfolio_id, number, clins, pdf):
|
||||
task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf)
|
||||
db.session.add(task_order)
|
||||
commit_or_raise_already_exists_error(message="task_order")
|
||||
TaskOrders.create_clins(task_order.id, clins)
|
||||
return task_order
|
||||
|
||||
@classmethod
|
||||
def update(cls, task_order_id, number, clins, pdf):
|
||||
task_order = TaskOrders.get(task_order_id)
|
||||
task_order.pdf = pdf
|
||||
|
||||
if len(clins) > 0:
|
||||
for clin in task_order.clins:
|
||||
db.session.delete(clin)
|
||||
|
||||
TaskOrders.create_clins(task_order_id, clins)
|
||||
|
||||
if number != task_order.number:
|
||||
task_order.number = number
|
||||
db.session.add(task_order)
|
||||
|
||||
commit_or_raise_already_exists_error(message="task_order")
|
||||
return task_order
|
||||
|
||||
@classmethod
|
||||
def sign(cls, task_order, signer_dod_id):
|
||||
task_order.signer_dod_id = signer_dod_id
|
||||
task_order.signed_at = pendulum.now(tz="utc")
|
||||
db.session.add(task_order)
|
||||
db.session.commit()
|
||||
|
||||
return task_order
|
||||
|
||||
@classmethod
|
||||
def create_clins(cls, task_order_id, clin_list):
|
||||
for clin_data in clin_list:
|
||||
clin = CLIN(
|
||||
task_order_id=task_order_id,
|
||||
number=clin_data["number"],
|
||||
start_date=clin_data["start_date"],
|
||||
end_date=clin_data["end_date"],
|
||||
total_amount=clin_data["total_amount"],
|
||||
obligated_amount=clin_data["obligated_amount"],
|
||||
jedi_clin_type=clin_data["jedi_clin_type"],
|
||||
)
|
||||
db.session.add(clin)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def sort_by_status(cls, task_orders):
|
||||
by_status = {status.value: [] for status in SORT_ORDERING}
|
||||
|
||||
for task_order in task_orders:
|
||||
by_status[task_order.display_status].append(task_order)
|
||||
|
||||
return by_status
|
||||
|
||||
@classmethod
|
||||
def delete(cls, task_order_id):
|
||||
task_order = TaskOrders.get(task_order_id)
|
||||
db.session.delete(task_order)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_for_send_task_order_files(cls):
|
||||
return (
|
||||
db.session.query(TaskOrder)
|
||||
.join(CLIN)
|
||||
.filter(
|
||||
or_(
|
||||
TaskOrder.pdf_last_sent_at < CLIN.last_sent_at,
|
||||
TaskOrder.pdf_last_sent_at.is_(None),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_clins_for_create_billing_instructions(cls):
|
||||
return (
|
||||
db.session.query(CLIN)
|
||||
.filter(
|
||||
CLIN.last_sent_at.is_(None), CLIN.start_date < pendulum.now(tz="UTC")
|
||||
)
|
||||
.all()
|
||||
)
|
122
atat/domain/users.py
Normal file
122
atat/domain/users.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import pendulum
|
||||
|
||||
from atat.database import db
|
||||
from atat.models import User
|
||||
|
||||
from .permission_sets import PermissionSets
|
||||
from .exceptions import NotFoundError, AlreadyExistsError, UnauthorizedError
|
||||
|
||||
|
||||
class Users(object):
|
||||
@classmethod
|
||||
def get(cls, user_id):
|
||||
try:
|
||||
user = db.session.query(User).filter_by(id=user_id).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError("user")
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def get_by_dod_id(cls, dod_id):
|
||||
try:
|
||||
user = db.session.query(User).filter_by(dod_id=dod_id).one()
|
||||
except NoResultFound:
|
||||
raise NotFoundError("user")
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def get_ccpo_users(cls):
|
||||
return (
|
||||
db.session.query(User)
|
||||
.filter(User.permission_sets != None)
|
||||
.order_by(User.last_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(cls, dod_id, permission_sets=None, **kwargs):
|
||||
if permission_sets:
|
||||
permission_sets = PermissionSets.get_many(permission_sets)
|
||||
else:
|
||||
permission_sets = []
|
||||
|
||||
try:
|
||||
user = User(dod_id=dod_id, permission_sets=permission_sets, **kwargs)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
raise AlreadyExistsError("user")
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def get_or_create_by_dod_id(cls, dod_id, **kwargs):
|
||||
try:
|
||||
user = Users.get_by_dod_id(dod_id)
|
||||
except NotFoundError:
|
||||
user = Users.create(dod_id, **kwargs)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
_UPDATEABLE_ATTRS = {
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"phone_number",
|
||||
"phone_ext",
|
||||
"service_branch",
|
||||
"citizenship",
|
||||
"designation",
|
||||
"date_latest_training",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update(cls, user, user_delta):
|
||||
delta_set = set(user_delta.keys())
|
||||
if not set(delta_set).issubset(Users._UPDATEABLE_ATTRS):
|
||||
unpermitted = delta_set - Users._UPDATEABLE_ATTRS
|
||||
raise UnauthorizedError(user, "update {}".format(", ".join(unpermitted)))
|
||||
|
||||
for key, value in user_delta.items():
|
||||
setattr(user, key, value)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def give_ccpo_perms(cls, user, commit=True):
|
||||
user.permission_sets = PermissionSets.get_all()
|
||||
db.session.add(user)
|
||||
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def revoke_ccpo_perms(cls, user):
|
||||
user.permission_sets = []
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def update_last_login(cls, user):
|
||||
user.last_login = pendulum.now(tz="utc")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def update_last_session_id(cls, user, session_id):
|
||||
user.last_session_id = session_id
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
Reference in New Issue
Block a user