diff --git a/Dockerfile b/Dockerfile index 8326ad3a7097..c29c1fe7925b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -138,7 +138,7 @@ ENV ACCESS_LOG_LOCATION=${ACCESS_LOG_LOCATION} \ DJANGO_SETTINGS_MODULE=app.settings.production \ GUNICORN_WORKERS=3 \ GUNICORN_THREADS=2 \ - APPLICATION_LOGGERS="app_analytics,audit,code_references,common,core,dynamodb,edge_api,environments,features,import_export,integrations,mcp,oauth2_metadata,organisations,projects,segment_membership,segments,task_processor,users,webhooks,workflows" + APPLICATION_LOGGERS="app_analytics,audit,code_references,common,core,dynamodb,edge_api,environments,features,import_export,integrations,mcp,oauth2_metadata,organisations,projects,segment_membership,segments,task_processor,trust_relationships,users,webhooks,workflows" ARG CI_COMMIT_SHA RUN echo ${CI_COMMIT_SHA} > /app/CI_COMMIT_SHA && \ diff --git a/api/api_keys/views.py b/api/api_keys/views.py index 4d1914c17ed8..3aebea0e41b2 100644 --- a/api/api_keys/views.py +++ b/api/api_keys/views.py @@ -1,6 +1,7 @@ from rest_framework import viewsets from rest_framework.authentication import BaseAuthentication from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView from organisations.permissions.permissions import ( NestedIsOrganisationAdminPermission, @@ -11,7 +12,20 @@ from .serializers import MasterAPIKeySerializer -class MasterAPIKeyViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] +class ExcludeMasterAPIKeyAuthenticationMixin(APIView): + # Machine credentials must not be able to manage machine credentials. + def get_authenticators(self) -> list[BaseAuthentication]: + return [ + authenticator + for authenticator in super().get_authenticators() + if not isinstance(authenticator, MasterAPIKeyAuthentication) + ] + + +class MasterAPIKeyViewSet( + ExcludeMasterAPIKeyAuthenticationMixin, + viewsets.ModelViewSet, # type: ignore[type-arg] +): lookup_field = "prefix" serializer_class = MasterAPIKeySerializer @@ -19,17 +33,11 @@ class MasterAPIKeyViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] def get_queryset(self): # type: ignore[no-untyped-def] return MasterAPIKey.objects.filter( - organisation_id=self.kwargs.get("organisation_pk"), revoked=False + organisation_id=self.kwargs.get("organisation_pk"), + revoked=False, + trust_relationship__isnull=True, ) - def get_authenticators(self) -> list[BaseAuthentication]: - # API Keys should not be able to create API Keys - return [ - authenticator - for authenticator in super().get_authenticators() - if not isinstance(authenticator, MasterAPIKeyAuthentication) - ] - def perform_create(self, serializer): # type: ignore[no-untyped-def] serializer.save( organisation_id=self.kwargs.get("organisation_pk"), diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 5adde3332224..ec0eb427a52c 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -129,6 +129,7 @@ "projects.code_references", "projects.tags", "api_keys", + "trust_relationships", "webhooks", "metrics", "onboarding", diff --git a/api/organisations/models.py b/api/organisations/models.py index e0965cd5e096..1cfb4e103568 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -64,7 +64,7 @@ class OrganisationRole(models.TextChoices): USER = ("USER", "User") -class Organisation(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc] +class Organisation(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[django-manager-missing,misc] name = models.CharField(max_length=2000) has_requested_features = models.BooleanField(default=False) webhook_notification_email = models.EmailField(null=True, blank=True) diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 05a0ed976096..fde7acaed713 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -23,6 +23,7 @@ OrganisationAPIUsageNotificationView, OrganisationWebhookViewSet, ) +from trust_relationships.views import TrustRelationshipViewSet from users.views import ( FFAdminUserViewSet, UserPermissionGroupViewSet, @@ -64,6 +65,11 @@ organisations_router.register( r"master-api-keys", MasterAPIKeyViewSet, basename="organisation-master-api-keys" ) +organisations_router.register( + r"trust-relationships", + TrustRelationshipViewSet, + basename="organisation-trust-relationships", +) organisations_router.register( "user-permissions", UserOrganisationPermissionViewSet, diff --git a/api/tests/integration/trust_relationships/__init__.py b/api/tests/integration/trust_relationships/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/integration/trust_relationships/conftest.py b/api/tests/integration/trust_relationships/conftest.py new file mode 100644 index 000000000000..5b499452131a --- /dev/null +++ b/api/tests/integration/trust_relationships/conftest.py @@ -0,0 +1,22 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient + + +@pytest.fixture() +def trust_relationship( + admin_client: APIClient, + organisation: int, +) -> int: + response = admin_client.post( + f"/api/v1/organisations/{organisation}/trust-relationships/", + data={ + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + "claim_rules": [{"claim": "repository", "values": ["Flagsmith/flagsmith"]}], + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + return response.json()["id"] # type: ignore[no-any-return] diff --git a/api/tests/integration/trust_relationships/test_viewset.py b/api/tests/integration/trust_relationships/test_viewset.py new file mode 100644 index 000000000000..ed237cfcf177 --- /dev/null +++ b/api/tests/integration/trust_relationships/test_viewset.py @@ -0,0 +1,307 @@ +from pytest_django.fixtures import SettingsWrapper +from rest_framework import status +from rest_framework.test import APIClient + + +def test_create_trust_relationship__valid_data__returns_backing_key_details( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + "claim_rules": [{"claim": "repository", "values": ["Flagsmith/flagsmith"]}], + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + response_json = response.json() + assert response_json["name"] == "GitHub Actions" + assert response_json["issuer"] == "https://token.actions.githubusercontent.com" + assert response_json["is_admin"] is True + assert response_json["master_api_key_prefix"] + assert response_json["master_api_key_id"] + assert response_json["claim_rules"] == [ + {"claim": "repository", "values": ["Flagsmith/flagsmith"]} + ] + + +def test_create_trust_relationship__trailing_slash_issuer__returns_normalised_issuer( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com/", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["issuer"] == "https://token.actions.githubusercontent.com" + + +def test_create_trust_relationship__http_issuer__returns_400( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "http://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["issuer"] == ["Issuer must be an https:// URL."] + + +def test_create_trust_relationship__non_admin_without_rbac__returns_400( + admin_client: APIClient, + organisation: int, + settings: SettingsWrapper, +) -> None: + # Given + settings.IS_RBAC_INSTALLED = False + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + "is_admin": False, + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["is_admin"] == [ + "RBAC is not installed, cannot create non-admin trust relationship" + ] + + +def test_create_trust_relationship__malformed_claim_rules__returns_400( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + "claim_rules": [{"claim": "repository", "values": []}], + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_list_trust_relationships__existing__returns_trust_relationships( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 1 + assert response.json()["results"][0]["id"] == trust_relationship + + +def test_update_trust_relationship__new_values__returns_updated_values( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + url = ( + f"/api/v1/organisations/{organisation}" + f"/trust-relationships/{trust_relationship}/" + ) + data = { + "name": "GitHub Actions (prod)", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "flagsmith-prod", + "claim_rules": [{"claim": "environment", "values": ["production"]}], + } + + # When + response = admin_client.put(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "GitHub Actions (prod)" + assert response.json()["audience"] == "flagsmith-prod" + assert response.json()["claim_rules"] == [ + {"claim": "environment", "values": ["production"]} + ] + + +def test_delete_trust_relationship__existing__removes_trust_relationship( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + detail_url = ( + f"/api/v1/organisations/{organisation}" + f"/trust-relationships/{trust_relationship}/" + ) + list_url = f"/api/v1/organisations/{organisation}/trust-relationships/" + + # When + response = admin_client.delete(detail_url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert admin_client.get(list_url).json()["count"] == 0 + + +def test_list_master_api_keys__trust_relationship_exists__hides_backing_key( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/master-api-keys/" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 0 + + +def test_create_trust_relationship__master_api_key_auth__returns_401( + organisation: int, + admin_master_api_key_client: APIClient, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_master_api_key_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_create_trust_relationship__duplicate_issuer_and_audience__returns_400( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions (duplicate)", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == [ + "The fields issuer, audience must make a unique set." + ] + + +def test_create_trust_relationship__same_issuer_and_audience_as_deleted__returns_201( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + admin_client.delete( + f"/api/v1/organisations/{organisation}" + f"/trust-relationships/{trust_relationship}/" + ) + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions (recreated)", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_create_trust_relationship__long_name__returns_201( + admin_client: APIClient, + organisation: int, +) -> None: + # Given: a name long enough that the derived backing key name would + # exceed MasterAPIKey.name's 50-character limit + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions (Flagsmith/flagsmith-workflow-tools)", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith/flagsmith-workflow-tools", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_create_trust_relationship__issuer_with_query_string__returns_400( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + url = f"/api/v1/organisations/{organisation}/trust-relationships/" + data = { + "name": "GitHub Actions", + "issuer": "https://token.actions.githubusercontent.com?ref=main", + "audience": "https://github.com/Flagsmith", + } + + # When + response = admin_client.post(url, data=data, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["issuer"] == [ + "Issuer must not contain a query string or fragment." + ] diff --git a/api/tests/unit/trust_relationships/__init__.py b/api/tests/unit/trust_relationships/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/trust_relationships/test_admin.py b/api/tests/unit/trust_relationships/test_admin.py new file mode 100644 index 000000000000..5a6a18452886 --- /dev/null +++ b/api/tests/unit/trust_relationships/test_admin.py @@ -0,0 +1,63 @@ +from django.contrib.admin.sites import AdminSite +from django.http import HttpRequest + +from api_keys.models import MasterAPIKey +from organisations.models import Organisation +from trust_relationships.admin import TrustRelationshipAdmin +from trust_relationships.models import TrustRelationship +from trust_relationships.services import create_trust_relationship + + +def test_delete_model__existing__revokes_backing_key( + organisation: Organisation, +) -> None: + # Given + trust_relationship = create_trust_relationship( + organisation_id=organisation.id, + name="GitHub Actions", + issuer="https://token.actions.githubusercontent.com", + audience="https://github.com/Flagsmith", + is_admin=True, + claim_rules=[], + ) + backing_key_id = trust_relationship.master_api_key_id + model_admin = TrustRelationshipAdmin(TrustRelationship, AdminSite()) + + # When + model_admin.delete_model(HttpRequest(), trust_relationship) + + # Then + assert not TrustRelationship.objects.filter(id=trust_relationship.id).exists() + assert MasterAPIKey.objects.get(id=backing_key_id).revoked is True + + +def test_delete_queryset__existing__revokes_backing_keys( + organisation: Organisation, +) -> None: + # Given + trust_relationships = [ + create_trust_relationship( + organisation_id=organisation.id, + name=f"GitHub Actions {i}", + issuer=f"https://token.actions.githubusercontent.com/{i}", + audience="https://github.com/Flagsmith", + is_admin=True, + claim_rules=[], + ) + for i in range(2) + ] + backing_key_ids = [ + trust_relationship.master_api_key_id + for trust_relationship in trust_relationships + ] + model_admin = TrustRelationshipAdmin(TrustRelationship, AdminSite()) + + # When + model_admin.delete_queryset(HttpRequest(), TrustRelationship.objects.all()) + + # Then + assert not TrustRelationship.objects.exists() + assert all( + MasterAPIKey.objects.get(id=backing_key_id).revoked + for backing_key_id in backing_key_ids + ) diff --git a/api/tests/unit/trust_relationships/test_services.py b/api/tests/unit/trust_relationships/test_services.py new file mode 100644 index 000000000000..9f957c4660d3 --- /dev/null +++ b/api/tests/unit/trust_relationships/test_services.py @@ -0,0 +1,118 @@ +from pytest_structlog import StructuredLogCapture + +from api_keys.models import MasterAPIKey +from organisations.models import Organisation +from trust_relationships.models import TrustRelationship +from trust_relationships.services import ( + create_trust_relationship, + delete_trust_relationship, + update_trust_relationship, +) +from users.models import FFAdminUser + + +def test_create_trust_relationship__valid_data__creates_hidden_backing_key( + organisation: Organisation, + admin_user: FFAdminUser, + log: StructuredLogCapture, +) -> None: + # Given + name = "GitHub Actions" + issuer = "https://token.actions.githubusercontent.com" + audience = "https://github.com/Flagsmith" + claim_rules = [{"claim": "repository", "values": ["Flagsmith/flagsmith"]}] + + # When + trust_relationship = create_trust_relationship( + organisation_id=organisation.id, + name=name, + issuer=issuer, + audience=audience, + is_admin=True, + claim_rules=claim_rules, + created_by=admin_user, + ) + + # Then + backing_key = trust_relationship.master_api_key + assert backing_key.organisation_id == organisation.id + assert backing_key.is_admin is True + assert backing_key.created_by == admin_user + assert backing_key.name == "Trust relationship: GitHub Actions" + assert backing_key.hashed_key + assert backing_key.revoked is False + assert log.has( + "created", + organisation__id=organisation.id, + trust_relationship__id=trust_relationship.id, + trust_relationship__issuer="https://token.actions.githubusercontent.com", + ) + + +def test_update_trust_relationship__new_values__syncs_backing_key( + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given + trust_relationship = create_trust_relationship( + organisation_id=organisation.id, + name="GitHub Actions", + issuer="https://token.actions.githubusercontent.com", + audience="https://github.com/Flagsmith", + is_admin=True, + claim_rules=[], + ) + + # When + updated = update_trust_relationship( + trust_relationship=trust_relationship, + name="GitHub Actions (prod)", + issuer="https://token.actions.githubusercontent.com", + audience="flagsmith-prod", + is_admin=False, + claim_rules=[{"claim": "environment", "values": ["production"]}], + ) + + # Then + assert updated.name == "GitHub Actions (prod)" + assert updated.audience == "flagsmith-prod" + assert updated.claim_rules == [{"claim": "environment", "values": ["production"]}] + backing_key = updated.master_api_key + assert backing_key.name == "Trust relationship: GitHub Actions (prod)" + assert backing_key.is_admin is False + assert log.has( + "updated", + organisation__id=organisation.id, + trust_relationship__id=trust_relationship.id, + trust_relationship__issuer="https://token.actions.githubusercontent.com", + ) + + +def test_delete_trust_relationship__existing__revokes_backing_key( + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given + trust_relationship = create_trust_relationship( + organisation_id=organisation.id, + name="GitHub Actions", + issuer="https://token.actions.githubusercontent.com", + audience="https://github.com/Flagsmith", + is_admin=True, + claim_rules=[], + ) + trust_relationship_id = trust_relationship.id + backing_key_id = trust_relationship.master_api_key_id + + # When + delete_trust_relationship(trust_relationship=trust_relationship) + + # Then + assert not TrustRelationship.objects.filter(id=trust_relationship_id).exists() + backing_key = MasterAPIKey.objects.get(id=backing_key_id) + assert backing_key.revoked is True + assert log.has( + "deleted", + organisation__id=organisation.id, + trust_relationship__id=trust_relationship_id, + ) diff --git a/api/trust_relationships/__init__.py b/api/trust_relationships/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/trust_relationships/admin.py b/api/trust_relationships/admin.py new file mode 100644 index 000000000000..400899ad325a --- /dev/null +++ b/api/trust_relationships/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from django.db.models import QuerySet +from django.http import HttpRequest + +from trust_relationships.models import TrustRelationship +from trust_relationships.services import delete_trust_relationship + + +@admin.register(TrustRelationship) +class TrustRelationshipAdmin(admin.ModelAdmin[TrustRelationship]): + list_display = ("name", "organisation", "issuer", "audience", "created_at") + list_filter = ("issuer",) + list_select_related = ("organisation",) + search_fields = ("name", "issuer", "audience") + + # Deletes must go through the service layer so the backing key is revoked. + def delete_model(self, request: HttpRequest, obj: TrustRelationship) -> None: + delete_trust_relationship(trust_relationship=obj) + + def delete_queryset( + self, request: HttpRequest, queryset: QuerySet[TrustRelationship] + ) -> None: + for obj in queryset: + delete_trust_relationship(trust_relationship=obj) diff --git a/api/trust_relationships/apps.py b/api/trust_relationships/apps.py new file mode 100644 index 000000000000..684b3d613f00 --- /dev/null +++ b/api/trust_relationships/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TrustRelationshipsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "trust_relationships" diff --git a/api/trust_relationships/migrations/0001_initial.py b/api/trust_relationships/migrations/0001_initial.py new file mode 100644 index 000000000000..6782c0185f2c --- /dev/null +++ b/api/trust_relationships/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 5.2.16 on 2026-07-18 13:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("api_keys", "0004_add_created_by"), + ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TrustRelationship", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "name", + models.CharField( + help_text="Display name for this trust relationship.", + max_length=100, + ), + ), + ( + "issuer", + models.URLField( + help_text="OIDC issuer URL expected in exchanged tokens' `iss` claim.", + max_length=500, + ), + ), + ( + "audience", + models.CharField( + help_text="Expected value of the `aud` claim in exchanged tokens.", + max_length=500, + ), + ), + ("claim_rules", models.JSONField(blank=True, default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "master_api_key", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="trust_relationship", + to="api_keys.masterapikey", + ), + ), + ( + "organisation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="trust_relationships", + to="organisations.organisation", + ), + ), + ], + options={ + "ordering": ("id",), + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issuer", "audience"), + name="unique_live_issuer_audience", + ) + ], + }, + ), + ] diff --git a/api/trust_relationships/migrations/__init__.py b/api/trust_relationships/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/trust_relationships/models.py b/api/trust_relationships/models.py new file mode 100644 index 000000000000..2d6c294946c2 --- /dev/null +++ b/api/trust_relationships/models.py @@ -0,0 +1,51 @@ +from django.db import models +from softdelete.models import SoftDeleteObject # type: ignore[import-untyped] + +from api_keys.models import MasterAPIKey +from organisations.models import Organisation + + +class TrustRelationship(SoftDeleteObject): # type: ignore[misc] + organisation = models.ForeignKey( # type: ignore[var-annotated] + Organisation, + on_delete=models.CASCADE, + related_name="trust_relationships", + ) + name = models.CharField( # type: ignore[var-annotated] + max_length=100, + help_text="Display name for this trust relationship.", + ) + issuer = models.URLField( # type: ignore[var-annotated] + max_length=500, + help_text="OIDC issuer URL expected in exchanged tokens' `iss` claim.", + ) + audience = models.CharField( # type: ignore[var-annotated] + max_length=500, + help_text="Expected value of the `aud` claim in exchanged tokens.", + ) + claim_rules = models.JSONField(default=list, blank=True) + master_api_key = models.OneToOneField( # type: ignore[var-annotated] + MasterAPIKey, + on_delete=models.CASCADE, + related_name="trust_relationship", + ) + created_by = models.ForeignKey( # type: ignore[var-annotated] + "users.FFAdminUser", on_delete=models.SET_NULL, null=True, blank=True + ) + created_at = models.DateTimeField(auto_now_add=True) # type: ignore[var-annotated] + + class Meta: + ordering = ("id",) + constraints = [ + # Tokens are matched on (issuer, audience) at exchange time, so + # a live pair must resolve to exactly one trust relationship. + models.UniqueConstraint( + fields=["issuer", "audience"], + condition=models.Q(deleted_at__isnull=True), + name="unique_live_issuer_audience", + ), + ] + + @property + def is_admin(self) -> bool: + return bool(self.master_api_key.is_admin) diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py new file mode 100644 index 000000000000..8303b1013759 --- /dev/null +++ b/api/trust_relationships/serializers.py @@ -0,0 +1,83 @@ +from typing import Any +from urllib.parse import urlparse + +from django.conf import settings +from rest_framework import serializers + +from trust_relationships.models import TrustRelationship +from trust_relationships.services import ( + create_trust_relationship, + update_trust_relationship, +) + + +class ClaimRuleSerializer(serializers.Serializer[None]): + claim = serializers.CharField(max_length=255) + values = serializers.ListField( + child=serializers.CharField(max_length=500), + min_length=1, + ) + + +class TrustRelationshipSerializer(serializers.ModelSerializer[TrustRelationship]): + is_admin = serializers.BooleanField(default=True) + claim_rules = ClaimRuleSerializer(many=True, required=False) + master_api_key_id = serializers.CharField(read_only=True) + master_api_key_prefix = serializers.CharField( + source="master_api_key.prefix", read_only=True + ) + + class Meta: + model = TrustRelationship + fields = ( + "id", + "name", + "issuer", + "audience", + "claim_rules", + "is_admin", + "master_api_key_id", + "master_api_key_prefix", + "created_at", + "created_by", + ) + read_only_fields = ("id", "created_at", "created_by") + + def validate_issuer(self, issuer: str) -> str: + parsed = urlparse(issuer) + if parsed.scheme != "https": + raise serializers.ValidationError("Issuer must be an https:// URL.") + if parsed.query or parsed.fragment: + raise serializers.ValidationError( + "Issuer must not contain a query string or fragment." + ) + return issuer.rstrip("/") + + def validate_is_admin(self, is_admin: bool) -> bool: + if is_admin is False and not settings.IS_RBAC_INSTALLED: + raise serializers.ValidationError( + "RBAC is not installed, cannot create non-admin trust relationship" + ) + return is_admin + + def validate_claim_rules( + self, claim_rules: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + # DRF yields OrderedDicts; store plain JSON. + return [dict(rule) for rule in claim_rules] + + def create(self, validated_data: dict[str, Any]) -> TrustRelationship: + validated_data.setdefault("claim_rules", []) + return create_trust_relationship(**validated_data) + + def update( + self, instance: TrustRelationship, validated_data: dict[str, Any] + ) -> TrustRelationship: + return update_trust_relationship( + trust_relationship=instance, + name=validated_data.get("name", instance.name), + issuer=validated_data.get("issuer", instance.issuer), + audience=validated_data.get("audience", instance.audience), + is_admin=validated_data.get("is_admin", instance.is_admin), + claim_rules=validated_data.get("claim_rules", instance.claim_rules), + ) diff --git a/api/trust_relationships/services.py b/api/trust_relationships/services.py new file mode 100644 index 000000000000..a09ccc4035f0 --- /dev/null +++ b/api/trust_relationships/services.py @@ -0,0 +1,98 @@ +from typing import Any + +import structlog +from django.db import transaction + +from api_keys.models import MasterAPIKey +from trust_relationships.models import TrustRelationship +from users.models import FFAdminUser + +logger = structlog.get_logger("trust_relationships") + +BACKING_KEY_NAME_TEMPLATE = "Trust relationship: {name}" + + +def _backing_key_name(name: str) -> str: + # The backing key name is display-only; MasterAPIKey.name caps at 50. + return BACKING_KEY_NAME_TEMPLATE.format(name=name)[:50] + + +def create_trust_relationship( + *, + organisation_id: int, + name: str, + issuer: str, + audience: str, + is_admin: bool, + claim_rules: list[dict[str, Any]], + created_by: FFAdminUser | None = None, +) -> TrustRelationship: + with transaction.atomic(): + # The plaintext key is deliberately discarded: the backing key only + # carries permissions and can never authenticate a request by itself. + master_api_key, _ = MasterAPIKey.objects.create_key( + name=_backing_key_name(name), + organisation_id=organisation_id, + is_admin=is_admin, + created_by=created_by, + ) + trust_relationship: TrustRelationship = TrustRelationship.objects.create( + organisation_id=organisation_id, + name=name, + issuer=issuer, + audience=audience, + claim_rules=claim_rules, + master_api_key=master_api_key, + created_by=created_by, + ) + logger.info( + "created", + organisation__id=organisation_id, + trust_relationship__id=trust_relationship.id, + trust_relationship__issuer=issuer, + ) + return trust_relationship + + +def update_trust_relationship( + *, + trust_relationship: TrustRelationship, + name: str, + issuer: str, + audience: str, + is_admin: bool, + claim_rules: list[dict[str, Any]], +) -> TrustRelationship: + with transaction.atomic(): + master_api_key = trust_relationship.master_api_key + master_api_key.name = _backing_key_name(name) + # Flipping is_admin back on detaches any RBAC roles via the + # MasterAPIKey lifecycle hook. + master_api_key.is_admin = is_admin + master_api_key.save() + + trust_relationship.name = name + trust_relationship.issuer = issuer + trust_relationship.audience = audience + trust_relationship.claim_rules = claim_rules + trust_relationship.save() + logger.info( + "updated", + organisation__id=trust_relationship.organisation_id, + trust_relationship__id=trust_relationship.id, + trust_relationship__issuer=issuer, + ) + return trust_relationship + + +def delete_trust_relationship(*, trust_relationship: TrustRelationship) -> None: + with transaction.atomic(): + master_api_key = trust_relationship.master_api_key + master_api_key.revoked = True + master_api_key.save() + trust_relationship.delete() + logger.info( + "deleted", + organisation__id=trust_relationship.organisation_id, + trust_relationship__id=trust_relationship.id, + ) diff --git a/api/trust_relationships/views.py b/api/trust_relationships/views.py new file mode 100644 index 000000000000..3f11999fef54 --- /dev/null +++ b/api/trust_relationships/views.py @@ -0,0 +1,39 @@ +from django.db.models import QuerySet +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.serializers import BaseSerializer + +from api_keys.views import ExcludeMasterAPIKeyAuthenticationMixin +from organisations.permissions.permissions import ( + NestedIsOrganisationAdminPermission, +) +from trust_relationships.models import TrustRelationship +from trust_relationships.serializers import TrustRelationshipSerializer +from trust_relationships.services import delete_trust_relationship + + +class TrustRelationshipViewSet( + ExcludeMasterAPIKeyAuthenticationMixin, + viewsets.ModelViewSet[TrustRelationship], +): + serializer_class = TrustRelationshipSerializer + + permission_classes = [IsAuthenticated, NestedIsOrganisationAdminPermission] + + def get_queryset(self) -> QuerySet[TrustRelationship]: + if getattr(self, "swagger_fake_view", False): + empty: QuerySet[TrustRelationship] = TrustRelationship.objects.none() + return empty + queryset: QuerySet[TrustRelationship] = TrustRelationship.objects.filter( + organisation_id=self.kwargs["organisation_pk"] + ).select_related("master_api_key") + return queryset + + def perform_create(self, serializer: BaseSerializer[TrustRelationship]) -> None: + serializer.save( + organisation_id=int(self.kwargs["organisation_pk"]), + created_by=self.request.user, + ) + + def perform_destroy(self, instance: TrustRelationship) -> None: + delete_trust_relationship(trust_relationship=instance) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 40fe1dd98bb8..eaa34053cc59 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -498,6 +498,35 @@ Attributes: - `feature_name` - `sentry_action` +### `trust_relationships.created` + +Logged at `info` from: + - `api/trust_relationships/services.py:48` + +Attributes: + - `organisation.id` + - `trust_relationship.id` + - `trust_relationship.issuer` + +### `trust_relationships.deleted` + +Logged at `info` from: + - `api/trust_relationships/services.py:94` + +Attributes: + - `organisation.id` + - `trust_relationship.id` + +### `trust_relationships.updated` + +Logged at `info` from: + - `api/trust_relationships/services.py:79` + +Attributes: + - `organisation.id` + - `trust_relationship.id` + - `trust_relationship.issuer` + ### `usage_reporting.run.skipped` Logged at `debug` from: diff --git a/openapi.yaml b/openapi.yaml index 9c04337341d9..fcae85fe12f8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12366,6 +12366,183 @@ paths: - Master API Key: [] tags: - Organisations + '/api/v1/organisations/{organisation_pk}/trust-relationships/': + get: + operationId: api_v1_organisations_trust_relationships_list + parameters: + - name: organisation_pk + in: path + required: true + schema: + type: integer + - name: page + in: query + description: A page number within the paginated result set. + required: false + schema: + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedTrustRelationshipList' + security: + - tokenAuth: [] + tags: + - Organisations + post: + operationId: api_v1_organisations_trust_relationships_create + parameters: + - name: organisation_pk + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TrustRelationship' + multipart/form-data: + schema: + $ref: '#/components/schemas/TrustRelationship' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + security: + - tokenAuth: [] + tags: + - Organisations + '/api/v1/organisations/{organisation_pk}/trust-relationships/{id}/': + get: + operationId: api_v1_organisations_trust_relationships_retrieve + parameters: + - name: id + in: path + description: A unique integer value identifying this trust relationship. + required: true + schema: + type: integer + - name: organisation_pk + in: path + required: true + schema: + type: integer + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + security: + - tokenAuth: [] + tags: + - Organisations + put: + operationId: api_v1_organisations_trust_relationships_update + parameters: + - name: id + in: path + description: A unique integer value identifying this trust relationship. + required: true + schema: + type: integer + - name: organisation_pk + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TrustRelationship' + multipart/form-data: + schema: + $ref: '#/components/schemas/TrustRelationship' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + security: + - tokenAuth: [] + tags: + - Organisations + patch: + operationId: api_v1_organisations_trust_relationships_partial_update + parameters: + - name: id + in: path + description: A unique integer value identifying this trust relationship. + required: true + schema: + type: integer + - name: organisation_pk + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedTrustRelationship' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedTrustRelationship' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedTrustRelationship' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TrustRelationship' + security: + - tokenAuth: [] + tags: + - Organisations + delete: + operationId: api_v1_organisations_trust_relationships_destroy + parameters: + - name: id + in: path + description: A unique integer value identifying this trust relationship. + required: true + schema: + type: integer + - name: organisation_pk + in: path + required: true + schema: + type: integer + responses: + '204': + description: No response body + security: + - tokenAuth: [] + tags: + - Organisations '/api/v1/organisations/{organisation_pk}/usage-data/': get: operationId: api_v1_organisations_usage_data_retrieve @@ -18557,6 +18734,21 @@ components: - UPDATE - DELETE - UNKNOWN + ClaimRule: + type: object + properties: + claim: + type: string + maxLength: 255 + values: + type: array + items: + type: string + maxLength: 500 + minItems: 1 + required: + - claim + - values CloneEnvironment: type: object properties: @@ -23153,6 +23345,29 @@ components: required: - count - results + PaginatedTrustRelationshipList: + type: object + properties: + count: + type: integer + example: 123 + next: + type: string + format: uri + example: 'http://api.example.org/accounts/?page=4' + nullable: true + previous: + type: string + format: uri + example: 'http://api.example.org/accounts/?page=2' + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/TrustRelationship' + required: + - count + - results PaginatedUserList: type: object properties: @@ -24613,6 +24828,47 @@ components: format: date-time readOnly: true title: DateCreated + PatchedTrustRelationship: + type: object + properties: + id: + type: integer + readOnly: true + name: + description: Display name for this trust relationship. + type: string + maxLength: 100 + issuer: + description: OIDC issuer URL expected in exchanged tokens' `iss` claim. + type: string + format: uri + maxLength: 500 + audience: + description: Expected value of the `aud` claim in exchanged tokens. + type: string + maxLength: 500 + claim_rules: + type: array + items: + $ref: '#/components/schemas/ClaimRule' + is_admin: + type: boolean + default: true + master_api_key_id: + type: string + readOnly: true + master_api_key_prefix: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + created_by: + type: + - integer + - 'null' + readOnly: true PatchedUpdateEnvironment: description: |- Mixin to add read only status to fields in a given serializer based on the existence @@ -26743,6 +26999,51 @@ components: enum: - ON_ENTER - WAIT_FOR + TrustRelationship: + type: object + properties: + id: + type: integer + readOnly: true + name: + description: Display name for this trust relationship. + type: string + maxLength: 100 + issuer: + description: OIDC issuer URL expected in exchanged tokens' `iss` claim. + type: string + format: uri + maxLength: 500 + audience: + description: Expected value of the `aud` claim in exchanged tokens. + type: string + maxLength: 500 + claim_rules: + type: array + items: + $ref: '#/components/schemas/ClaimRule' + is_admin: + type: boolean + default: true + master_api_key_id: + type: string + readOnly: true + master_api_key_prefix: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + created_by: + type: + - integer + - 'null' + readOnly: true + required: + - audience + - issuer + - name Type975Enum: description: |- * `int` - Integer