diff --git a/authentik/core/api/property_mappings.py b/authentik/core/api/property_mappings.py index 34c8fa59103b..bf157426355e 100644 --- a/authentik/core/api/property_mappings.py +++ b/authentik/core/api/property_mappings.py @@ -2,8 +2,15 @@ from json import dumps +from django_filters.filters import AllValuesMultipleFilter, BooleanFilter +from django_filters.filterset import FilterSet from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_field, +) from guardian.shortcuts import get_objects_for_user from rest_framework import mixins from rest_framework.decorators import action @@ -67,6 +74,18 @@ class Meta: ] +class PropertyMappingFilterSet(FilterSet): + """Filter for PropertyMapping""" + + managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) + + managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull") + + class Meta: + model = PropertyMapping + fields = ["name", "managed"] + + class PropertyMappingViewSet( TypesMixin, mixins.RetrieveModelMixin, @@ -87,11 +106,9 @@ class PropertyMappingTestSerializer(PolicyTestSerializer): queryset = PropertyMapping.objects.select_subclasses() serializer_class = PropertyMappingSerializer - search_fields = [ - "name", - ] - filterset_fields = {"managed": ["isnull"]} + filterset_class = PropertyMappingFilterSet ordering = ["name"] + search_fields = ["name"] @permission_required("authentik_core.view_propertymapping") @extend_schema( diff --git a/authentik/core/models.py b/authentik/core/models.py index c1deb85ace82..42824b1f563f 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -28,6 +28,7 @@ from authentik.lib.avatars import get_avatar from authentik.lib.expression.exceptions import ControlFlowException from authentik.lib.generators import generate_id +from authentik.lib.merge import MERGE_LIST_UNIQUE from authentik.lib.models import ( CreatedUpdatedModel, DomainlessFormattedURLValidator, @@ -100,6 +101,38 @@ class UserTypes(models.TextChoices): INTERNAL_SERVICE_ACCOUNT = "internal_service_account" +class AttributesMixin(models.Model): + """Adds an attributes property to a model""" + + attributes = models.JSONField(default=dict, blank=True) + + class Meta: + abstract = True + + def update_attributes(self, properties: dict[str, Any]): + """Update fields and attributes, but correctly by merging dicts""" + for key, value in properties.items(): + if key == "attributes": + continue + setattr(self, key, value) + final_attributes = {} + MERGE_LIST_UNIQUE.merge(final_attributes, self.attributes) + MERGE_LIST_UNIQUE.merge(final_attributes, properties.get("attributes", {})) + self.attributes = final_attributes + self.save() + + @classmethod + def update_or_create_attributes( + cls, query: dict[str, Any], properties: dict[str, Any] + ) -> tuple[models.Model, bool]: + """Same as django's update_or_create but correctly updates attributes by merging dicts""" + instance = cls.objects.filter(**query).first() + if not instance: + return cls.objects.create(**properties), True + instance.update_attributes(properties) + return instance, False + + class GroupQuerySet(CTEQuerySet): def with_children_recursive(self): """Recursively get all groups that have the current queryset as parents @@ -134,7 +167,7 @@ def make_cte(cte): return cte.join(Group, group_uuid=cte.col.group_uuid).with_cte(cte) -class Group(SerializerModel): +class Group(SerializerModel, AttributesMixin): """Group model which supports a basic hierarchy and has attributes""" group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) @@ -154,10 +187,27 @@ class Group(SerializerModel): on_delete=models.SET_NULL, related_name="children", ) - attributes = models.JSONField(default=dict, blank=True) objects = GroupQuerySet.as_manager() + class Meta: + unique_together = ( + ( + "name", + "parent", + ), + ) + indexes = [models.Index(fields=["name"])] + verbose_name = _("Group") + verbose_name_plural = _("Groups") + permissions = [ + ("add_user_to_group", _("Add user to group")), + ("remove_user_from_group", _("Remove user from group")), + ] + + def __str__(self): + return f"Group {self.name}" + @property def serializer(self) -> Serializer: from authentik.core.api.groups import GroupSerializer @@ -182,24 +232,6 @@ def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]: qs = Group.objects.filter(group_uuid=self.group_uuid) return qs.with_children_recursive() - def __str__(self): - return f"Group {self.name}" - - class Meta: - unique_together = ( - ( - "name", - "parent", - ), - ) - indexes = [models.Index(fields=["name"])] - verbose_name = _("Group") - verbose_name_plural = _("Groups") - permissions = [ - ("add_user_to_group", _("Add user to group")), - ("remove_user_from_group", _("Remove user from group")), - ] - class UserQuerySet(models.QuerySet): """User queryset""" @@ -225,7 +257,7 @@ def exclude_anonymous(self) -> QuerySet: return self.get_queryset().exclude_anonymous() -class User(SerializerModel, GuardianUserMixin, AbstractUser): +class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): """authentik User model, based on django's contrib auth user model.""" uuid = models.UUIDField(default=uuid4, editable=False, unique=True) @@ -241,6 +273,28 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): objects = UserManager() + class Meta: + verbose_name = _("User") + verbose_name_plural = _("Users") + permissions = [ + ("reset_user_password", _("Reset Password")), + ("impersonate", _("Can impersonate other users")), + ("assign_user_permissions", _("Can assign permissions to users")), + ("unassign_user_permissions", _("Can unassign permissions from users")), + ("preview_user", _("Can preview user data sent to providers")), + ("view_user_applications", _("View applications the user has access to")), + ] + indexes = [ + models.Index(fields=["last_login"]), + models.Index(fields=["password_change_date"]), + models.Index(fields=["uuid"]), + models.Index(fields=["path"]), + models.Index(fields=["type"]), + ] + + def __str__(self): + return self.username + @staticmethod def default_path() -> str: """Get the default user path""" @@ -322,25 +376,6 @@ def avatar(self) -> str: """Get avatar, depending on authentik.avatar setting""" return get_avatar(self) - class Meta: - verbose_name = _("User") - verbose_name_plural = _("Users") - permissions = [ - ("reset_user_password", _("Reset Password")), - ("impersonate", _("Can impersonate other users")), - ("assign_user_permissions", _("Can assign permissions to users")), - ("unassign_user_permissions", _("Can unassign permissions from users")), - ("preview_user", _("Can preview user data sent to providers")), - ("view_user_applications", _("View applications the user has access to")), - ] - indexes = [ - models.Index(fields=["last_login"]), - models.Index(fields=["password_change_date"]), - models.Index(fields=["uuid"]), - models.Index(fields=["path"]), - models.Index(fields=["type"]), - ] - class Provider(SerializerModel): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" diff --git a/authentik/providers/oauth2/api/scopes.py b/authentik/providers/oauth2/api/scopes.py index ccb4a212e742..2ebbd73fc871 100644 --- a/authentik/providers/oauth2/api/scopes.py +++ b/authentik/providers/oauth2/api/scopes.py @@ -1,14 +1,10 @@ """OAuth2Provider API Views""" -from django_filters.filters import AllValuesMultipleFilter -from django_filters.filterset import FilterSet -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from rest_framework.fields import CharField from rest_framework.serializers import ValidationError from rest_framework.viewsets import ModelViewSet -from authentik.core.api.property_mappings import PropertyMappingSerializer +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.used_by import UsedByMixin from authentik.providers.oauth2.models import ScopeMapping @@ -33,14 +29,12 @@ class Meta: ] -class ScopeMappingFilter(FilterSet): +class ScopeMappingFilter(PropertyMappingFilterSet): """Filter for ScopeMapping""" - managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) - - class Meta: + class Meta(PropertyMappingFilterSet.Meta): model = ScopeMapping - fields = ["scope_name", "name", "managed"] + fields = PropertyMappingFilterSet.Meta.fields + ["scope_name"] class ScopeMappingViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/providers/radius/api/property_mappings.py b/authentik/providers/radius/api/property_mappings.py index b8bfaefff6d9..3b316b6d0e0d 100644 --- a/authentik/providers/radius/api/property_mappings.py +++ b/authentik/providers/radius/api/property_mappings.py @@ -1,12 +1,8 @@ """Radius Property mappings API Views""" -from django_filters.filters import AllValuesMultipleFilter -from django_filters.filterset import FilterSet -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from rest_framework.viewsets import ModelViewSet -from authentik.core.api.property_mappings import PropertyMappingSerializer +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.used_by import UsedByMixin from authentik.providers.radius.models import RadiusProviderPropertyMapping @@ -19,14 +15,11 @@ class Meta: fields = PropertyMappingSerializer.Meta.fields -class RadiusProviderPropertyMappingFilter(FilterSet): +class RadiusProviderPropertyMappingFilter(PropertyMappingFilterSet): """Filter for RadiusProviderPropertyMapping""" - managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) - - class Meta: + class Meta(PropertyMappingFilterSet.Meta): model = RadiusProviderPropertyMapping - fields = "__all__" class RadiusProviderPropertyMappingViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/providers/saml/api/property_mappings.py b/authentik/providers/saml/api/property_mappings.py index aff06dee7451..5108de995473 100644 --- a/authentik/providers/saml/api/property_mappings.py +++ b/authentik/providers/saml/api/property_mappings.py @@ -1,12 +1,8 @@ """SAML Property mappings API Views""" -from django_filters.filters import AllValuesMultipleFilter -from django_filters.filterset import FilterSet -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from rest_framework.viewsets import ModelViewSet -from authentik.core.api.property_mappings import PropertyMappingSerializer +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.used_by import UsedByMixin from authentik.providers.saml.models import SAMLPropertyMapping @@ -22,14 +18,11 @@ class Meta: ] -class SAMLPropertyMappingFilter(FilterSet): +class SAMLPropertyMappingFilter(PropertyMappingFilterSet): """Filter for SAMLPropertyMapping""" - managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) - - class Meta: + class Meta(PropertyMappingFilterSet.Meta): model = SAMLPropertyMapping - fields = "__all__" class SAMLPropertyMappingViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/providers/scim/api/property_mappings.py b/authentik/providers/scim/api/property_mappings.py index 37c5f09fb3e1..01ebb093e035 100644 --- a/authentik/providers/scim/api/property_mappings.py +++ b/authentik/providers/scim/api/property_mappings.py @@ -1,12 +1,8 @@ """scim Property mappings API Views""" -from django_filters.filters import AllValuesMultipleFilter -from django_filters.filterset import FilterSet -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from rest_framework.viewsets import ModelViewSet -from authentik.core.api.property_mappings import PropertyMappingSerializer +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.used_by import UsedByMixin from authentik.providers.scim.models import SCIMMapping @@ -19,14 +15,11 @@ class Meta: fields = PropertyMappingSerializer.Meta.fields -class SCIMMappingFilter(FilterSet): +class SCIMMappingFilter(PropertyMappingFilterSet): """Filter for SCIMMapping""" - managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) - - class Meta: + class Meta(PropertyMappingFilterSet.Meta): model = SCIMMapping - fields = "__all__" class SCIMMappingViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index d17ef00c6b3b..be7625c0a9a2 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -3,10 +3,7 @@ from typing import Any from django.core.cache import cache -from django_filters.filters import AllValuesMultipleFilter -from django_filters.filterset import FilterSet -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema, extend_schema_field, inline_serializer +from drf_spectacular.utils import extend_schema, inline_serializer from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.exceptions import ValidationError @@ -16,7 +13,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from authentik.core.api.property_mappings import PropertyMappingSerializer +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.crypto.models import CertificateKeyPair @@ -185,14 +182,11 @@ class Meta: fields = PropertyMappingSerializer.Meta.fields -class LDAPSourcePropertyMappingFilter(FilterSet): +class LDAPSourcePropertyMappingFilter(PropertyMappingFilterSet): """Filter for LDAPSourcePropertyMapping""" - managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) - - class Meta: + class Meta(PropertyMappingFilterSet.Meta): model = LDAPSourcePropertyMapping - fields = "__all__" class LDAPSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet): diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 4a3480fa81fd..5fa7d699bf1a 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -1,16 +1,13 @@ """Sync LDAP Users and groups into authentik""" from collections.abc import Generator -from typing import Any from django.conf import settings -from django.db.models.base import Model from ldap3 import DEREF_ALWAYS, SUBTREE, Connection from structlog.stdlib import BoundLogger, get_logger from authentik.core.sources.mapper import SourceMapper from authentik.lib.config import CONFIG -from authentik.lib.merge import MERGE_LIST_UNIQUE from authentik.lib.sync.mapper import PropertyMappingManager from authentik.sources.ldap.models import LDAPSource @@ -122,24 +119,3 @@ def search_paginator( # noqa: PLR0913 except KeyError: cookie = None yield self._connection.response - - def update_or_create_attributes( - self, - obj: type[Model], - query: dict[str, Any], - data: dict[str, Any], - ) -> tuple[Model, bool]: - """Same as django's update_or_create but correctly update attributes by merging dicts""" - instance = obj.objects.filter(**query).first() - if not instance: - return (obj.objects.create(**data), True) - for key, value in data.items(): - if key == "attributes": - continue - setattr(instance, key, value) - final_attributes = {} - MERGE_LIST_UNIQUE.merge(final_attributes, instance.attributes) - MERGE_LIST_UNIQUE.merge(final_attributes, data.get("attributes", {})) - instance.attributes = final_attributes - instance.save() - return (instance, False) diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index b27d2c8f1802..69b81ef5c2ca 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -78,8 +78,7 @@ def sync(self, page_data: list) -> int: # Special check for `users` field, as this is an M2M relation, and cannot be sync'd if "users" in defaults: del defaults["users"] - ak_group, created = self.update_or_create_attributes( - Group, + ak_group, created = Group.update_or_create_attributes( { f"attributes__{LDAP_UNIQUENESS}": uniq, }, diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index ff316bfe2bc9..6cbaec421406 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -78,8 +78,8 @@ def sync(self, page_data: list) -> int: self._logger.debug("Writing user with attributes", **defaults) if "username" not in defaults: raise IntegrityError("Username was not set by propertymappings") - ak_user, created = self.update_or_create_attributes( - User, {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults + ak_user, created = User.update_or_create_attributes( + {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults ) except PropertyMappingExpressionException as exc: raise StopSync(exc, None, exc.mapping) from exc diff --git a/schema.yml b/schema.yml index 67fa9a07617d..ff936bb1b0b6 100644 --- a/schema.yml +++ b/schema.yml @@ -13187,10 +13187,22 @@ paths: operationId: propertymappings_all_list description: PropertyMapping Viewset parameters: + - in: query + name: managed + schema: + type: array + items: + type: string + explode: true + style: form - in: query name: managed__isnull schema: type: boolean + - in: query + name: name + schema: + type: string - name: ordering required: false in: query @@ -14532,10 +14544,6 @@ paths: operationId: propertymappings_radius_list description: RadiusProviderPropertyMapping Viewset parameters: - - in: query - name: expression - schema: - type: string - in: query name: managed schema: @@ -14544,6 +14552,10 @@ paths: type: string explode: true style: form + - in: query + name: managed__isnull + schema: + type: boolean - in: query name: name schema: @@ -14566,11 +14578,6 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: pm_uuid - schema: - type: string - format: uuid - name: search required: false in: query @@ -14818,14 +14825,6 @@ paths: operationId: propertymappings_saml_list description: SAMLPropertyMapping Viewset parameters: - - in: query - name: expression - schema: - type: string - - in: query - name: friendly_name - schema: - type: string - in: query name: managed schema: @@ -14834,6 +14833,10 @@ paths: type: string explode: true style: form + - in: query + name: managed__isnull + schema: + type: boolean - in: query name: name schema: @@ -14856,15 +14859,6 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: pm_uuid - schema: - type: string - format: uuid - - in: query - name: saml_name - schema: - type: string - name: search required: false in: query @@ -15112,10 +15106,6 @@ paths: operationId: propertymappings_scim_list description: SCIMMapping Viewset parameters: - - in: query - name: expression - schema: - type: string - in: query name: managed schema: @@ -15124,6 +15114,10 @@ paths: type: string explode: true style: form + - in: query + name: managed__isnull + schema: + type: boolean - in: query name: name schema: @@ -15146,11 +15140,6 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: pm_uuid - schema: - type: string - format: uuid - name: search required: false in: query @@ -15406,6 +15395,10 @@ paths: type: string explode: true style: form + - in: query + name: managed__isnull + schema: + type: boolean - in: query name: name schema: @@ -15679,10 +15672,6 @@ paths: operationId: propertymappings_source_ldap_list description: LDAP PropertyMapping Viewset parameters: - - in: query - name: expression - schema: - type: string - in: query name: managed schema: @@ -15691,6 +15680,10 @@ paths: type: string explode: true style: form + - in: query + name: managed__isnull + schema: + type: boolean - in: query name: name schema: @@ -15713,11 +15706,6 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: pm_uuid - schema: - type: string - format: uuid - name: search required: false in: query