Skip to content

Commit

Permalink
core: add ability to provide reason for impersonation (#11951)
Browse files Browse the repository at this point in the history
* core: add ability to provide reason for impersonation

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* tenants api things

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add missing implem

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* A tooltip needs a DOM object to determine the coordinates where it should render.  A solitary string is not enough; a  is needed here.

* web: user impersonation reason

To determine where to render the Tooltip content, the object associated with the Tooltip must be a DOM object with an HTML tag.  A naked string is not enough; a `<span>` will do nicely here.

Also, fixed a build failure: PFSize was not defined in RelatedUserList.

* add and fix tests

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* avoid migration change

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* small fixes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

---------

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Ken Sternberg <ken@goauthentik.io>
  • Loading branch information
rissson and kensternberg-authentik authored Nov 12, 2024
1 parent 6d5a611 commit 0cffe0c
Show file tree
Hide file tree
Showing 13 changed files with 195 additions and 59 deletions.
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(
help_text=_("Require administrators to provide a reason for impersonating a user."),
default=True,
)
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 @@ -42720,6 +42726,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 @@ -50029,6 +50043,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 @@ -53758,6 +53776,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 @@ -53797,6 +53819,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

0 comments on commit 0cffe0c

Please sign in to comment.