Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions api/api_keys/migrations/0004_add_created_by.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
4 changes: 4 additions & 0 deletions api/api_keys/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion api/api_keys/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion api/api_keys/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
)
6 changes: 5 additions & 1 deletion api/tests/integration/api_keys/test_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
124 changes: 74 additions & 50 deletions frontend/web/components/AdminAPIKeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -458,6 +459,17 @@ export default class AdminAPIKeys extends PureComponent {
<Row className='table-header'>
<Flex className='table-column px-3'>API Keys</Flex>
<Flex className='table-column'>Created</Flex>
{Utils.getFlagsmithHasFeature(
'organisation_api_keys_created_by',
) && (
<Flex className='table-column'>
<Tooltip title='Created by' place='right'>
This field may be blank if the key was created before
this information was tracked, or if the user has since
left the organisation.
</Tooltip>
</Flex>
)}
<Flex className='table-column'>Is Admin</Flex>
<Flex className='table-column'>Active</Flex>
<div
Expand All @@ -468,59 +480,71 @@ export default class AdminAPIKeys extends PureComponent {
</div>
</Row>
}
renderRow={(v) =>
!v.revoked && (
<Row
className='list-item'
key={v.id}
onClick={() => this.editAPIKey(v.name, v.id, v.prefix)}
>
<Flex className='table-column px-3'>
<div className='font-weight-medium mb-1'>{v.name}</div>
<div className='list-item-subtitle'>
<div>{v.prefix}*****************</div>
</div>
</Flex>
<Flex className='table-column fs-small lh-sm'>
{moment(v.created).format('Do MMM YYYY HH:mma')}
</Flex>
<Flex className='table-column fs-small lh-sm'>
<Switch checked={v.is_admin} disabled={true} />
</Flex>
<Flex className='table-column fs-small lh-sm'>
{v.has_expired ? (
<div className='ml-1'>
<Tooltip title={<Icon name='close-circle' />}>
{'This API key has expired'}
</Tooltip>
renderRow={(v) => {
const orgUsers = OrganisationStore.model?.users
const createdByUser =
v.created_by && orgUsers?.find((u) => u.id === v.created_by)
return (
!v.revoked && (
<Row
className='list-item'
key={v.id}
onClick={() => this.editAPIKey(v.name, v.id, v.prefix)}
>
<Flex className='table-column px-3'>
<div className='font-weight-medium mb-1'>{v.name}</div>
<div className='list-item-subtitle'>
<div>{v.prefix}*****************</div>
</div>
) : (
<span className='ml-1'>
<Icon
name='checkmark-circle'
fill='#27AB95'
width={28}
/>
</span>
</Flex>
<Flex className='table-column fs-small lh-sm'>
{moment(v.created).format('Do MMM YYYY HH:mma')}
</Flex>
{Utils.getFlagsmithHasFeature(
'organisation_api_keys_created_by',
) && (
<Flex className='table-column fs-small lh-sm'>
{getUserDisplayName(createdByUser, '-')}
</Flex>
)}
</Flex>
<div
className='table-column text-center'
style={{ width: '80px' }}
>
<Button
onClick={(e) => {
e.stopPropagation()
this.remove(v)
}}
className='btn btn-with-icon'
<Flex className='table-column fs-small lh-sm'>
<Switch checked={v.is_admin} disabled={true} />
</Flex>
<Flex className='table-column fs-small lh-sm'>
{v.has_expired ? (
<div className='ml-1'>
<Tooltip title={<Icon name='close-circle' />}>
{'This API key has expired'}
</Tooltip>
</div>
) : (
<span className='ml-1'>
<Icon
name='checkmark-circle'
fill='#27AB95'
width={28}
/>
</span>
)}
</Flex>
<div
className='table-column text-center'
style={{ width: '80px' }}
>
<Icon name='trash-2' width={20} fill='#656D7B' />
</Button>
</div>
</Row>
<Button
onClick={(e) => {
e.stopPropagation()
this.remove(v)
}}
className='btn btn-with-icon'
>
<Icon name='trash-2' width={20} fill='#656D7B' />
</Button>
</div>
</Row>
)
)
}
}}
/>
)}
</div>
Expand Down
Loading