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 @@

{{ page_title }}

}) }} {% endif %} - +
@@ -90,7 +90,7 @@

{{ page_title }}

}) }} {% endif %} Enter namespace with the -env suffix removed - +
@@ -128,6 +128,44 @@

{{ page_title }}

] }) }} + {% if show_cloud_platform_assume_role %} + {{ govukCheckboxes({ + "name": "allow_cloud_platform_assume_role", + "fieldset": { + "legend": { + "text": "Access via Cloud Platform", + "classes": "govuk-fieldset__legend--m", + }, + }, + "hint": { + "text": "Allows Cloud Platform role to assume role in the Analytical Platform" + }, + "items": [ + { + "value": "True", + "text": "Access via Cloud Platform", + "checked": form.allow_cloud_platform_assume_role.value() == True + } + ] + }) }} + +
+
+
+ {{ govukInput({ + "name": "cloud_platform_role_arn", + "classes": "govuk-!-width-one-half", + "label": { + "text": "Cloud Platform Role ARN", + "classes": "govuk-label--s", + }, + "errorMessage": {"text": form.errors.get("cloud_platform_role_arn")} if form.errors.get("cloud_platform_role_arn") else {}, + "value": form.cloud_platform_role_arn.value() + }) }} +
+
+ {% endif %} +
diff --git a/controlpanel/frontend/views/app.py b/controlpanel/frontend/views/app.py index 56ace1dab..1b7e1ef76 100644 --- a/controlpanel/frontend/views/app.py +++ b/controlpanel/frontend/views/app.py @@ -206,6 +206,13 @@ def form_valid(self, form): return FormMixin.form_invalid(self, form) return FormMixin.form_valid(self, form) + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["show_cloud_platform_assume_role"] = ( + settings.features.cloud_platform_assume_role.enabled + ) + return context + class UpdateAppAuth0Connections(OIDCLoginRequiredMixin, PermissionRequiredMixin, UpdateView): diff --git a/controlpanel/frontend/views/apps_mng.py b/controlpanel/frontend/views/apps_mng.py index f103f52a6..4676150e0 100644 --- a/controlpanel/frontend/views/apps_mng.py +++ b/controlpanel/frontend/views/apps_mng.py @@ -25,6 +25,7 @@ class AppManager: def register_app(self, user, app_data): repo_url = app_data["repo_url"] _, name = repo_url.rsplit("/", 1) + cloud_platform_role_arn = app_data.get("cloud_platform_role_arn", None) # Create app and all the related sources new_app = self._create_app( @@ -32,6 +33,7 @@ def register_app(self, user, app_data): repo_url=repo_url, current_user=user, namespace=app_data["namespace"], + cloud_platform_role_arn=cloud_platform_role_arn, ) self._add_app_to_users(new_app, user) self._create_or_link_datasource(new_app, user, app_data) diff --git a/settings.yaml b/settings.yaml index 5258fce67..b024537b6 100644 --- a/settings.yaml +++ b/settings.yaml @@ -11,6 +11,11 @@ enabled_features: _HOST_dev: true _HOST_test: true _HOST_prod: true + cloud_platform_assume_role: + _DEFAULT: false + _HOST_dev: true + _HOST_prod: false + _HOST_alpha: false AWS_SERVICE_URL: _HOST_dev: "https://aws.services.dev.analytical-platform.service.justice.gov.uk" diff --git a/tests/frontend/views/test_app.py b/tests/frontend/views/test_app.py index 99a263cb7..ae1f4fefc 100644 --- a/tests/frontend/views/test_app.py +++ b/tests/frontend/views/test_app.py @@ -738,6 +738,26 @@ def test_app_settings_permission(client, app, users, repos_with_auth, user, can_ assert "Edit" not in auth_item_ui.text +def test_register_app_with_xacct_policy(client, users): + test_app_name = "test_app_with_xacct_policy" + assert App.objects.filter(name=test_app_name).count() == 0 + client.force_login(users["superuser"]) + data = dict( + repo_url=f"https://github.com/ministryofjustice/{test_app_name}", + namespace="test-app-namespace", + connect_bucket="later", + allow_cloud_platform_assume_role=True, + cloud_platform_role_arn="arn:aws:iam::123456789012:role/test_role", + ) + response = client.post(reverse("create-app"), data) + + assert response.status_code == 302 + assert App.objects.filter(name=test_app_name).count() == 1 + created_app = App.objects.filter(name=test_app_name).first() + assert created_app.cloud_platform_role_arn == "arn:aws:iam::123456789012:role/test_role" + assert response.url == reverse("manage-app", kwargs={"pk": created_app.pk}) + + def test_register_app_with_creating_datasource(client, users): test_app_name = "test_app_with_creating_datasource" test_bucket_name = "test-bucket"