drf-impersonation 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmad Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,390 @@
1
+ Metadata-Version: 2.4
2
+ Name: drf-impersonation
3
+ Version: 0.1.0
4
+ Summary: Header-based user impersonation for Django REST Framework, works with any DRF authentication class
5
+ Author: Ahmad Ali
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ahmadjanan/drf-impersonation
8
+ Project-URL: Repository, https://github.com/ahmadjanan/drf-impersonation
9
+ Project-URL: Issues, https://github.com/ahmadjanan/drf-impersonation/issues
10
+ Project-URL: Changelog, https://github.com/ahmadjanan/drf-impersonation/blob/main/CHANGELOG.md
11
+ Keywords: django,djangorestframework,drf,impersonation,authentication,admin,support
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 4.2
17
+ Classifier: Framework :: Django :: 5.2
18
+ Classifier: Framework :: Django :: 6.0
19
+ Classifier: Framework :: Django :: 6.1
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: Python :: 3.15
27
+ Classifier: Operating System :: OS Independent
28
+ Classifier: Topic :: Internet :: WWW/HTTP
29
+ Classifier: Topic :: Security
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.10
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: Django>=4.2
35
+ Requires-Dist: djangorestframework>=3.14
36
+ Provides-Extra: openapi
37
+ Requires-Dist: drf-spectacular>=0.27; extra == "openapi"
38
+ Provides-Extra: test
39
+ Requires-Dist: pytest; extra == "test"
40
+ Requires-Dist: pytest-django; extra == "test"
41
+ Requires-Dist: pytest-cov; extra == "test"
42
+ Requires-Dist: drf-spectacular>=0.27; extra == "test"
43
+ Provides-Extra: lint
44
+ Requires-Dist: ruff; extra == "lint"
45
+ Requires-Dist: mypy; extra == "lint"
46
+ Dynamic: license-file
47
+
48
+ # drf-impersonation
49
+
50
+ [![PyPI version](https://img.shields.io/pypi/v/drf-impersonation.svg)](https://pypi.org/project/drf-impersonation/)
51
+ [![Python versions](https://img.shields.io/pypi/pyversions/drf-impersonation.svg)](https://pypi.org/project/drf-impersonation/)
52
+ [![Django versions](https://img.shields.io/pypi/frameworkversions/django/drf-impersonation.svg)](https://pypi.org/project/drf-impersonation/)
53
+ [![CI](https://github.com/ahmadjanan/drf-impersonation/actions/workflows/ci.yml/badge.svg)](https://github.com/ahmadjanan/drf-impersonation/actions/workflows/ci.yml)
54
+ [![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/ahmadjanan/drf-impersonation/blob/main/pyproject.toml)
55
+ [![Typed](https://img.shields.io/badge/typing-typed-blue.svg)](https://peps.python.org/pep-0561/)
56
+ [![License: MIT](https://img.shields.io/pypi/l/drf-impersonation.svg)](https://github.com/ahmadjanan/drf-impersonation/blob/main/LICENSE)
57
+
58
+ **Header-based user impersonation for Django REST Framework.** A permitted user sends one extra
59
+ header and the request runs as someone else. This works with every DRF authentication class:
60
+ Token, Session, JWT and OAuth.
61
+
62
+ ```http
63
+ GET /api/orders/ HTTP/1.1
64
+ Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b
65
+ X-Impersonate-User: 42
66
+ ```
67
+
68
+ The view sees `request.user` as user `42`. The real caller is still available on
69
+ `request.impersonator`.
70
+
71
+ ## Contents
72
+
73
+ - [Why](#why)
74
+ - [Features](#features)
75
+ - [Requirements](#requirements)
76
+ - [Installation](#installation)
77
+ - [Quick start](#quick-start)
78
+ - [How it works](#how-it-works)
79
+ - [Behaviour](#behaviour)
80
+ - [Settings](#settings)
81
+ - [Customising the authentication class](#customising-the-authentication-class)
82
+ - [Signals](#signals)
83
+ - [OpenAPI](#openapi)
84
+ - [Type checking](#type-checking)
85
+ - [Recipes](#recipes)
86
+ - [Security considerations](#security-considerations)
87
+ - [Contributing](#contributing)
88
+ - [License](#license)
89
+
90
+ ## Why
91
+
92
+ Support staff often need to see what a customer sees. Packages like `django-impersonate` and
93
+ `django-hijack` do this by changing the session, so they don't fit token-authenticated APIs that
94
+ have no session to change.
95
+
96
+ `drf-impersonation` runs inside the DRF authentication class. The base class authenticates the
97
+ real caller as normal. Then the mixin decides whether to swap in the target user. Impersonation
98
+ lasts for one request only, and the server keeps no state.
99
+
100
+ ## Features
101
+
102
+ - **Any auth backend.** Mix it into Token, Session, SimpleJWT, OAuth or a custom class.
103
+ - **Stateless.** One header on one request. Nothing to start, stop or clean up.
104
+ - **Read-only by default.** While impersonating, write requests are refused unless you turn this off.
105
+ - **Safe defaults.** Only superusers can impersonate. Inactive users and privilege escalation are
106
+ refused.
107
+ - **No user enumeration.** A target that doesn't exist and a target you may not impersonate give
108
+ the same `404`.
109
+ - **Flexible lookups.** Find the target by primary key, UUID, email or any other field.
110
+ - **Audit hooks.** Each impersonated request is logged and sends a Django signal.
111
+ - **OpenAPI support.** The header appears in drf-spectacular schemas and Swagger UI.
112
+ - **Typed.** Ships `py.typed`.
113
+
114
+ ## Requirements
115
+
116
+ | Dependency | Supported versions |
117
+ | --- | --- |
118
+ | Python | 3.10 – 3.15 |
119
+ | Django | 4.2, 5.2, 6.0, 6.1 |
120
+ | Django REST Framework | 3.14+ |
121
+ | drf-spectacular (optional) | 0.27+ |
122
+
123
+ CI runs the tests across the supported Python, Django and DRF versions.
124
+
125
+ ## Installation
126
+
127
+ ```bash
128
+ pip install drf-impersonation
129
+ ```
130
+
131
+ To include OpenAPI support:
132
+
133
+ ```bash
134
+ pip install "drf-impersonation[openapi]"
135
+ ```
136
+
137
+ ## Quick start
138
+
139
+ 1. Add the app. This is only needed for [OpenAPI](#openapi) support, but adding it is harmless:
140
+
141
+ ```python
142
+ INSTALLED_APPS = [
143
+ # ...
144
+ "rest_framework",
145
+ "drf_impersonation",
146
+ ]
147
+ ```
148
+
149
+ 2. Use the ready-made token class:
150
+
151
+ ```python
152
+ REST_FRAMEWORK = {
153
+ "DEFAULT_AUTHENTICATION_CLASSES": [
154
+ "drf_impersonation.authentication.ImpersonationTokenAuthentication",
155
+ ],
156
+ }
157
+ ```
158
+
159
+ For any other auth class, put `ImpersonationMixin` **before** it:
160
+
161
+ ```python
162
+ # myproject/authentication.py
163
+ from drf_impersonation.authentication import ImpersonationMixin
164
+ from rest_framework_simplejwt.authentication import JWTAuthentication
165
+
166
+
167
+ class ImpersonationJWTAuthentication(ImpersonationMixin, JWTAuthentication):
168
+ pass
169
+ ```
170
+
171
+ 3. Send the header as a superuser:
172
+
173
+ ```bash
174
+ curl https://api.example.com/api/me/ \
175
+ -H "Authorization: Token <superuser token>" \
176
+ -H "X-Impersonate-User: 42"
177
+ ```
178
+
179
+ In a view, `request.user` is the target and `request.impersonator` is the real caller. When
180
+ there is no impersonation, `request.impersonator` is `None`:
181
+
182
+ ```python
183
+ @api_view(["GET"])
184
+ def me(request):
185
+ impersonator = request.impersonator
186
+ return Response(
187
+ {
188
+ "user": request.user.pk,
189
+ "impersonated_by": impersonator.pk if impersonator else None,
190
+ }
191
+ )
192
+ ```
193
+
194
+ ## How it works
195
+
196
+ ```
197
+ Base auth class (Token / JWT / Session / ...)
198
+ │ no credentials ─────────────────────────▶ None. Bad credentials raise as usual.
199
+ ▼ actor authenticated
200
+ X-Impersonate-User header?
201
+ │ no ─────────────────────────────────────▶ actor, request.impersonator = None
202
+ ▼ yes
203
+ can_impersonate(actor)?
204
+ │ no ─────────────────────────────────────▶ header ignored, actor
205
+ ▼ yes
206
+ READ_ONLY and method is not GET/HEAD/OPTIONS?
207
+ │ yes ────────────────────────────────────▶ 403 Impersonated requests are read-only
208
+ ▼ no
209
+ target found and can_impersonate_target(actor, target)?
210
+ │ no ─────────────────────────────────────▶ 404 Impersonation target not found
211
+ ▼ yes
212
+ on_impersonate() logs, user_impersonated signal ─▶ target, request.impersonator = actor
213
+ ```
214
+
215
+ `request.auth` (the token, JWT payload and so on) still belongs to the **actor**.
216
+
217
+ ## Behaviour
218
+
219
+ | Situation | Result |
220
+ | --- | --- |
221
+ | No header | No change. `request.impersonator` is `None`. |
222
+ | Caller may not impersonate | Header ignored. The request runs as the caller. |
223
+ | Write method (POST, PUT, PATCH, DELETE) while `READ_ONLY` is on | `403 Impersonated requests are read-only` |
224
+ | No user matches the header value | `404 Impersonation target not found` |
225
+ | Target is the caller, is inactive, or is a superuser while the caller is not | `404` |
226
+ | Success | `request.user` is the target, `request.impersonator` is the caller, a warning is logged and the signal is sent. |
227
+
228
+ The read-only check happens before the target lookup. A write request gets `403` whether or not
229
+ the target exists.
230
+
231
+ ## Settings
232
+
233
+ All settings are optional. Put them in a `DRF_IMPERSONATION` dict:
234
+
235
+ ```python
236
+ DRF_IMPERSONATION = {
237
+ "READ_ONLY": True,
238
+ "SIGNALS": True,
239
+ "OPENAPI": True,
240
+ }
241
+ ```
242
+
243
+ | Key | Default | Effect |
244
+ | --- | --- | --- |
245
+ | `READ_ONLY` | `True` | Refuse POST, PUT, PATCH and DELETE with `403` while impersonating. |
246
+ | `SIGNALS` | `True` | Send [`user_impersonated`](#signals) for each impersonated request. |
247
+ | `OPENAPI` | `True` | Document the header in [drf-spectacular](#openapi) schemas. |
248
+
249
+ Settings are read on every request, so `override_settings` works in tests. An unknown key raises
250
+ `ImproperlyConfigured`, which catches typos.
251
+
252
+ ## Customising the authentication class
253
+
254
+ For behaviour that differs per class, subclass and override any of these:
255
+
256
+ | Attribute / method | Default | Purpose |
257
+ | --- | --- | --- |
258
+ | `impersonation_header` | `"X-Impersonate-User"` | Header that names the target. |
259
+ | `impersonation_lookup_fields` | `("pk",)` | Fields to match the header value against. All valid fields are tried together with OR, for example `("uuid", "email__iexact")`. |
260
+ | `can_impersonate(request, actor)` | `actor.is_superuser` | Whether the caller may impersonate anyone. |
261
+ | `can_impersonate_target(request, actor, target)` | Target is active, and no escalation to superuser | Whether the caller may impersonate this specific target. |
262
+ | `on_impersonate(request, actor, target)` | Logs a warning on the `drf_impersonation` logger | Runs for each impersonated request, before the signal. |
263
+
264
+ ```python
265
+ from drf_impersonation.authentication import ImpersonationTokenAuthentication
266
+
267
+
268
+ class SupportImpersonation(ImpersonationTokenAuthentication):
269
+ impersonation_lookup_fields = ("pk", "email__iexact")
270
+
271
+ def can_impersonate(self, request, actor):
272
+ return actor.has_perm("accounts.impersonate_user")
273
+ ```
274
+
275
+ ## Signals
276
+
277
+ `drf_impersonation.signals.user_impersonated` is sent after each impersonated request is
278
+ authenticated. The sender is the authentication class. The keyword arguments are `request`,
279
+ `actor` and `target`.
280
+
281
+ ```python
282
+ from django.dispatch import receiver
283
+ from drf_impersonation.signals import user_impersonated
284
+
285
+
286
+ @receiver(user_impersonated)
287
+ def record_impersonation(sender, request, actor, target, **kwargs):
288
+ AuditEvent.objects.create(actor=actor, target=target, path=request.path)
289
+ ```
290
+
291
+ Set `"SIGNALS": False` to stop sending it.
292
+
293
+ ## OpenAPI
294
+
295
+ With [drf-spectacular](https://drf-spectacular.readthedocs.io/) installed and `drf_impersonation`
296
+ in `INSTALLED_APPS`, every endpoint that uses an impersonation class documents the header. The
297
+ header is a security scheme called `impersonation`, and it is optional. Each operation lists the
298
+ base scheme alone and the base scheme together with the header:
299
+
300
+ ```yaml
301
+ security:
302
+ - tokenAuth: []
303
+ - tokenAuth: []
304
+ impersonation: []
305
+ ```
306
+
307
+ Swagger UI then shows an `X-Impersonate-User` field under **Authorize**. If `READ_ONLY` is on,
308
+ the scheme description says that only safe methods are allowed.
309
+
310
+ The extension wraps whatever scheme drf-spectacular already uses for the base class, such as
311
+ Token, SimpleJWT or Session. If the base class has no scheme, nothing is added. Set
312
+ `"OPENAPI": False` to turn the extension off.
313
+
314
+ ## Type checking
315
+
316
+ The package ships `py.typed`, so mypy and pyright check calls to its hooks. `request.impersonator`
317
+ is set at runtime on DRF's `Request`, which type checkers don't know about. Use
318
+ `getattr(request, "impersonator", None)` if your checker complains.
319
+
320
+ ## Recipes
321
+
322
+ ### Allow writes for some callers only
323
+
324
+ Turn off `READ_ONLY` and make the decision in a permission class:
325
+
326
+ ```python
327
+ from rest_framework.permissions import SAFE_METHODS, BasePermission
328
+
329
+
330
+ class ImpersonationWritesForSuperusers(BasePermission):
331
+ def has_permission(self, request, view):
332
+ impersonator = getattr(request, "impersonator", None)
333
+ if impersonator is None or request.method in SAFE_METHODS:
334
+ return True
335
+ return impersonator.is_superuser
336
+ ```
337
+
338
+ ### Protect staff accounts from support agents
339
+
340
+ ```python
341
+ def can_impersonate_target(self, request, actor, target):
342
+ if not super().can_impersonate_target(request, actor, target):
343
+ return False
344
+ return actor.is_superuser or not target.is_staff
345
+ ```
346
+
347
+ ### Route the default log
348
+
349
+ ```python
350
+ LOGGING = {
351
+ "version": 1,
352
+ "handlers": {"audit": {"class": "logging.FileHandler", "filename": "impersonation.log"}},
353
+ "loggers": {"drf_impersonation": {"handlers": ["audit"], "level": "WARNING"}},
354
+ }
355
+ ```
356
+
357
+ ### Allow the header through CORS
358
+
359
+ Browsers only send custom headers that the server allows. With `django-cors-headers`:
360
+
361
+ ```python
362
+ from corsheaders.defaults import default_headers
363
+
364
+ CORS_ALLOW_HEADERS = (*default_headers, "x-impersonate-user")
365
+ ```
366
+
367
+ ## Security considerations
368
+
369
+ - **Keep `can_impersonate` narrow.** Anyone it allows can act as almost any user.
370
+ - **Leave `READ_ONLY` on** unless support staff really need to make changes on a user's behalf.
371
+ - **Keep the escalation check.** If you override `can_impersonate_target`, call `super()` so that
372
+ inactive users and superusers stay protected.
373
+ - **Keep a durable audit record.** The default log line is a minimum. Use the signal to store
374
+ each event.
375
+ - **`request.auth` is the actor's.** Token scopes and claims belong to the real caller, not the
376
+ target.
377
+
378
+ To report a vulnerability, see [SECURITY.md](https://github.com/ahmadjanan/drf-impersonation/blob/main/SECURITY.md).
379
+
380
+ ## Contributing
381
+
382
+ Bug reports and pull requests are welcome. See
383
+ [CONTRIBUTING.md](https://github.com/ahmadjanan/drf-impersonation/blob/main/CONTRIBUTING.md) for
384
+ the development setup and the release process, and
385
+ [CHANGELOG.md](https://github.com/ahmadjanan/drf-impersonation/blob/main/CHANGELOG.md) for the
386
+ history.
387
+
388
+ ## License
389
+
390
+ [MIT](https://github.com/ahmadjanan/drf-impersonation/blob/main/LICENSE) © Ahmad Ali