diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 7c10a39be567ef573337fea4b254db21b3a83ed0..74fe79ed0115a752e2e64fffdee7bc8c5beb703e 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -156,6 +156,7 @@ LOCAL_APPS = (
     "funkwhale_api.requests",
     "funkwhale_api.favorites",
     "funkwhale_api.federation",
+    "funkwhale_api.moderation",
     "funkwhale_api.radios",
     "funkwhale_api.history",
     "funkwhale_api.playlists",
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index 91a202b6465b099588002c5ee4b99a07c9a9ad3a..bea2dd5f6005162ec3bb1a636bf794e67eb65a23 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -38,7 +38,7 @@ DEBUG_TOOLBAR_CONFIG = {
     "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
     "SHOW_TEMPLATE_CONTEXT": True,
     "SHOW_TOOLBAR_CALLBACK": lambda request: True,
-    "JQUERY_URL": "",
+    "JQUERY_URL": "/staticfiles/admin/js/vendor/jquery/jquery.js",
 }
 
 # django-extensions
diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ecedc5121eb25b079fea3d1bfdec76d41a90886
--- /dev/null
+++ b/api/funkwhale_api/common/decorators.py
@@ -0,0 +1,14 @@
+from rest_framework import response
+from rest_framework.decorators import list_route
+
+
+def action_route(serializer_class):
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializer_class(request.data, queryset=queryset)
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
+
+    return action
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index e7bbf8f1f00c44935ce972792bdba580979377af..fafa6152d09edf95052f2346b16e3135756a6114 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -123,7 +123,7 @@ class ActionSerializer(serializers.Serializer):
         if type(value) in [list, tuple]:
             return self.queryset.filter(
                 **{"{}__in".format(self.pk_field): value}
-            ).order_by("id")
+            ).order_by(self.pk_field)
 
         raise serializers.ValidationError(
             "{} is not a valid value for objects. You must provide either a "
diff --git a/api/funkwhale_api/common/validators.py b/api/funkwhale_api/common/validators.py
index b5f26cac5421450fccaac039720e1238cf118ca8..78a4b4c7c4c3c9bf40d33616f4e0538ba7ca6ebc 100644
--- a/api/funkwhale_api/common/validators.py
+++ b/api/funkwhale_api/common/validators.py
@@ -1,6 +1,7 @@
 import mimetypes
 from os.path import splitext
 
+from django.core import validators
 from django.core.exceptions import ValidationError
 from django.core.files.images import get_image_dimensions
 from django.template.defaultfilters import filesizeformat
@@ -150,3 +151,17 @@ class FileValidator(object):
             }
 
             raise ValidationError(message)
+
+
+class DomainValidator(validators.URLValidator):
+    message = "Enter a valid domain name."
+
+    def __call__(self, value):
+        """
+        This is a bit hackish but since we don't have any built-in domain validator,
+        we use the url one, and prepend http:// in front of it.
+
+        If it fails, we know the domain is not valid.
+        """
+        super().__call__("http://{}".format(value))
+        return value
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 211b8230a6891fbf660d69387c8fd4741a95621f..86c79107a3f562af0bdd77881861fc9c63177acf 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -80,6 +80,30 @@ OBJECT_TYPES = (
 BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
 
 
+def should_reject(id, actor_id=None, payload={}):
+    from funkwhale_api.moderation import models as moderation_models
+
+    policies = moderation_models.InstancePolicy.objects.active()
+
+    media_types = ["Audio", "Artist", "Album", "Track", "Library", "Image"]
+    relevant_values = [
+        recursive_gettattr(payload, "type", permissive=True),
+        recursive_gettattr(payload, "object.type", permissive=True),
+        recursive_gettattr(payload, "target.type", permissive=True),
+    ]
+    # if one of the payload types match our internal media types, then
+    # we apply policies that reject media
+    if set(media_types) & set(relevant_values):
+        policy_type = Q(block_all=True) | Q(reject_media=True)
+    else:
+        policy_type = Q(block_all=True)
+
+    query = policies.matching_url_query(id) & policy_type
+    if actor_id:
+        query |= policies.matching_url_query(actor_id) & policy_type
+    return policies.filter(query).exists()
+
+
 @transaction.atomic
 def receive(activity, on_behalf_of):
     from . import models
@@ -92,6 +116,16 @@ def receive(activity, on_behalf_of):
         data=activity, context={"actor": on_behalf_of, "local_recipients": True}
     )
     serializer.is_valid(raise_exception=True)
+    if should_reject(
+        id=serializer.validated_data["id"],
+        actor_id=serializer.validated_data["actor"].fid,
+        payload=activity,
+    ):
+        logger.info(
+            "[federation] Discarding activity due to instance policies %s",
+            serializer.validated_data.get("id"),
+        )
+        return
     try:
         copy = serializer.save()
     except IntegrityError:
@@ -283,7 +317,7 @@ class OutboxRouter(Router):
             return activities
 
 
-def recursive_gettattr(obj, key):
+def recursive_gettattr(obj, key, permissive=False):
     """
     Given a dictionary such as {'user': {'name': 'Bob'}} and
     a dotted string such as user.name, returns 'Bob'.
@@ -292,7 +326,12 @@ def recursive_gettattr(obj, key):
     """
     v = obj
     for k in key.split("."):
-        v = v.get(k)
+        try:
+            v = v.get(k)
+        except (TypeError, AttributeError):
+            if not permissive:
+                raise
+            return
         if v is None:
             return
 
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
index 75ffad0b221ddc7bf56c279d4e1ef369084c972c..4c5aaf92a2d76e3ef3ab87aeb24afe5a57adb31b 100644
--- a/api/funkwhale_api/federation/api_views.py
+++ b/api/funkwhale_api/federation/api_views.py
@@ -13,6 +13,7 @@ from funkwhale_api.music import models as music_models
 
 from . import activity
 from . import api_serializers
+from . import exceptions
 from . import filters
 from . import models
 from . import routes
@@ -128,11 +129,16 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
         except KeyError:
             return response.Response({"fid": ["This field is required"]})
         try:
-            library = utils.retrieve(
+            library = utils.retrieve_ap_object(
                 fid,
                 queryset=self.queryset,
                 serializer_class=serializers.LibrarySerializer,
             )
+        except exceptions.BlockedActorOrDomain:
+            return response.Response(
+                {"detail": "This domain/account is blocked on your instance."},
+                status=400,
+            )
         except requests.exceptions.RequestException as e:
             return response.Response(
                 {"detail": "Error while fetching the library: {}".format(str(e))},
diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py
index f32c78ff30f00079d822bbf2921d2c4845fff5fc..adfb8a18161dc91f11b0957530054ed85c33b460 100644
--- a/api/funkwhale_api/federation/authentication.py
+++ b/api/funkwhale_api/federation/authentication.py
@@ -1,8 +1,14 @@
 import cryptography
+import logging
+
 from django.contrib.auth.models import AnonymousUser
-from rest_framework import authentication, exceptions
+from rest_framework import authentication, exceptions as rest_exceptions
+
+from funkwhale_api.moderation import models as moderation_models
+from . import actors, exceptions, keys, signing, utils
+
 
-from . import actors, keys, signing, utils
+logger = logging.getLogger(__name__)
 
 
 class SignatureAuthentication(authentication.BaseAuthentication):
@@ -14,20 +20,36 @@ class SignatureAuthentication(authentication.BaseAuthentication):
         except KeyError:
             return
         except ValueError as e:
-            raise exceptions.AuthenticationFailed(str(e))
+            raise rest_exceptions.AuthenticationFailed(str(e))
+
+        try:
+            actor_url = key_id.split("#")[0]
+        except (TypeError, IndexError, AttributeError):
+            raise rest_exceptions.AuthenticationFailed("Invalid key id")
+
+        policies = (
+            moderation_models.InstancePolicy.objects.active()
+            .filter(block_all=True)
+            .matching_url(actor_url)
+        )
+        if policies.exists():
+            raise exceptions.BlockedActorOrDomain()
 
         try:
-            actor = actors.get_actor(key_id.split("#")[0])
+            actor = actors.get_actor(actor_url)
         except Exception as e:
-            raise exceptions.AuthenticationFailed(str(e))
+            logger.info(
+                "Discarding HTTP request from blocked actor/domain %s", actor_url
+            )
+            raise rest_exceptions.AuthenticationFailed(str(e))
 
         if not actor.public_key:
-            raise exceptions.AuthenticationFailed("No public key found")
+            raise rest_exceptions.AuthenticationFailed("No public key found")
 
         try:
             signing.verify_django(request, actor.public_key.encode("utf-8"))
         except cryptography.exceptions.InvalidSignature:
-            raise exceptions.AuthenticationFailed("Invalid signature")
+            raise rest_exceptions.AuthenticationFailed("Invalid signature")
 
         return actor
 
diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py
index b3fb73ab8ee549d92250c48fd8bffff2667ee15e..eb2bd2161537b1498c8905de45476bf705b70e1c 100644
--- a/api/funkwhale_api/federation/exceptions.py
+++ b/api/funkwhale_api/federation/exceptions.py
@@ -1,6 +1,13 @@
+from rest_framework import exceptions
+
+
 class MalformedPayload(ValueError):
     pass
 
 
 class MissingSignature(KeyError):
     pass
+
+
+class BlockedActorOrDomain(exceptions.AuthenticationFailed):
+    pass
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 331a5f5d68df35d53823640eb2c5a5b7518a8764..f54f6867861230e3b2bc7ffd4fcf1adbcd61fe3a 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -67,7 +67,7 @@ def create_user(actor):
 
 
 @registry.register
-class Domain(NoUpdateOnCreate, factory.django.DjangoModelFactory):
+class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     name = factory.Faker("domain_name")
 
     class Meta:
@@ -81,7 +81,7 @@ class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
     private_key = None
     preferred_username = factory.Faker("user_name")
     summary = factory.Faker("paragraph")
-    domain = factory.SubFactory(Domain)
+    domain = factory.SubFactory(DomainFactory)
     fid = factory.LazyAttribute(
         lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username)
     )
diff --git a/api/funkwhale_api/federation/fields.py b/api/funkwhale_api/federation/fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..3523396dbceb86a2aa8c84c1767cad9b6f767db1
--- /dev/null
+++ b/api/funkwhale_api/federation/fields.py
@@ -0,0 +1,18 @@
+from rest_framework import serializers
+
+from . import models
+
+
+class ActorRelatedField(serializers.EmailField):
+    def to_representation(self, value):
+        return value.full_username
+
+    def to_internal_value(self, value):
+        value = super().to_internal_value(value)
+        username, domain = value.split("@")
+        try:
+            return models.Actor.objects.get(
+                preferred_username=username, domain_id=domain
+            )
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError("Invalid actor name")
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 2fdeaaa7675abf7ccef9e3a2ce05319682ea0f85..59360aea10374ff2d4e804008fb9f62aeecb156f 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -13,6 +13,7 @@ from django.urls import reverse
 
 from funkwhale_api.common import session
 from funkwhale_api.common import utils as common_utils
+from funkwhale_api.common import validators as common_validators
 from funkwhale_api.music import utils as music_utils
 
 from . import utils as federation_utils
@@ -83,7 +84,11 @@ class DomainQuerySet(models.QuerySet):
 
 
 class Domain(models.Model):
-    name = models.CharField(primary_key=True, max_length=255)
+    name = models.CharField(
+        primary_key=True,
+        max_length=255,
+        validators=[common_validators.DomainValidator()],
+    )
     creation_date = models.DateTimeField(default=timezone.now)
     nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
     nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 1cece3b97bbef1ad52a9667b456500ecc4448572..d0e07cd85cdea5f62157455afe23729e87dc4f71 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -567,7 +567,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
         return r
 
     def create(self, validated_data):
-        actor = utils.retrieve(
+        actor = utils.retrieve_ap_object(
             validated_data["actor"],
             queryset=models.Actor,
             serializer_class=ActorSerializer,
diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py
index 4ed07aa25f9769aa41905eef402490b5536c99b4..d7c48957ab6a3b47e46d688ab8c9cd7495ffa724 100644
--- a/api/funkwhale_api/federation/tasks.py
+++ b/api/funkwhale_api/federation/tasks.py
@@ -186,3 +186,46 @@ def update_domain_nodeinfo(domain):
     domain.nodeinfo_fetch_date = now
     domain.nodeinfo = nodeinfo
     domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date"])
+
+
+def delete_qs(qs):
+    label = qs.model._meta.label
+    result = qs.delete()
+    related = sum(result[1].values())
+
+    logger.info(
+        "Purged %s %s objects (and %s related entities)", result[0], label, related
+    )
+
+
+def handle_purge_actors(ids, only=[]):
+    """
+    Empty only means we purge everything
+    Otherwise, we purge only the requested bits: media
+    """
+    # purge follows (received emitted)
+    if not only:
+        delete_qs(models.LibraryFollow.objects.filter(target__actor_id__in=ids))
+        delete_qs(models.Follow.objects.filter(actor_id__in=ids))
+
+    # purge audio content
+    if not only or "media" in only:
+        delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
+        delete_qs(models.Follow.objects.filter(target_id__in=ids))
+        delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
+        delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
+
+    # purge remaining activities / deliveries
+    if not only:
+        delete_qs(models.InboxItem.objects.filter(actor_id__in=ids))
+        delete_qs(models.Activity.objects.filter(actor_id__in=ids))
+
+
+@celery.app.task(name="federation.purge_actors")
+def purge_actors(ids=[], domains=[], only=[]):
+    actors = models.Actor.objects.filter(
+        Q(id__in=ids) | Q(domain_id__in=domains)
+    ).order_by("id")
+    found_ids = list(actors.values_list("id", flat=True))
+    logger.info("Starting purging %s accounts", len(found_ids))
+    handle_purge_actors(ids=found_ids, only=only)
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index d02c8bf6874cf83240ab7cbe3a5835b2dab3bdea..e49a4dd63c4f8140c8f8e52ed13cb095585927c7 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -3,7 +3,9 @@ import re
 from django.conf import settings
 
 from funkwhale_api.common import session
+from funkwhale_api.moderation import models as moderation_models
 
+from . import exceptions
 from . import signing
 
 
@@ -58,7 +60,14 @@ def slugify_username(username):
     return re.sub(r"[-\s]+", "_", value)
 
 
-def retrieve(fid, actor=None, serializer_class=None, queryset=None):
+def retrieve_ap_object(
+    fid, actor=None, serializer_class=None, queryset=None, apply_instance_policies=True
+):
+    from . import activity
+
+    policies = moderation_models.InstancePolicy.objects.active().filter(block_all=True)
+    if apply_instance_policies and policies.matching_url(fid):
+        raise exceptions.BlockedActorOrDomain()
     if queryset:
         try:
             # queryset can also be a Model class
@@ -83,6 +92,16 @@ def retrieve(fid, actor=None, serializer_class=None, queryset=None):
     )
     response.raise_for_status()
     data = response.json()
+
+    # we match against moderation policies here again, because the FID of the returned
+    # object may not be the same as the URL used to access it
+    try:
+        id = data["id"]
+    except KeyError:
+        pass
+    else:
+        if apply_instance_policies and activity.should_reject(id=id, payload=data):
+            raise exceptions.BlockedActorOrDomain()
     if not serializer_class:
         return data
     serializer = serializer_class(data=data)
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 51648298a1ad92e96289ffbb08c5de3d57c52333..b2088b5a6f076416722b1802bed7dcd744fa9c58 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -4,6 +4,7 @@ from funkwhale_api.common import fields
 from funkwhale_api.common import search
 
 from funkwhale_api.federation import models as federation_models
+from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.users import models as users_models
 
@@ -87,3 +88,24 @@ class ManageInvitationFilterSet(filters.FilterSet):
         if value is None:
             return queryset
         return queryset.open(value)
+
+
+class ManageInstancePolicyFilterSet(filters.FilterSet):
+    q = fields.SearchFilter(
+        search_fields=[
+            "summary",
+            "target_domain__name",
+            "target_actor__username",
+            "target_actor__domain__name",
+        ]
+    )
+
+    class Meta:
+        model = moderation_models.InstancePolicy
+        fields = [
+            "q",
+            "block_all",
+            "silence_activity",
+            "silence_notifications",
+            "reject_media",
+        ]
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 76d0cf05fc49fb789e10385ad8dd4cb3f3e719e1..ed50d86777d30c7d8fc255944f5034f70a3f4bef 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -3,7 +3,11 @@ from django.db import transaction
 from rest_framework import serializers
 
 from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.common import utils as common_utils
 from funkwhale_api.federation import models as federation_models
+from funkwhale_api.federation import fields as federation_fields
+from funkwhale_api.federation import tasks as federation_tasks
+from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.users import models as users_models
 
@@ -185,6 +189,13 @@ class ManageDomainSerializer(serializers.ModelSerializer):
             "outbox_activities_count",
             "nodeinfo",
             "nodeinfo_fetch_date",
+            "instance_policy",
+        ]
+        read_only_fields = [
+            "creation_date",
+            "instance_policy",
+            "nodeinfo",
+            "nodeinfo_fetch_date",
         ]
 
     def get_actors_count(self, o):
@@ -194,6 +205,17 @@ class ManageDomainSerializer(serializers.ModelSerializer):
         return getattr(o, "outbox_activities_count", 0)
 
 
+class ManageDomainActionSerializer(common_serializers.ActionSerializer):
+    actions = [common_serializers.Action("purge", allow_all=False)]
+    filterset_class = filters.ManageDomainFilterSet
+    pk_field = "name"
+
+    @transaction.atomic
+    def handle_purge(self, objects):
+        ids = objects.values_list("pk", flat=True)
+        common_utils.on_commit(federation_tasks.purge_actors.delay, domains=list(ids))
+
+
 class ManageActorSerializer(serializers.ModelSerializer):
     uploads_count = serializers.SerializerMethodField()
     user = ManageUserSerializer()
@@ -218,7 +240,102 @@ class ManageActorSerializer(serializers.ModelSerializer):
             "manually_approves_followers",
             "uploads_count",
             "user",
+            "instance_policy",
         ]
+        read_only_fields = ["creation_date", "instance_policy"]
 
     def get_uploads_count(self, o):
         return getattr(o, "uploads_count", 0)
+
+
+class ManageActorActionSerializer(common_serializers.ActionSerializer):
+    actions = [common_serializers.Action("purge", allow_all=False)]
+    filterset_class = filters.ManageActorFilterSet
+
+    @transaction.atomic
+    def handle_purge(self, objects):
+        ids = objects.values_list("id", flat=True)
+        common_utils.on_commit(federation_tasks.purge_actors.delay, ids=list(ids))
+
+
+class TargetSerializer(serializers.Serializer):
+    type = serializers.ChoiceField(choices=["domain", "actor"])
+    id = serializers.CharField()
+
+    def to_representation(self, value):
+        if value["type"] == "domain":
+            return {"type": "domain", "id": value["obj"].name}
+        if value["type"] == "actor":
+            return {"type": "actor", "id": value["obj"].full_username}
+
+    def to_internal_value(self, value):
+        if value["type"] == "domain":
+            field = serializers.PrimaryKeyRelatedField(
+                queryset=federation_models.Domain.objects.external()
+            )
+        if value["type"] == "actor":
+            field = federation_fields.ActorRelatedField()
+        value["obj"] = field.to_internal_value(value["id"])
+        return value
+
+
+class ManageInstancePolicySerializer(serializers.ModelSerializer):
+    target = TargetSerializer()
+    actor = federation_fields.ActorRelatedField(read_only=True)
+
+    class Meta:
+        model = moderation_models.InstancePolicy
+        fields = [
+            "id",
+            "uuid",
+            "target",
+            "creation_date",
+            "actor",
+            "summary",
+            "is_active",
+            "block_all",
+            "silence_activity",
+            "silence_notifications",
+            "reject_media",
+        ]
+
+        read_only_fields = ["uuid", "id", "creation_date", "actor", "target"]
+
+    def validate(self, data):
+        try:
+            target = data.pop("target")
+        except KeyError:
+            # partial update
+            return data
+        if target["type"] == "domain":
+            data["target_domain"] = target["obj"]
+        if target["type"] == "actor":
+            data["target_actor"] = target["obj"]
+
+        return data
+
+    @transaction.atomic
+    def save(self, *args, **kwargs):
+        instance = super().save(*args, **kwargs)
+        need_purge = self.instance.is_active and (
+            self.instance.block_all or self.instance.reject_media
+        )
+        if need_purge:
+            only = []
+            if self.instance.reject_media:
+                only.append("media")
+            target = instance.target
+            if target["type"] == "domain":
+                common_utils.on_commit(
+                    federation_tasks.purge_actors.delay,
+                    domains=[target["obj"].pk],
+                    only=only,
+                )
+            if target["type"] == "actor":
+                common_utils.on_commit(
+                    federation_tasks.purge_actors.delay,
+                    ids=[target["obj"].pk],
+                    only=only,
+                )
+
+        return instance
diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py
index 232b887113a609abe8a5f9c2547fc1801c78198a..4c220fe0ef28598eab3feec59c9fb19ecf33bd86 100644
--- a/api/funkwhale_api/manage/urls.py
+++ b/api/funkwhale_api/manage/urls.py
@@ -5,8 +5,15 @@ from . import views
 
 federation_router = routers.SimpleRouter()
 federation_router.register(r"domains", views.ManageDomainViewSet, "domains")
+
 library_router = routers.SimpleRouter()
 library_router.register(r"uploads", views.ManageUploadViewSet, "uploads")
+
+moderation_router = routers.SimpleRouter()
+moderation_router.register(
+    r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
+)
+
 users_router = routers.SimpleRouter()
 users_router.register(r"users", views.ManageUserViewSet, "users")
 users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
@@ -20,5 +27,9 @@ urlpatterns = [
         include((federation_router.urls, "federation"), namespace="federation"),
     ),
     url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
+    url(
+        r"^moderation/",
+        include((moderation_router.urls, "moderation"), namespace="moderation"),
+    ),
     url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
 ] + other_router.urls
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index 0697c6c14e3609fc32c508807312d47913e11407..e42915eb5b11bfdccf12d84db2c8d00a14c5627b 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -2,10 +2,11 @@ from rest_framework import mixins, response, viewsets
 from rest_framework.decorators import detail_route, list_route
 from django.shortcuts import get_object_or_404
 
-from funkwhale_api.common import preferences
+from funkwhale_api.common import preferences, decorators
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import tasks as federation_tasks
 from funkwhale_api.music import models as music_models
+from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.users import models as users_models
 from funkwhale_api.users.permissions import HasUserPermission
 
@@ -98,13 +99,17 @@ class ManageInvitationViewSet(
 
 
 class ManageDomainViewSet(
-    mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
+    mixins.CreateModelMixin,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    viewsets.GenericViewSet,
 ):
     lookup_value_regex = r"[a-zA-Z0-9\-\.]+"
     queryset = (
         federation_models.Domain.objects.external()
         .with_actors_count()
         .with_outbox_activities_count()
+        .prefetch_related("instance_policy")
         .order_by("name")
     )
     serializer_class = serializers.ManageDomainSerializer
@@ -117,6 +122,7 @@ class ManageDomainViewSet(
         "nodeinfo_fetch_date",
         "actors_count",
         "outbox_activities_count",
+        "instance_policy",
     ]
 
     @detail_route(methods=["get"])
@@ -131,6 +137,8 @@ class ManageDomainViewSet(
         domain = self.get_object()
         return response.Response(domain.get_stats(), status=200)
 
+    action = decorators.action_route(serializers.ManageDomainActionSerializer)
+
 
 class ManageActorViewSet(
     mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
@@ -141,6 +149,7 @@ class ManageActorViewSet(
         .with_uploads_count()
         .order_by("-creation_date")
         .select_related("user")
+        .prefetch_related("instance_policy")
     )
     serializer_class = serializers.ManageActorSerializer
     filter_class = filters.ManageActorFilterSet
@@ -155,6 +164,7 @@ class ManageActorViewSet(
         "last_fetch_date",
         "uploads_count",
         "outbox_activities_count",
+        "instance_policy",
     ]
 
     def get_object(self):
@@ -170,3 +180,28 @@ class ManageActorViewSet(
     def stats(self, request, *args, **kwargs):
         domain = self.get_object()
         return response.Response(domain.get_stats(), status=200)
+
+    action = decorators.action_route(serializers.ManageActorActionSerializer)
+
+
+class ManageInstancePolicyViewSet(
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.CreateModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    queryset = (
+        moderation_models.InstancePolicy.objects.all()
+        .order_by("-creation_date")
+        .select_related()
+    )
+    serializer_class = serializers.ManageInstancePolicySerializer
+    filter_class = filters.ManageInstancePolicyFilterSet
+    permission_classes = (HasUserPermission,)
+    required_permissions = ["moderation"]
+    ordering_fields = ["id", "creation_date"]
+
+    def perform_create(self, serializer):
+        serializer.save(actor=self.request.user.actor)
diff --git a/api/funkwhale_api/moderation/__init__.py b/api/funkwhale_api/moderation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/moderation/admin.py b/api/funkwhale_api/moderation/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e421255ed344d61335edbb588f7792d07ac21ec
--- /dev/null
+++ b/api/funkwhale_api/moderation/admin.py
@@ -0,0 +1,30 @@
+from funkwhale_api.common import admin
+
+from . import models
+
+
+@admin.register(models.InstancePolicy)
+class InstancePolicyAdmin(admin.ModelAdmin):
+    list_display = [
+        "actor",
+        "target_domain",
+        "target_actor",
+        "creation_date",
+        "block_all",
+        "reject_media",
+        "silence_activity",
+        "silence_notifications",
+    ]
+    list_filter = [
+        "block_all",
+        "reject_media",
+        "silence_activity",
+        "silence_notifications",
+    ]
+    search_fields = [
+        "actor__fid",
+        "target_domain__name",
+        "target_domain__actor__fid",
+        "summary",
+    ]
+    list_select_related = True
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..aba5256c9cba399cf2e345101eb0d9958f1b13f4
--- /dev/null
+++ b/api/funkwhale_api/moderation/factories.py
@@ -0,0 +1,23 @@
+import factory
+
+from funkwhale_api.factories import registry, NoUpdateOnCreate
+from funkwhale_api.federation import factories as federation_factories
+
+
+@registry.register
+class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
+    summary = factory.Faker("paragraph")
+    actor = factory.SubFactory(federation_factories.ActorFactory)
+    block_all = True
+    is_active = True
+
+    class Meta:
+        model = "moderation.InstancePolicy"
+
+    class Params:
+        for_domain = factory.Trait(
+            target_domain=factory.SubFactory(federation_factories.DomainFactory)
+        )
+        for_actor = factory.Trait(
+            target_actor=factory.SubFactory(federation_factories.ActorFactory)
+        )
diff --git a/api/funkwhale_api/moderation/migrations/0001_initial.py b/api/funkwhale_api/moderation/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..33151e00f2b4a0cfe950dd36ee9a17e711adb534
--- /dev/null
+++ b/api/funkwhale_api/moderation/migrations/0001_initial.py
@@ -0,0 +1,35 @@
+# Generated by Django 2.0.9 on 2019-01-07 06:06
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('federation', '0016_auto_20181227_1605'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='InstancePolicy',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('is_active', models.BooleanField(default=True)),
+                ('summary', models.TextField(blank=True, max_length=10000, null=True)),
+                ('block_all', models.BooleanField(default=False)),
+                ('silence_activity', models.BooleanField(default=False)),
+                ('silence_notifications', models.BooleanField(default=False)),
+                ('reject_media', models.BooleanField(default=False)),
+                ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_instance_policies', to='federation.Actor')),
+                ('target_actor', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Actor')),
+                ('target_domain', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Domain')),
+            ],
+        ),
+    ]
diff --git a/api/funkwhale_api/moderation/migrations/__init__.py b/api/funkwhale_api/moderation/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..c184bbda8117929da26940a1197b36c6cb75f6f4
--- /dev/null
+++ b/api/funkwhale_api/moderation/models.py
@@ -0,0 +1,75 @@
+import urllib.parse
+import uuid
+
+from django.db import models
+from django.utils import timezone
+
+
+class InstancePolicyQuerySet(models.QuerySet):
+    def active(self):
+        return self.filter(is_active=True)
+
+    def matching_url(self, *urls):
+        if not urls:
+            return self.none()
+        query = None
+        for url in urls:
+            new_query = self.matching_url_query(url)
+            if query:
+                query = query | new_query
+            else:
+                query = new_query
+        return self.filter(query)
+
+    def matching_url_query(self, url):
+        parsed = urllib.parse.urlparse(url)
+        return models.Q(target_domain_id=parsed.hostname) | models.Q(
+            target_actor__fid=url
+        )
+
+
+class InstancePolicy(models.Model):
+    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+    actor = models.ForeignKey(
+        "federation.Actor",
+        related_name="created_instance_policies",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+    target_domain = models.OneToOneField(
+        "federation.Domain",
+        related_name="instance_policy",
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    target_actor = models.OneToOneField(
+        "federation.Actor",
+        related_name="instance_policy",
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    creation_date = models.DateTimeField(default=timezone.now)
+
+    is_active = models.BooleanField(default=True)
+    # a summary explaining why the policy is in place
+    summary = models.TextField(max_length=10000, null=True, blank=True)
+    # either block everything (simpler, but less granularity)
+    block_all = models.BooleanField(default=False)
+    # or pick individual restrictions below
+    # do not show in timelines/notifications, except for actual followers
+    silence_activity = models.BooleanField(default=False)
+    silence_notifications = models.BooleanField(default=False)
+    # do not download any media from the target
+    reject_media = models.BooleanField(default=False)
+
+    objects = InstancePolicyQuerySet.as_manager()
+
+    @property
+    def target(self):
+        if self.target_actor:
+            return {"type": "actor", "obj": self.target_actor}
+        if self.target_domain_id:
+            return {"type": "domain", "obj": self.target_domain}
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
index a65b7b0cc8cba956bdcc9ad7c5a5863a35b770d5..67f60b4861a2af90be332ee41707cbf28580b1e0 100644
--- a/api/tests/federation/test_activity.py
+++ b/api/tests/federation/test_activity.py
@@ -46,6 +46,69 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now,
         assert ii.is_read is False
 
 
+def test_receive_calls_should_reject(factories, now, mocker):
+    should_reject = mocker.patch.object(activity, "should_reject", return_value=True)
+    local_to_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    a = {
+        "@context": [],
+        "actor": remote_actor.fid,
+        "type": "Noop",
+        "id": "https://test.activity",
+        "to": [local_to_actor.fid, remote_actor.fid],
+    }
+
+    copy = activity.receive(activity=a, on_behalf_of=remote_actor)
+    should_reject.assert_called_once_with(
+        id=a["id"], actor_id=remote_actor.fid, payload=a
+    )
+    assert copy is None
+
+
+@pytest.mark.parametrize(
+    "params, policy_kwargs, expected",
+    [
+        ({"id": "https://ok.test"}, {"target_domain__name": "notok.test"}, False),
+        (
+            {"id": "https://ok.test"},
+            {"target_domain__name": "ok.test", "is_active": False},
+            False,
+        ),
+        (
+            {"id": "https://ok.test"},
+            {"target_domain__name": "ok.test", "block_all": False},
+            False,
+        ),
+        # id match blocked domain
+        ({"id": "http://notok.test"}, {"target_domain__name": "notok.test"}, True),
+        # actor id match blocked domain
+        (
+            {"id": "http://ok.test", "actor_id": "https://notok.test"},
+            {"target_domain__name": "notok.test"},
+            True,
+        ),
+        # reject media
+        (
+            {
+                "payload": {"type": "Library"},
+                "id": "http://ok.test",
+                "actor_id": "http://notok.test",
+            },
+            {
+                "target_domain__name": "notok.test",
+                "block_all": False,
+                "reject_media": True,
+            },
+            True,
+        ),
+    ],
+)
+def test_should_reject(factories, params, policy_kwargs, expected):
+    factories["moderation.InstancePolicy"](for_domain=True, **policy_kwargs)
+
+    assert activity.should_reject(**params) is expected
+
+
 def test_get_actors_from_audience_urls(settings, db):
     settings.FEDERATION_HOSTNAME = "federation.hostname"
     library_uuid1 = uuid.uuid4()
diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py
index c2d695184572fcfcbcd5bfc9a24f62f445d9640c..feb2ea24685b8e6d56087ecd41489bbef28f7a19 100644
--- a/api/tests/federation/test_api_views.py
+++ b/api/tests/federation/test_api_views.py
@@ -23,7 +23,7 @@ def test_user_can_list_their_library_follows(factories, logged_in_api_client):
 def test_user_can_fetch_library_using_url(mocker, factories, logged_in_api_client):
     library = factories["music.Library"]()
     mocked_retrieve = mocker.patch(
-        "funkwhale_api.federation.utils.retrieve", return_value=library
+        "funkwhale_api.federation.utils.retrieve_ap_object", return_value=library
     )
     url = reverse("api:v1:federation:libraries-fetch")
     response = logged_in_api_client.post(url, {"fid": library.fid})
diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py
index 100971a3b737dd6a3a968d6df773a236cff6251d..2888588b88bc7ae4b60ea058d49827cc7c243b3a 100644
--- a/api/tests/federation/test_authentication.py
+++ b/api/tests/federation/test_authentication.py
@@ -1,4 +1,6 @@
-from funkwhale_api.federation import authentication, keys
+import pytest
+
+from funkwhale_api.federation import authentication, exceptions, keys
 
 
 def test_authenticate(factories, mocker, api_request):
@@ -38,3 +40,89 @@ def test_authenticate(factories, mocker, api_request):
     assert user.is_anonymous is True
     assert actor.public_key == public.decode("utf-8")
     assert actor.fid == actor_url
+
+
+def test_authenticate_skips_blocked_domain(factories, api_request):
+    policy = factories["moderation.InstancePolicy"](block_all=True, for_domain=True)
+    private, public = keys.get_key_pair()
+    actor_url = "https://{}/actor".format(policy.target_domain.name)
+
+    signed_request = factories["federation.SignedRequest"](
+        auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"]
+    )
+    prepared = signed_request.prepare()
+    django_request = api_request.get(
+        "/",
+        **{
+            "HTTP_DATE": prepared.headers["date"],
+            "HTTP_SIGNATURE": prepared.headers["signature"],
+        }
+    )
+    authenticator = authentication.SignatureAuthentication()
+
+    with pytest.raises(exceptions.BlockedActorOrDomain):
+        authenticator.authenticate(django_request)
+
+
+def test_authenticate_skips_blocked_actor(factories, api_request):
+    policy = factories["moderation.InstancePolicy"](block_all=True, for_actor=True)
+    private, public = keys.get_key_pair()
+    actor_url = policy.target_actor.fid
+
+    signed_request = factories["federation.SignedRequest"](
+        auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"]
+    )
+    prepared = signed_request.prepare()
+    django_request = api_request.get(
+        "/",
+        **{
+            "HTTP_DATE": prepared.headers["date"],
+            "HTTP_SIGNATURE": prepared.headers["signature"],
+        }
+    )
+    authenticator = authentication.SignatureAuthentication()
+
+    with pytest.raises(exceptions.BlockedActorOrDomain):
+        authenticator.authenticate(django_request)
+
+
+def test_authenticate_ignore_inactive_policy(factories, api_request, mocker):
+    policy = factories["moderation.InstancePolicy"](
+        block_all=True, for_domain=True, is_active=False
+    )
+    private, public = keys.get_key_pair()
+    actor_url = "https://{}/actor".format(policy.target_domain.name)
+
+    signed_request = factories["federation.SignedRequest"](
+        auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"]
+    )
+    mocker.patch(
+        "funkwhale_api.federation.actors.get_actor_data",
+        return_value={
+            "id": actor_url,
+            "type": "Person",
+            "outbox": "https://test.com",
+            "inbox": "https://test.com",
+            "followers": "https://test.com",
+            "preferredUsername": "test",
+            "publicKey": {
+                "publicKeyPem": public.decode("utf-8"),
+                "owner": actor_url,
+                "id": actor_url + "#main-key",
+            },
+        },
+    )
+    prepared = signed_request.prepare()
+    django_request = api_request.get(
+        "/",
+        **{
+            "HTTP_DATE": prepared.headers["date"],
+            "HTTP_SIGNATURE": prepared.headers["signature"],
+        }
+    )
+    authenticator = authentication.SignatureAuthentication()
+    authenticator.authenticate(django_request)
+    actor = django_request.actor
+
+    assert actor.public_key == public.decode("utf-8")
+    assert actor.fid == actor_url
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index fb151b2d7d36d5823e128668ab0277fb8c5c21e0..207a8fbe5e21f8e0fd29f53b41350bc7a91d18e3 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -507,7 +507,7 @@ def test_music_library_serializer_to_ap(factories):
 def test_music_library_serializer_from_public(factories, mocker):
     actor = factories["federation.Actor"]()
     retrieve = mocker.patch(
-        "funkwhale_api.federation.utils.retrieve", return_value=actor
+        "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
     )
     data = {
         "@context": [
@@ -550,7 +550,7 @@ def test_music_library_serializer_from_public(factories, mocker):
 def test_music_library_serializer_from_private(factories, mocker):
     actor = factories["federation.Actor"]()
     retrieve = mocker.patch(
-        "funkwhale_api.federation.utils.retrieve", return_value=actor
+        "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
     )
     data = {
         "@context": [
diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py
index ad7a577ef0c574263c60136ace64546dc05d3d6a..5e10dfa509189781b6e3e588579a46986c2b63bc 100644
--- a/api/tests/federation/test_tasks.py
+++ b/api/tests/federation/test_tasks.py
@@ -190,3 +190,79 @@ def test_update_domain_nodeinfo_error(factories, r_mock, now):
         "status": "error",
         "error": "500 Server Error: None for url: {}".format(wellknown_url),
     }
+
+
+def test_handle_purge_actors(factories, mocker):
+    to_purge = factories["federation.Actor"]()
+    keeped = [
+        factories["music.Upload"](),
+        factories["federation.Activity"](),
+        factories["federation.InboxItem"](),
+        factories["federation.Follow"](),
+        factories["federation.LibraryFollow"](),
+    ]
+
+    library = factories["music.Library"](actor=to_purge)
+    deleted = [
+        library,
+        factories["music.Upload"](library=library),
+        factories["federation.Activity"](actor=to_purge),
+        factories["federation.InboxItem"](actor=to_purge),
+        factories["federation.Follow"](actor=to_purge),
+        factories["federation.LibraryFollow"](actor=to_purge),
+    ]
+
+    tasks.handle_purge_actors([to_purge.pk])
+
+    for k in keeped:
+        # this should not be deleted
+        k.refresh_from_db()
+
+    for d in deleted:
+        with pytest.raises(d.__class__.DoesNotExist):
+            d.refresh_from_db()
+
+
+def test_handle_purge_actors_restrict_media(factories, mocker):
+    to_purge = factories["federation.Actor"]()
+    keeped = [
+        factories["music.Upload"](),
+        factories["federation.Activity"](),
+        factories["federation.InboxItem"](),
+        factories["federation.Follow"](),
+        factories["federation.LibraryFollow"](),
+        factories["federation.Activity"](actor=to_purge),
+        factories["federation.InboxItem"](actor=to_purge),
+        factories["federation.Follow"](actor=to_purge),
+    ]
+
+    library = factories["music.Library"](actor=to_purge)
+    deleted = [
+        library,
+        factories["music.Upload"](library=library),
+        factories["federation.LibraryFollow"](actor=to_purge),
+    ]
+
+    tasks.handle_purge_actors([to_purge.pk], only=["media"])
+
+    for k in keeped:
+        # this should not be deleted
+        k.refresh_from_db()
+
+    for d in deleted:
+        with pytest.raises(d.__class__.DoesNotExist):
+            d.refresh_from_db()
+
+
+def test_purge_actors(factories, mocker):
+    handle_purge_actors = mocker.spy(tasks, "handle_purge_actors")
+    factories["federation.Actor"]()
+    to_delete = factories["federation.Actor"]()
+    to_delete_domain = factories["federation.Actor"]()
+    tasks.purge_actors(
+        ids=[to_delete.pk], domains=[to_delete_domain.domain.name], only=["hello"]
+    )
+
+    handle_purge_actors.assert_called_once_with(
+        ids=[to_delete.pk, to_delete_domain.pk], only=["hello"]
+    )
diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py
index e89c52543e92df3862d0b64b196084bd2a2c7a02..a8d02cdb9cc30ae7468a01bbc5b787ce1da73b2e 100644
--- a/api/tests/federation/test_utils.py
+++ b/api/tests/federation/test_utils.py
@@ -1,7 +1,7 @@
 from rest_framework import serializers
 import pytest
 
-from funkwhale_api.federation import utils
+from funkwhale_api.federation import exceptions, utils
 
 
 @pytest.mark.parametrize(
@@ -53,20 +53,43 @@ def test_extract_headers_from_meta():
     assert cleaned_headers == expected
 
 
-def test_retrieve(r_mock):
+def test_retrieve_ap_object(db, r_mock):
     fid = "https://some.url"
     m = r_mock.get(fid, json={"hello": "world"})
-    result = utils.retrieve(fid)
+    result = utils.retrieve_ap_object(fid)
 
     assert result == {"hello": "world"}
     assert m.request_history[-1].headers["Accept"] == "application/activity+json"
 
 
+def test_retrieve_ap_object_honor_instance_policy_domain(factories):
+    domain = factories["moderation.InstancePolicy"](
+        block_all=True, for_domain=True
+    ).target_domain
+    fid = "https://{}/test".format(domain.name)
+
+    with pytest.raises(exceptions.BlockedActorOrDomain):
+        utils.retrieve_ap_object(fid)
+
+
+def test_retrieve_ap_object_honor_instance_policy_different_url_and_id(
+    r_mock, factories
+):
+    domain = factories["moderation.InstancePolicy"](
+        block_all=True, for_domain=True
+    ).target_domain
+    fid = "https://ok/test"
+    r_mock.get(fid, json={"id": "http://{}/test".format(domain.name)})
+
+    with pytest.raises(exceptions.BlockedActorOrDomain):
+        utils.retrieve_ap_object(fid)
+
+
 def test_retrieve_with_actor(r_mock, factories):
     actor = factories["federation.Actor"]()
     fid = "https://some.url"
     m = r_mock.get(fid, json={"hello": "world"})
-    result = utils.retrieve(fid, actor=actor)
+    result = utils.retrieve_ap_object(fid, actor=actor)
 
     assert result == {"hello": "world"}
     assert m.request_history[-1].headers["Accept"] == "application/activity+json"
@@ -76,16 +99,16 @@ def test_retrieve_with_actor(r_mock, factories):
 def test_retrieve_with_queryset(factories):
     actor = factories["federation.Actor"]()
 
-    assert utils.retrieve(actor.fid, queryset=actor.__class__)
+    assert utils.retrieve_ap_object(actor.fid, queryset=actor.__class__)
 
 
-def test_retrieve_with_serializer(r_mock):
+def test_retrieve_with_serializer(db, r_mock):
     class S(serializers.Serializer):
         def create(self, validated_data):
             return {"persisted": "object"}
 
     fid = "https://some.url"
     r_mock.get(fid, json={"hello": "world"})
-    result = utils.retrieve(fid, serializer_class=S)
+    result = utils.retrieve_ap_object(fid, serializer_class=S)
 
     assert result == {"persisted": "object"}
diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py
index 803820b489350fcbf795abb424bace0f3e3b614c..53bc2504b2487e018448755f4cd25b9438108efe 100644
--- a/api/tests/manage/test_serializers.py
+++ b/api/tests/manage/test_serializers.py
@@ -1,4 +1,7 @@
+import pytest
+
 from funkwhale_api.manage import serializers
+from funkwhale_api.federation import tasks as federation_tasks
 
 
 def test_manage_upload_action_delete(factories):
@@ -47,12 +50,20 @@ def test_manage_domain_serializer(factories, now):
         "outbox_activities_count": 23,
         "nodeinfo": {},
         "nodeinfo_fetch_date": None,
+        "instance_policy": None,
     }
     s = serializers.ManageDomainSerializer(domain)
 
     assert s.data == expected
 
 
+def test_manage_domain_serializer_validates_hostname(db):
+    s = serializers.ManageDomainSerializer(data={"name": "hello world"})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.is_valid(raise_exception=True)
+
+
 def test_manage_actor_serializer(factories, now):
     actor = factories["federation.Actor"]()
     setattr(actor, "uploads_count", 66)
@@ -74,7 +85,175 @@ def test_manage_actor_serializer(factories, now):
         "manually_approves_followers": actor.manually_approves_followers,
         "full_username": actor.full_username,
         "user": None,
+        "instance_policy": None,
     }
     s = serializers.ManageActorSerializer(actor)
 
     assert s.data == expected
+
+
+@pytest.mark.parametrize(
+    "factory_kwargs,expected",
+    [
+        (
+            {"for_domain": True, "target_domain__name": "test.federation"},
+            {"target": {"type": "domain", "id": "test.federation"}},
+        ),
+        (
+            {
+                "for_actor": True,
+                "target_actor__domain__name": "test.federation",
+                "target_actor__preferred_username": "hello",
+            },
+            {"target": {"type": "actor", "id": "hello@test.federation"}},
+        ),
+    ],
+)
+def test_instance_policy_serializer_repr(factories, factory_kwargs, expected):
+    policy = factories["moderation.InstancePolicy"](block_all=True, **factory_kwargs)
+
+    e = {
+        "id": policy.id,
+        "uuid": str(policy.uuid),
+        "creation_date": policy.creation_date.isoformat().split("+")[0] + "Z",
+        "actor": policy.actor.full_username,
+        "block_all": True,
+        "silence_activity": False,
+        "silence_notifications": False,
+        "reject_media": False,
+        "is_active": policy.is_active,
+        "summary": policy.summary,
+    }
+    e.update(expected)
+
+    assert serializers.ManageInstancePolicySerializer(policy).data == e
+
+
+def test_instance_policy_serializer_save_domain(factories):
+    domain = factories["federation.Domain"]()
+
+    data = {"target": {"id": domain.name, "type": "domain"}, "block_all": True}
+
+    serializer = serializers.ManageInstancePolicySerializer(data=data)
+    serializer.is_valid(raise_exception=True)
+    policy = serializer.save()
+
+    assert policy.target_domain == domain
+
+
+def test_instance_policy_serializer_save_actor(factories):
+    actor = factories["federation.Actor"]()
+
+    data = {"target": {"id": actor.full_username, "type": "actor"}, "block_all": True}
+
+    serializer = serializers.ManageInstancePolicySerializer(data=data)
+    serializer.is_valid(raise_exception=True)
+    policy = serializer.save()
+
+    assert policy.target_actor == actor
+
+
+def test_manage_actor_action_purge(factories, mocker):
+    actors = factories["federation.Actor"].create_batch(size=3)
+    s = serializers.ManageActorActionSerializer(queryset=None)
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    s.handle_purge(actors[0].__class__.objects.all())
+    on_commit.assert_called_once_with(
+        federation_tasks.purge_actors.delay, ids=[a.pk for a in actors]
+    )
+
+
+def test_manage_domain_action_purge(factories, mocker):
+    domains = factories["federation.Domain"].create_batch(size=3)
+    s = serializers.ManageDomainActionSerializer(queryset=None)
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    s.handle_purge(domains[0].__class__.objects.all())
+    on_commit.assert_called_once_with(
+        federation_tasks.purge_actors.delay, domains=[d.pk for d in domains]
+    )
+
+
+@pytest.mark.parametrize(
+    "param,expected_only", [("block_all", []), ("reject_media", ["media"])]
+)
+def test_instance_policy_serializer_purges_target_domain(
+    factories, mocker, param, expected_only
+):
+    params = {param: False}
+    if param != "block_all":
+        params["block_all"] = False
+    policy = factories["moderation.InstancePolicy"](for_domain=True, **params)
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    serializer = serializers.ManageInstancePolicySerializer(
+        policy, data={param: True}, partial=True
+    )
+    serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    policy.refresh_from_db()
+
+    assert getattr(policy, param) is True
+    on_commit.assert_called_once_with(
+        federation_tasks.purge_actors.delay,
+        domains=[policy.target_domain_id],
+        only=expected_only,
+    )
+
+    on_commit.reset_mock()
+
+    # setting to false should have no effect
+    serializer = serializers.ManageInstancePolicySerializer(
+        policy, data={param: False}, partial=True
+    )
+    serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    policy.refresh_from_db()
+
+    assert getattr(policy, param) is False
+    assert on_commit.call_count == 0
+
+
+@pytest.mark.parametrize(
+    "param,expected_only", [("block_all", []), ("reject_media", ["media"])]
+)
+def test_instance_policy_serializer_purges_target_actor(
+    factories, mocker, param, expected_only
+):
+    params = {param: False}
+    if param != "block_all":
+        params["block_all"] = False
+    policy = factories["moderation.InstancePolicy"](for_actor=True, **params)
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    serializer = serializers.ManageInstancePolicySerializer(
+        policy, data={param: True}, partial=True
+    )
+    serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    policy.refresh_from_db()
+
+    assert getattr(policy, param) is True
+    on_commit.assert_called_once_with(
+        federation_tasks.purge_actors.delay,
+        ids=[policy.target_actor_id],
+        only=expected_only,
+    )
+
+    on_commit.reset_mock()
+
+    # setting to false should have no effect
+    serializer = serializers.ManageInstancePolicySerializer(
+        policy, data={param: False}, partial=True
+    )
+    serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    policy.refresh_from_db()
+
+    assert getattr(policy, param) is False
+    assert on_commit.call_count == 0
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index 72e945bcad5186c2f6919b3db8bffa15aad54ef5..6402fb6505cdd1ff66d833631a55903ab4924687 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -1,6 +1,7 @@
 import pytest
 from django.urls import reverse
 
+from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import tasks as federation_tasks
 from funkwhale_api.manage import serializers, views
 
@@ -13,6 +14,7 @@ from funkwhale_api.manage import serializers, views
         (views.ManageInvitationViewSet, ["settings"], "and"),
         (views.ManageDomainViewSet, ["moderation"], "and"),
         (views.ManageActorViewSet, ["moderation"], "and"),
+        (views.ManageInstancePolicyViewSet, ["moderation"], "and"),
     ],
 )
 def test_permissions(assert_user_permission, view, permissions, operator):
@@ -90,6 +92,14 @@ def test_domain_detail(factories, superuser_api_client):
     assert response.data["name"] == d.pk
 
 
+def test_domain_create(superuser_api_client):
+    url = reverse("api:v1:manage:federation:domains-list")
+    response = superuser_api_client.post(url, {"name": "test.federation"})
+
+    assert response.status_code == 201
+    assert federation_models.Domain.objects.filter(pk="test.federation").exists()
+
+
 def test_domain_nodeinfo(factories, superuser_api_client, mocker):
     domain = factories["federation.Domain"]()
     url = reverse(
@@ -133,3 +143,19 @@ def test_actor_detail(factories, superuser_api_client):
 
     assert response.status_code == 200
     assert response.data["id"] == actor.id
+
+
+def test_instance_policy_create(superuser_api_client, factories):
+    domain = factories["federation.Domain"]()
+    actor = superuser_api_client.user.create_actor()
+    url = reverse("api:v1:manage:moderation:instance-policies-list")
+    response = superuser_api_client.post(
+        url,
+        {"target": {"type": "domain", "id": domain.name}, "block_all": True},
+        format="json",
+    )
+
+    assert response.status_code == 201
+
+    policy = domain.instance_policy
+    assert policy.actor == actor
diff --git a/changes/changelog.d/moderation-tools.feature b/changes/changelog.d/moderation-tools.feature
new file mode 100644
index 0000000000000000000000000000000000000000..4b3e670caad95dbeb6c82a2ecefc1f44a2234534
--- /dev/null
+++ b/changes/changelog.d/moderation-tools.feature
@@ -0,0 +1,20 @@
+
+First set of instance level moderation tools (#580, !521)
+
+
+Instance-level moderation tools
+-------------------------------
+
+This release includes a first set of moderation tools that will give more control
+to admins about the way their instance federate with other instance and accounts on the network.
+Using these tools, it's now possible to:
+
+- Browse known accounts and domains, and associated data (storage size, software version, etc.)
+- Purge data belonging to given accounts and domains
+- Block or partially restrict interactions with any account or domain
+
+All those features are usable using a brand new "moderation" permission, meaning
+you can appoints one or nultiple moderators to help with this task.
+
+I'd like to thank all Mastodon contributors, because some of the these tools are heavily
+inspired from what's being done in Mastodon. Thank you so much!
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index 5b04d3caba5c19654aa249685f6dc8b1b20ac601..d6d19076c075dff427b3b51f2e67aa5681852104 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -27,14 +27,8 @@
                 </select>
               </div>
               <div class="field">
-                <div
-                  v-if="!selectAll"
-                  @click="launchAction"
-                  :disabled="checked.length === 0"
-                  :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
-                  <translate>Go</translate></div>
                 <dangerous-button
-                  v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
+                  v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
                   confirm-color="green"
                   color=""
                   @confirm="launchAction">
@@ -42,17 +36,23 @@
                   <p slot="modal-header">
                     <translate
                       key="1"
-                      :translate-n="objectsData.count"
-                      :translate-params="{count: objectsData.count, action: currentActionName}"
+                      :translate-n="checked.length"
+                      :translate-params="{count: checked.length, action: currentActionName}"
                       translate-plural="Do you want to launch %{ action } on %{ count } elements?">
                       Do you want to launch %{ action } on %{ count } element?
                     </translate>
                   </p>
                   <p slot="modal-content">
-                    <translate>This may affect a lot of elements, please double check this is really what you want.</translate>
+                    <translate>This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.</translate>
                   </p>
                   <p slot="modal-confirm"><translate>Launch</translate></p>
                 </dangerous-button>
+                <div
+                  v-else
+                  @click="launchAction"
+                  :disabled="checked.length === 0"
+                  :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
+                  <translate>Go</translate></div>
               </div>
               <div class="count field">
                 <translate
diff --git a/front/src/components/common/Tooltip.vue b/front/src/components/common/Tooltip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d9ba4c13cd9fbe4312c758cc6566d6734878bdd5
--- /dev/null
+++ b/front/src/components/common/Tooltip.vue
@@ -0,0 +1,12 @@
+<template>
+  <span  class="tooltip" :data-tooltip="content"><i class="question circle icon"></i></span>
+</template>
+
+<script>
+
+export default {
+  props: {
+    content: {type: String, required: true},
+  }
+}
+</script>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index d5a1fb4a447eb6bf819b6fbf6e2a4c370c60ae0f..99e57095c0735a96c55d99e8fd00827203eaa929 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -40,5 +40,9 @@ import AjaxButton from '@/components/common/AjaxButton'
 
 Vue.component('ajax-button', AjaxButton)
 
+import Tooltip from '@/components/common/Tooltip'
+
+Vue.component('tooltip', Tooltip)
+
 
 export default {}
diff --git a/front/src/components/manage/moderation/AccountsTable.vue b/front/src/components/manage/moderation/AccountsTable.vue
index 8750b4ec97a0497f7033446857cda80055bad63c..d8050ecb5f1ddda804df94860fedccca5619dcf6 100644
--- a/front/src/components/manage/moderation/AccountsTable.vue
+++ b/front/src/components/manage/moderation/AccountsTable.vue
@@ -34,6 +34,7 @@
         @action-launched="fetchData"
         :objects-data="result"
         :actions="actions"
+        action-url="manage/accounts/action/"
         :filters="actionFilters">
         <template slot="header-cells">
           <th><translate>Name</translate></th>
@@ -41,6 +42,7 @@
           <th><translate>Uploads</translate></th>
           <th><translate>First seen</translate></th>
           <th><translate>Last seen</translate></th>
+          <th><translate>Under moderation rule</translate></th>
         </template>
         <template slot="row-cells" slot-scope="scope">
           <td>
@@ -67,6 +69,9 @@
           <td>
             <human-date v-if="scope.obj.last_fetch_date" :date="scope.obj.last_fetch_date"></human-date>
           </td>
+          <td>
+            <span v-if="scope.obj.instance_policy"><i class="shield icon"></i> <translate>Yes</translate></span>
+          </td>
         </template>
       </action-table>
     </div>
@@ -178,11 +183,11 @@ export default {
     },
     actions () {
       return [
-        // {
-        //   name: 'delete',
-        //   label: this.$gettext('Delete'),
-        //   isDangerous: true
-        // }
+        {
+          name: 'purge',
+          label: this.$gettext('Purge'),
+          isDangerous: true
+        }
       ]
     }
   },
diff --git a/front/src/components/manage/moderation/DomainsTable.vue b/front/src/components/manage/moderation/DomainsTable.vue
index fd6a2bd46a43066970fa1465e61ee4cb02181a2d..835b04db585427dc66904bccb870366ca2f1e98c 100644
--- a/front/src/components/manage/moderation/DomainsTable.vue
+++ b/front/src/components/manage/moderation/DomainsTable.vue
@@ -32,12 +32,15 @@
         @action-launched="fetchData"
         :objects-data="result"
         :actions="actions"
+        action-url="manage/federation/domains/action/"
+        idField="name"
         :filters="actionFilters">
         <template slot="header-cells">
           <th><translate>Name</translate></th>
           <th><translate>Users</translate></th>
           <th><translate>Received messages</translate></th>
           <th><translate>First seen</translate></th>
+          <th><translate>Under moderation rule</translate></th>
         </template>
         <template slot="row-cells" slot-scope="scope">
           <td>
@@ -52,6 +55,9 @@
           <td>
             <human-date :date="scope.obj.creation_date"></human-date>
           </td>
+          <td>
+            <span v-if="scope.obj.instance_policy"><i class="shield icon"></i> <translate>Yes</translate></span>
+          </td>
         </template>
       </action-table>
     </div>
@@ -157,11 +163,11 @@ export default {
     },
     actions () {
       return [
-        // {
-        //   name: 'delete',
-        //   label: this.$gettext('Delete'),
-        //   isDangerous: true
-        // }
+        {
+          name: 'purge',
+          label: this.$gettext('Purge'),
+          isDangerous: true
+        }
       ]
     }
   },
diff --git a/front/src/components/manage/moderation/InstancePolicyCard.vue b/front/src/components/manage/moderation/InstancePolicyCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c7d11585611fa02aab39f2d88ff36aaa31249523
--- /dev/null
+++ b/front/src/components/manage/moderation/InstancePolicyCard.vue
@@ -0,0 +1,72 @@
+<template>
+  <div>
+    <slot></slot>
+    <p>
+      <i class="clock outline icon"></i><human-date :date="object.creation_date" /> &nbsp;
+      <i class="user icon"></i>{{ object.actor }}  &nbsp;
+      <template v-if="object.is_active">
+        <i class="play icon"></i>
+        <translate>Enabled</translate>
+      </template>
+      <template v-if="!object.is_active">
+        <i class="pause icon"></i>
+        <translate>Paused</translate>
+      </template>
+    </p>
+    <div>
+      <p><strong><translate>Rule</translate></strong></p>
+      <p v-if="object.block_all">
+        <i class="ban icon"></i>
+        <translate>Block everything</translate>
+      </p>
+      <div v-else class="ui list">
+        <div class="ui item" v-if="object.silence_activity">
+          <i class="feed icon"></i>
+          <div class="content"><translate>Silence activity</translate></div>
+        </div>
+        <div class="ui item" v-if="object.silence_notifications">
+          <i class="bell icon"></i>
+          <div class="content"><translate>Silence notifications</translate></div>
+        </div>
+        <div class="ui item" v-if="object.reject_media">
+          <i class="file icon"></i>
+          <div class="content"><translate>Reject media</translate></div>
+        </div>
+
+      </div>
+    </div>
+    <div v-if="markdown && object.summary">
+      <div class="ui hidden divider"></div>
+      <p><strong><translate>Reason</translate></strong></p>
+      <div v-html="markdown.makeHtml(object.summary)"></div>
+    </div>
+    <div class="ui hidden divider"></div>
+    <button @click="$emit('update')" class="ui right floated labeled icon button">
+      <i class="edit icon"></i>
+      <translate>Update</translate>
+    </button>
+  </div>
+</template>
+
+<script>
+
+export default {
+  props: {
+    object: {type: Object, default: null},
+  },
+  data () {
+    return {
+      markdown: null
+    }
+  },
+  created () {
+    let self = this
+    import('showdown').then(module => {
+      self.markdown = new module.default.Converter({simplifiedAutoLink: true, openLinksInNewWindow: true})
+    })
+  }
+}
+</script>
+
+<style scoped>
+</style>
diff --git a/front/src/components/manage/moderation/InstancePolicyForm.vue b/front/src/components/manage/moderation/InstancePolicyForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98fd1d644fcbf3c12477a6c1125ceac03fdd63d7
--- /dev/null
+++ b/front/src/components/manage/moderation/InstancePolicyForm.vue
@@ -0,0 +1,214 @@
+<template>
+  <form class="ui form" @submit.prevent="createOrUpdate">
+    <h3 class="ui header">
+      <translate v-if="object" key="1">Update moderation rule</translate>
+      <translate v-else key="2">Add a new moderation rule</translate>
+    </h3>
+    <div v-if="errors && errors.length > 0" class="ui negative message">
+      <div class="header"><translate>Error while creating rule</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+
+    <div class="field" v-if="object">
+      <div class="ui toggle checkbox">
+        <input id="policy-is-active" v-model="current.isActive" type="checkbox">
+        <label for="policy-is-active">
+          <translate v-if="current.isActive" key="1">Enabled</translate>
+          <translate v-else key="2">Disabled</translate>
+          <tooltip :content="labels.isActiveHelp" />
+        </label>
+      </div>
+    </div>
+    <div class="field">
+      <label for="policy-summary">
+        <translate>Reason</translate>
+        <tooltip :content="labels.summaryHelp" />
+      </label>
+      <textarea name="policy-summary" id="policy-summary" rows="5" v-model="current.summary"></textarea>
+    </div>
+    <div class="field">
+      <div class="ui toggle checkbox">
+        <input id="policy-is-active" v-model="current.blockAll" type="checkbox">
+        <label for="policy-is-active">
+          <translate>Block everything</translate>
+          <tooltip :content="labels.blockAllHelp" />
+        </label>
+      </div>
+    </div>
+    <div class="ui horizontal divider">
+      <translate>Or customize your rule</translate>
+    </div>
+    <div v-for="config in fieldConfig" :class="['field']">
+      <div class="ui toggle checkbox">
+        <input :id="'policy-' + config.id" v-model="current[config.id]" type="checkbox">
+        <label :for="'policy-' + config.id">
+          <i :class="[config.icon, 'icon']"></i>
+          {{ labels[config.id].label }}
+          <tooltip :content="labels[config.id].help" />
+        </label>
+      </div>
+    </div>
+    <div class="ui hidden divider"></div>
+    <button @click="$emit('cancel')" class="ui basic left floated button">
+      <translate>Cancel</translate>
+    </button>
+    <button :class="['ui', 'right', 'floated', 'green', {'disabled loading': isLoading}, 'button']" :disabled="isLoading">
+      <translate v-if="object" key="1">Update</translate>
+      <translate v-else key="2">Create</translate>
+    </button>
+    <dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove">
+      <translate>Delete</translate>
+      <p slot="modal-header">
+        <translate>Delete this moderation rule?</translate>
+      </p>
+      <p slot="modal-content">
+        <translate>This action is irreversible.</translate>
+      </p>
+      <p slot="modal-confirm">
+        <translate>Delete moderation rule</translate>
+      </p>
+    </dangerous-button>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+import _ from 'lodash'
+
+export default {
+  props: {
+    type: {type: String, required: true},
+    object: {type: Object, default: null},
+    target: {type: String, required: true},
+  },
+  data () {
+    let current = this.object || {}
+    return {
+      isLoading: false,
+      errors: [],
+      current: {
+        summary: _.get(current, 'summary', ''),
+        isActive: _.get(current, 'is_active', true),
+        blockAll: _.get(current, 'block_all', true),
+        silenceActivity: _.get(current, 'silence_activity', false),
+        silenceNotifications: _.get(current, 'silence_notifications', false),
+        rejectMedia: _.get(current, 'reject_media', false),
+      },
+      fieldConfig: [
+        // we hide those until we actually have the related features implemented :)
+        // {id: "silenceActivity", icon: "feed"},
+        // {id: "silenceNotifications", icon: "bell"},
+        {id: "rejectMedia", icon: "file"},
+      ]
+    }
+  },
+  computed: {
+    labels () {
+      return {
+        summaryHelp: this.$gettext("Explain why you're applying this policy. Depending on your instance configuration, this will help you remember why you acted on this account or domain, and may be displayed publicly to help users understand what moderation rules are in place."),
+        isActiveHelp: this.$gettext("Use this setting to temporarily enable/disable the policy without completely removing it."),
+        blockAllHelp: this.$gettext("Block everything from this account or domain. This will prevent any interaction with the entity, and purge related content (uploads, libraries, follows, etc.)"),
+        silenceActivity: {
+          help: this.$gettext("Hide account or domain content, except from followers."),
+          label: this.$gettext("Silence activity"),
+        },
+        silenceNotifications: {
+          help: this.$gettext("Prevent account or domain from triggering notifications, except from followers."),
+          label: this.$gettext("Silence notifications"),
+        },
+        rejectMedia: {
+          help: this.$gettext("Do not download any media file (audio, album cover, account avatar…) from this account or domain. This will purge existing content as well."),
+          label: this.$gettext("Reject media"),
+        }
+      }
+    }
+  },
+  methods: {
+    createOrUpdate () {
+      let self = this
+      this.isLoading = true
+      this.errors = []
+      let url, method
+      let data = {
+        summary: this.current.summary,
+        is_active: this.current.isActive,
+        block_all: this.current.blockAll,
+        silence_activity: this.current.silenceActivity,
+        silence_notifications: this.current.silenceNotifications,
+        reject_media: this.current.rejectMedia,
+        target: {
+          type: this.type,
+          id: this.target,
+        }
+      }
+      if (this.object) {
+        url = `manage/moderation/instance-policies/${this.object.id}/`
+        method = 'patch'
+      } else {
+        url = `manage/moderation/instance-policies/`
+        method = 'post'
+      }
+      axios[method](url, data).then((response) => {
+        this.isLoading = false
+        self.$emit('save', response.data)
+      }, (error) => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    remove () {
+      let self = this
+      this.isLoading = true
+      this.errors = []
+
+      let url = `manage/moderation/instance-policies/${this.object.id}/`
+      axios.delete(url).then((response) => {
+        this.isLoading = false
+        self.$emit('delete')
+      }, (error) => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  watch: {
+    'current.silenceActivity': function (v) {
+      if (v) {
+        this.current.blockAll = false
+      }
+    },
+    'current.silenceNotifications': function (v) {
+      if (v) {
+        this.current.blockAll = false
+      }
+    },
+    'current.rejectMedia': function (v) {
+      if (v) {
+        this.current.blockAll = false
+      }
+    },
+    'current.blockAll': function (v) {
+      if (v) {
+        let self = this
+        this.fieldConfig.forEach((f) => {
+          self.current[f.id] = false
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ui.placeholder.segment .field,
+.ui.placeholder.segment textarea,
+.ui.placeholder.segment > .ui.input,
+.ui.placeholder.segment .button {
+  max-width: 100%;
+}
+.segment .right.floated.button {
+  margin-left: 1em;
+}
+</style>
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 1ce8144c66c6e7473937a9dffc21297e5c71257a..0c165c76f1ccfd99a090c02ae3cb0bfd08ad3e94 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -255,7 +255,11 @@ button.reset {
 
 [data-tooltip]::after {
   white-space: normal;
-  width: 300px;
-  max-width: 300px;
+  width: 500px;
+  max-width: 500px;
   z-index: 999;
 }
+
+label .tooltip {
+  margin-left: 1em;
+}
diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue
index abed7baad28b92dd5fac2b72e2947c90504edd11..5a339db5656e7e453fcabbafd9c9af20ca6eb3b8 100644
--- a/front/src/views/admin/moderation/AccountsDetail.vue
+++ b/front/src/views/admin/moderation/AccountsDetail.vue
@@ -5,26 +5,68 @@
     </div>
     <template v-if="object">
       <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.full_username">
-        <div class="segment-content">
-          <h2 class="ui header">
-            <i class="circular inverted user icon"></i>
-            <div class="content">
-              {{ object.full_username }}
-              <div class="sub header">
-                <template v-if="object.user">
-                  <span class="ui tiny teal icon label">
-                    <i class="home icon"></i>
-                    <translate>Local account</translate>
-                  </span>
-                  &nbsp;
-                </template>
-                <a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
-                  <translate>Open profile</translate>&nbsp;
-                  <i class="external icon"></i>
-                </a>
-              </div>
+        <div class="ui stackable two column grid">
+          <div class="ui column">
+            <div class="segment-content">
+              <h2 class="ui header">
+                <i class="circular inverted user icon"></i>
+                <div class="content">
+                  {{ object.full_username }}
+                  <div class="sub header">
+                    <template v-if="object.user">
+                      <span class="ui tiny teal icon label">
+                        <i class="home icon"></i>
+                        <translate>Local account</translate>
+                      </span>
+                      &nbsp;
+                    </template>
+                    <a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
+                      <translate>Open profile</translate>&nbsp;
+                      <i class="external icon"></i>
+                    </a>
+                  </div>
+                </div>
+              </h2>
+            </div>
+          </div>
+          <div class="ui column">
+            <div v-if="!object.user" class="ui compact clearing placeholder segment">
+              <template v-if="isLoadingPolicy">
+                <div class="paragraph">
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                </div>
+              </template>
+              <template v-else-if="!policy && !showPolicyForm">
+                <header class="ui header">
+                  <h3>
+                    <i class="shield icon"></i>
+                    <translate>You don't have any rule in place for this account.</translate>
+                  </h3>
+                </header>
+                <p><translate>Moderation policies help you control how your instance interact with a given domain or account.</translate></p>
+                <button @click="showPolicyForm = true" class="ui primary button">Add a moderation policy</button>
+              </template>
+              <instance-policy-card v-else-if="policy && !showPolicyForm" :object="policy" @update="showPolicyForm = true">
+                <header class="ui header">
+                  <h3>
+                    <translate>This domain is subject to specific moderation rules</translate>
+                  </h3>
+                </header>
+              </instance-policy-card>
+              <instance-policy-form
+                v-else-if="showPolicyForm"
+                @cancel="showPolicyForm = false"
+                @save="updatePolicy"
+                @delete="policy = null; showPolicyForm = false"
+                :object="policy"
+                type="actor"
+                :target="object.full_username" />
             </div>
-          </h2>
+          </div>
         </div>
       </section>
       <div class="ui vertical stripe segment">
@@ -309,15 +351,24 @@ import logger from "@/logging"
 import lodash from '@/lodash'
 import $ from "jquery"
 
+import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm"
+import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard"
+
 export default {
   props: ["id"],
+  components: {
+    InstancePolicyForm,
+    InstancePolicyCard,
+  },
   data() {
     return {
       lodash,
       isLoading: true,
       isLoadingStats: false,
+      isLoadingPolicy: false,
       object: null,
       stats: null,
+      showPolicyForm: false,
       permissions: [],
     }
   },
@@ -333,6 +384,9 @@ export default {
       axios.get(url).then(response => {
         self.object = response.data
         self.isLoading = false
+        if (self.object.instance_policy) {
+          self.fetchPolicy(self.object.instance_policy)
+        }
         if (response.data.user) {
           self.allPermissions.forEach(p => {
             if (self.object.user.permissions[p.code]) {
@@ -342,6 +396,15 @@ export default {
         }
       })
     },
+    fetchPolicy(id) {
+      var self = this
+      this.isLoadingPolicy = true
+      let url = `manage/moderation/instance-policies/${id}/`
+      axios.get(url).then(response => {
+        self.policy = response.data
+        self.isLoadingPolicy = false
+      })
+    },
     fetchStats() {
       var self = this
       this.isLoadingStats = true
@@ -423,4 +486,7 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped>
+.placeholder.segment {
+  width: 100%;
+}
 </style>
diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue
index 1adb1c3055b063e21669ec97d276a047b277b9ae..f5f9643c8a0eddcf65098d6076ddffc9fe99aa13 100644
--- a/front/src/views/admin/moderation/DomainsDetail.vue
+++ b/front/src/views/admin/moderation/DomainsDetail.vue
@@ -5,19 +5,61 @@
     </div>
     <template v-if="object">
       <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name">
-        <div class="segment-content">
-          <h2 class="ui header">
-            <i class="circular inverted cloud icon"></i>
-            <div class="content">
-              {{ object.name }}
-              <div class="sub header">
-                <a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
-                  <translate>Open website</translate>&nbsp;
-                  <i class="external icon"></i>
-                </a>
-              </div>
+        <div class="ui stackable two column grid">
+          <div class="ui column">
+            <div class="segment-content">
+              <h2 class="ui header">
+                <i class="circular inverted cloud icon"></i>
+                <div class="content">
+                  {{ object.name }}
+                  <div class="sub header">
+                    <a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
+                      <translate>Open website</translate>&nbsp;
+                      <i class="external icon"></i>
+                    </a>
+                  </div>
+                </div>
+              </h2>
+            </div>
+          </div>
+          <div class="ui column">
+            <div class="ui compact clearing placeholder segment">
+              <template v-if="isLoadingPolicy">
+                <div class="paragraph">
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                  <div class="line"></div>
+                </div>
+              </template>
+              <template v-else-if="!policy && !showPolicyForm">
+                <header class="ui header">
+                  <h3>
+                    <i class="shield icon"></i>
+                    <translate>You don't have any rule in place for this domain.</translate>
+                  </h3>
+                </header>
+                <p><translate>Moderation policies help you control how your instance interact with a given domain or account.</translate></p>
+                <button @click="showPolicyForm = true" class="ui primary button">Add a moderation policy</button>
+              </template>
+              <instance-policy-card v-else-if="policy && !showPolicyForm" :object="policy" @update="showPolicyForm = true">
+                <header class="ui header">
+                  <h3>
+                    <translate>This domain is subject to specific moderation rules</translate>
+                  </h3>
+                </header>
+              </instance-policy-card>
+              <instance-policy-form
+                v-else-if="showPolicyForm"
+                @cancel="showPolicyForm = false"
+                @save="updatePolicy"
+                @delete="policy = null; showPolicyForm = false"
+                :object="policy"
+                type="domain"
+                :target="object.name" />
             </div>
-          </h2>
+          </div>
         </div>
       </section>
       <div class="ui vertical stripe segment">
@@ -244,15 +286,25 @@ import axios from "axios"
 import logger from "@/logging"
 import lodash from '@/lodash'
 
+import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm"
+import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard"
+
 export default {
   props: ["id"],
+  components: {
+    InstancePolicyForm,
+    InstancePolicyCard,
+  },
   data() {
     return {
       lodash,
       isLoading: true,
       isLoadingStats: false,
+      isLoadingPolicy: false,
+      policy: null,
       object: null,
       stats: null,
+      showPolicyForm: false,
       permissions: [],
     }
   },
@@ -268,6 +320,9 @@ export default {
       axios.get(url).then(response => {
         self.object = response.data
         self.isLoading = false
+        if (self.object.instance_policy) {
+          self.fetchPolicy(self.object.instance_policy)
+        }
       })
     },
     fetchStats() {
@@ -279,10 +334,23 @@ export default {
         self.isLoadingStats = false
       })
     },
+    fetchPolicy(id) {
+      var self = this
+      this.isLoadingPolicy = true
+      let url = `manage/moderation/instance-policies/${id}/`
+      axios.get(url).then(response => {
+        self.policy = response.data
+        self.isLoadingPolicy = false
+      })
+    },
     refreshNodeInfo (data) {
       this.object.nodeinfo = data
       this.object.nodeinfo_fetch_date = new Date()
     },
+    updatePolicy (policy) {
+      this.policy = policy
+      this.showPolicyForm = false
+    }
   },
   computed: {
     labels() {
@@ -299,4 +367,7 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped>
+.placeholder.segment {
+  width: 100%;
+}
 </style>
diff --git a/front/src/views/admin/moderation/DomainsList.vue b/front/src/views/admin/moderation/DomainsList.vue
index 84fb1df4300dffecc15db22048bafc50fb0e27dc..259d05f0cecbdb1019d59201d38d374cbe155447 100644
--- a/front/src/views/admin/moderation/DomainsList.vue
+++ b/front/src/views/admin/moderation/DomainsList.vue
@@ -1,26 +1,70 @@
 <template>
   <main v-title="labels.domains">
     <section class="ui vertical stripe segment">
-      <h2 class="ui header"><translate>Domains</translate></h2>
-      <div class="ui hidden divider"></div>
+      <h2 class="ui left floated header"><translate>Domains</translate></h2>
+      <form class="ui right floated form" @submit.prevent="createDomain">
+        <div v-if="errors && errors.length > 0" class="ui negative message">
+          <div class="header"><translate>Error while creating domain</translate></div>
+          <ul class="list">
+            <li v-for="error in errors">{{ error }}</li>
+          </ul>
+        </div>
+        <div class="inline fields">
+          <div class="field">
+            <label for="domain"><translate>Add a domain</translate></label>
+            <input type="text" id="domain" v-model="domainName">
+          </div>
+          <div class="field">
+            <button :class="['ui', {'loading': isCreating}, 'green', 'button']" type="submit" :disabled="isCreating">
+              <label for="domain"><translate>Add</translate></label>
+            </button>
+          </div>
+        </div>
+      </form>
+      <div class="ui clearing hidden divider"></div>
       <domains-table></domains-table>
     </section>
   </main>
 </template>
 
 <script>
-import DomainsTable from "@/components/manage/moderation/DomainsTable"
+import axios from 'axios'
 
+import DomainsTable from "@/components/manage/moderation/DomainsTable"
 export default {
   components: {
     DomainsTable
   },
+  data () {
+    return {
+      domainName: '',
+      isCreating: false,
+      errors: []
+    }
+  },
   computed: {
     labels() {
       return {
         domains: this.$gettext("Domains")
       }
     }
+  },
+  methods: {
+    createDomain () {
+      let self = this
+      this.isCreating = true
+      this.errors = []
+      axios.post('manage/federation/domains/', {name: this.domainName}).then((response) => {
+        this.isCreating = false
+        this.$router.push({
+          name: "manage.moderation.domains.detail",
+          params: {'id': response.data.name}
+        })
+      }, (error) => {
+        self.isCreating = false
+        self.errors = error.backendErrors
+      })
+    }
   }
 }
 </script>
diff --git a/front/src/views/content/remote/ScanForm.vue b/front/src/views/content/remote/ScanForm.vue
index ddd9eff8d2b04576add2265697f7816fe88c53bb..dfc4807240ffdfa1e478e29a7cb94d331965c539 100644
--- a/front/src/views/content/remote/ScanForm.vue
+++ b/front/src/views/content/remote/ScanForm.vue
@@ -34,6 +34,8 @@ export default {
         return
       }
       let self = this
+      self.errors = []
+      self.isLoading = true
       axios.post('federation/libraries/fetch/', {fid: this.query}).then((response) => {
         self.$emit('scanned', response.data)
         self.isLoading = false