diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 75da4fdc6..253c7fef1 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -45,6 +45,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER from authentik.core.models import ( + USER_ATTRIBUTE_CHANGE_USERNAME, USER_ATTRIBUTE_SA, USER_ATTRIBUTE_TOKEN_EXPIRING, Group, @@ -113,14 +114,22 @@ class UserSelfSerializer(ModelSerializer): ) ) ) - def get_groups(self, user: User): + def get_groups(self, _: User): """Return only the group names a user is member of""" - for group in user.ak_groups.all(): + for group in self.instance.ak_groups.all(): yield { "name": group.name, "pk": group.pk, } + def validate_username(self, username: str): + """Check if the user is allowed to change their username""" + if self.instance.group_attributes().get(USER_ATTRIBUTE_CHANGE_USERNAME, True): + return username + if username != self.instance.username: + raise ValidationError("Not allowed to change username.") + return username + class Meta: model = User @@ -337,7 +346,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): # since it caches the full object if SESSION_IMPERSONATE_USER in request.session: request.session[SESSION_IMPERSONATE_USER] = new_user - serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data}) + serializer = SessionUserSerializer(data={"user": data.data}) serializer.is_valid() return Response(serializer.data) diff --git a/authentik/core/models.py b/authentik/core/models.py index d60452dd1..0c73a5619 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -39,6 +39,7 @@ USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec +USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" # nosec USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" GRAVATAR_URL = "https://secure.gravatar.com" diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 20dfbb227..643ac38e4 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -2,7 +2,7 @@ from django.urls.base import reverse from rest_framework.test import APITestCase -from authentik.core.models import User +from authentik.core.models import USER_ATTRIBUTE_CHANGE_USERNAME, User from authentik.flows.models import Flow, FlowDesignation from authentik.stages.email.models import EmailStage from authentik.tenants.models import Tenant @@ -15,6 +15,24 @@ class TestUsersAPI(APITestCase): self.admin = User.objects.get(username="akadmin") self.user = User.objects.create(username="test-user") + def test_update_self(self): + """Test update_self""" + self.client.force_login(self.admin) + response = self.client.put( + reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} + ) + self.assertEqual(response.status_code, 200) + + def test_update_self_username_denied(self): + """Test update_self""" + self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False + self.admin.save() + self.client.force_login(self.admin) + response = self.client.put( + reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} + ) + self.assertEqual(response.status_code, 400) + def test_metrics(self): """Test user's metrics""" self.client.force_login(self.admin) diff --git a/authentik/sources/oauth/api/source_connection.py b/authentik/sources/oauth/api/source_connection.py index 8c18b5bea..a810a250d 100644 --- a/authentik/sources/oauth/api/source_connection.py +++ b/authentik/sources/oauth/api/source_connection.py @@ -38,3 +38,4 @@ class UserOAuthSourceConnectionViewSet( filterset_fields = ["source__slug"] permission_classes = [OwnerPermissions] filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] + ordering = ["source__slug"] diff --git a/website/docs/expressions/_functions.md b/website/docs/expressions/_functions.md index 7a05dd828..e65ab66f1 100644 --- a/website/docs/expressions/_functions.md +++ b/website/docs/expressions/_functions.md @@ -32,7 +32,7 @@ return ak_is_group_member(request.user, name="test_group") Fetch a user matching `**filters`. -Returns "None" if no user was found, otherwise [User](/docs/expressions/reference/user-object) +Returns "None" if no user was found, otherwise [User](/docs/user-group/user) Example: diff --git a/website/docs/policies/expression.mdx b/website/docs/policies/expression.mdx index fc387646e..5beca9b8c 100644 --- a/website/docs/policies/expression.mdx +++ b/website/docs/policies/expression.mdx @@ -53,7 +53,7 @@ import Objects from '../expressions/_objects.md' - `request`: A PolicyRequest object, which has the following properties: - - `request.user`: The current user, against which the policy is applied. See [User](../expressions/reference/user-object.md) + - `request.user`: The current user, against which the policy is applied. See [User](../user-group/user.md#object-attributes) - `request.http_request`: The Django HTTP Request. See ([Django documentation](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects)) - `request.obj`: A Django Model instance. This is only set if the policy is ran against an object. - `request.context`: A dictionary with dynamic data. This depends on the origin of the execution. @@ -75,7 +75,7 @@ This includes the following: - `context['prompt_data']`: Data which has been saved from a prompt stage or an external source. - `context['application']`: The application the user is in the process of authorizing. -- `context['pending_user']`: The currently pending user, see [User](/docs/expressions/reference/user-object) +- `context['pending_user']`: The currently pending user, see [User](../user-group/user.md#object-attributes) - `context['auth_method']`: Authentication method set (this value is set by password stages) Depending on method, `context['auth_method_args']` is also set. diff --git a/website/docs/property-mappings/expression.mdx b/website/docs/property-mappings/expression.mdx index 2621d1549..0d69974b1 100644 --- a/website/docs/property-mappings/expression.mdx +++ b/website/docs/property-mappings/expression.mdx @@ -17,6 +17,6 @@ import Objects from '../expressions/_objects.md' -- `user`: The current user. This may be `None` if there is no contextual user. See ([User](../expressions/reference/user-object.md)) +- `user`: The current user. This may be `None` if there is no contextual user. See ([User](../user-group/user.md#object-attributes)) - `request`: The current request. This may be `None` if there is no contextual request. See ([Django documentation](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects)) - Other arbitrary arguments given by the provider, this is documented on the Provider/Source. diff --git a/website/docs/expressions/reference/user-object.md b/website/docs/user-group/user.md similarity index 56% rename from website/docs/expressions/reference/user-object.md rename to website/docs/user-group/user.md index f56560b66..bbcdaeb7b 100644 --- a/website/docs/expressions/reference/user-object.md +++ b/website/docs/user-group/user.md @@ -1,7 +1,23 @@ --- -title: User Object +title: User --- +## Attributes + +### `goauthentik.io/user/can-change-username` + +Optional flag, when set to false prevents the user from changing their own username. + +### `goauthentik.io/user/token-expires`: + +Optional flag, when set to false, Tokens created by the user will not expire. + +### `goauthentik.io/user/debug`: + +See [Troubleshooting access problems](../troubleshooting/access.md), when set, the user gets a more detailed explanation of access decisions. + +## Object attributes + The User object has the following attributes: - `username`: User's username. @@ -11,8 +27,8 @@ The User object has the following attributes: - `is_active` Boolean field if user is active. - `date_joined` Date user joined/was created. - `password_change_date` Date password was last changed. -- `attributes` Dynamic attributes. -- `group_attributes` Merged attributes of all groups the user is member of and the user's own attributes. +- `attributes` Dynamic attributes, see above +- `group_attributes()` Merged attributes of all groups the user is member of and the user's own attributes. - `ak_groups` This is a queryset of all the user's groups. You can do additional filtering like diff --git a/website/sidebars.js b/website/sidebars.js index d453a46ae..aabce2d6f 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -8,6 +8,13 @@ module.exports = { type: "doc", id: "terminology", }, + { + type: "category", + label: "Users & Groups", + items: [ + "user-group/user" + ] + }, { type: "category", label: "Installation", @@ -145,17 +152,6 @@ module.exports = { label: "Property Mappings", items: ["property-mappings/index", "property-mappings/expression"], }, - { - type: "category", - label: "Expressions", - items: [ - { - type: "category", - label: "Reference", - items: ["expressions/reference/user-object"], - }, - ], - }, { type: "category", label: "Events",