diff --git a/controlpanel/api/cluster.py b/controlpanel/api/cluster.py index 3d1a7aa13..22d68961d 100644 --- a/controlpanel/api/cluster.py +++ b/controlpanel/api/cluster.py @@ -501,13 +501,31 @@ def oidc_provider_statement(self): ) return json.loads(statement) + @property + def xacct_trust_statement(self): + """ + Builds an assume role statement for a Cloud Platform IAM role + """ + statement = render_to_string( + template_name="assume_roles/cloud_platform_xacct.json", + context={"app_role": self.app.cloud_platform_role_arn}, + ) + return json.loads(statement) + def create_iam_role(self): + statement = self._get_statement() assume_role_policy = deepcopy(BASE_ASSUME_ROLE_POLICY) - assume_role_policy["Statement"].append(self.oidc_provider_statement) + assume_role_policy["Statement"].append(statement) self.aws_role_service.create_role(self.iam_role_name, assume_role_policy) for env in self.get_deployment_envs(): self._create_secrets(env_name=env) + def _get_statement(self): + if self.app.cloud_platform_role_arn: + return self.xacct_trust_statement + + return self.oidc_provider_statement + def grant_bucket_access(self, bucket_arn, access_level, path_arns): self.aws_role_service.grant_bucket_access( self.iam_role_name, bucket_arn, access_level, path_arns diff --git a/controlpanel/api/migrations/0047_app_cloud_platform_role_arn.py b/controlpanel/api/migrations/0047_app_cloud_platform_role_arn.py new file mode 100644 index 000000000..cecbc6e4d --- /dev/null +++ b/controlpanel/api/migrations/0047_app_cloud_platform_role_arn.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-11-27 15:46 + +# Third-party +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0046_alter_user_options"), + ] + + operations = [ + migrations.AddField( + model_name="app", + name="cloud_platform_role_arn", + field=models.CharField( + default=None, + help_text="The cloud platform arn for the app", + max_length=130, + null=True, + ), + ), + ] diff --git a/controlpanel/api/models/app.py b/controlpanel/api/models/app.py index 384ba5d91..7f5f8f06f 100644 --- a/controlpanel/api/models/app.py +++ b/controlpanel/api/models/app.py @@ -20,6 +20,7 @@ class App(TimeStampedModel): + name = models.CharField(max_length=100, blank=False) description = models.TextField(blank=True) slug = AutoSlugField(populate_from="_repo_name", slugify_function=s3_slugify) @@ -32,6 +33,12 @@ class App(TimeStampedModel): related_query_name="app", blank=True, ) + cloud_platform_role_arn = models.CharField( + help_text="The cloud platform arn for the app", + max_length=130, + null=True, + default=None, + ) res_id = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) is_bedrock_enabled = models.BooleanField(default=False) is_textract_enabled = models.BooleanField(default=False) diff --git a/controlpanel/api/templates/assume_roles/cloud_platform_xacct.json b/controlpanel/api/templates/assume_roles/cloud_platform_xacct.json new file mode 100644 index 000000000..3389474cd --- /dev/null +++ b/controlpanel/api/templates/assume_roles/cloud_platform_xacct.json @@ -0,0 +1,9 @@ +{ + "Sid": "AllowCloudPlatformCrossAccountIAM", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { + "AWS": "{{ app_role }}" + }, + "Condition": {} +} diff --git a/controlpanel/api/validators.py b/controlpanel/api/validators.py index 659d5e2ee..fdc8cfb1d 100644 --- a/controlpanel/api/validators.py +++ b/controlpanel/api/validators.py @@ -67,6 +67,14 @@ def __call__(self, value): "can only contain alphanumeric, underscores and hyphens)", ) +validate_aws_role_arn = RegexValidator( + regex=r"^arn:aws:iam::[0-9]{12}:role/[a-zA-Z0-9-_]+$", + message=( + "ARN is invalid. Check AWS ARN format " + "(for example, 'arn:aws:iam::123456789012:role/role_name')" + ), +) + def validate_github_repository_url(value): github_base_url = "https://github.com/" diff --git a/controlpanel/frontend/forms.py b/controlpanel/frontend/forms.py index 5750d5d74..c688ace24 100644 --- a/controlpanel/frontend/forms.py +++ b/controlpanel/frontend/forms.py @@ -147,6 +147,10 @@ class CreateAppForm(forms.Form): required=False, ) namespace = forms.CharField(required=True, max_length=63) + allow_cloud_platform_assume_role = forms.BooleanField(initial=False, required=False) + cloud_platform_role_arn = forms.CharField( + required=False, max_length=130, validators=[validators.validate_aws_role_arn] + ) def __init__(self, *args, **kwargs): self.request = kwargs.pop("request", None) @@ -185,6 +189,15 @@ def clean(self): if connect_data_source == "existing" and not existing_datasource: self.add_error("existing_datasource_id", "This field is required.") + assume_role = cleaned_data.get("allow_cloud_platform_assume_role") + role_arn = cleaned_data.get("cloud_platform_role_arn") + + if assume_role and not role_arn: + self.add_error("cloud_platform_role_arn", "Role ARN is required") + + if not assume_role and role_arn: + cleaned_data.pop("cloud_platform_role_arn") + return cleaned_data def clean_repo_url(self): diff --git a/controlpanel/frontend/jinja2/webapp-create.html b/controlpanel/frontend/jinja2/webapp-create.html index f02fa3c25..945862d71 100644 --- a/controlpanel/frontend/jinja2/webapp-create.html +++ b/controlpanel/frontend/jinja2/webapp-create.html @@ -73,7 +73,7 @@