Merge branch 'staging' into empty-states

This commit is contained in:
leigh-mil 2020-01-27 13:23:14 -05:00 committed by GitHub
commit 78e5fbf7b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2546 additions and 1094 deletions

View File

@ -33,6 +33,10 @@ azure-mgmt-authorization = "*"
azure-mgmt-managementgroups = "*"
azure-mgmt-resource = "*"
transitions = "*"
azure-mgmt-consumption = "*"
adal = "*"
azure-identity = "*"
azure-keyvault = "*"
[dev-packages]
bandit = "*"

134
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "63b8f9d203f306a6f0ff20514b024909aa7e64917e1befcc9ea79931b5b4bd34"
"sha256": "4dbb023bcb860eb6dc56e1c201c91f272e1e67ad03e5e5eeb3a7a7fdff350eed"
},
"pipfile-spec": 6,
"requires": {
@ -21,14 +21,15 @@
"sha256:5a7f1e037c6290c6d7609cab33a9e5e988c2fbec5c51d1c4c649ee3faff37eaf",
"sha256:fd17e5661f60634ddf96a569b95d34ccb8a98de60593d729c28bdcfe360eaad1"
],
"index": "pypi",
"version": "==1.2.2"
},
"alembic": {
"hashes": [
"sha256:3b0cb1948833e062f4048992fbc97ecfaaaac24aaa0d83a1202a99fb58af8c6d"
"sha256:d412982920653db6e5a44bfd13b1d0db5685cbaaccaf226195749c706e1e862a"
],
"index": "pypi",
"version": "==1.3.2"
"version": "==1.3.3"
},
"amqp": {
"hashes": [
@ -44,6 +45,13 @@
],
"version": "==1.1.24"
},
"azure-core": {
"hashes": [
"sha256:b8ccbd901d085048e4e3e72627b066923c5bd3780e4c43cf9cf9948aee9bdf9e",
"sha256:e2cd99f0c0aef12c168d498cb5bc47a3a45c8ab08112183e3ec97e4dcb33ceb9"
],
"version": "==1.2.1"
},
"azure-graphrbac": {
"hashes": [
"sha256:53e98ae2ca7c19b349e9e9bb1b6a824aeae8dcfcbe17190d20fe69c0f185b2e2",
@ -52,6 +60,36 @@
"index": "pypi",
"version": "==0.61.1"
},
"azure-identity": {
"hashes": [
"sha256:4ce65058461c277991763ed3f121efc6b9eb9c2edefb62c414dfa85c814690d3",
"sha256:b32acd1cdb6202bfe10d9a0858dc463d8960295da70ae18097eb3b85ab12cb91"
],
"index": "pypi",
"version": "==1.2.0"
},
"azure-keyvault": {
"hashes": [
"sha256:76f75cb83929f312a08616d426ad6f597f1beae180131cf445876fb88f2c8ef1",
"sha256:e85f5bd6cb4f10b3248b99bbf02e3acc6371d366846897027d4153f18025a2d7"
],
"index": "pypi",
"version": "==4.0.0"
},
"azure-keyvault-keys": {
"hashes": [
"sha256:2983fa42e20a0e6bf6b87976716129c108e613e0292d34c5b0f0c8dc1d488e89",
"sha256:38c27322637a2c52620a8b96da1942ad6a8d22d09b5a01f6fa257f7a51e52ed0"
],
"version": "==4.0.0"
},
"azure-keyvault-secrets": {
"hashes": [
"sha256:2eae9264a8f6f59277e1a9bfdbc8b0a15969ee5a80d8efe403d7744805b4a481",
"sha256:97a602406a833e8f117c540c66059c818f4321a35168dd17365fab1e4527d718"
],
"version": "==4.0.0"
},
"azure-mgmt-authorization": {
"hashes": [
"sha256:31e875a34ac2c5d6fefe77b4a8079a8b2bdbe9edb957e47e8b44222fb212d6a7",
@ -60,6 +98,14 @@
"index": "pypi",
"version": "==0.60.0"
},
"azure-mgmt-consumption": {
"hashes": [
"sha256:035d4b74ca7c47e2683bea17105fd9014c27060336fb6255324ac86b27f70f5b",
"sha256:af319ad6e3ec162a7578563f149e3cdd7d833a62ec80761cfd93caf79467610b"
],
"index": "pypi",
"version": "==3.0.0"
},
"azure-mgmt-managementgroups": {
"hashes": [
"sha256:3d5237947458dc94b4a392141174b1c1258d26611241ee104e9006d1d798f682",
@ -208,6 +254,14 @@
],
"version": "==2.8"
},
"dataclasses": {
"hashes": [
"sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836",
"sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"
],
"markers": "python_version < '3.7'",
"version": "==0.7"
},
"flask": {
"hashes": [
"sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52",
@ -301,9 +355,9 @@
},
"mako": {
"hashes": [
"sha256:a36919599a9b7dc5d86a7a8988f23a9a3a3d083070023bab23d64f7f1d1e0a4b"
"sha256:2984a6733e1d472796ceef37ad48c26f4a984bb18119bb2dbc37a44d8f6e75a4"
],
"version": "==1.1.0"
"version": "==1.1.1"
},
"markupsafe": {
"hashes": [
@ -345,6 +399,20 @@
],
"version": "==8.1.0"
},
"msal": {
"hashes": [
"sha256:c944b833bf686dfbc973e9affdef94b77e616cb52ab397e76cde82e26b8a3373",
"sha256:ecbe3f5ac77facad16abf08eb9d8562af3bc7184be5d4d90c9ef4db5bde26340"
],
"version": "==1.0.0"
},
"msal-extensions": {
"hashes": [
"sha256:59e171a9a4baacdbf001c66915efeaef372fb424421f1a4397115a3ddd6205dc",
"sha256:c5a32b8e1dce1c67733dcdf8aa8bebcff5ab123e779ef7bc14e416bd0da90037"
],
"version": "==0.1.3"
},
"msrest": {
"hashes": [
"sha256:56b8b5b4556fb2a92cac640df267d560889bdc9e2921187772d4691d97bc4e8d",
@ -379,6 +447,13 @@
"index": "pypi",
"version": "==2.0.5"
},
"portalocker": {
"hashes": [
"sha256:6f57aabb25ba176462dc7c63b86c42ad6a9b5bd3d679a9d776d0536bfb803d54",
"sha256:dac62e53e5670cb40d2ee4cdc785e6b829665932c3ee75307ad677cf5f7d2e9f"
],
"version": "==1.5.2"
},
"psycopg2-binary": {
"hashes": [
"sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29",
@ -444,6 +519,9 @@
"version": "==1.3"
},
"pyjwt": {
"extras": [
"crypto"
],
"hashes": [
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
"sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
@ -529,17 +607,17 @@
},
"six": {
"hashes": [
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.13.0"
"version": "==1.14.0"
},
"sqlalchemy": {
"hashes": [
"sha256:bfb8f464a5000b567ac1d350b9090cf081180ec1ab4aa87e7bca12dab25320ec"
"sha256:64a7b71846db6423807e96820993fa12a03b89127d278290ca25c0b11ed7b4fb"
],
"index": "pypi",
"version": "==1.3.12"
"version": "==1.3.13"
},
"sqlalchemy-json": {
"hashes": [
@ -572,10 +650,10 @@
},
"urllib3": {
"hashes": [
"sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293",
"sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
],
"version": "==1.25.7"
"version": "==1.25.8"
},
"vine": {
"hashes": [
@ -609,10 +687,10 @@
},
"zipp": {
"hashes": [
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
"sha256:b338014b9bc7102ca69e0fb96ed07215a8954d2989bc5d83658494ab2ba634af",
"sha256:e013e7800f60ec4dde789ebf4e9f7a54236e4bbf5df2a1a4e20ce9e1d9609d67"
],
"version": "==1.0.0"
"version": "==2.0.1"
}
},
"develop": {
@ -1022,11 +1100,11 @@
},
"pexpect": {
"hashes": [
"sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1",
"sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"
"sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
"sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
],
"markers": "sys_platform != 'win32'",
"version": "==4.7.0"
"version": "==4.8.0"
},
"pickleshare": {
"hashes": [
@ -1201,10 +1279,10 @@
},
"six": {
"hashes": [
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.13.0"
"version": "==1.14.0"
},
"smmap2": {
"hashes": [
@ -1285,10 +1363,10 @@
},
"urllib3": {
"hashes": [
"sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293",
"sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
],
"version": "==1.25.7"
"version": "==1.25.8"
},
"watchdog": {
"hashes": [
@ -1319,10 +1397,10 @@
},
"zipp": {
"hashes": [
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
"sha256:b338014b9bc7102ca69e0fb96ed07215a8954d2989bc5d83658494ab2ba634af",
"sha256:e013e7800f60ec4dde789ebf4e9f7a54236e4bbf5df2a1a4e20ce9e1d9609d67"
],
"version": "==1.0.0"
"version": "==2.0.1"
}
}
}

View File

@ -29,11 +29,13 @@ parent_dir = Path(__file__).parent.parent
sys.path.append(parent_dir)
from atst.app import make_config
app_config = make_config()
config.set_main_option('sqlalchemy.url', app_config['DATABASE_URI'])
config.set_main_option("sqlalchemy.url", app_config["DATABASE_URI"])
from atst.database import db
from atst.models import *
target_metadata = Base.metadata
@ -51,7 +53,8 @@ def run_migrations_offline():
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)
url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
@ -66,18 +69,19 @@ def run_migrations_online():
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:

View File

@ -0,0 +1,132 @@
"""state machine states extended
Revision ID: 26319c44a8d5
Revises: 59973fa17ded
Create Date: 2020-01-22 15:54:03.186751
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "26319c44a8d5" # pragma: allowlist secret
down_revision = "59973fa17ded" # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"portfolio_state_machines",
"state",
existing_type=sa.Enum(
"UNSTARTED",
"STARTING",
"STARTED",
"COMPLETED",
"FAILED",
"TENANT_CREATED",
"TENANT_IN_PROGRESS",
"TENANT_FAILED",
"BILLING_PROFILE_CREATED",
"BILLING_PROFILE_IN_PROGRESS",
"BILLING_PROFILE_FAILED",
"ADMIN_SUBSCRIPTION_CREATED",
"ADMIN_SUBSCRIPTION_IN_PROGRESS",
"ADMIN_SUBSCRIPTION_FAILED",
name="fsmstates",
native_enum=False,
),
type_=sa.Enum(
"UNSTARTED",
"STARTING",
"STARTED",
"COMPLETED",
"FAILED",
"TENANT_CREATED",
"TENANT_IN_PROGRESS",
"TENANT_FAILED",
"BILLING_PROFILE_CREATION_CREATED",
"BILLING_PROFILE_CREATION_IN_PROGRESS",
"BILLING_PROFILE_CREATION_FAILED",
"BILLING_PROFILE_VERIFICATION_CREATED",
"BILLING_PROFILE_VERIFICATION_IN_PROGRESS",
"BILLING_PROFILE_VERIFICATION_FAILED",
"BILLING_PROFILE_TENANT_ACCESS_CREATED",
"BILLING_PROFILE_TENANT_ACCESS_IN_PROGRESS",
"BILLING_PROFILE_TENANT_ACCESS_FAILED",
"TASK_ORDER_BILLING_CREATION_CREATED",
"TASK_ORDER_BILLING_CREATION_IN_PROGRESS",
"TASK_ORDER_BILLING_CREATION_FAILED",
"TASK_ORDER_BILLING_VERIFICATION_CREATED",
"TASK_ORDER_BILLING_VERIFICATION_IN_PROGRESS",
"TASK_ORDER_BILLING_VERIFICATION_FAILED",
"BILLING_INSTRUCTION_CREATED",
"BILLING_INSTRUCTION_IN_PROGRESS",
"BILLING_INSTRUCTION_FAILED",
name="fsmstates",
native_enum=False,
create_constraint=False,
),
existing_nullable=False,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"portfolio_state_machines",
"state",
existing_type=sa.Enum(
"UNSTARTED",
"STARTING",
"STARTED",
"COMPLETED",
"FAILED",
"TENANT_CREATED",
"TENANT_IN_PROGRESS",
"TENANT_FAILED",
"BILLING_PROFILE_CREATION_CREATED",
"BILLING_PROFILE_CREATION_IN_PROGRESS",
"BILLING_PROFILE_CREATION_FAILED",
"BILLING_PROFILE_VERIFICATION_CREATED",
"BILLING_PROFILE_VERIFICATION_IN_PROGRESS",
"BILLING_PROFILE_VERIFICATION_FAILED",
"BILLING_PROFILE_TENANT_ACCESS_CREATED",
"BILLING_PROFILE_TENANT_ACCESS_IN_PROGRESS",
"BILLING_PROFILE_TENANT_ACCESS_FAILED",
"TASK_ORDER_BILLING_CREATION_CREATED",
"TASK_ORDER_BILLING_CREATION_IN_PROGRESS",
"TASK_ORDER_BILLING_CREATION_FAILED",
"TASK_ORDER_BILLING_VERIFICATION_CREATED",
"TASK_ORDER_BILLING_VERIFICATION_IN_PROGRESS",
"TASK_ORDER_BILLING_VERIFICATION_FAILED",
"BILLING_INSTRUCTION_CREATED",
"BILLING_INSTRUCTION_IN_PROGRESS",
"BILLING_INSTRUCTION_FAILED",
name="fsmstates",
native_enum=False,
),
type_=sa.Enum(
"UNSTARTED",
"STARTING",
"STARTED",
"COMPLETED",
"FAILED",
"TENANT_CREATED",
"TENANT_IN_PROGRESS",
"TENANT_FAILED",
"BILLING_PROFILE_CREATED",
"BILLING_PROFILE_IN_PROGRESS",
"BILLING_PROFILE_FAILED",
"ADMIN_SUBSCRIPTION_CREATED",
"ADMIN_SUBSCRIPTION_IN_PROGRESS",
"ADMIN_SUBSCRIPTION_FAILED",
name="fsmstates",
native_enum=False,
),
existing_nullable=False,
)
# ### end Alembic commands ###

View File

@ -1,5 +1,3 @@
import importlib
from .cloud import MockCloudProvider
from .file_uploads import AzureUploader, MockUploader
from .reports import MockReportingProvider
@ -31,22 +29,3 @@ def make_csp_provider(app, csp=None):
app.csp = MockCSP(app, test_mode=True)
else:
app.csp = MockCSP(app)
def _stage_to_classname(stage):
return "".join(
map(lambda word: word.capitalize(), stage.replace("_", " ").split(" "))
)
def get_stage_csp_class(stage, class_type):
"""
given a stage name and class_type return the class
class_type is either 'payload' or 'result'
"""
cls_name = "".join([_stage_to_classname(stage), "CSP", class_type.capitalize()])
try:
return getattr(importlib.import_module("atst.domain.csp.cloud"), cls_name)
except AttributeError:
print("could not import CSP Result class <%s>" % cls_name)

View File

@ -1,889 +0,0 @@
import re
from typing import Dict
from uuid import uuid4
from pydantic import BaseModel
from atst.models.user import User
from atst.models.environment import Environment
from atst.models.environment_role import EnvironmentRole
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, server_error):
self.server_error = server_error
@property
def message(self):
return "A server error occured: {}".format(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
"""
def __init__(self, env_identifier, user_identifier, reason):
self.env_identifier = env_identifier
self.user_identifier = user_identifier
self.reason = reason
@property
def message(self):
return "Failed to create user {} for environment {}: {}".format(
self.user_identifier, self.env_identifier, self.reason
)
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 BaseCSPPayload(BaseModel):
# {"username": "mock-cloud", "pass": "shh"}
creds: Dict
class TenantCSPPayload(BaseCSPPayload):
user_id: str
password: str
domain_name: str
first_name: str
last_name: str
country_code: str
password_recovery_email_address: str
class TenantCSPResult(BaseModel):
user_id: str
tenant_id: str
user_object_id: str
class BillingProfileAddress(BaseModel):
address: Dict
"""
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"""
class BillingProfileCLINBudget(BaseModel):
clinBudget: Dict
"""
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
"""
class BillingProfileCSPPayload(
BaseCSPPayload, BillingProfileAddress, BillingProfileCLINBudget
):
displayName: str
poNumber: str
invoiceEmailOptIn: str
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
class CloudProviderInterface:
def root_creds(self) -> Dict:
raise NotImplementedError()
def create_environment(
self, auth_credentials: Dict, user: User, environment: Environment
) -> str:
"""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_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
"""Creates a new, programmatic user in the CSP. Grants this user full permissions to administer
the CSP.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_environment_id -- ID of the CSP Environment the admin user should be created in
Returns:
object: Object representing new remote admin user, including credentials
Something like:
{
"user_id": string,
"credentials": dict, # structure TBD based on csp
}
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: Problem creating the root user
"""
raise NotImplementedError()
def create_or_update_user(
self, auth_credentials: Dict, user_info: EnvironmentRole, 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, auth_credentials: Dict, csp_user_id: str) -> bool:
"""Revoke all privileges for a user. Used to prevent user access while a full
delete is being processed.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_user_id -- CSP internal user identifier
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()
def create_subscription(self, environment):
"""Returns True if a new subscription has been created or raises an
exception if an error occurs while creating a subscription.
"""
raise NotImplementedError()
class MockCloudProvider(CloudProviderInterface):
# TODO: All of these constants
AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.")
AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.")
NETWORK_EXCEPTION = ConnectionException("Network failure.")
SERVER_EXCEPTION = UnknownServerException("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 create_environment(self, auth_credentials, user, environment):
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.ENV_CREATE_FAILURE_PCT,
EnvironmentCreationException(
environment.id, "Could not create environment."
),
)
csp_environment_id = self._id()
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,
BaselineProvisionException(
csp_environment_id, "Could not create environment baseline."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return csp_environment_id
def create_atat_admin_user(self, auth_credentials, csp_environment_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(
csp_environment_id, "atat_admin", "Could not create admin user."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return {"id": self._id(), "credentials": self._auth_credentials}
def create_tenant(self, payload):
"""
payload is an instance of TenantCSPPayload data class
"""
self._authorize(payload.creds)
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.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
# return tenant id, tenant owner id and tenant owner object id from:
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
return {
"tenant_id": response["tenantId"],
"user_id": response["userId"],
"user_object_id": response["objectId"],
}
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
# call billing profile creation endpoint, specifying owner
# Payload:
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# 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)
response = {"id": "string"}
return {"billing_profile_id": response["id"]}
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, auth_credentials, csp_user_id):
self._authorize(auth_credentials)
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(csp_user_id, "Could not disable user."),
)
return self._maybe(12)
def create_subscription(self, environment):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return True
def get_calculator_url(self):
return "https://www.rackspace.com/en-us/calculator"
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", "pass": "shh"}
def _authorize(self, credentials):
self._delay(1, 5)
if self._with_authorization and credentials != self._auth_credentials:
raise self.AUTHENTICATION_EXCEPTION
AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD
AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI
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,
)
# This needs to be a fully pathed role definition identifier, not just a UUID
REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000"
class AzureSDKProvider(object):
def __init__(self):
from azure.mgmt import subscription, authorization
import azure.graphrbac as graphrbac
import azure.common.credentials as credentials
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
self.subscription = subscription
self.authorization = authorization
self.graphrbac = graphrbac
self.credentials = credentials
# may change to a JEDI cloud
self.cloud = AZURE_PUBLIC_CLOUD
class AzureCloudProvider(CloudProviderInterface):
def __init__(self, config, azure_sdk_provider=None):
self.config = config
self.client_id = config["AZURE_CLIENT_ID"]
self.secret_key = config["AZURE_SECRET_KEY"]
self.tenant_id = config["AZURE_TENANT_ID"]
if azure_sdk_provider is None:
self.sdk = AzureSDKProvider()
else:
self.sdk = azure_sdk_provider
def create_environment(
self, auth_credentials: Dict, user: User, environment: Environment
):
credentials = self._get_credential_obj(self._root_creds)
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
billing_profile_id = "?" # something chained from environment?
sku_id = AZURE_SKU_ID
# we want to set AT-AT as an owner here
# we could potentially associate subscriptions with "management groups" per DOD component
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
display_name,
billing_profile_id,
sku_id,
# owner=<AdPrincipal: for AT-AT user>
)
# These 2 seem like something that might be worthwhile to allow tiebacks to
# TOs filed for the environment
billing_account_name = "?"
invoice_section_name = "?"
# We may also want to create billing sections in the enrollment account
sub_creation_operation = sub_client.subscription_factory.create_subscription(
billing_account_name, invoice_section_name, body
)
# the resulting object from this process is a link to the new subscription
# not a subscription model, so we'll have to unpack the ID
new_sub = sub_creation_operation.result()
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
if subscription_id:
return subscription_id
else:
# troublesome error, subscription should exist at this point
# but we just don't have a valid ID
pass
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
root_creds = self._root_creds
credentials = self._get_credential_obj(root_creds)
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
subscription = sub_client.subscriptions.get(csp_environment_id)
managment_principal = self._get_management_service_principal()
auth_client = self.sdk.authorization.AuthorizationManagementClient(
credentials,
# TODO: Determine which subscription this needs to point at
# Once we're in a multi-sub environment
subscription.id,
)
# Create role assignment for
role_assignment_id = str(uuid4())
role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters(
role_definition_id=REMOTE_ROOT_ROLE_DEF_ID,
principal_id=managment_principal.id,
)
auth_client.role_assignments.create(
scope=f"/subscriptions/{subscription.id}/",
role_assignment_name=role_assignment_id,
parameters=role_assignment_create_params,
)
return {
"csp_user_id": managment_principal.object_id,
"credentials": managment_principal.password_credentials,
"role_name": role_assignment_id,
}
def create_tenant(self, payload):
# auth as SP that is allowed to create tenant? (tenant creation sp creds)
# create tenant with owner details (populated from portfolio point of contact, pw is generated)
# return tenant id, tenant owner id and tenant owner object id from:
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
return self._ok(
{
"tenant_id": response["tenantId"],
"user_id": response["userId"],
"user_object_id": response["objectId"],
}
)
def create_billing_owner(self, creds, tenant_admin_details):
# authenticate as tenant_admin
# create billing owner identity
# TODO: Lookup response format
# Managed service identity?
response = {"id": "string"}
return self._ok({"billing_owner_id": response["id"]})
def assign_billing_owner(self, creds, billing_owner_id, tenant_id):
# TODO: Do we source role definition ID from config, api or self-defined?
# TODO: If from api,
"""
{
"principalId": "string",
"principalTenantId": "string",
"billingRoleDefinitionId": "string"
}
"""
return self.ok()
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
# call billing profile creation endpoint, specifying owner
# Payload:
"""
{
"displayName": "string",
"poNumber": "string",
"address": {
"firstName": "string",
"lastName": "string",
"companyName": "string",
"addressLine1": "string",
"addressLine2": "string",
"addressLine3": "string",
"city": "string",
"region": "string",
"country": "string",
"postalCode": "string"
},
"invoiceEmailOptIn": true,
Note: These last 2 are also the body for adding/updating new TOs/clins
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# response will be mostly the same as the body, but we only really care about the id
response = {"id": "string"}
return self._ok({"billing_profile_id": response["id"]})
def report_clin(self, creds, clin_id, clin_amount, clin_start, clin_end, clin_to):
# should consumer be responsible for reporting each clin or
# should this take a list and manage the sequential reporting?
""" Payload
{
"enabledAzurePlans": [
{
"skuId": "string"
}
],
"clinBudget": {
"amount": 0,
"startDate": "2019-12-18T16:47:40.909Z",
"endDate": "2019-12-18T16:47:40.909Z",
"externalReferenceId": "string"
}
}
"""
# we don't need any of the returned info for this
return self._ok()
def create_remote_admin(self, creds, tenant_details):
# create app/service principal within tenant, with name constructed from tenant details
# assign principal global admin
# needs to call out to CLI with tenant owner username/password, prototyping for that underway
# return identifier and creds to consumer for storage
response = {"clientId": "string", "secretKey": "string", "tenantId": "string"}
return self._ok(
{
"client_id": response["clientId"],
"secret_key": response["secret_key"],
"tenant_id": response["tenantId"],
}
)
def force_tenant_admin_pw_update(self, creds, tenant_owner_id):
# use creds to update to force password recovery?
# not sure what the endpoint/method for this is, yet
return self._ok()
def create_billing_alerts(self, TBD):
# TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations
# TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment
# TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates
# TODO: determine what the keys in the Notifications dict are supposed to be
# we may need to rotate budget objects when new TOs/CLINs are reported?
# we likely only want the budget ID, can be updated or replaced?
response = {"id": "id"}
return self._ok({"budget_id": response["id"]})
def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting
# "expired token" errors for that
# graph_resource = "https://graph.microsoft.com"
graph_resource = "https://graph.windows.net"
graph_creds = self._get_credential_obj(
self._root_creds, resource=graph_resource
)
# I needed to set permissions for the graph.windows.net API before I
# could get this to work.
# how do we scope the graph client to the new subscription rather than
# the cloud0 subscription? tenant id seems to be separate from subscription id
graph_client = self.sdk.graphrbac.GraphRbacManagementClient(
graph_creds, self._root_creds.get("tenant_id")
)
# do we need to create a new application to manage each subscripition
# or should we manage access to each subscription from a single service
# principal with multiple role assignments?
app_display_name = "?" # name should reflect the subscription it exists
app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters(
display_name=app_display_name
)
# we need the appropriate perms here:
# https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http
# https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names
# set app perms in app registration portal
# https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph
app: self.sdk.graphrbac.models.Application = graph_client.applications.create(
app_create_param
)
# create a new service principle for the new application, which should be scoped
# to the new subscription
app_id = app.app_id
sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters(
app_id=app_id, account_enabled=True
)
service_principal = graph_client.service_principals.create(sp_create_params)
return service_principal
def _extract_subscription_id(self, subscription_url):
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
if sub_id_match:
return sub_id_match.group(1)
def _get_credential_obj(self, creds, resource=None):
return self.sdk.credentials.ServicePrincipalCredentials(
client_id=creds.get("client_id"),
secret=creds.get("secret_key"),
tenant=creds.get("tenant_id"),
resource=resource,
cloud_environment=self.sdk.cloud,
)
def _make_tenant_admin_cred_obj(self, username, password):
return self.sdk.credentials.UserPassCredentials(username, password)
def _ok(self, body=None):
return self._make_response("ok", body)
def _error(self, body=None):
return self._make_response("error", body)
def _make_response(self, status, body=dict()):
"""Create body for responses from API
Arguments:
status {string} -- "ok" or "error"
body {dict} -- dict containing details of response or error, if applicable
Returns:
dict -- status of call with body containing details
"""
return {"status": status, "body": body}
@property
def _root_creds(self):
return {
"client_id": self.client_id,
"secret_key": self.secret_key,
"tenant_id": self.tenant_id,
}

View File

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

View File

@ -0,0 +1,628 @@
import re
from secrets import token_urlsafe
from typing import Dict
from uuid import uuid4
from atst.models.application import Application
from atst.models.environment import Environment
from atst.models.user import User
from .cloud_provider_interface import CloudProviderInterface
from .exceptions import AuthenticationException
from .models import (
BillingInstructionCSPPayload,
BillingInstructionCSPResult,
BillingProfileCreationCSPPayload,
BillingProfileCreationCSPResult,
BillingProfileTenantAccessCSPPayload,
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload,
TaskOrderBillingVerificationCSPResult,
TenantCSPPayload,
TenantCSPResult,
)
from .policy import AzurePolicyManager
AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD
AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI
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,
)
# This needs to be a fully pathed role definition identifier, not just a UUID
REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000"
AZURE_MANAGEMENT_API = "https://management.azure.com"
class AzureSDKProvider(object):
def __init__(self):
from azure.mgmt import subscription, authorization, managementgroups
from azure.mgmt.resource import policy
import azure.graphrbac as graphrbac
import azure.common.credentials as credentials
import azure.identity as identity
from azure.keyvault import secrets
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
import adal
import requests
self.subscription = subscription
self.policy = policy
self.managementgroups = managementgroups
self.authorization = authorization
self.adal = adal
self.graphrbac = graphrbac
self.credentials = credentials
self.identity = identity
self.exceptions = exceptions
self.secrets = secrets
self.requests = requests
# may change to a JEDI cloud
self.cloud = AZURE_PUBLIC_CLOUD
class AzureCloudProvider(CloudProviderInterface):
def __init__(self, config, azure_sdk_provider=None):
self.config = config
self.client_id = config["AZURE_CLIENT_ID"]
self.secret_key = config["AZURE_SECRET_KEY"]
self.tenant_id = config["AZURE_TENANT_ID"]
self.vault_url = config["AZURE_VAULT_URL"]
if azure_sdk_provider is None:
self.sdk = AzureSDKProvider()
else:
self.sdk = azure_sdk_provider
self.policy_manager = AzurePolicyManager(config["AZURE_POLICY_LOCATION"])
def set_secret(self, secret_key, secret_value):
credential = self._get_client_secret_credential_obj({})
secret_client = self.secrets.SecretClient(
vault_url=self.vault_url, credential=credential,
)
try:
return secret_client.set_secret(secret_key, secret_value)
except self.exceptions.HttpResponseError:
app.logger.error(
f"Could not SET secret in Azure keyvault for key {secret_key}.",
exc_info=1,
)
def get_secret(self, secret_key):
credential = self._get_client_secret_credential_obj({})
secret_client = self.secrets.SecretClient(
vault_url=self.vault_url, credential=credential,
)
try:
return secret_client.get_secret(secret_key).value
except self.exceptions.HttpResponseError:
app.logger.error(
f"Could not GET secret in Azure keyvault for key {secret_key}.",
exc_info=1,
)
def create_environment(
self, auth_credentials: Dict, user: User, environment: Environment
):
# since this operation would only occur within a tenant, should we source the tenant
# via lookup from environment once we've created the portfolio csp data schema
# something like this:
# environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None)
# though we'd probably source the whole credentials for these calls from the portfolio csp
# data, as it would have to be where we store the creds for the at-at user within the portfolio tenant
# credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds())
credentials = self._get_credential_obj(self._root_creds)
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
management_group_id = "?" # management group id chained from environment
parent_id = "?" # from environment.application
management_group = self._create_management_group(
credentials, management_group_id, display_name, parent_id,
)
return management_group
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
root_creds = self._root_creds
credentials = self._get_credential_obj(root_creds)
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
subscription = sub_client.subscriptions.get(csp_environment_id)
managment_principal = self._get_management_service_principal()
auth_client = self.sdk.authorization.AuthorizationManagementClient(
credentials,
# TODO: Determine which subscription this needs to point at
# Once we're in a multi-sub environment
subscription.id,
)
# Create role assignment for
role_assignment_id = str(uuid4())
role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters(
role_definition_id=REMOTE_ROOT_ROLE_DEF_ID,
principal_id=managment_principal.id,
)
auth_client.role_assignments.create(
scope=f"/subscriptions/{subscription.id}/",
role_assignment_name=role_assignment_id,
parameters=role_assignment_create_params,
)
return {
"csp_user_id": managment_principal.object_id,
"credentials": managment_principal.password_credentials,
"role_name": role_assignment_id,
}
def _create_application(self, auth_credentials: Dict, application: Application):
management_group_name = str(uuid4()) # can be anything, not just uuid
display_name = application.name # Does this need to be unique?
credentials = self._get_credential_obj(auth_credentials)
parent_id = "?" # application.portfolio.csp_details.management_group_id
return self._create_management_group(
credentials, management_group_name, display_name, parent_id,
)
def _create_management_group(
self, credentials, management_group_id, display_name, parent_id=None,
):
mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials)
create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo(
id=parent_id
)
create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails(
parent=create_parent_grp_info
)
mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest(
name=management_group_id,
display_name=display_name,
details=create_mgmt_grp_details,
)
create_request = mgmgt_group_client.management_groups.create_or_update(
management_group_id, mgmt_grp_create
)
# result is a synchronous wait, might need to do a poll instead to handle first mgmt group create
# since we were told it could take 10+ minutes to complete, unless this handles that polling internally
return create_request.result()
def _create_subscription(
self,
credentials,
display_name,
billing_profile_id,
sku_id,
management_group_id,
billing_account_name,
invoice_section_name,
):
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
billing_profile_id = "?" # where do we source this?
sku_id = AZURE_SKU_ID
# These 2 seem like something that might be worthwhile to allow tiebacks to
# TOs filed for the environment
billing_account_name = "?" # from TO?
invoice_section_name = "?" # from TO?
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
display_name=display_name,
billing_profile_id=billing_profile_id,
sku_id=sku_id,
management_group_id=management_group_id,
)
# We may also want to create billing sections in the enrollment account
sub_creation_operation = sub_client.subscription_factory.create_subscription(
billing_account_name, invoice_section_name, body
)
# the resulting object from this process is a link to the new subscription
# not a subscription model, so we'll have to unpack the ID
new_sub = sub_creation_operation.result()
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
if subscription_id:
return subscription_id
else:
# troublesome error, subscription should exist at this point
# but we just don't have a valid ID
pass
def _create_policy_definition(
self, credentials, subscription_id, management_group_id, properties,
):
"""
Requires credentials that have AZURE_MANAGEMENT_API
specified as the resource. The Service Principal
specified in the credentials must have the "Resource
Policy Contributor" role assigned with a scope at least
as high as the management group specified by
management_group_id.
Arguments:
credentials -- ServicePrincipalCredentials
subscription_id -- str, ID of the subscription (just the UUID, not the path)
management_group_id -- str, ID of the management group (just the UUID, not the path)
properties -- dictionary, the "properties" section of a valid Azure policy definition document
Returns:
azure.mgmt.resource.policy.[api version].models.PolicyDefinition: the PolicyDefinition object provided to Azure
Raises:
TBD
"""
# TODO: which subscription would this be?
client = self.sdk.policy.PolicyClient(credentials, subscription_id)
definition = client.policy_definitions.models.PolicyDefinition(
policy_type=properties.get("policyType"),
mode=properties.get("mode"),
display_name=properties.get("displayName"),
description=properties.get("description"),
policy_rule=properties.get("policyRule"),
parameters=properties.get("parameters"),
)
name = properties.get("displayName")
return client.policy_definitions.create_or_update_at_management_group(
policy_definition_name=name,
parameters=definition,
management_group_id=management_group_id,
)
def create_tenant(self, payload: TenantCSPPayload):
sp_token = self._get_sp_token(payload.creds)
if sp_token is None:
raise AuthenticationException("Could not resolve token for tenant creation")
payload.password = token_urlsafe(16)
create_tenant_body = payload.dict(by_alias=True)
create_tenant_headers = {
"Authorization": f"Bearer {sp_token}",
}
result = self.sdk.requests.post(
"https://management.azure.com/providers/Microsoft.SignUp/createTenant?api-version=2020-01-01-preview",
json=create_tenant_body,
headers=create_tenant_headers,
)
if result.status_code == 200:
return self._ok(
TenantCSPResult(
**result.json(),
tenant_admin_password=payload.password,
tenant_admin_username=payload.user_id,
)
)
else:
return self._error(result.json())
def create_billing_profile_creation(
self, payload: BillingProfileCreationCSPPayload
):
sp_token = self._get_sp_token(payload.creds)
if sp_token is None:
raise AuthenticationException(
"Could not resolve token for billing profile creation"
)
create_billing_account_body = payload.dict(by_alias=True)
create_billing_account_headers = {
"Authorization": f"Bearer {sp_token}",
}
billing_account_create_url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles?api-version=2019-10-01-preview"
result = self.sdk.requests.post(
billing_account_create_url,
json=create_billing_account_body,
headers=create_billing_account_headers,
)
if result.status_code == 202:
# 202 has location/retry after headers
return self._ok(BillingProfileCreationCSPResult(**result.headers))
elif result.status_code == 200:
# NB: Swagger docs imply call can sometimes resolve immediately
return self._ok(BillingProfileVerificationCSPResult(**result.json()))
else:
return self._error(result.json())
def create_billing_profile_verification(
self, payload: BillingProfileVerificationCSPPayload
):
sp_token = self._get_sp_token(payload.creds)
if sp_token is None:
raise AuthenticationException(
"Could not resolve token for billing profile validation"
)
auth_header = {
"Authorization": f"Bearer {sp_token}",
}
result = self.sdk.requests.get(
payload.billing_profile_verify_url, headers=auth_header
)
if result.status_code == 202:
# 202 has location/retry after headers
return self._ok(BillingProfileCreationCSPResult(**result.headers))
elif result.status_code == 200:
return self._ok(BillingProfileVerificationCSPResult(**result.json()))
else:
return self._error(result.json())
def create_billing_profile_tenant_access(
self, payload: BillingProfileTenantAccessCSPPayload
):
sp_token = self._get_sp_token(payload.creds)
request_body = {
"properties": {
"principalTenantId": payload.tenant_id, # from tenant creation
"principalId": payload.user_object_id, # from tenant creationn
"roleDefinitionId": f"/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
}
}
headers = {
"Authorization": f"Bearer {sp_token}",
}
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/createBillingRoleAssignment?api-version=2019-10-01-preview"
result = self.sdk.requests.post(url, headers=headers, json=request_body)
if result.status_code == 201:
return self._ok(BillingProfileTenantAccessCSPResult(**result.json()))
else:
return self._error(result.json())
def create_task_order_billing_creation(
self, payload: TaskOrderBillingCreationCSPPayload
):
sp_token = self._get_sp_token(payload.creds)
request_body = [
{
"op": "replace",
"path": "/enabledAzurePlans",
"value": [{"skuId": "0001"}],
}
]
request_headers = {
"Authorization": f"Bearer {sp_token}",
}
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}?api-version=2019-10-01-preview"
result = self.sdk.requests.patch(
url, headers=request_headers, json=request_body
)
if result.status_code == 202:
# 202 has location/retry after headers
return self._ok(TaskOrderBillingCreationCSPResult(**result.headers))
elif result.status_code == 200:
return self._ok(TaskOrderBillingVerificationCSPResult(**result.json()))
else:
return self._error(result.json())
def create_task_order_billing_verification(
self, payload: TaskOrderBillingVerificationCSPPayload
):
sp_token = self._get_sp_token(payload.creds)
if sp_token is None:
raise AuthenticationException(
"Could not resolve token for task order billing validation"
)
auth_header = {
"Authorization": f"Bearer {sp_token}",
}
result = self.sdk.requests.get(
payload.task_order_billing_verify_url, headers=auth_header
)
if result.status_code == 202:
# 202 has location/retry after headers
return self._ok(TaskOrderBillingCreationCSPResult(**result.headers))
elif result.status_code == 200:
return self._ok(TaskOrderBillingVerificationCSPResult(**result.json()))
else:
return self._error(result.json())
def create_billing_instruction(self, payload: BillingInstructionCSPPayload):
sp_token = self._get_sp_token(payload.creds)
if sp_token is None:
raise AuthenticationException(
"Could not resolve token for task order billing validation"
)
request_body = {
"properties": {
"amount": payload.initial_clin_amount,
"startDate": payload.initial_clin_start_date,
"endDate": payload.initial_clin_end_date,
}
}
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/instructions/{payload.initial_task_order_id}:CLIN00{payload.initial_clin_type}?api-version=2019-10-01-preview"
auth_header = {
"Authorization": f"Bearer {sp_token}",
}
result = self.sdk.requests.put(url, headers=auth_header, json=request_body)
if result.status_code == 200:
return self._ok(BillingInstructionCSPResult(**result.json()))
else:
return self._error(result.json())
def create_remote_admin(self, creds, tenant_details):
# create app/service principal within tenant, with name constructed from tenant details
# assign principal global admin
# needs to call out to CLI with tenant owner username/password, prototyping for that underway
# return identifier and creds to consumer for storage
response = {"clientId": "string", "secretKey": "string", "tenantId": "string"}
return self._ok(
{
"client_id": response["clientId"],
"secret_key": response["secret_key"],
"tenant_id": response["tenantId"],
}
)
def force_tenant_admin_pw_update(self, creds, tenant_owner_id):
# use creds to update to force password recovery?
# not sure what the endpoint/method for this is, yet
return self._ok()
def create_billing_alerts(self, TBD):
# TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations
# TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment
# TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates
# TODO: determine what the keys in the Notifications dict are supposed to be
# we may need to rotate budget objects when new TOs/CLINs are reported?
# we likely only want the budget ID, can be updated or replaced?
response = {"id": "id"}
return self._ok({"budget_id": response["id"]})
def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting
# "expired token" errors for that
# graph_resource = "https://graph.microsoft.com"
graph_resource = "https://graph.windows.net"
graph_creds = self._get_credential_obj(
self._root_creds, resource=graph_resource
)
# I needed to set permissions for the graph.windows.net API before I
# could get this to work.
# how do we scope the graph client to the new subscription rather than
# the cloud0 subscription? tenant id seems to be separate from subscription id
graph_client = self.sdk.graphrbac.GraphRbacManagementClient(
graph_creds, self._root_creds.get("tenant_id")
)
# do we need to create a new application to manage each subscripition
# or should we manage access to each subscription from a single service
# principal with multiple role assignments?
app_display_name = "?" # name should reflect the subscription it exists
app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters(
display_name=app_display_name
)
# we need the appropriate perms here:
# https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http
# https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names
# set app perms in app registration portal
# https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph
app: self.sdk.graphrbac.models.Application = graph_client.applications.create(
app_create_param
)
# create a new service principle for the new application, which should be scoped
# to the new subscription
app_id = app.app_id
sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters(
app_id=app_id, account_enabled=True
)
service_principal = graph_client.service_principals.create(sp_create_params)
return service_principal
def _extract_subscription_id(self, subscription_url):
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
if sub_id_match:
return sub_id_match.group(1)
def _get_sp_token(self, creds):
home_tenant_id = creds.get("home_tenant_id")
client_id = creds.get("client_id")
secret_key = creds.get("secret_key")
# TODO: Make endpoints consts or configs
authentication_endpoint = "https://login.microsoftonline.com/"
resource = "https://management.azure.com/"
context = self.sdk.adal.AuthenticationContext(
authentication_endpoint + home_tenant_id
)
# TODO: handle failure states here
token_response = context.acquire_token_with_client_credentials(
resource, client_id, secret_key
)
return token_response.get("accessToken", None)
def _get_credential_obj(self, creds, resource=None):
return self.sdk.credentials.ServicePrincipalCredentials(
client_id=creds.get("client_id"),
secret=creds.get("secret_key"),
tenant=creds.get("tenant_id"),
resource=resource,
cloud_environment=self.sdk.cloud,
)
def _get_client_secret_credential_obj(self, creds):
return self.sdk.identity.ClientSecretCredential(
tenant_id=creds.get("tenant_id"),
client_id=creds.get("client_id"),
client_secret=creds.get("secret_key"),
)
def _make_tenant_admin_cred_obj(self, username, password):
return self.sdk.credentials.UserPassCredentials(username, password)
def _ok(self, body=None):
return self._make_response("ok", body)
def _error(self, body=None):
return self._make_response("error", body)
def _make_response(self, status, body=dict()):
"""Create body for responses from API
Arguments:
status {string} -- "ok" or "error"
body {dict} -- dict containing details of response or error, if applicable
Returns:
dict -- status of call with body containing details
"""
return {"status": status, "body": body}
@property
def _root_creds(self):
return {
"client_id": self.client_id,
"secret_key": self.secret_key,
"tenant_id": self.tenant_id,
}

View File

@ -0,0 +1,126 @@
from typing import Dict
from atst.models.user import User
from atst.models.environment import Environment
from atst.models.environment_role import EnvironmentRole
class CloudProviderInterface:
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, auth_credentials: Dict, user: User, environment: Environment
) -> str:
"""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_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
"""Creates a new, programmatic user in the CSP. Grants this user full permissions to administer
the CSP.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_environment_id -- ID of the CSP Environment the admin user should be created in
Returns:
object: Object representing new remote admin user, including credentials
Something like:
{
"user_id": string,
"credentials": dict, # structure TBD based on csp
}
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: Problem creating the root user
"""
raise NotImplementedError()
def create_or_update_user(
self, auth_credentials: Dict, user_info: EnvironmentRole, 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, auth_credentials: Dict, csp_user_id: str) -> bool:
"""Revoke all privileges for a user. Used to prevent user access while a full
delete is being processed.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_user_id -- CSP internal user identifier
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()
def create_subscription(self, environment):
"""Returns True if a new subscription has been created or raises an
exception if an error occurs while creating a subscription.
"""
raise NotImplementedError()

View File

@ -0,0 +1,131 @@
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, server_error):
self.server_error = server_error
@property
def message(self):
return "A server error occured: {}".format(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
"""
def __init__(self, env_identifier, user_identifier, reason):
self.env_identifier = env_identifier
self.user_identifier = user_identifier
self.reason = reason
@property
def message(self):
return "Failed to create user {} for environment {}: {}".format(
self.user_identifier, self.env_identifier, self.reason
)
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
)

View File

@ -0,0 +1,342 @@
from uuid import uuid4
from atst.domain.csp.cloud.exceptions import (
BaselineProvisionException,
EnvironmentCreationException,
GeneralCSPException,
UserProvisioningException,
UserRemovalException,
)
from atst.domain.csp.cloud.models import BillingProfileTenantAccessCSPResult
from .cloud_provider_interface import CloudProviderInterface
from .exceptions import (
AuthenticationException,
AuthorizationException,
ConnectionException,
UnknownServerException,
)
from .models import (
BillingInstructionCSPPayload,
BillingInstructionCSPResult,
BillingProfileCreationCSPPayload,
BillingProfileCreationCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload,
TaskOrderBillingVerificationCSPResult,
TenantCSPPayload,
TenantCSPResult,
)
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("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_environment(self, auth_credentials, user, environment):
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.ENV_CREATE_FAILURE_PCT,
EnvironmentCreationException(
environment.id, "Could not create environment."
),
)
csp_environment_id = self._id()
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,
BaselineProvisionException(
csp_environment_id, "Could not create environment baseline."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return csp_environment_id
def create_atat_admin_user(self, auth_credentials, csp_environment_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(
csp_environment_id, "atat_admin", "Could not create admin user."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return {"id": self._id(), "credentials": self._auth_credentials}
def create_tenant(self, payload: TenantCSPPayload):
"""
payload is an instance of TenantCSPPayload data class
"""
self._authorize(payload.creds)
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.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return TenantCSPResult(
**{
"tenant_id": "",
"user_id": "",
"user_object_id": "",
"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_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, auth_credentials, csp_user_id):
self._authorize(auth_credentials)
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(csp_user_id, "Could not disable user."),
)
return self._maybe(12)
def create_subscription(self, environment):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return True
def get_calculator_url(self):
return "https://www.rackspace.com/en-us/calculator"
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

View File

@ -0,0 +1,234 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, validator
from atst.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):
# {"username": "mock-cloud", "pass": "shh"}
creds: Dict
def dict(self, *args, **kwargs):
exclude = {"creds"}
if "exclude" not in kwargs:
kwargs["exclude"] = exclude
else:
kwargs["exclude"].update(exclude)
return super().dict(*args, **kwargs)
class TenantCSPPayload(BaseCSPPayload):
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
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",
}

View File

@ -9,10 +9,10 @@ from atst.models import (
EnvironmentRole,
PortfolioJobFailure,
)
from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.environments import Environments
from atst.domain.portfolios import Portfolios
from atst.domain.environment_roles import EnvironmentRoles
from atst.models.utils import claim_for_update
from atst.utils.localization import translate

View File

@ -1,5 +1,7 @@
from enum import Enum
from flask import current_app as app
class StageStates(Enum):
CREATED = "created"
@ -9,8 +11,12 @@ class StageStates(Enum):
class AzureStages(Enum):
TENANT = "tenant"
BILLING_PROFILE = "billing profile"
ADMIN_SUBSCRIPTION = "admin subscription"
BILLING_PROFILE_CREATION = "billing profile creation"
BILLING_PROFILE_VERIFICATION = "billing profile verification"
BILLING_PROFILE_TENANT_ACCESS = "billing profile tenant access"
TASK_ORDER_BILLING_CREATION = "task order billing creation"
TASK_ORDER_BILLING_VERIFICATION = "task order billing verification"
BILLING_INSTRUCTION = "billing instruction"
def _build_csp_states(csp_stages):
@ -31,14 +37,14 @@ def _build_csp_states(csp_stages):
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
def _build_transitions(csp_stages):
transitions = []
states = []
compose_state = lambda csp_stage, state: getattr(
FSMStates, "_".join([csp_stage.name, state.name])
)
for stage_i, csp_stage in enumerate(csp_stages):
for state in StageStates:
states.append(
@ -99,6 +105,22 @@ class FSMMixin:
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
]
def fail_stage(self, stage):
fail_trigger = "fail" + stage
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
app.logger.info(
f"calling fail trigger '{fail_trigger}' for '{self.__repr__()}'"
)
def finish_stage(self, stage):
finish_trigger = "finish_" + stage
if finish_trigger in self.machine.get_triggers(self.current_state.name):
app.logger.info(
f"calling finish trigger '{finish_trigger}' for '{self.__repr__()}'"
)
self.trigger(finish_trigger)
def prepare_init(self, event):
pass
@ -125,13 +147,3 @@ class FSMMixin:
def after_reset(self, event):
pass
def fail_stage(self, stage):
fail_trigger = "fail" + stage
if fail_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(fail_trigger)
def finish_stage(self, stage):
finish_trigger = "finish_" + stage
if finish_trigger in self.machine.get_triggers(self.current_state.name):
self.trigger(finish_trigger)

View File

@ -1,3 +1,5 @@
import importlib
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
from sqlalchemy.orm import relationship, reconstructor
from sqlalchemy.dialects.postgresql import UUID
@ -8,8 +10,7 @@ from transitions.extensions.states import add_state_features, Tags
from flask import current_app as app
from atst.domain.csp.cloud import ConnectionException, UnknownServerException
from atst.domain.csp import MockCSP, AzureCSP, get_stage_csp_class
from atst.domain.csp.cloud.exceptions import ConnectionException, UnknownServerException
from atst.database import db
from atst.models.types import Id
from atst.models.base import Base
@ -17,6 +18,25 @@ import atst.models.mixins as mixins
from atst.models.mixins.state_machines import FSMStates, AzureStages, _build_transitions
def _stage_to_classname(stage):
return "".join(map(lambda word: word.capitalize(), stage.split("_")))
def get_stage_csp_class(stage, class_type):
"""
given a stage name and class_type return the class
class_type is either 'payload' or 'result'
"""
cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}"
try:
return getattr(
importlib.import_module("atst.domain.csp.cloud.models"), cls_name
)
except AttributeError:
print("could not import CSP Result class <%s>" % cls_name)
@add_state_features(Tags)
class StateMachineWithTags(Machine):
pass
@ -50,6 +70,9 @@ class PortfolioStateMachine(
db.session.add(self)
db.session.commit()
def __repr__(self):
return f"<PortfolioStateMachine(state='{self.current_state.name}', portfolio='{self.portfolio.name}'"
@reconstructor
def attach_machine(self):
"""
@ -73,109 +96,115 @@ class PortfolioStateMachine(
return getattr(FSMStates, self.state)
return self.state
def trigger_next_transition(self):
def trigger_next_transition(self, **kwargs):
state_obj = self.machine.get_state(self.state)
if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
self.trigger(trigger_name)
self.trigger(trigger_name, **kwargs)
elif self.current_state == FSMStates.STARTED:
# get the first trigger that starts with 'create_'
create_trigger = list(
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(FSMStates.STARTED.name),
),
None,
)
if create_trigger:
self.trigger(create_trigger, **kwargs)
else:
app.logger.info(
f"could not locate 'create trigger' for {self.__repr__()}"
)
)[0]
self.trigger(create_trigger)
self.fail_stage(stage)
elif state_obj.is_IN_PROGRESS:
pass
elif state_obj.is_CREATED:
# the create trigger for the next stage should be in the available
# triggers for the current state
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
self.machine.get_triggers(self.state.name),
),
None,
)
if create_trigger is not None:
self.trigger(create_trigger, **kwargs)
# elif state_obj.is_TENANT:
# pass
# elif state_obj.is_BILLING_PROFILE:
# pass
# @with_payload
def after_in_progress_callback(self, event):
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
if stage == "tenant":
payload = dict( # nosec
creds={"username": "mock-cloud", "pass": "shh"},
user_id="123",
password="123",
domain_name="123",
first_name="john",
last_name="doe",
country_code="US",
password_recovery_email_address="password@email.com",
)
elif stage == "billing_profile":
payload = dict(creds={"username": "mock-cloud", "pass": "shh"},)
# Accumulate payload w/ creds
payload = event.kwargs.get("csp_data")
payload["creds"] = event.kwargs.get("creds")
payload_data_cls = get_stage_csp_class(stage, "payload")
if not payload_data_cls:
app.logger.info(f"could not resolve payload data class for stage {stage}")
self.fail_stage(stage)
try:
payload_data = payload_data_cls(**payload)
except PydanticValidationError as exc:
app.logger.error(
f"Payload Validation Error in {self.__repr__()}:", exc_info=1
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload)
self.fail_stage(stage)
csp = event.kwargs.get("csp")
if csp is not None:
self.csp = AzureCSP(app).cloud
else:
self.csp = MockCSP(app).cloud
# TODO: Determine best place to do this, maybe @reconstructor
self.csp = app.csp.cloud
for attempt in range(5):
try:
response = getattr(self.csp, "create_" + stage)(payload_data)
except (ConnectionException, UnknownServerException) as exc:
print("caught exception. retry", attempt)
continue
else:
break
else:
# failed all attempts
try:
func_name = f"create_{stage}"
response = getattr(self.csp, func_name)(payload_data)
if self.portfolio.csp_data is None:
self.portfolio.csp_data = {}
self.portfolio.csp_data.update(response.dict())
db.session.add(self.portfolio)
db.session.commit()
if getattr(response, "get_creds", None) is not None:
new_creds = response.get_creds()
# TODO: one way salted hash of tenant_id to use as kv key name?
tenant_id = new_creds.get("tenant_id")
secret = self.csp.get_secret(tenant_id, new_creds)
secret.update(new_creds)
self.csp.set_secret(tenant_id, secret)
except PydanticValidationError as exc:
app.logger.error(
f"Failed to cast response to valid result class {self.__repr__()}:",
exc_info=1,
)
app.logger.info(exc.json())
print(exc.json())
app.logger.info(payload_data)
self.fail_stage(stage)
except (ConnectionException, UnknownServerException) as exc:
app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
)
self.fail_stage(stage)
if self.portfolio.csp_data is None:
self.portfolio.csp_data = {}
self.portfolio.csp_data[stage + "_data"] = response
db.session.add(self.portfolio)
db.session.commit()
self.finish_stage(stage)
def is_csp_data_valid(self, event):
# check portfolio csp details json field for fields
"""
This function guards advancing states from *_IN_PROGRESS to *_COMPLETED.
"""
if self.portfolio.csp_data is None or not isinstance(
self.portfolio.csp_data, dict
):
return False
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
stage_data = self.portfolio.csp_data.get(stage + "_data")
cls = get_stage_csp_class(stage, "result")
if not cls:
return False
try:
cls(**stage_data)
except PydanticValidationError as exc:
print(exc.json())
print("no csp data")
return False
return True
# print('failed condition', self.portfolio.csp_data)
@property
def application_id(self):
return None

View File

@ -13,7 +13,7 @@ from atst.domain.environments import Environments
from atst.domain.applications import Applications
from atst.domain.application_roles import ApplicationRoles
from atst.domain.audit_log import AuditLog
from atst.domain.csp.cloud import GeneralCSPException
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.common import Paginator
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.invitations import ApplicationInvitations

View File

@ -25,6 +25,11 @@ def camel_to_snake(camel_cased):
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def snake_to_camel(snake_cased):
parts = snake_cased.split("_")
return f"{parts[0]}{''.join([w.capitalize() for w in parts[1:]])}"
def pick(keys, dct):
_keys = set(keys)
return {k: v for (k, v) in dct.items() if k in _keys}

View File

@ -8,7 +8,7 @@ run_python_lint() {
}
run_python_typecheck() {
run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud.py"
run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud/__init__.py"
return $?
}

1
static/icons/clock.svg Normal file
View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="clock" class="svg-inline--fa fa-clock fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"></path></svg>

After

Width:  |  Height:  |  Size: 554 B

View File

@ -21,7 +21,7 @@
text-transform: uppercase;
&--default {
background-color: $color-gray-dark;
background-color: $color-gray;
}
&--info {

View File

@ -1,3 +1,4 @@
{% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %}
{% from "components/label.html" import Label %}
{% from 'components/save_button.html' import SaveButton %}
@ -10,10 +11,13 @@
new_env_form) %}
<h3>{{ "portfolios.applications.settings.environments" | translate }}</h3>
{% if portfolio.num_task_orders == 0 -%}
{{ Alert(message="portfolios.applications.environments.funding_alert"|translate({'name': portfolio.name})) }}
{%- endif %}
{% if g.matchesPath("application-environments") -%}
{% include "fragments/flash.html" %}
{%- endif %}
<section class="panel" id="application-environments">
{% if g.matchesPath("application-environments") -%}
{% include "fragments/flash.html" %}
{%- endif %}
{% if 0 == environments_obj | length -%}
<div class="empty-state panel__content">
<p class="empty-state__message">
@ -30,14 +34,21 @@
<li class="accordion-table__item">
<div class="accordion-table__item-content">
<div class="environment-list__item">
<span>
<a
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
target='_blank'
rel='noopener noreferrer'>
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
</a>
</span>
{% if not env["pending"] -%}
<span>
<a
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
target='_blank'
rel='noopener noreferrer'>
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
</a>
</span>
{% else -%}
<span>
{{ env['name'] }}
</span>
{{ Label(type="pending_creation", classes='label--below')}}
{%- endif %}
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
{{
ToggleButton(
@ -57,10 +68,6 @@
classes="environment-list__item__members"
)
}}
<br>
{% if env['pending'] -%}
{{ Label(type="changes_pending", classes='label--below')}}
{%- endif %}
</div>
</div>

View File

@ -9,6 +9,11 @@
"text": "changes pending",
"color": "default",
},
"pending_creation": {
"icon": "clock",
"text": "pending creation",
"color": "default",
},
"ppoc": {"text": "primary point of contact"}
} %}

View File

@ -281,3 +281,4 @@ secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ cr
`terraform apply`
*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)*

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,13 @@ resource "azurerm_key_vault" "keyvault" {
sku_name = "premium"
network_acls {
default_action = var.policy
bypass = "AzureServices"
virtual_network_subnet_ids = var.subnet_ids
ip_rules = values(var.whitelist)
}
tags = {
environment = var.environment
owner = var.owner

View File

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

View File

@ -37,9 +37,9 @@ resource "azurerm_postgresql_virtual_network_rule" "sql" {
}
resource "azurerm_postgresql_database" "db" {
name = "${var.environment}-atat"
name = "${var.name}-${var.environment}-atat"
resource_group_name = azurerm_resource_group.sql.name
server_name = azurerm_postgresql_server.sql.name
charset = "UTF8"
collation = "en_US.utf8"
collation = "en-US"
}

View File

@ -93,4 +93,3 @@ variable "ssl_enforcement" {
description = "Enforce SSL (Enabled/Disable)"
default = "Enabled"
}

View File

@ -13,6 +13,7 @@ resource "azurerm_redis_cache" "redis" {
sku_name = var.sku_name
enable_non_ssl_port = var.enable_non_ssl_port
minimum_tls_version = var.minimum_tls_version
subnet_id = var.subnet_id
redis_configuration {
enable_authentication = var.enable_authentication

View File

@ -22,35 +22,30 @@ variable "capacity" {
type = string
default = 2
description = "The capacity of the redis cache"
}
variable "family" {
type = string
default = "C"
description = "The subscription family for redis"
}
variable "sku_name" {
type = string
default = "Standard"
description = "The sku to use"
}
variable "enable_non_ssl_port" {
type = bool
default = false
description = "Enable non TLS port (default: false)"
}
variable "minimum_tls_version" {
type = string
default = "1.2"
description = "Minimum TLS version to use"
}
variable "enable_authentication" {
@ -58,3 +53,8 @@ variable "enable_authentication" {
default = true
description = "Enable or disable authentication (default: true)"
}
variable "subnet_id" {
type = string
description = "Subnet ID that the service_endpoint should reside"
}

View File

@ -39,6 +39,8 @@ resource "azurerm_subnet" "subnet" {
lifecycle {
ignore_changes = [route_table_id]
}
service_endpoints = split(",", var.service_endpoints[each.key])
#delegation {
# name = "acctestdelegation"
#
@ -108,7 +110,7 @@ resource "azurerm_virtual_network_gateway" "vnet_gateway" {
}
vpn_client_configuration {
address_space = ["172.16.1.0/24"]
address_space = var.vpn_client_cidr
vpn_client_protocols = ["OpenVPN"]
}
}

View File

@ -1,3 +1,9 @@
output "subnets" {
value = azurerm_subnet.subnet["private"].id #FIXME - output should be a map
value = azurerm_subnet.subnet["private"].id #FIXED: this is now legacy, use subnet_list
}
output "subnet_list" {
value = {
for k, id in azurerm_subnet.subnet : k => id
}
}

View File

@ -46,3 +46,15 @@ variable "gateway_subnet" {
type = string
description = "The Subnet CIDR that we'll use for the virtual_network_gateway 'GatewaySubnet'"
}
variable "service_endpoints" {
type = map
description = "A map of the service endpoints and its mapping to subnets"
}
variable "vpn_client_cidr" {
type = list
description = "The CIDR range used for clients on the VPN"
default = ["172.16.0.0/16"]
}

View File

@ -1,3 +1,5 @@
# Task order bucket is required to be accessible publicly by the users.
# which is why the policy here is "Allow"
module "task_order_bucket" {
source = "../../modules/bucket"
service_name = "jeditasksatat"
@ -5,8 +7,15 @@ module "task_order_bucket" {
name = var.name
environment = var.environment
region = var.region
policy = "Allow"
subnet_ids = [module.vpc.subnets]
whitelist = var.storage_admin_whitelist
}
# TF State should be restricted to admins only, but IP protected
# This has to be public due to a chicken/egg issue of VPN not
# existing until TF is run. If this bucket is private, you would
# not be able to access it when running TF without being on a VPN.
module "tf_state" {
source = "../../modules/bucket"
service_name = "jedidevtfstate"
@ -14,4 +23,7 @@ module "tf_state" {
name = var.name
environment = var.environment
region = var.region
policy = "Deny"
subnet_ids = []
whitelist = var.storage_admin_whitelist
}

View File

@ -5,4 +5,7 @@ module "container_registry" {
environment = var.environment
owner = var.owner
backup_region = var.backup_region
policy = "Deny"
subnet_ids = []
whitelist = var.admin_user_whitelist
}

View File

@ -7,5 +7,8 @@ module "keyvault" {
tenant_id = var.tenant_id
principal_id = "f9bcbe58-8b73-4957-aee2-133dc3e58063"
admin_principals = var.admin_users
policy = "Deny"
subnet_ids = [module.vpc.subnets]
whitelist = var.admin_user_whitelist
}

View File

@ -4,4 +4,7 @@ module "redis" {
environment = var.environment
region = var.region
name = var.name
subnet_id = module.vpc.subnet_list["redis"].id
sku_name = "Premium"
family = "P"
}

View File

@ -7,4 +7,7 @@ module "operator_keyvault" {
tenant_id = var.tenant_id
principal_id = ""
admin_principals = var.admin_users
policy = "Deny"
subnet_ids = [module.vpc.subnets]
whitelist = var.admin_user_whitelist
}

View File

@ -32,7 +32,17 @@ variable "networks" {
#format
#name = "CIDR, route table, Security Group Name"
public = "10.1.1.0/24,public" # LBs
private = "10.1.2.0/24,private" # k8s, postgres, redis, dns, ad
private = "10.1.2.0/24,private" # k8s, postgres, keyvault
redis = "10.1.3.0/24,private" # Redis
}
}
variable "service_endpoints" {
type = map
default = {
public = "Microsoft.ContainerRegistry" # Not necessary but added to avoid infinite state loop
private = "Microsoft.Storage,Microsoft.KeyVault,Microsoft.ContainerRegistry,Microsoft.Sql"
redis = "Microsoft.Storage,Microsoft.Sql" # FIXME: There is no Microsoft.Redis
}
}
@ -48,6 +58,7 @@ variable "route_tables" {
default = {
public = "Internet"
private = "Internet"
redis = "VnetLocal"
#private = "VnetLocal"
}
}
@ -79,3 +90,26 @@ variable "admin_users" {
"Dan Corrigan" = "7e852ceb-eb0d-49b1-b71e-e9dcd1082ffc"
}
}
variable "admin_user_whitelist" {
type = map
default = {
"Rob Gil" = "66.220.238.246/32"
"Dan Corrigan Work" = "108.16.207.173/32"
"Dan Corrigan Home" = "71.162.221.27/32"
}
}
variable "storage_admin_whitelist" {
type = map
default = {
"Rob Gil" = "66.220.238.246"
"Dan Corrigan Work" = "108.16.207.173"
"Dan Corrigan Home" = "71.162.221.27"
}
}
variable "vpn_client_cidr" {
type = list
default = ["172.16.255.0/24"]
}

View File

@ -1,13 +1,15 @@
module "vpc" {
source = "../../modules/vpc/"
environment = var.environment
region = var.region
virtual_network = var.virtual_network
networks = var.networks
gateway_subnet = var.gateway_subnet
route_tables = var.route_tables
owner = var.owner
name = var.name
dns_servers = var.dns_servers
source = "../../modules/vpc/"
environment = var.environment
region = var.region
virtual_network = var.virtual_network
networks = var.networks
gateway_subnet = var.gateway_subnet
route_tables = var.route_tables
owner = var.owner
name = var.name
dns_servers = var.dns_servers
service_endpoints = var.service_endpoints
vpn_client_cidr = var.vpn_client_cidr
}

View File

@ -1,22 +1,35 @@
import pytest
from unittest.mock import Mock
from uuid import uuid4
from tests.factories import ApplicationFactory, EnvironmentFactory
from tests.mock_azure import AUTH_CREDENTIALS, mock_azure
from atst.domain.csp.cloud import AzureCloudProvider
from atst.domain.csp.cloud.models import (
BillingInstructionCSPPayload,
BillingInstructionCSPResult,
BillingProfileCreationCSPPayload,
BillingProfileCreationCSPResult,
BillingProfileTenantAccessCSPPayload,
BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult,
TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload,
TaskOrderBillingVerificationCSPResult,
TenantCSPPayload,
TenantCSPResult,
)
from tests.mock_azure import mock_azure, AUTH_CREDENTIALS
from tests.factories import EnvironmentFactory, ApplicationFactory
creds = {
"home_tenant_id": "tenant_id",
"client_id": "client_id",
"secret_key": "secret_key",
}
BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31"
# TODO: Directly test create subscription, provide all args √
# TODO: Test create environment (create management group with parent)
# TODO: Test create application (create manageemnt group with parent)
# Create reusable mock for mocking the management group calls for multiple services
#
@pytest.mark.skip()
def test_create_subscription_succeeds(mock_azure: AzureCloudProvider):
environment = EnvironmentFactory.create()
@ -51,14 +64,12 @@ def test_create_subscription_succeeds(mock_azure: AzureCloudProvider):
assert result == subscription_id
@pytest.mark.skip()
def mock_management_group_create(mock_azure, spec_dict):
mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = Mock(
**spec_dict
)
@pytest.mark.skip()
def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
environment = EnvironmentFactory.create()
@ -71,7 +82,6 @@ def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
assert result.id == "Test Id"
@pytest.mark.skip()
def test_create_application_succeeds(mock_azure: AzureCloudProvider):
application = ApplicationFactory.create()
@ -82,7 +92,6 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider):
assert result.id == "Test Id"
@pytest.mark.skip()
def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
environment_id = str(uuid4())
@ -97,7 +106,6 @@ def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
assert result.get("csp_user_id") == csp_user_id
@pytest.mark.skip()
def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
subscription_id = str(uuid4())
management_group_id = str(uuid4())
@ -121,3 +129,287 @@ def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
policy_definition_name=properties.get("displayName"),
parameters=mock_policy_definition,
)
def test_create_tenant(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.json.return_value = {
"objectId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
"tenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
"userId": "1153801116406515559",
}
mock_result.status_code = 200
mock_azure.sdk.requests.post.return_value = mock_result
payload = TenantCSPPayload(
**dict(
creds=creds,
user_id="admin",
password="JediJan13$coot", # pragma: allowlist secret
domain_name="jediccpospawnedtenant2",
first_name="Tedry",
last_name="Tenet",
country_code="US",
password_recovery_email_address="thomas@promptworks.com",
)
)
result = mock_azure.create_tenant(payload)
body: TenantCSPResult = result.get("body")
assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435"
def test_create_billing_profile_creation(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.headers = {
"Location": "http://retry-url",
"Retry-After": "10",
}
mock_result.status_code = 202
mock_azure.sdk.requests.post.return_value = mock_result
payload = BillingProfileCreationCSPPayload(
**dict(
address=dict(
address_line_1="123 S Broad Street, Suite 2400",
company_name="Promptworks",
city="Philadelphia",
region="PA",
country="US",
postal_code="19109",
),
creds=creds,
tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435",
billing_profile_display_name="Test Billing Profile",
billing_account_name=BILLING_ACCOUNT_NAME,
)
)
result = mock_azure.create_billing_profile_creation(payload)
body: BillingProfileCreationCSPResult = result.get("body")
assert body.billing_profile_retry_after == 10
def test_validate_billing_profile_creation(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.status_code = 200
mock_result.json.return_value = {
"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": "First Portfolio 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/6HMZ-2HLO-PJA-TGB",
"name": "6HMZ-2HLO-PJA-TGB",
"properties": {"displayName": "First Portfolio Billing Profile"},
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
}
],
},
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
}
mock_azure.sdk.requests.get.return_value = mock_result
payload = BillingProfileVerificationCSPPayload(
**dict(
creds=creds,
billing_profile_verify_url="https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/createBillingProfile_478d5706-71f9-4a8b-8d4e-2cbaca27a668?api-version=2019-10-01-preview",
)
)
result = mock_azure.create_billing_profile_verification(payload)
body: BillingProfileVerificationCSPResult = result.get("body")
assert body.billing_profile_name == "KQWI-W2SU-BG7-TGB"
assert (
body.billing_profile_properties.billing_profile_display_name
== "First Portfolio Billing Profile"
)
def test_create_billing_profile_tenant_access(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.status_code = 201
mock_result.json.return_value = {
"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",
}
mock_azure.sdk.requests.post.return_value = mock_result
payload = BillingProfileTenantAccessCSPPayload(
**dict(
creds=creds,
tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435",
user_object_id="0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
billing_profile_name="KQWI-W2SU-BG7-TGB",
)
)
result = mock_azure.create_billing_profile_tenant_access(payload)
body: BillingProfileTenantAccessCSPResult = result.get("body")
assert (
body.billing_role_assignment_name
== "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d"
)
def test_create_task_order_billing_creation(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.status_code = 202
mock_result.headers = {
"Location": "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/patchBillingProfile_KQWI-W2SU-BG7-TGB:02715576-4118-466c-bca7-b1cd3169ff46?api-version=2019-10-01-preview",
"Retry-After": "10",
}
mock_azure.sdk.requests.patch.return_value = mock_result
payload = TaskOrderBillingCreationCSPPayload(
**dict(
creds=creds,
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
billing_profile_name="KQWI-W2SU-BG7-TGB",
)
)
result = mock_azure.create_task_order_billing_creation(payload)
body: TaskOrderBillingCreationCSPResult = result.get("body")
assert (
body.task_order_billing_verify_url
== "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/patchBillingProfile_KQWI-W2SU-BG7-TGB:02715576-4118-466c-bca7-b1cd3169ff46?api-version=2019-10-01-preview"
)
def test_create_task_order_billing_verification(mock_azure):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.status_code = 200
mock_result.json.return_value = {
"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": [
{
"productId": "DZH318Z0BPS6",
"skuId": "0001",
"skuDescription": "Microsoft Azure Plan",
}
],
"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",
}
mock_azure.sdk.requests.get.return_value = mock_result
payload = TaskOrderBillingVerificationCSPPayload(
**dict(
creds=creds,
task_order_billing_verify_url="https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/createBillingProfile_478d5706-71f9-4a8b-8d4e-2cbaca27a668?api-version=2019-10-01-preview",
)
)
result = mock_azure.create_task_order_billing_verification(payload)
body: TaskOrderBillingVerificationCSPResult = result.get("body")
assert body.billing_profile_name == "KQWI-W2SU-BG7-TGB"
assert (
body.billing_profile_enabled_plan_details.enabled_azure_plans[0].get("skuId")
== "0001"
)
def test_create_billing_instruction(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
mock_result = Mock()
mock_result.status_code = 200
mock_result.json.return_value = {
"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",
}
mock_azure.sdk.requests.put.return_value = mock_result
payload = BillingInstructionCSPPayload(
**dict(
creds=creds,
initial_clin_amount=1000.00,
initial_clin_start_date="2020/1/1",
initial_clin_end_date="2020/3/1",
initial_clin_type="1",
initial_task_order_id="TO1",
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
billing_profile_name="KQWI-W2SU-BG7-TGB",
)
)
result = mock_azure.create_billing_instruction(payload)
body: BillingInstructionCSPResult = result.get("body")
assert body.reported_clin_name == "TO1:CLIN001"

View File

@ -1,4 +1,4 @@
from atst.domain.csp.policy import AzurePolicyManager, AzurePolicy
from atst.domain.csp.cloud.policy import AzurePolicyManager, AzurePolicy
def test_portfolio_definitions():

View File

@ -1,16 +1,24 @@
import pytest
import re
from unittest import mock
from tests.factories import (
PortfolioFactory,
PortfolioStateMachineFactory,
CLINFactory,
)
from atst.models import FSMStates
from atst.models import FSMStates, PortfolioStateMachine, TaskOrder
from atst.models.mixins.state_machines import AzureStages, StageStates, compose_state
from atst.models.portfolio import Portfolio
from atst.models.portfolio_state_machine import get_stage_csp_class
# TODO: Write failure case tests
@pytest.fixture(scope="function")
def portfolio():
portfolio = PortfolioFactory.create()
# TODO: setup clin/to as active/funded/ready
portfolio = CLINFactory.create().task_order.portfolio
return portfolio
@ -19,18 +27,132 @@ def test_fsm_creation(portfolio):
assert sm.portfolio
def test_fsm_transition_start(portfolio):
def test_state_machine_trigger_next_transition(portfolio):
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
sm.trigger_next_transition()
assert sm.current_state == FSMStates.STARTING
sm.trigger_next_transition()
assert sm.current_state == FSMStates.STARTED
def test_state_machine_compose_state(portfolio):
PortfolioStateMachineFactory.create(portfolio=portfolio)
assert (
compose_state(AzureStages.TENANT, StageStates.CREATED)
== FSMStates.TENANT_CREATED
)
def test_state_machine_valid_data_classes_for_stages(portfolio):
PortfolioStateMachineFactory.create(portfolio=portfolio)
for stage in AzureStages:
assert get_stage_csp_class(stage.name.lower(), "payload") is not None
assert get_stage_csp_class(stage.name.lower(), "result") is not None
def test_state_machine_initialization(portfolio):
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
for stage in AzureStages:
# check that all stages have a 'create' and 'fail' triggers
stage_name = stage.name.lower()
for trigger_prefix in ["create", "fail"]:
assert hasattr(sm, trigger_prefix + "_" + stage_name)
# check that machine
in_progress_triggers = sm.machine.get_triggers(stage.name + "_IN_PROGRESS")
assert [
"reset",
"fail",
"finish_" + stage_name,
"fail_" + stage_name,
] == in_progress_triggers
started_triggers = sm.machine.get_triggers("STARTED")
create_trigger = next(
filter(
lambda trigger: trigger.startswith("create_"),
sm.machine.get_triggers(FSMStates.STARTED.name),
),
None,
)
assert ["reset", "fail", create_trigger] == started_triggers
@mock.patch("atst.domain.csp.cloud.MockCloudProvider")
def test_fsm_transition_start(mock_cloud_provider, portfolio: Portfolio):
mock_cloud_provider._authorize.return_value = None
mock_cloud_provider._maybe_raise.return_value = None
sm: PortfolioStateMachine = PortfolioStateMachineFactory.create(portfolio=portfolio)
assert sm.portfolio
assert sm.state == FSMStates.UNSTARTED
# next_state does not create the trigger callbacks !!!
# sm.next_state()
sm.init()
assert sm.state == FSMStates.STARTING
sm.start()
assert sm.state == FSMStates.STARTED
sm.create_tenant(a=1, b=2)
assert sm.state == FSMStates.TENANT_CREATED
expected_states = [
FSMStates.TENANT_CREATED,
FSMStates.BILLING_PROFILE_CREATION_CREATED,
FSMStates.BILLING_PROFILE_VERIFICATION_CREATED,
FSMStates.BILLING_PROFILE_TENANT_ACCESS_CREATED,
FSMStates.TASK_ORDER_BILLING_CREATION_CREATED,
FSMStates.TASK_ORDER_BILLING_VERIFICATION_CREATED,
FSMStates.BILLING_INSTRUCTION_CREATED,
]
# Should source all creds for portfolio? might be easier to manage than per-step specific ones
creds = {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret
if portfolio.csp_data is not None:
csp_data = portfolio.csp_data
else:
csp_data = {}
ppoc = portfolio.owner
user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower()
domain_name = re.sub("[^0-9a-zA-Z]+", "", portfolio.name).lower()
initial_task_order: TaskOrder = portfolio.task_orders[0]
initial_clin = initial_task_order.sorted_clins[0]
portfolio_data = {
"user_id": user_id,
"password": "jklfsdNCVD83nklds2#202", # pragma: allowlist secret
"domain_name": domain_name,
"first_name": ppoc.first_name,
"last_name": ppoc.last_name,
"country_code": "US",
"password_recovery_email_address": ppoc.email,
"address": { # TODO: TBD if we're sourcing this from data or config
"company_name": "",
"address_line_1": "",
"city": "",
"region": "",
"country": "",
"postal_code": "",
},
"billing_profile_display_name": "My Billing Profile",
"initial_clin_amount": initial_clin.obligated_amount,
"initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"),
"initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"),
"initial_clin_type": initial_clin.number,
"initial_task_order_id": initial_task_order.number,
}
config = {"billing_account_name": "billing_account_name"}
for expected_state in expected_states:
collected_data = dict(
list(csp_data.items()) + list(portfolio_data.items()) + list(config.items())
)
sm.trigger_next_transition(creds=creds, csp_data=collected_data)
assert sm.state == expected_state
if portfolio.csp_data is not None:
csp_data = portfolio.csp_data
else:
csp_data = {}

View File

@ -8,6 +8,7 @@ AZURE_CONFIG = {
"AZURE_SECRET_KEY": "MOCK",
"AZURE_TENANT_ID": "MOCK",
"AZURE_POLICY_LOCATION": "policies",
"AZURE_VAULT_URL": "http://vault",
}
AUTH_CREDENTIALS = {
@ -53,16 +54,38 @@ def mock_policy():
return Mock(spec=policy)
def mock_adal():
import adal
return Mock(spec=adal)
def mock_requests():
import requests
return Mock(spec=requests)
def mock_secrets():
from azure.keyvault import secrets
return Mock(spec=secrets)
class MockAzureSDK(object):
def __init__(self):
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
self.subscription = mock_subscription()
self.authorization = mock_authorization()
self.policy = mock_policy()
self.adal = mock_adal()
self.managementgroups = mock_managementgroups()
self.graphrbac = mock_graphrbac()
self.credentials = mock_credentials()
self.policy = mock_policy()
self.secrets = mock_secrets()
self.requests = mock_requests()
# may change to a JEDI cloud
self.cloud = AZURE_PUBLIC_CLOUD

View File

@ -12,7 +12,7 @@ from atst.domain.application_roles import ApplicationRoles
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.invitations import ApplicationInvitations
from atst.domain.common import Paginator
from atst.domain.csp.cloud import GeneralCSPException
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.permission_sets import PermissionSets
from atst.models.application_role import Status as ApplicationRoleStatus
from atst.models.environment_role import CSPRole, EnvironmentRole

View File

@ -437,6 +437,7 @@ portfolios:
add_subscription: Add new subscription
blank_slate: This Application has no environments
disabled: ": Access Suspended"
funding_alert: "Application environments will not be created until the {name} portfolio is funded."
environments_heading: Application Environments
existing_application_title: "{application_name} Application Settings"
member_count: "{count} Members"