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,
)
8 changes: 5 additions & 3 deletions api/tests/integration/api_keys/test_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
139 changes: 88 additions & 51 deletions frontend/web/components/AdminAPIKeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
}

Expand Down Expand Up @@ -444,19 +458,30 @@ export default class AdminAPIKeys extends PureComponent {
{`Create API Key`}
</Button>
</Column>
{this.state.isLoading && (
{(this.state.isLoading || !OrganisationStore.model?.users) && (
<div className='text-center'>
<Loader />
</div>
)}
{!!apiKeys && !!apiKeys.length && (
{!!apiKeys && !!apiKeys.length && !!OrganisationStore.model?.users && (
<PanelSearch
className='no-pad'
items={apiKeys}
header={
<Row className='table-header'>
<Flex className='table-column px-3'>API Keys</Flex>
<Flex className='table-column'>Created</Flex>
{Utils.getFlagsmithHasFeature(
Comment thread
kyle-ssg marked this conversation as resolved.
'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 @@ -467,59 +492,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