Skip to content
Draft
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
27 changes: 27 additions & 0 deletions apps/common/migrations/0009_alter_editevent_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 6.0.7 on 2026-08-05 05:49

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("common", "0008_sitelabel_per_key"),
]

operations = [
migrations.AlterField(
model_name="editevent",
name="action",
field=models.CharField(
choices=[
("created", "Created"),
("updated", "Updated"),
("deleted", "Deleted"),
("status_changed", "Status changed"),
("commented", "Commented"),
("impersonated", "Impersonated"),
],
max_length=24,
),
),
]
1 change: 1 addition & 0 deletions apps/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class Action(models.TextChoices):
DELETED = "deleted", "Deleted"
STATUS_CHANGED = "status_changed", "Status changed"
COMMENTED = "commented", "Commented"
IMPERSONATED = "impersonated", "Impersonated"

actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
Expand Down
44 changes: 44 additions & 0 deletions apps/users/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Application services for user management workflows."""

from typing import cast

from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import PermissionDenied, ValidationError

from apps.common.audit import log_edit
from apps.common.models import EditEvent

User = get_user_model()


def impersonate_user(*, actor: AbstractBaseUser, target: AbstractBaseUser) -> Token:
"""Let a superuser browse the app as *target* without knowing their password.

This project authenticates via DRF `TokenAuthentication` (bearer tokens),
not server-side sessions, so there is no session to swap the way
`django-impersonate`'s middleware does. Instead we hand the caller the
target's own auth token (minted on first use) so the frontend can swap
its stored token and make subsequent requests as that user.

Restricted to genuine "support" impersonation: never yourself, and never
another staff/superuser account (that would let a superuser silently
assume another admin's privileges without their knowledge).
"""
if actor.pk == target.pk:
raise ValidationError("You cannot impersonate yourself.")
if target.is_staff or target.is_superuser:
raise PermissionDenied("Cannot impersonate a staff or superuser account.")

token, _ = Token.objects.get_or_create(user=target)

log_edit(
actor=actor,
action=cast(str, EditEvent.Action.IMPERSONATED),
target_type="user",
target_id=target.pk,
summary=f"{actor} impersonated {target}",
)

return token
97 changes: 97 additions & 0 deletions apps/users/tests/test_impersonate_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""API tests for the superuser "impersonate another user" endpoint.

Token-swap equivalent of django-impersonate for this project's token-auth
architecture: a superuser POSTs to /management/users/{id}/impersonate/ and
gets back the target user's own auth token to swap into the frontend client.
"""

from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient, APITestCase

from apps.common.models import EditEvent
from apps.users.tests.factories import SuperuserFactory, UserFactory

User = get_user_model()


def _impersonate_url(user_id: int) -> str:
return f"/api/v1/auth/management/users/{user_id}/impersonate/"


class ImpersonateAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
self.superuser = SuperuserFactory(username="admin")
self.target = UserFactory(username="regular")

def test_non_superuser_is_forbidden(self):
requester = UserFactory(username="plain")
self.client.force_authenticate(user=requester)

response = self.client.post(_impersonate_url(self.target.id))

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(EditEvent.objects.exists())

def test_anonymous_is_unauthorized_or_forbidden(self):
response = self.client.post(_impersonate_url(self.target.id))

self.assertIn(response.status_code, (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN))

def test_superuser_impersonating_regular_user_returns_token(self):
self.client.force_authenticate(user=self.superuser)

response = self.client.post(_impersonate_url(self.target.id))

self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_token = Token.objects.get(user=self.target)
self.assertEqual(response.data["auth_token"], expected_token.key)

def test_impersonation_reuses_existing_token(self):
self.client.force_authenticate(user=self.superuser)
existing_token = Token.objects.create(user=self.target)

response = self.client.post(_impersonate_url(self.target.id))

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["auth_token"], existing_token.key)
self.assertEqual(Token.objects.filter(user=self.target).count(), 1)

def test_impersonation_creates_audit_event(self):
self.client.force_authenticate(user=self.superuser)

self.client.post(_impersonate_url(self.target.id))

event = EditEvent.objects.get()
self.assertEqual(event.actor, self.superuser)
self.assertEqual(event.action, EditEvent.Action.IMPERSONATED)
self.assertEqual(event.target_type, "user")
self.assertEqual(event.target_id, self.target.id)

def test_cannot_impersonate_self(self):
self.client.force_authenticate(user=self.superuser)

response = self.client.post(_impersonate_url(self.superuser.id))

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(EditEvent.objects.exists())

def test_cannot_impersonate_staff_user(self):
self.client.force_authenticate(user=self.superuser)
staff_user = UserFactory(username="staffer", is_staff=True)

response = self.client.post(_impersonate_url(staff_user.id))

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(EditEvent.objects.exists())

def test_cannot_impersonate_another_superuser(self):
self.client.force_authenticate(user=self.superuser)
other_superuser = SuperuserFactory(username="admin2")

response = self.client.post(_impersonate_url(other_superuser.id))

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(EditEvent.objects.exists())
17 changes: 17 additions & 0 deletions apps/users/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from django.contrib.auth import get_user_model
from rest_framework.decorators import action
from rest_framework.generics import RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from apps.common.views import ActionSerializerMixin, BasePrivilegedViewSet

from .serializers import UserListManagementSerializer, UserSerializer, UserWriteManagementSerializer
from .services import impersonate_user

User = get_user_model()

Expand All @@ -25,3 +29,16 @@ class UserManagementViewSet(ActionSerializerMixin, BasePrivilegedViewSet):
"update": UserWriteManagementSerializer,
"partial_update": UserWriteManagementSerializer,
}

@action(detail=True, methods=["post"])
def impersonate(self, request: Request, pk: str | None = None) -> Response:
"""Mint (or reuse) the target user's auth token for support-style impersonation.

Superuser-only (inherited from `BasePrivilegedViewSet`). The caller
swaps its stored auth token for the one returned here to browse the
app as the target user; see `apps.users.services.impersonate_user`
for the safety checks and audit logging.
"""
target = self.get_object()
token = impersonate_user(actor=request.user, target=target)
return Response({"auth_token": token.key})
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dependencies = [
"pyyaml>=6.0.3",
"whitenoise>=6.12.0",
"pyjwt>=2.12.0",
"cryptography>=49.0.0",
"cryptography>=50.0.0",
"python-json-logger>=4.1.0",
]

Expand Down
Loading
Loading