Skip to content

Commit

Permalink
🛂 Add statement for cross account assumption (#1392)
Browse files Browse the repository at this point in the history
* Add cross account statement

Signed-off-by: Jacob Woffenden <[email protected]>

* Add xacct_trust_statement

Signed-off-by: Jacob Woffenden <[email protected]>

* the linters have spoken

Signed-off-by: Jacob Woffenden <[email protected]>

* Added option to add cloud platform arn role to allow CP role to assume AP role

* Hidden additional form through feature flag

* Moved code to correct view

* Ran black

* Fixed flake8 error

* Changed text on checkbox

* Ran black again

* Updated migrations

* removed logging from JS

* Moved code to clean method in form. Enabled in dev for testing

---------

Signed-off-by: Jacob Woffenden <[email protected]>
Co-authored-by: jamesstottmoj <[email protected]>
Co-authored-by: James Stott <[email protected]>
  • Loading branch information
3 people authored Nov 28, 2024
1 parent 157dacf commit 9ccef74
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 3 deletions.
20 changes: 19 additions & 1 deletion controlpanel/api/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions controlpanel/api/migrations/0047_app_cloud_platform_role_arn.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
7 changes: 7 additions & 0 deletions controlpanel/api/models/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Sid": "AllowCloudPlatformCrossAccountIAM",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Principal": {
"AWS": "{{ app_role }}"
},
"Condition": {}
}
8 changes: 8 additions & 0 deletions controlpanel/api/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
13 changes: 13 additions & 0 deletions controlpanel/frontend/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
42 changes: 40 additions & 2 deletions controlpanel/frontend/jinja2/webapp-create.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ <h1 class="govuk-heading-xl">{{ page_title }}</h1>
}) }}
{% endif %}

<input type="text" class="govuk-input" id="display_result_repo" name="repo_url" required />
<input type="text" class="govuk-input" id="display_result_repo" name="repo_url" {% if form.repo_url.value() %} value="{{ form.repo_url.value() }}" {% endif %} required />

</div>
<div class="govuk-form-group" id="container-element">
Expand All @@ -90,7 +90,7 @@ <h1 class="govuk-heading-xl">{{ page_title }}</h1>
}) }}
{% endif %}
<span class="govuk-hint">Enter namespace with the -env suffix removed</span>
<input type="text" class="govuk-input" id="id_namespace" name="namespace" required />
<input type="text" class="govuk-input" id="id_namespace" name="namespace" {% if form.namespace.value() %} value="{{ form.namespace.value() }}" {% endif %} required />

</div>

Expand Down Expand Up @@ -128,6 +128,44 @@ <h1 class="govuk-heading-xl">{{ page_title }}</h1>
]
}) }}

{% 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
}
]
}) }}

<div class="govuk-grid-row checkbox-subform" data-show-if-selected="True">
<div class="govuk-grid-column-full">
<br/>
{{ 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()
}) }}
</div>
</div>
{% endif %}

<div class="govuk-form-group">
<button class="govuk-button">Register app</button>
</div>
Expand Down
7 changes: 7 additions & 0 deletions controlpanel/frontend/views/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
2 changes: 2 additions & 0 deletions controlpanel/frontend/views/apps_mng.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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(
name=name,
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)
Expand Down
5 changes: 5 additions & 0 deletions settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions tests/frontend/views/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 9ccef74

Please sign in to comment.