API: add endpoint to show by what objects an object is used (#995)
* core: add used_by API to show what objects are affected before deletion Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web/elements: add support for used_by API Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: add authentik_used_by_shadows to shadow other models Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: implement used_by API Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * *: fix duplicate imports Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: add action field to used_by api Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: add UI for used_by action Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: add notice to tenant form Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: fix naming in used_by Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: check length for used_by Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: fix used_by for non-pk models Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * *: improve __str__ on models Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: add support for many to many in used_by Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		
							
								
								
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,102 @@
 | 
			
		||||
"""used_by mixin"""
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from inspect import getmembers
 | 
			
		||||
 | 
			
		||||
from django.db.models.base import Model
 | 
			
		||||
from django.db.models.deletion import SET_DEFAULT, SET_NULL
 | 
			
		||||
from django.db.models.manager import Manager
 | 
			
		||||
from drf_spectacular.utils import extend_schema
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import CharField, ChoiceField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteAction(Enum):
 | 
			
		||||
    """Which action a delete will have on a used object"""
 | 
			
		||||
 | 
			
		||||
    CASCADE = "cascade"
 | 
			
		||||
    CASCADE_MANY = "cascade_many"
 | 
			
		||||
    SET_NULL = "set_null"
 | 
			
		||||
    SET_DEFAULT = "set_default"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UsedBySerializer(PassiveSerializer):
 | 
			
		||||
    """A list of all objects referencing the queried object"""
 | 
			
		||||
 | 
			
		||||
    app = CharField()
 | 
			
		||||
    model_name = CharField()
 | 
			
		||||
    pk = CharField()
 | 
			
		||||
    name = CharField()
 | 
			
		||||
    action = ChoiceField(choices=[(x.name, x.name) for x in DeleteAction])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_delete_action(manager: Manager) -> str:
 | 
			
		||||
    """Get the delete action from the Foreign key, falls back to cascade"""
 | 
			
		||||
    if hasattr(manager, "field"):
 | 
			
		||||
        if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__:
 | 
			
		||||
            return DeleteAction.SET_NULL.name
 | 
			
		||||
        if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__:
 | 
			
		||||
            return DeleteAction.SET_DEFAULT.name
 | 
			
		||||
    if hasattr(manager, "source_field"):
 | 
			
		||||
        return DeleteAction.CASCADE_MANY.name
 | 
			
		||||
    return DeleteAction.CASCADE.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UsedByMixin:
 | 
			
		||||
    """Mixin to add a used_by endpoint to return a list of all objects using this object"""
 | 
			
		||||
 | 
			
		||||
    @extend_schema(
 | 
			
		||||
        responses={200: UsedBySerializer(many=True)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument, too-many-locals
 | 
			
		||||
    def used_by(self, request: Request, *args, **kwargs) -> Response:
 | 
			
		||||
        """Get a list of all objects that use this object"""
 | 
			
		||||
        # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
        model: Model = self.get_object()
 | 
			
		||||
        used_by = []
 | 
			
		||||
        shadows = []
 | 
			
		||||
        for attr_name, manager in getmembers(model, lambda x: isinstance(x, Manager)):
 | 
			
		||||
            if attr_name == "objects":  # pragma: no cover
 | 
			
		||||
                continue
 | 
			
		||||
            manager: Manager
 | 
			
		||||
            if manager.model._meta.abstract:
 | 
			
		||||
                continue
 | 
			
		||||
            app = manager.model._meta.app_label
 | 
			
		||||
            model_name = manager.model._meta.model_name
 | 
			
		||||
            delete_action = get_delete_action(manager)
 | 
			
		||||
 | 
			
		||||
            # To make sure we only apply shadows when there are any objects,
 | 
			
		||||
            # but so we only apply them once, have a simple flag for the first object
 | 
			
		||||
            first_object = True
 | 
			
		||||
 | 
			
		||||
            for obj in get_objects_for_user(
 | 
			
		||||
                request.user, f"{app}.view_{model_name}", manager
 | 
			
		||||
            ).all():
 | 
			
		||||
                # Only merge shadows on first object
 | 
			
		||||
                if first_object:
 | 
			
		||||
                    shadows += getattr(
 | 
			
		||||
                        manager.model._meta, "authentik_used_by_shadows", []
 | 
			
		||||
                    )
 | 
			
		||||
                first_object = False
 | 
			
		||||
                serializer = UsedBySerializer(
 | 
			
		||||
                    data={
 | 
			
		||||
                        "app": app,
 | 
			
		||||
                        "model_name": model_name,
 | 
			
		||||
                        "pk": str(obj.pk),
 | 
			
		||||
                        "name": str(obj),
 | 
			
		||||
                        "action": delete_action,
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
                serializer.is_valid()
 | 
			
		||||
                used_by.append(serializer.data)
 | 
			
		||||
        # Check the shadows map and remove anything that should be shadowed
 | 
			
		||||
        for idx, user in enumerate(used_by):
 | 
			
		||||
            full_model_name = f"{user['app']}.{user['model_name']}"
 | 
			
		||||
            if full_model_name in shadows:
 | 
			
		||||
                del used_by[idx]
 | 
			
		||||
        return Response(used_by)
 | 
			
		||||
		Reference in New Issue
	
	Block a user