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/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) 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 529232ee3d74..f1c8dc3a6d89 100644 --- a/api/tests/integration/api_keys/test_viewset.py +++ b/api/tests/integration/api_keys/test_viewset.py @@ -3,11 +3,12 @@ from rest_framework.test import APIClient from organisations.models import Organisation +from users.models import FFAdminUser -def test_create_master_api_key__valid_data__returns_key_in_response( # type: ignore[no-untyped-def] - admin_client, organisation -): +def test_create_master_api_key__valid_data__returns_key_in_response( + admin_user: FFAdminUser, admin_client: APIClient, organisation: Organisation +) -> None: # Given url = reverse( "api-v1:organisations:organisation-master-api-keys-list", @@ -22,6 +23,7 @@ def test_create_master_api_key__valid_data__returns_key_in_response( # type: ig 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_create_master_api_key__non_admin_without_rbac__returns_400( # type: ignore[no-untyped-def] diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index f66194921c17..38f7e4ae104e 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -2,6 +2,9 @@ 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 AppActions from 'common/dispatcher/app-actions' +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' @@ -339,12 +342,23 @@ export default class AdminAPIKeys extends PureComponent { componentDidMount() { this.fetch() + AppActions.getOrganisation(this.props.organisationId) + OrganisationStore.on('change', this.onOrganisationChange) + } + + componentWillUnmount() { + OrganisationStore.off('change', this.onOrganisationChange) + } + + onOrganisationChange = () => { + this.forceUpdate() } componentDidUpdate() { if (this.props.organisationId === this.state.organisationId) return this.fetch() + AppActions.getOrganisation(this.props.organisationId) this.setState({ organisationId: this.props.organisationId }) } @@ -444,12 +458,12 @@ export default class AdminAPIKeys extends PureComponent { {`Create API Key`} - {this.state.isLoading && ( + {(this.state.isLoading || !OrganisationStore.model?.users) && (