From 67c7425a34b1c8e41b8c11c8180fd2a88c1a6564 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 18:29:46 +0000 Subject: [PATCH 1/3] Add created_by to MasterAPIKey model and prevent API Keys from creating other API Keys --- .../migrations/0004_add_created_by.py | 26 +++++++++++++++++++ api/api_keys/models.py | 4 +++ api/api_keys/views.py | 15 ++++++++++- .../integration/api_keys/test_viewset.py | 6 ++++- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 api/api_keys/migrations/0004_add_created_by.py diff --git a/api/api_keys/migrations/0004_add_created_by.py b/api/api_keys/migrations/0004_add_created_by.py new file mode 100644 index 000000000000..7aa0990a0dc0 --- /dev/null +++ b/api/api_keys/migrations/0004_add_created_by.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.11 on 2026-03-04 18:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api_keys", "0003_masterapikey_is_admin"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="masterapikey", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/api/api_keys/models.py b/api/api_keys/models.py index 7fcfaaf589a7..2dcfcfe0f33b 100644 --- a/api/api_keys/models.py +++ b/api/api_keys/models.py @@ -28,6 +28,10 @@ class MasterAPIKey(AbstractAPIKey, LifecycleModelMixin, SoftDeleteObject): # ty objects = MasterAPIKeyManager() # type: ignore[misc] is_admin = models.BooleanField(default=True) + created_by = models.ForeignKey( + "users.FFAdminUser", on_delete=models.SET_NULL, null=True, blank=True + ) + @hook(BEFORE_UPDATE, when="is_admin", was=False, is_now=True) def delete_role_api_keys( # type: ignore[no-untyped-def] self, diff --git a/api/api_keys/views.py b/api/api_keys/views.py index 79d1f2d3d341..4d1914c17ed8 100644 --- a/api/api_keys/views.py +++ b/api/api_keys/views.py @@ -1,10 +1,12 @@ from rest_framework import viewsets +from rest_framework.authentication import BaseAuthentication from rest_framework.permissions import IsAuthenticated from organisations.permissions.permissions import ( NestedIsOrganisationAdminPermission, ) +from .authentication import MasterAPIKeyAuthentication from .models import MasterAPIKey from .serializers import MasterAPIKeySerializer @@ -20,5 +22,16 @@ def get_queryset(self): # type: ignore[no-untyped-def] organisation_id=self.kwargs.get("organisation_pk"), revoked=False ) + 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")) + serializer.save( + organisation_id=self.kwargs.get("organisation_pk"), + created_by=self.request.user, + ) diff --git a/api/tests/integration/api_keys/test_viewset.py b/api/tests/integration/api_keys/test_viewset.py index 21744cb00220..1cfd2e758adc 100644 --- a/api/tests/integration/api_keys/test_viewset.py +++ b/api/tests/integration/api_keys/test_viewset.py @@ -3,9 +3,12 @@ from rest_framework.test import APIClient from organisations.models import Organisation +from users.models import FFAdminUser -def test_create_master_api_key_returns_key_in_response(admin_client, organisation): # type: ignore[no-untyped-def] +def test_create_master_api_key( + admin_user: FFAdminUser, admin_client: APIClient, organisation: Organisation +) -> None: # Given url = reverse( "api-v1:organisations:organisation-master-api-keys-list", @@ -20,6 +23,7 @@ def test_create_master_api_key_returns_key_in_response(admin_client, organisatio assert response.status_code == status.HTTP_201_CREATED assert response.json()["key"] is not None assert response.json()["is_admin"] is True + assert response.json()["created_by"] == admin_user.id def test_creating_non_admin_master_api_key_without_rbac_returns_400( # type: ignore[no-untyped-def] From 6afe7952969f39a2bdadecfa5b0e35c10c4559e2 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 18:32:46 +0000 Subject: [PATCH 2/3] Add created_by to serializer --- api/api_keys/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/api_keys/serializers.py b/api/api_keys/serializers.py index 3f43b7cf4e7b..9240ab4d4e1e 100644 --- a/api/api_keys/serializers.py +++ b/api/api_keys/serializers.py @@ -24,8 +24,9 @@ class Meta: "key", "is_admin", "has_expired", + "created_by", ) - read_only_fields = ("prefix", "created", "key") + read_only_fields = ("prefix", "created", "key", "created_by") def create(self, validated_data): # type: ignore[no-untyped-def] obj, key = MasterAPIKey.objects.create_key(**validated_data) From 6f2f9ece5c5b03e1b6cb8aed0966617534fce210 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 4 Mar 2026 21:24:04 +0000 Subject: [PATCH 3/3] Add new column to table in the FE --- frontend/web/components/AdminAPIKeys.js | 124 ++++++++++++++---------- 1 file changed, 74 insertions(+), 50 deletions(-) diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index 950b42306a70..e185a8cd7a0e 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -2,7 +2,8 @@ import React, { PureComponent } from 'react' import { close as closeIcon } from 'ionicons/icons' import { IonIcon } from '@ionic/react' import data from 'common/data/base/_data' -import InfoMessage from './InfoMessage' +import OrganisationStore from 'common/stores/organisation-store' +import getUserDisplayName from 'common/utils/getUserDisplayName' import Token from './Token' import JSONReference from './JSONReference' import Button from './base/forms/Button' @@ -458,6 +459,17 @@ export default class AdminAPIKeys extends PureComponent { API Keys Created + {Utils.getFlagsmithHasFeature( + 'organisation_api_keys_created_by', + ) && ( + + + This field may be blank if the key was created before + this information was tracked, or if the user has since + left the organisation. + + + )} Is Admin Active
} - renderRow={(v) => - !v.revoked && ( - this.editAPIKey(v.name, v.id, v.prefix)} - > - -
{v.name}
-
-
{v.prefix}*****************
-
-
- - {moment(v.created).format('Do MMM YYYY HH:mma')} - - - - - - {v.has_expired ? ( -
- }> - {'This API key has expired'} - + renderRow={(v) => { + const orgUsers = OrganisationStore.model?.users + const createdByUser = + v.created_by && orgUsers?.find((u) => u.id === v.created_by) + return ( + !v.revoked && ( + this.editAPIKey(v.name, v.id, v.prefix)} + > + +
{v.name}
+
+
{v.prefix}*****************
- ) : ( - - - +
+ + {moment(v.created).format('Do MMM YYYY HH:mma')} + + {Utils.getFlagsmithHasFeature( + 'organisation_api_keys_created_by', + ) && ( + + {getUserDisplayName(createdByUser, '-')} + )} - -
- -
-
+ +
+
+ ) ) - } + }} /> )}