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..f15ac1cd 100644 --- a/atst/domain/csp/__init__.py +++ b/atst/domain/csp/__init__.py @@ -34,9 +34,7 @@ def make_csp_provider(app, csp=None): def _stage_to_classname(stage): - return "".join( - map(lambda word: word.capitalize(), stage.replace("_", " ").split(" ")) - ) + return "".join(map(lambda word: word.capitalize(), stage.split("_"))) def get_stage_csp_class(stage, class_type): @@ -45,7 +43,7 @@ def get_stage_csp_class(stage, class_type): class_type is either 'payload' or 'result' """ - cls_name = "".join([_stage_to_classname(stage), "CSP", class_type.capitalize()]) + cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}" try: return getattr(importlib.import_module("atst.domain.csp.cloud"), cls_name) except AttributeError: diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index b04ed91f..d22f9475 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -1,12 +1,17 @@ import re -from typing import Dict +from typing import Dict, List, Optional from uuid import uuid4 -from pydantic import BaseModel +from pydantic import BaseModel, validator + +from flask import current_app as app from atst.models.user import User +from atst.models.application import Application from atst.models.environment import Environment from atst.models.environment_role import EnvironmentRole +from atst.utils import snake_to_camel +from .policy import AzurePolicyManager class GeneralCSPException(Exception): @@ -142,10 +147,33 @@ class BaselineProvisionException(GeneralCSPException): ) -class BaseCSPPayload(BaseModel): +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 @@ -157,32 +185,47 @@ class TenantCSPPayload(BaseCSPPayload): password_recovery_email_address: str -class TenantCSPResult(BaseModel): +class TenantCSPResult(AliasModel): user_id: str tenant_id: str user_object_id: str + tenant_admin_username: Optional[str] + tenant_admin_password: Optional[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 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 BillingProfileCLINBudget(BaseModel): - clinBudget: Dict +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, @@ -193,47 +236,153 @@ class BillingProfileCLINBudget(BaseModel): """ -class BillingProfileCSPPayload( - BaseCSPPayload, BillingProfileAddress, BillingProfileCLINBudget -): - displayName: str - poNumber: str - invoiceEmailOptIn: str +class BillingProfileCreationCSPPayload(BaseCSPPayload): + tenant_id: str + billing_profile_display_name: str + billing_account_name: str + enabled_azure_plans: Optional[List[str]] + address: BillingProfileAddress - """ - { - "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" + @validator("enabled_azure_plans", pre=True, always=True) + def default_enabled_azure_plans(cls, v): + """ + Normally you'd implement this by setting the field with a value of: + dataclasses.field(default_factory=list) + but that prevents the object from being correctly pickled, so instead we need + to rely on a validator to ensure this has an empty value when not specified + """ + return v or [] + + class Config: + fields = {"billing_profile_display_name": "displayName"} + + +class BillingProfileCreationCSPResult(AliasModel): + billing_profile_verify_url: str + billing_profile_retry_after: int + + class Config: + fields = { + "billing_profile_verify_url": "Location", + "billing_profile_retry_after": "Retry-After", + } + + +class BillingProfileVerificationCSPPayload(BaseCSPPayload): + billing_profile_verify_url: str + + +class BillingInvoiceSection(AliasModel): + invoice_section_id: str + invoice_section_name: str + + class Config: + fields = {"invoice_section_id": "id", "invoice_section_name": "name"} + + +class BillingProfileProperties(AliasModel): + address: BillingProfileAddress + billing_profile_display_name: str + invoice_sections: List[BillingInvoiceSection] + + class Config: + fields = {"billing_profile_display_name": "displayName"} + + +class BillingProfileVerificationCSPResult(AliasModel): + billing_profile_id: str + billing_profile_name: str + billing_profile_properties: BillingProfileProperties + + class Config: + fields = { + "billing_profile_id": "id", + "billing_profile_name": "name", + "billing_profile_properties": "properties", + } + + +class BillingProfileTenantAccessCSPPayload(BaseCSPPayload): + tenant_id: str + user_object_id: str + billing_account_name: str + billing_profile_name: str + + +class BillingProfileTenantAccessCSPResult(AliasModel): + billing_role_assignment_id: str + billing_role_assignment_name: str + + class Config: + fields = { + "billing_role_assignment_id": "id", + "billing_role_assignment_name": "name", + } + + +class TaskOrderBillingCreationCSPPayload(BaseCSPPayload): + billing_account_name: str + billing_profile_name: str + + +class TaskOrderBillingCreationCSPResult(AliasModel): + task_order_billing_verify_url: str + task_order_retry_after: int + + class Config: + fields = { + "task_order_billing_verify_url": "Location", + "task_order_retry_after": "Retry-After", + } + + +class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload): + task_order_billing_verify_url: str + + +class BillingProfileEnabledPlanDetails(AliasModel): + enabled_azure_plans: List[Dict] + + +class TaskOrderBillingVerificationCSPResult(AliasModel): + billing_profile_id: str + billing_profile_name: str + billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails + + class Config: + fields = { + "billing_profile_id": "id", + "billing_profile_name": "name", + "billing_profile_enabled_plan_details": "properties", + } + + +class BillingInstructionCSPPayload(BaseCSPPayload): + initial_clin_amount: float + initial_clin_start_date: str + initial_clin_end_date: str + initial_clin_type: str + initial_task_order_id: str + billing_account_name: str + billing_profile_name: str + + +class BillingInstructionCSPResult(AliasModel): + reported_clin_name: str + + class Config: + fields = { + "reported_clin_name": "name", } - } - """ class 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() @@ -377,6 +526,12 @@ class MockCloudProvider(CloudProviderInterface): 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): + return {} + def create_environment(self, auth_credentials, user, environment): self._authorize(auth_credentials) @@ -422,7 +577,7 @@ class MockCloudProvider(CloudProviderInterface): return {"id": self._id(), "credentials": self._auth_credentials} - def create_tenant(self, payload): + def create_tenant(self, payload: TenantCSPPayload): """ payload is an instance of TenantCSPPayload data class """ @@ -434,55 +589,155 @@ class MockCloudProvider(CloudProviderInterface): 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" + return TenantCSPResult( + **{ + "tenant_id": "", + "user_id": "", + "user_object_id": "", + "tenant_admin_username": "test", + "tenant_admin_password": "test", } - } - """ + ).dict() + + 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) - response = {"id": "string"} - return {"billing_profile_id": response["id"]} + return BillingProfileCreationCSPResult( + **dict( + billing_profile_verify_url="https://zombo.com", + billing_profile_retry_after=10, + ) + ).dict() + + 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", + } + ).dict() + + 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", + } + ).dict() + + 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"} + ).dict() + + 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", + } + ).dict() + + 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", + } + ).dict() def create_or_update_user(self, auth_credentials, user_info, csp_role_id): self._authorize(auth_credentials) @@ -544,7 +799,7 @@ class MockCloudProvider(CloudProviderInterface): @property def _auth_credentials(self): - return {"username": "mock-cloud", "pass": "shh"} + return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret def _authorize(self, credentials): self._delay(1, 5) @@ -561,19 +816,33 @@ SUBSCRIPTION_ID_REGEX = re.compile( # 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 + 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 @@ -585,51 +854,61 @@ class AzureCloudProvider(CloudProviderInterface): 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) - sub_client = self.sdk.subscription.SubscriptionClient(credentials) - 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 - 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= + management_group = self._create_management_group( + credentials, management_group_id, display_name, parent_id, ) - # 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 + return management_group def create_atat_admin_user( self, auth_credentials: Dict, csp_environment_id: str @@ -668,102 +947,316 @@ class AzureCloudProvider(CloudProviderInterface): "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) + 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 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"], - } + return self._create_management_group( + credentials, management_group_name, display_name, parent_id, ) - def create_billing_owner(self, creds, tenant_admin_details): - # authenticate as tenant_admin - # create billing owner identity + 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 + ) - # TODO: Lookup response format - # Managed service identity? - response = {"id": "string"} - return self._ok({"billing_owner_id": response["id"]}) + # 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 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, + 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, + ): """ - { - "principalId": "string", - "principalTenantId": "string", - "billingRoleDefinitionId": "string" + 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") + + create_tenant_body = payload.dict(by_alias=True) + + create_tenant_headers = { + "Authorization": f"Bearer {sp_token}", } - """ - return self.ok() + 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, + ) - 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" + 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", } } - """ - # 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"]}) + headers = { + "Authorization": f"Bearer {sp_token}", + } - 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" + 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, } } - """ - # we don't need any of the returned info for this - return self._ok() + 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 @@ -849,8 +1342,27 @@ class AzureCloudProvider(CloudProviderInterface): if sub_id_match: return sub_id_match.group(1) - def _get_credential_obj(self, creds, resource=None): + 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"), @@ -859,6 +1371,13 @@ class AzureCloudProvider(CloudProviderInterface): 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) diff --git a/atst/jobs.py b/atst/jobs.py index f4611a9a..ab52cf17 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -12,7 +12,6 @@ from atst.models import ( from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException 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..a0cc77cd 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -50,6 +50,9 @@ class PortfolioStateMachine( db.session.add(self) db.session.commit() + def __repr__(self): + return f"