core: add primitives for source property mappings (#10651)

This commit is contained in:
Marc 'risson' Schmitt
2024-07-26 19:14:27 +02:00
committed by GitHub
parent ecd6c0a4d8
commit 45e464368e
11 changed files with 149 additions and 167 deletions

View File

@ -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 PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
]
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 PropertyMappingViewSet(
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(

View File

@ -28,6 +28,7 @@ from authentik.core.types import UILoginButton, UserSettingSerializer
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 @@ class GroupQuerySet(CTEQuerySet):
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 @@ class Group(SerializerModel):
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 @@ class UserManager(DjangoUserManager):
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 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
"""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"""

View File

@ -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 ScopeMappingSerializer(PropertyMappingSerializer):
]
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):

View File

@ -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 RadiusProviderPropertyMappingSerializer(PropertyMappingSerializer):
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):

View File

@ -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 SAMLPropertyMappingSerializer(PropertyMappingSerializer):
]
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):

View File

@ -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 SCIMMappingSerializer(PropertyMappingSerializer):
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):

View File

@ -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.request import Request
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 LDAPSourcePropertyMappingSerializer(PropertyMappingSerializer):
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):

View File

@ -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 @@ class BaseLDAPSynchronizer:
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)

View File

@ -78,8 +78,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
# 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,
},

View File

@ -78,8 +78,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
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

View File

@ -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