Skip to content
Merged
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
15 changes: 13 additions & 2 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,12 @@ def recovery_email(self, request: Request, pk: int) -> Response:

@permission_required("authentik_core.impersonate")
@extend_schema(
request=OpenApiTypes.NONE,
request=inline_serializer(
"ImpersonationSerializer",
{
"reason": CharField(required=True),
},
),
responses={
"204": OpenApiResponse(description="Successfully started impersonation"),
"401": OpenApiResponse(description="Access denied"),
Expand All @@ -679,6 +684,7 @@ def impersonate(self, request: Request, pk: int) -> Response:
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
reason = request.data.get("reason", "")
# Check both object-level perms and global perms
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
Expand All @@ -688,11 +694,16 @@ def impersonate(self, request: Request, pk: int) -> Response:
if user_to_be.pk == self.request.user.pk:
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
return Response(status=401)
if not reason and request.tenant.impersonation_require_reason:
LOGGER.debug(
"User attempted to impersonate without providing a reason", user=request.user
)
return Response(status=401)

request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be

Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be)

return Response(status=201)

Expand Down
32 changes: 26 additions & 6 deletions authentik/core/tests/test_impersonation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ def test_impersonate_simple(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)

response = self.client.get(reverse("authentik_api:user-me"))
Expand All @@ -55,7 +56,8 @@ def test_impersonate_global(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)

Expand All @@ -75,7 +77,8 @@ def test_impersonate_scoped(self):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)

Expand All @@ -89,7 +92,8 @@ def test_impersonate_denied(self):
self.client.force_login(self.other_user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 403)

Expand All @@ -105,7 +109,8 @@ def test_impersonate_disabled(self):
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 401)

Expand All @@ -118,7 +123,22 @@ def test_impersonate_self(self):
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 401)

response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.user.username)

def test_impersonate_reason_required(self):
"""test impersonation that user must provide reason"""
self.client.force_login(self.user)

response = self.client.post(
reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
data={"reason": ""},
)
self.assertEqual(response.status_code, 401)

Expand Down
2 changes: 1 addition & 1 deletion authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def default_event_duration():
"""Default duration an Event is saved.
This is used as a fallback when no brand is available"""
try:
tenant = get_current_tenant()
tenant = get_current_tenant(only=["event_retention"])
return now() + timedelta_from_string(tenant.event_retention)
except Tenant.DoesNotExist:
return now() + timedelta(days=365)
Expand Down
1 change: 1 addition & 0 deletions authentik/tenants/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Meta:
"footer_links",
"gdpr_compliance",
"impersonation",
"impersonation_require_reason",
"default_token_duration",
"default_token_length",
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.0.9 on 2024-11-07 15:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_tenants", "0003_alter_tenant_default_token_duration"),
]

operations = [
migrations.AddField(
model_name="tenant",
name="impersonation_require_reason",
field=models.BooleanField(
default=True,
help_text="Require administrators to provide a reason for impersonating a user.",
),
),
]
4 changes: 4 additions & 0 deletions authentik/tenants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ class Tenant(TenantMixin, SerializerModel):
impersonation = models.BooleanField(
help_text=_("Globally enable/disable impersonation."), default=True
)
impersonation_require_reason = models.BooleanField(
Comment thread
rissson marked this conversation as resolved.
help_text=_("Require administrators to provide a reason for impersonating a user."),
default=True,
Comment thread
BeryJu marked this conversation as resolved.
)
default_token_duration = models.TextField(
help_text=_("Default token duration"),
default=DEFAULT_TOKEN_DURATION,
Expand Down
6 changes: 4 additions & 2 deletions authentik/tenants/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from authentik.tenants.models import Tenant


def get_current_tenant() -> Tenant:
def get_current_tenant(only: list[str] | None = None) -> Tenant:
"""Get tenant for current request"""
return Tenant.objects.get(schema_name=connection.schema_name)
if only is None:
only = []
return Tenant.objects.only(*only).get(schema_name=connection.schema_name)


def get_unique_identifier() -> str:
Expand Down
26 changes: 26 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5295,6 +5295,12 @@ paths:
required: true
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ImpersonationRequest'
required: true
security:
- authentik: []
responses:
Expand Down Expand Up @@ -42713,6 +42719,14 @@ components:
incorrect user info is entered.
required:
- name
ImpersonationRequest:
type: object
properties:
reason:
type: string
minLength: 1
required:
- reason
InstallID:
type: object
properties:
Expand Down Expand Up @@ -50020,6 +50034,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
impersonation_require_reason:
type: boolean
description: Require administrators to provide a reason for impersonating
a user.
default_token_duration:
type: string
minLength: 1
Expand Down Expand Up @@ -53749,6 +53767,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
impersonation_require_reason:
type: boolean
description: Require administrators to provide a reason for impersonating
a user.
default_token_duration:
type: string
description: Default token duration
Expand Down Expand Up @@ -53788,6 +53810,10 @@ components:
impersonation:
type: boolean
description: Globally enable/disable impersonation.
impersonation_require_reason:
type: boolean
description: Require administrators to provide a reason for impersonating
a user.
default_token_duration:
type: string
minLength: 1
Expand Down
7 changes: 7 additions & 0 deletions web/src/admin/admin-settings/AdminSettingsForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
help=${msg("Globally enable/disable impersonation.")}
>
</ak-switch-input>
<ak-switch-input
name="impersonationRequireReason"
label=${msg("Require reason for impersonation")}
?checked="${this._settings?.impersonationRequireReason}"
help=${msg("Require administrators to provide a reason for impersonating a user.")}
>
</ak-switch-input>
<ak-text-input
name="defaultTokenDuration"
label=${msg("Default token duration")}
Expand Down
32 changes: 18 additions & 14 deletions web/src/admin/groups/RelatedUserList.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserImpersonateForm";
import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
Expand Down Expand Up @@ -213,20 +215,22 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</ak-forms-modal>
${canImpersonate
? html`
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: item.pk,
})
.then(() => {
window.location.href = "/";
});
}}
>
${msg("Impersonate")}
</ak-action-button>
<ak-forms-modal size=${PFSize.Medium} id="impersonate-request">
<span slot="submit">${msg("Impersonate")}</span>
<span slot="header">${msg("Impersonate")} ${item.username}</span>
<ak-user-impersonate-form
slot="form"
.instancePk=${item.pk}
></ak-user-impersonate-form>
<button slot="trigger" class="pf-c-button pf-m-tertiary">
<pf-tooltip
position="top"
content=${msg("Temporarily assume the identity of this user")}
>
<span>${msg("Impersonate")}</span>
</pf-tooltip>
</button>
</ak-forms-modal>
`
: html``}`,
];
Expand Down
40 changes: 40 additions & 0 deletions web/src/admin/users/UserImpersonateForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-text-input";
import { Form } from "@goauthentik/elements/forms/Form";

import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";

import { CoreApi, ImpersonationRequest } from "@goauthentik/api";

@customElement("ak-user-impersonate-form")
export class UserImpersonateForm extends Form<ImpersonationRequest> {
@property({ type: Number })
instancePk?: number;

async send(data: ImpersonationRequest): Promise<void> {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: this.instancePk || 0,
impersonationRequest: data,
})
.then(() => {
window.location.href = "/";
});
}

renderForm(): TemplateResult {
return html`<ak-text-input
name="reason"
label=${msg("Reason")}
help=${msg("Reason for impersonating the user")}
></ak-text-input>`;
}
}

declare global {
interface HTMLElementTagNameMap {
"ak-user-impersonate-form": UserImpersonateForm;
}
}
31 changes: 17 additions & 14 deletions web/src/admin/users/UserListPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AdminInterface } from "@goauthentik/admin/AdminInterface";
import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserImpersonateForm";
import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
Expand Down Expand Up @@ -266,20 +267,22 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
</ak-forms-modal>
${canImpersonate
? html`
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: item.pk,
})
.then(() => {
window.location.href = "/";
});
}}
>
${msg("Impersonate")}
</ak-action-button>
<ak-forms-modal size=${PFSize.Medium} id="impersonate-request">
<span slot="submit">${msg("Impersonate")}</span>
<span slot="header">${msg("Impersonate")} ${item.username}</span>
<ak-user-impersonate-form
slot="form"
.instancePk=${item.pk}
></ak-user-impersonate-form>
<button slot="trigger" class="pf-c-button pf-m-tertiary">
<pf-tooltip
position="top"
content=${msg("Temporarily assume the identity of this user")}
>
<span>${msg("Impersonate")}</span>
</pf-tooltip>
</button>
</ak-forms-modal>
`
: html``}`,
];
Expand Down
Loading