diff --git a/Pipfile b/Pipfile index 0de14fa2..5d3a7920 100644 --- a/Pipfile +++ b/Pipfile @@ -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 = "*" diff --git a/Pipfile.lock b/Pipfile.lock index ef6d0203..53a28b17 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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" } } } diff --git a/alembic/env.py b/alembic/env.py index 91b96364..5ea815e3 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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: diff --git a/alembic/versions/26319c44a8d5_state_machine_states_extended.py b/alembic/versions/26319c44a8d5_state_machine_states_extended.py new file mode 100644 index 00000000..720e50ee --- /dev/null +++ b/alembic/versions/26319c44a8d5_state_machine_states_extended.py @@ -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 ### diff --git a/atst/domain/csp/__init__.py b/atst/domain/csp/__init__.py index fc452935..d886f8a2 100644 --- a/atst/domain/csp/__init__.py +++ b/atst/domain/csp/__init__.py @@ -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) diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py deleted file mode 100644 index b04ed91f..00000000 --- a/atst/domain/csp/cloud.py +++ /dev/null @@ -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= - ) - - # 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, - } diff --git a/atst/domain/csp/cloud/__init__.py b/atst/domain/csp/cloud/__init__.py new file mode 100644 index 00000000..99128d9c --- /dev/null +++ b/atst/domain/csp/cloud/__init__.py @@ -0,0 +1,3 @@ +from .azure_cloud_provider import AzureCloudProvider +from .cloud_provider_interface import CloudProviderInterface +from .mock_cloud_provider import MockCloudProvider diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py new file mode 100644 index 00000000..5a6a9253 --- /dev/null +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -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, + } diff --git a/atst/domain/csp/cloud/cloud_provider_interface.py b/atst/domain/csp/cloud/cloud_provider_interface.py new file mode 100644 index 00000000..7f975c07 --- /dev/null +++ b/atst/domain/csp/cloud/cloud_provider_interface.py @@ -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() diff --git a/atst/domain/csp/cloud/exceptions.py b/atst/domain/csp/cloud/exceptions.py new file mode 100644 index 00000000..6ed47dff --- /dev/null +++ b/atst/domain/csp/cloud/exceptions.py @@ -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 + ) diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py new file mode 100644 index 00000000..a6c338b5 --- /dev/null +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -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 diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py new file mode 100644 index 00000000..c6bf0ede --- /dev/null +++ b/atst/domain/csp/cloud/models.py @@ -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", + } diff --git a/atst/domain/csp/policy.py b/atst/domain/csp/cloud/policy.py similarity index 100% rename from atst/domain/csp/policy.py rename to atst/domain/csp/cloud/policy.py diff --git a/atst/jobs.py b/atst/jobs.py index f4611a9a..7172343b 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -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 diff --git a/atst/models/mixins/state_machines.py b/atst/models/mixins/state_machines.py index bc35209d..37682e5b 100644 --- a/atst/models/mixins/state_machines.py +++ b/atst/models/mixins/state_machines.py @@ -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) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 3c934197..cf42710b 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -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"