From c0022c56332f81aae60d5bfdc366f56dd2d00585 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" <216534828+flagsmith-engineering[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:20:53 +0000 Subject: [PATCH 1/6] deps(api): update dependency djangorestframework to >=3.17.2,<3.18.0 [security] --- api/pyproject.toml | 2 +- api/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 538b3fa5f090..8c3c75d71bcb 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "backoff>=2.2.1,<2.3.0", "appdirs>=1.4.4,<1.5.0", "django-cors-headers>=3.5.0,<3.6.0", - "djangorestframework>=3.15.2,<3.16.0", + "djangorestframework>=3.17.2,<3.18.0", "gunicorn>=23.0.0,<23.1.0", "pyparsing>=2.4.7,<2.5.0", "requests>=2.33.0,<2.34.0", diff --git a/api/uv.lock b/api/uv.lock index c2c97e5e5383..16cf62474c29 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1039,14 +1039,14 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.15.2" +version = "3.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" }, + { url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" }, ] [[package]] @@ -1408,7 +1408,7 @@ requires-dist = [ { name = "django-storages", specifier = ">=1.10.1,<1.11.0" }, { name = "django-stubs", specifier = ">=5.1.3,<6.0.0" }, { name = "django-test-migrations", marker = "extra == 'dev'", specifier = ">=1.2.0,<2.0.0" }, - { name = "djangorestframework", specifier = ">=3.15.2,<3.16.0" }, + { name = "djangorestframework", specifier = ">=3.17.2,<3.18.0" }, { name = "djangorestframework-api-key", specifier = ">=2.2.0,<2.3.0" }, { name = "djangorestframework-dataclasses", specifier = ">=1.3.1,<2.0.0" }, { name = "djangorestframework-recursive", specifier = ">=0.1.2,<0.2.0" }, From fd49eca7881534aef3f8bb005ff6b84b32bc3073 Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Wed, 2 Sep 2026 03:21:56 +0000 Subject: [PATCH 2/6] chore: Update documentation artefacts --- openapi.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 7481be959734..529c69a0fb2d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -25891,6 +25891,7 @@ components: is_system_tag: description: 'Indicates that a tag was created by the system, not the user.' type: boolean + default: false readOnly: true type: description: |- @@ -27985,6 +27986,7 @@ components: is_system_tag: description: 'Indicates that a tag was created by the system, not the user.' type: boolean + default: false readOnly: true type: description: |- From 89896dd6006c4f07f5e3f7951e513d9589f9f5a5 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 2 Sep 2026 16:49:50 +0100 Subject: [PATCH 3/6] fix(trust-relationships): Restore duplicate issuer/audience validation DRF 3.16 made unique-together validation condition-aware: the auto-generated `UniqueTogetherValidator` is now silently dropped unless every field the constraint's condition references is writable on the serializer. `unique_live_issuer_audience` is conditional on `deleted_at`, which `TrustRelationshipSerializer` does not expose, so a duplicate live (issuer, audience) pair reached the database and raised an IntegrityError instead of returning a 400. Declare the validator explicitly against the default manager, which already scopes to live rows, so reusing a soft-deleted pair still works. Also cover the update path, which was affected but untested. Co-Authored-By: Claude Opus 5 --- .../trust_relationships/test_viewset.py | 38 +++++++++++++++++++ api/trust_relationships/serializers.py | 14 +++++++ 2 files changed, 52 insertions(+) diff --git a/api/tests/integration/trust_relationships/test_viewset.py b/api/tests/integration/trust_relationships/test_viewset.py index 327047856196..68ca1f848405 100644 --- a/api/tests/integration/trust_relationships/test_viewset.py +++ b/api/tests/integration/trust_relationships/test_viewset.py @@ -327,6 +327,44 @@ def test_create_trust_relationship__duplicate_issuer_and_audience__returns_400( ] +def test_update_trust_relationship__duplicate_issuer_and_audience__returns_400( + admin_client: APIClient, + organisation: int, + trust_relationship: int, +) -> None: + # Given + create_response = admin_client.post( + f"/api/v1/organisations/{organisation}/trust-relationships/", + data={ + "name": "GitLab CI", + "issuer": "https://gitlab.com", + "audience": "https://gitlab.com/Flagsmith", + "is_admin": True, + }, + format="json", + ) + assert create_response.status_code == status.HTTP_201_CREATED + url = ( + f"/api/v1/organisations/{organisation}" + f"/trust-relationships/{create_response.json()['id']}/" + ) + data = { + "name": "GitLab CI", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://github.com/Flagsmith", + "is_admin": True, + } + + # When + response = admin_client.put(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, diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py index 8df7625d4941..dc3fa2602b0f 100644 --- a/api/trust_relationships/serializers.py +++ b/api/trust_relationships/serializers.py @@ -3,6 +3,7 @@ from django.conf import settings from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from trust_relationships.models import TrustRelationship from trust_relationships.services import ( @@ -62,6 +63,19 @@ class Meta: "created_by", ) read_only_fields = ("id", "created_at", "created_by") + # `unique_live_issuer_audience` is conditional on `deleted_at`, which + # this serializer does not expose. Since DRF 3.16, condition-aware + # unique validation silently drops the auto-generated validator unless + # every field the condition references is writable on the serializer, + # which would let a duplicate live pair reach the database and 500. + # The default manager already scopes to live rows, so validating + # against it keeps a soft-deleted pair reusable. + validators = [ + UniqueTogetherValidator( + queryset=TrustRelationship.objects.all(), + fields=("issuer", "audience"), + ) + ] def validate_issuer(self, issuer: str) -> str: parsed = urlparse(issuer) From c40dc4b51aa1682ecef0522f59f4c3fd925c88c4 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 2 Sep 2026 19:34:55 +0100 Subject: [PATCH 4/6] refactor(trust-relationships): Trim the unique validator comment Co-Authored-By: Claude Opus 5 --- api/trust_relationships/serializers.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py index dc3fa2602b0f..1abf0dc341d3 100644 --- a/api/trust_relationships/serializers.py +++ b/api/trust_relationships/serializers.py @@ -63,13 +63,9 @@ class Meta: "created_by", ) read_only_fields = ("id", "created_at", "created_by") - # `unique_live_issuer_audience` is conditional on `deleted_at`, which - # this serializer does not expose. Since DRF 3.16, condition-aware - # unique validation silently drops the auto-generated validator unless - # every field the condition references is writable on the serializer, - # which would let a duplicate live pair reach the database and 500. - # The default manager already scopes to live rows, so validating - # against it keeps a soft-deleted pair reusable. + # Declared manually — DRF can't auto-generate a validator for + # `unique_live_issuer_audience`, whose condition is on `deleted_at`, + # a field not writable here. `objects` excludes soft-deleted rows. validators = [ UniqueTogetherValidator( queryset=TrustRelationship.objects.all(), From 326303065d9e868fc089e1afdb0c47a480e45c90 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 2 Sep 2026 19:43:04 +0100 Subject: [PATCH 5/6] docs(trust-relationships): Link the upstream DRF change in the validator comment Co-Authored-By: Claude Opus 5 --- api/trust_relationships/serializers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py index 1abf0dc341d3..ec8758b647bc 100644 --- a/api/trust_relationships/serializers.py +++ b/api/trust_relationships/serializers.py @@ -63,9 +63,10 @@ class Meta: "created_by", ) read_only_fields = ("id", "created_at", "created_by") - # Declared manually — DRF can't auto-generate a validator for - # `unique_live_issuer_audience`, whose condition is on `deleted_at`, + # Declared manually — since DRF 3.16 it can't auto-generate a validator + # for `unique_live_issuer_audience`, whose condition is on `deleted_at`, # a field not writable here. `objects` excludes soft-deleted rows. + # https://github.com/encode/django-rest-framework/pull/9360 validators = [ UniqueTogetherValidator( queryset=TrustRelationship.objects.all(), From d77c8d1cab58108474c10e5cc768fde23930c18c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 2 Sep 2026 22:28:24 +0100 Subject: [PATCH 6/6] Update with discussion link instead of PR --- api/trust_relationships/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py index ec8758b647bc..f804b20b75af 100644 --- a/api/trust_relationships/serializers.py +++ b/api/trust_relationships/serializers.py @@ -66,7 +66,7 @@ class Meta: # Declared manually — since DRF 3.16 it can't auto-generate a validator # for `unique_live_issuer_audience`, whose condition is on `deleted_at`, # a field not writable here. `objects` excludes soft-deleted rows. - # https://github.com/encode/django-rest-framework/pull/9360 + # https://github.com/encode/django-rest-framework/discussions/9777 validators = [ UniqueTogetherValidator( queryset=TrustRelationship.objects.all(),