From c5e96697f12fd877973b6bce812227f9964b0cdd Mon Sep 17 00:00:00 2001 From: Michael Collins <15347726+michaeljcollinsuk@users.noreply.github.com> Date: Wed, 21 Feb 2024 11:42:45 +0000 Subject: [PATCH 1/2] Bugfix/namespace length (#1257) * Add namespace field to App Will be used to store the Cloud Platform namespace name. Adds migrations that add the field, then a data migration to add values for existing apps, then finally a third migration to require values when creating a new object. * Add namespace field to the register app form Adds the field in the HTML. Update the OIDC template to use the entered namespace when creating the app IAM role. * Add APP_ROLE_ARN secret after creating role Updates the create app role task handler to pass a github api token, so this can be used to create the APP_ROLE_ARN secret as soon as the App role is created. This is required for apps that will be deployed without Auth0 clients. --- controlpanel/api/cluster.py | 4 ++- .../api/migrations/0032_app_namespace.py | 17 ++++++++++++ .../0033_add_namespaces_values_to_apps.py | 20 ++++++++++++++ .../0034_remove_null_blank_from_namespace.py | 17 ++++++++++++ controlpanel/api/models/app.py | 7 +++-- controlpanel/api/tasks/handlers/app.py | 3 ++- .../assume_roles/cloud_platform_oidc.json | 4 +-- controlpanel/frontend/forms.py | 16 +++++++++++- .../frontend/jinja2/webapp-create.html | 26 ++++++++++++++++--- controlpanel/frontend/views/app.py | 2 -- controlpanel/frontend/views/apps_mng.py | 1 + tests/api/cluster/test_app.py | 24 ++++++++++++----- tests/api/models/test_app.py | 4 +-- tests/api/tasks/test_create_app_aws_role.py | 5 ++-- tests/frontend/test_forms.py | 16 ++++++++++++ tests/frontend/views/test_app.py | 1 + 16 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 controlpanel/api/migrations/0032_app_namespace.py create mode 100644 controlpanel/api/migrations/0033_add_namespaces_values_to_apps.py create mode 100644 controlpanel/api/migrations/0034_remove_null_blank_from_namespace.py diff --git a/controlpanel/api/cluster.py b/controlpanel/api/cluster.py index eb66629df..1a1df2459 100644 --- a/controlpanel/api/cluster.py +++ b/controlpanel/api/cluster.py @@ -499,7 +499,7 @@ def oidc_provider_statement(self): "identity_provider_arn": iam_arn( f"oidc-provider/{settings.OIDC_APP_EKS_PROVIDER}" ), - "app_name": self.app.slug, + "app_namespace": self.app.namespace, } ) return json.loads(statement) @@ -508,6 +508,8 @@ def create_iam_role(self): assume_role_policy = deepcopy(BASE_ASSUME_ROLE_POLICY) assume_role_policy["Statement"].append(self.oidc_provider_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 grant_bucket_access(self, bucket_arn, access_level, path_arns): self.aws_role_service.grant_bucket_access( diff --git a/controlpanel/api/migrations/0032_app_namespace.py b/controlpanel/api/migrations/0032_app_namespace.py new file mode 100644 index 000000000..c0bb1d482 --- /dev/null +++ b/controlpanel/api/migrations/0032_app_namespace.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-02-20 16:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0031_add_soft_delete_fields"), + ] + + operations = [ + migrations.AddField( + model_name="app", + name="namespace", + field=models.CharField(blank=True, max_length=63, null=True, unique=True), + ), + ] diff --git a/controlpanel/api/migrations/0033_add_namespaces_values_to_apps.py b/controlpanel/api/migrations/0033_add_namespaces_values_to_apps.py new file mode 100644 index 000000000..64dd9c9b6 --- /dev/null +++ b/controlpanel/api/migrations/0033_add_namespaces_values_to_apps.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.7 on 2024-02-20 16:01 + +from django.db import migrations + + +def add_namespaces(apps, schema_editor): + App = apps.get_model("api", "App") + for app in App.objects.all(): + app.namespace = f"data-platform-app-{app.slug}" + app.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0032_app_namespace"), + ] + + operations = [ + migrations.RunPython(code=add_namespaces, reverse_code=migrations.RunPython.noop) + ] diff --git a/controlpanel/api/migrations/0034_remove_null_blank_from_namespace.py b/controlpanel/api/migrations/0034_remove_null_blank_from_namespace.py new file mode 100644 index 000000000..c406a65fb --- /dev/null +++ b/controlpanel/api/migrations/0034_remove_null_blank_from_namespace.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-02-20 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0033_add_namespaces_values_to_apps"), + ] + + operations = [ + migrations.AlterField( + model_name="app", + name="namespace", + field=models.CharField(max_length=63, unique=True), + ), + ] diff --git a/controlpanel/api/models/app.py b/controlpanel/api/models/app.py index 54b270da2..93140edef 100644 --- a/controlpanel/api/models/app.py +++ b/controlpanel/api/models/app.py @@ -34,6 +34,9 @@ class App(TimeStampedModel): # are not within the fields which will be searched frequently app_conf = models.JSONField(null=True) + # Stores the Cloud Platform namespace name + namespace = models.CharField(unique=True, max_length=63) + # Non database field just for passing extra parameters disable_authentication = False connections = {} @@ -254,9 +257,9 @@ def app_url_name(self, env_name): if not format_pattern: format_pattern = settings.APP_URL_NAME_PATTERN.get(self.DEFAULT_SETTING_KEY_WORD) if format_pattern: - return format_pattern.format(app_name=self.slug, env=env_name) + return format_pattern.format(app_name=self.namespace, env=env_name) else: - return self.slug + return self.namespace def get_auth_client(self, env_name): env_name = env_name or self.DEFAULT_AUTH_CATEGORY diff --git a/controlpanel/api/tasks/handlers/app.py b/controlpanel/api/tasks/handlers/app.py index accc6d68b..0b9d05f8d 100644 --- a/controlpanel/api/tasks/handlers/app.py +++ b/controlpanel/api/tasks/handlers/app.py @@ -28,5 +28,6 @@ class CreateAppAWSRole(BaseModelTaskHandler): name = "create_app_aws_role" def handle(self): - cluster.App(self.object).create_iam_role() + task_user = User.objects.filter(pk=self.task_user_pk).first() + cluster.App(self.object, task_user.github_api_token).create_iam_role() self.complete() diff --git a/controlpanel/api/templates/assume_roles/cloud_platform_oidc.json b/controlpanel/api/templates/assume_roles/cloud_platform_oidc.json index 9c13e16ba..a5e5b8b42 100644 --- a/controlpanel/api/templates/assume_roles/cloud_platform_oidc.json +++ b/controlpanel/api/templates/assume_roles/cloud_platform_oidc.json @@ -9,8 +9,8 @@ "StringEquals": { "{{ identity_provider }}:aud": "sts.amazonaws.com", "{{ identity_provider }}:sub": [ - "system:serviceaccount:data-platform-app-{{ app_name }}-dev:data-platform-app-{{ app_name }}-dev-sa", - "system:serviceaccount:data-platform-app-{{ app_name }}-prod:data-platform-app-{{ app_name }}-prod-sa" + "system:serviceaccount:{{ app_namespace }}-dev:{{ app_namespace }}-dev-sa", + "system:serviceaccount:{{ app_namespace }}-prod:{{ app_namespace }}-prod-sa" ] } } diff --git a/controlpanel/frontend/forms.py b/controlpanel/frontend/forms.py index 982c37d87..be8a27dd4 100644 --- a/controlpanel/frontend/forms.py +++ b/controlpanel/frontend/forms.py @@ -116,7 +116,7 @@ def _check_inputs_for_custom_connection(self, cleaned_data): return auth0_conn_data -class CreateAppForm(AppAuth0Form): +class CreateAppForm(forms.Form): repo_url = forms.CharField( max_length=512, @@ -148,8 +148,10 @@ class CreateAppForm(AppAuth0Form): empty_label="Select", required=False, ) + namespace = forms.CharField(required=True, max_length=63) def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) super().__init__(*args, **kwargs) self.fields["existing_datasource_id"].queryset = self.get_datasource_queryset() @@ -204,6 +206,18 @@ def clean_repo_url(self): return repo_url + def clean_namespace(self): + """ + Removes the env suffix if the user included it + """ + namespace = self.cleaned_data["namespace"] + for suffix in ["-dev", "-prod"]: + if suffix in namespace: + namespace = namespace.removesuffix(suffix) + break + + return namespace + class UpdateAppAuth0ConnectionsForm(AppAuth0Form): env_name = forms.CharField(widget=forms.HiddenInput) diff --git a/controlpanel/frontend/jinja2/webapp-create.html b/controlpanel/frontend/jinja2/webapp-create.html index 30b62d58b..33ef3768c 100644 --- a/controlpanel/frontend/jinja2/webapp-create.html +++ b/controlpanel/frontend/jinja2/webapp-create.html @@ -73,13 +73,28 @@

{{ page_title }}

}) }} {% endif %} - + -
-
+ +
+ + + + {% set error_repo_msg = form.errors.get("namespace") %} + {% if error_repo_msg %} + {% set errorId = 'namespace-error' %} + {{ govukErrorMessage({ + "id": errorId, + "html": error_repo_msg|join(". "), + }) }} + {% endif %} + Enter namespace with the -env suffix removed +
+ {{ govukRadios({ "name": "connect_bucket", "fieldset": { @@ -89,7 +104,7 @@

{{ page_title }}

}, }, "hint": { - "text": "Connect an existing app data source to your app, or create a new one.", + "text": "Connect an existing app data source to your app, or create a new one. If you don't need to connect to an S3 bucket, select 'Do this later'", }, "items": [ { @@ -100,6 +115,9 @@

{{ page_title }}

{ "value": "existing", "html": existing_datasource_html|safe, + "hint": { + "text": "Only buckets that you have admin access to are displayed", + }, "checked": form.connect_bucket.value() == "existing" }, { diff --git a/controlpanel/frontend/views/app.py b/controlpanel/frontend/views/app.py index 7d252f0f7..1fa990525 100644 --- a/controlpanel/frontend/views/app.py +++ b/controlpanel/frontend/views/app.py @@ -141,8 +141,6 @@ def get_form_kwargs(self): kwargs = FormMixin.get_form_kwargs(self) kwargs.update( request=self.request, - all_connections_names=auth0.ExtendedAuth0().connections.get_all_connection_names(), # noqa: E501 - custom_connections=auth0.ExtendedConnections.custom_connections(), ) return kwargs diff --git a/controlpanel/frontend/views/apps_mng.py b/controlpanel/frontend/views/apps_mng.py index c930b52f1..58331f48c 100644 --- a/controlpanel/frontend/views/apps_mng.py +++ b/controlpanel/frontend/views/apps_mng.py @@ -31,6 +31,7 @@ def register_app(self, user, app_data): name=name, repo_url=repo_url, current_user=user, + namespace=app_data["namespace"], ) self._add_app_to_users(new_app, user) self._create_or_link_datasource(new_app, user, app_data) diff --git a/tests/api/cluster/test_app.py b/tests/api/cluster/test_app.py index 4b082bc24..c9b4c6e48 100644 --- a/tests/api/cluster/test_app.py +++ b/tests/api/cluster/test_app.py @@ -1,10 +1,9 @@ # Standard library from copy import deepcopy -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, call # Third-party import pytest -from django.conf import settings # First-party/Local from controlpanel.api import cluster, models @@ -13,7 +12,11 @@ @pytest.fixture def app(): - return models.App(slug="test-app", repo_url="https://gitpub.example.com/test-repo") + return models.App( + slug="test-app", + repo_url="https://gitpub.example.com/test-repo", + namespace="test-namespace", + ) @pytest.fixture @@ -65,8 +68,8 @@ def oidc_provider_statement(app, settings): "StringEquals": { f"{settings.OIDC_APP_EKS_PROVIDER}:aud": "sts.amazonaws.com", f"{settings.OIDC_APP_EKS_PROVIDER}:sub": [ - f"system:serviceaccount:data-platform-app-{app.slug}-dev:data-platform-app-{app.slug}-dev-sa", # noqa - f"system:serviceaccount:data-platform-app-{app.slug}-prod:data-platform-app-{app.slug}-prod-sa" # noqa + f"system:serviceaccount:{app.namespace}-dev:{app.namespace}-dev-sa", # noqa + f"system:serviceaccount:{app.namespace}-prod:{app.namespace}-prod-sa" # noqa ] } } @@ -77,13 +80,22 @@ def test_oidc_provider_statement(app, oidc_provider_statement): assert cluster.App(app).oidc_provider_statement == oidc_provider_statement -def test_app_create_iam_role(aws_create_role, app, oidc_provider_statement): +@patch("controlpanel.api.cluster.App.get_deployment_envs") +@patch("controlpanel.api.cluster.App._create_secrets") +def test_app_create_iam_role( + _create_secrets, get_deployment_envs, aws_create_role, app, oidc_provider_statement +): expected_assume_role = deepcopy(BASE_ASSUME_ROLE_POLICY) expected_assume_role["Statement"].append(oidc_provider_statement) + get_deployment_envs.return_value = ["dev", "prod"] cluster.App(app).create_iam_role() aws_create_role.assert_called_with(app.iam_role_name, expected_assume_role) + _create_secrets.assert_has_calls([ + call(env_name="dev"), + call(env_name="prod"), + ]) @pytest.fixture # noqa: F405 diff --git a/tests/api/models/test_app.py b/tests/api/models/test_app.py index c9132c5b0..39277aea6 100644 --- a/tests/api/models/test_app.py +++ b/tests/api/models/test_app.py @@ -61,10 +61,10 @@ def test_slug_characters_replaced(): @pytest.mark.django_db def test_slug_collisions_increments(): - app = App.objects.create(repo_url="git@github.com:org/foo-bar.git") + app = App.objects.create(repo_url="git@github.com:org/foo-bar.git", namespace="foo-bar") assert "foo-bar" == app.slug - app2 = App.objects.create(repo_url="https://www.example.com/org/foo-bar") + app2 = App.objects.create(repo_url="https://www.example.com/org/foo-bar", namespace="foo-bar-2") assert "foo-bar-2" == app2.slug diff --git a/tests/api/tasks/test_create_app_aws_role.py b/tests/api/tasks/test_create_app_aws_role.py index a882a9845..b377323fa 100644 --- a/tests/api/tasks/test_create_app_aws_role.py +++ b/tests/api/tasks/test_create_app_aws_role.py @@ -1,5 +1,5 @@ # Standard library -from unittest.mock import patch +from unittest.mock import patch, MagicMock # Third-party import pytest @@ -25,6 +25,7 @@ def test_cluster_not_called_without_valid_app(cluster, complete, users): @pytest.mark.django_db +@patch("controlpanel.api.auth0.ExtendedAuth0", new=MagicMock()) @patch("controlpanel.api.tasks.handlers.base.BaseModelTaskHandler.complete") @patch("controlpanel.api.tasks.handlers.app.cluster") def test_valid_app_and_user(cluster, complete, users): @@ -32,6 +33,6 @@ def test_valid_app_and_user(cluster, complete, users): create_app_aws_role(app.pk, users["superuser"].pk) - cluster.App.assert_called_once_with(app) + cluster.App.assert_called_once_with(app, users["superuser"].github_api_token) cluster.App.return_value.create_iam_role.assert_called_once() complete.assert_called_once() diff --git a/tests/frontend/test_forms.py b/tests/frontend/test_forms.py index 8473182a8..91a9baff0 100644 --- a/tests/frontend/test_forms.py +++ b/tests/frontend/test_forms.py @@ -5,6 +5,7 @@ import pytest from django.core.exceptions import ValidationError from django.urls import reverse +from mock import MagicMock # First-party/Local from controlpanel.api import aws @@ -136,6 +137,7 @@ def test_create_app_form_clean_new_datasource(create_app_request_superuser): "repo_url": "https://github.com/ministryofjustice/my_repo", "connect_bucket": "new", "new_datasource_name": "test-bucketname", + "namespace": "my-repo" }, request=create_app_request_superuser, ) @@ -151,6 +153,7 @@ def test_create_app_form_clean_new_datasource(create_app_request_superuser): "deployment_envs": ["test"], "repo_url": "https://github.com/ministryofjustice/my_repo", "connect_bucket": "new", + "namespace": "my-repo" }, request=create_app_request_superuser, ) @@ -268,6 +271,7 @@ def test_create_app_form_clean_repo_url(create_app_request_superuser): "repo_url": "https://github.com/ministryofjustice/my_repo", "connect_bucket": "new", "new_datasource_name": "test-bucketname", + "namespace": "my-repo" }, request=create_app_request_superuser, ) @@ -441,3 +445,15 @@ def test_ip_allowlist_form_missing_name(): } f = forms.IPAllowlistForm(data) assert f.errors["name"] == ["This field is required."] + + +@pytest.mark.parametrize("env", ["dev", "prod"]) +@mock.patch( + "controlpanel.frontend.forms.CreateAppForm.get_datasource_queryset", + new=MagicMock, +) +def test_clean_namespace(env): + form = forms.CreateAppForm() + form.cleaned_data = {"namespace": f"my-namespace-{env}"} + + assert form.clean_namespace() == "my-namespace" diff --git a/tests/frontend/views/test_app.py b/tests/frontend/views/test_app.py index 041a7f2fb..d25ed94ff 100644 --- a/tests/frontend/views/test_app.py +++ b/tests/frontend/views/test_app.py @@ -710,6 +710,7 @@ def test_register_app_with_creating_datasource(client, users): repo_url=f"https://github.com/ministryofjustice/{test_app_name}", connect_bucket="new", new_datasource_name=test_bucket_name, + namespace="test-app-namespace", ) response = client.post(reverse("create-app"), data) From cb57411b05ce474ef34fd19d513b7411cb036a75 Mon Sep 17 00:00:00 2001 From: Michael Collins <15347726+michaeljcollinsuk@users.noreply.github.com> Date: Wed, 21 Feb 2024 15:14:51 +0000 Subject: [PATCH 2/2] Fix app_url_name by stripping data-platform-app prefix (#1258) --- controlpanel/api/models/app.py | 5 +++-- tests/api/models/test_app.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/controlpanel/api/models/app.py b/controlpanel/api/models/app.py index 93140edef..b5365c80e 100644 --- a/controlpanel/api/models/app.py +++ b/controlpanel/api/models/app.py @@ -254,12 +254,13 @@ def auth0_client_name(self, env_name=None): def app_url_name(self, env_name): format_pattern = settings.APP_URL_NAME_PATTERN.get(env_name.upper()) + namespace = self.namespace.removeprefix("data-platform-app-") if not format_pattern: format_pattern = settings.APP_URL_NAME_PATTERN.get(self.DEFAULT_SETTING_KEY_WORD) if format_pattern: - return format_pattern.format(app_name=self.namespace, env=env_name) + return format_pattern.format(app_name=namespace, env=env_name) else: - return self.namespace + return namespace def get_auth_client(self, env_name): env_name = env_name or self.DEFAULT_AUTH_CATEGORY diff --git a/tests/api/models/test_app.py b/tests/api/models/test_app.py index 39277aea6..58d5e8d8c 100644 --- a/tests/api/models/test_app.py +++ b/tests/api/models/test_app.py @@ -209,3 +209,16 @@ def test_app_allowed_ip_ranges(): def test_iam_role_arn(): app = App(slug="example-app") assert app.iam_role_arn == f"arn:aws:iam::{settings.AWS_DATA_ACCOUNT_ID}:role/test_app_example-app" + + +@pytest.mark.parametrize("namespace, env, expected", [ + ("data-platform-app-example", "dev", "example-dev"), + ("example", "dev", "example-dev"), + ("data-platform-example", "dev", "data-platform-example-dev"), + ("data-platform-app-example", "prod", "example"), + ("example", "prod", "example"), + ("data-platform-example", "prod", "data-platform-example"), +]) +def test_app_url_name(namespace, env, expected): + app = App(namespace=namespace) + assert app.app_url_name(env_name=env) == expected