diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py
index c3e92c15b1579675a4ce243029ffe8a93614ab63..dfc8ba85e525bcfe442739b97966c794b6fbb58f 100644
--- a/api/funkwhale_api/common/mutations.py
+++ b/api/funkwhale_api/common/mutations.py
@@ -2,7 +2,7 @@ import persisting_theory
 
 from rest_framework import serializers
 
-from django.db import models
+from django.db import models, transaction
 
 
 class ConfNotFound(KeyError):
@@ -23,6 +23,7 @@ class Registry(persisting_theory.Registry):
 
         return decorator
 
+    @transaction.atomic
     def apply(self, type, obj, payload):
         conf = self.get_conf(type, obj)
         serializer = conf["serializer_class"](obj, data=payload)
@@ -73,6 +74,9 @@ class MutationSerializer(serializers.Serializer):
     def apply(self, obj, validated_data):
         raise NotImplementedError()
 
+    def post_apply(self, obj, validated_data):
+        pass
+
     def get_previous_state(self, obj, validated_data):
         return
 
@@ -88,8 +92,11 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
         kwargs.setdefault("partial", True)
         super().__init__(*args, **kwargs)
 
+    @transaction.atomic
     def apply(self, obj, validated_data):
-        return self.update(obj, validated_data)
+        r = self.update(obj, validated_data)
+        self.post_apply(r, validated_data)
+        return r
 
     def validate(self, validated_data):
         if not validated_data:
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 6a21517f62c14add6dbdad0b946ff3d7031cc538..57bcba932f89008be946ea62e429659c45a12426 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -201,3 +201,30 @@ def concat_dicts(*dicts):
         n.update(d)
 
     return n
+
+
+def get_updated_fields(conf, data, obj):
+    """
+    Given a list of fields, a dict and an object, will return the dict keys/values
+    that differ from the corresponding fields on the object.
+    """
+    final_conf = []
+    for c in conf:
+        if isinstance(c, str):
+            final_conf.append((c, c))
+        else:
+            final_conf.append(c)
+
+    final_data = {}
+
+    for data_field, obj_field in final_conf:
+        try:
+            data_value = data[data_field]
+        except KeyError:
+            continue
+
+        obj_value = getattr(obj, obj_field)
+        if obj_value != data_value:
+            final_data[obj_field] = data_value
+
+    return final_data
diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py
index 472ff3feb83f89bd047c950eadcfc674fbd5a3da..3517ea007981b4745887791a807f4bad32e30f99 100644
--- a/api/funkwhale_api/factories.py
+++ b/api/funkwhale_api/factories.py
@@ -2,6 +2,8 @@ import uuid
 import factory
 import persisting_theory
 
+from django.conf import settings
+
 from faker.providers import internet as internet_provider
 
 
@@ -50,11 +52,11 @@ class FunkwhaleProvider(internet_provider.Provider):
     not random enough
     """
 
-    def federation_url(self, prefix=""):
+    def federation_url(self, prefix="", local=False):
         def path_generator():
             return "{}/{}".format(prefix, uuid.uuid4())
 
-        domain = self.domain_name()
+        domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
         protocol = "https"
         path = path_generator()
         return "{}://{}/{}".format(protocol, domain, path)
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 4e6e82369f0cc12b872a570955a7f8b6ab5af734..979b8aa1befb356e1c423163d1d863de96393b30 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -365,27 +365,6 @@ class OutboxRouter(Router):
             return activities
 
 
-def recursive_getattr(obj, key, permissive=False):
-    """
-    Given a dictionary such as {'user': {'name': 'Bob'}} and
-    a dotted string such as user.name, returns 'Bob'.
-
-    If the value is not present, returns None
-    """
-    v = obj
-    for k in key.split("."):
-        try:
-            v = v.get(k)
-        except (TypeError, AttributeError):
-            if not permissive:
-                raise
-            return
-        if v is None:
-            return
-
-    return v
-
-
 def match_route(route, payload):
     for key, value in route.items():
         payload_value = recursive_getattr(payload, key, permissive=True)
@@ -432,6 +411,27 @@ def prepare_deliveries_and_inbox_items(recipient_list, type):
                     remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
             urls.append(r["target"].followers_url)
 
+        elif isinstance(r, dict) and r["type"] == "instances_with_followers":
+            # we want to broadcast the activity to other instances service actors
+            # when we have at least one follower from this instance
+            follows = (
+                models.LibraryFollow.objects.filter(approved=True)
+                .exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
+                .exclude(actor__domain=None)
+                .union(
+                    models.Follow.objects.filter(approved=True)
+                    .exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
+                    .exclude(actor__domain=None)
+                )
+            )
+            actors = models.Actor.objects.filter(
+                managed_domains__name__in=follows.values_list(
+                    "actor__domain_id", flat=True
+                )
+            )
+            values = actors.values("shared_inbox_url", "inbox_url")
+            for v in values:
+                remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
     deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls]
     inbox_items = [
         models.InboxItem(actor=actor, type=type) for actor in local_recipients
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index e9a51779e2a98bb96d2e7bfc627bda8ad4c12c05..cf9546447a66dc55edd7636f6c25b711b92aed8f 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -75,6 +75,15 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
         model = "federation.Domain"
         django_get_or_create = ("name",)
 
+    @factory.post_generation
+    def with_service_actor(self, create, extracted, **kwargs):
+        if not create or not extracted:
+            return
+
+        self.service_actor = ActorFactory(domain=self)
+        self.save(update_fields=["service_actor"])
+        return self.service_actor
+
 
 @registry.register
 class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
diff --git a/api/funkwhale_api/federation/jsonld.py b/api/funkwhale_api/federation/jsonld.py
index 319ab3b6be9396cff8470d0178c9dee56d44a8ba..ad67323f239bb7be2fb42be90a75091915971eba 100644
--- a/api/funkwhale_api/federation/jsonld.py
+++ b/api/funkwhale_api/federation/jsonld.py
@@ -57,7 +57,9 @@ def insert_context(ctx, doc):
     existing = doc["@context"]
     if isinstance(existing, list):
         if ctx not in existing:
+            existing = existing[:]
             existing.append(ctx)
+            doc["@context"] = existing
     else:
         doc["@context"] = [existing, ctx]
     return doc
@@ -215,6 +217,15 @@ def get_default_context():
     return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}]
 
 
+def get_default_context_fw():
+    return [
+        "https://www.w3.org/ns/activitystreams",
+        "https://w3id.org/security/v1",
+        {},
+        "https://funkwhale.audio/ns",
+    ]
+
+
 class JsonLdSerializer(serializers.Serializer):
     def run_validation(self, data=empty):
         if data and data is not empty and self.context.get("expand", True):
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index ba1ae3e08f2104a0efa4d96848bc9524c058868a..caf8c7db670b4cfbe8c64a03364034145635a434 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -264,6 +264,25 @@ class Actor(models.Model):
         self.private_key = v[0].decode("utf-8")
         self.public_key = v[1].decode("utf-8")
 
+    def can_manage(self, obj):
+        attributed_to = getattr(obj, "attributed_to_id", None)
+        if attributed_to is not None and attributed_to == self.pk:
+            # easiest case, the obj is attributed to the actor
+            return True
+
+        if self.domain.service_actor_id != self.pk:
+            # actor is not system actor, so there is no way the actor can manage
+            # the object
+            return False
+
+        # actor is service actor of its domain, so if the fid domain
+        # matches, we consider the actor has the permission to manage
+        # the object
+        domain = self.domain_id
+        return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
+            "https://{}/".format(domain)
+        )
+
 
 class InboxItem(models.Model):
     """
diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py
index 0295aa46ce4004648075371be181b64072c773e9..9f14fd110407c45be61bba819fc5eaa681c6adf0 100644
--- a/api/funkwhale_api/federation/routes.py
+++ b/api/funkwhale_api/federation/routes.py
@@ -3,6 +3,7 @@ import logging
 from funkwhale_api.music import models as music_models
 
 from . import activity
+from . import actors
 from . import serializers
 
 logger = logging.getLogger(__name__)
@@ -269,3 +270,79 @@ def outbox_delete_audio(context):
             serializer.data, to=[{"type": "followers", "target": library}]
         ),
     }
+
+
+def handle_library_entry_update(payload, context, queryset, serializer_class):
+    actor = context["actor"]
+    obj_id = payload["object"].get("id")
+    if not obj_id:
+        logger.debug("Discarding update of empty obj")
+        return
+
+    try:
+        obj = queryset.select_related("attributed_to").get(fid=obj_id)
+    except queryset.model.DoesNotExist:
+        logger.debug("Discarding update of unkwnown obj %s", obj_id)
+        return
+    if not actor.can_manage(obj):
+        logger.debug(
+            "Discarding unauthorize update of obj %s from %s", obj_id, actor.fid
+        )
+        return
+
+    serializer = serializer_class(obj, data=payload["object"])
+    if serializer.is_valid():
+        serializer.save()
+    else:
+        logger.debug(
+            "Discarding update of obj %s because of payload errors: %s",
+            obj_id,
+            serializer.errors,
+        )
+
+
+@inbox.register({"type": "Update", "object.type": "Track"})
+def inbox_update_track(payload, context):
+    return handle_library_entry_update(
+        payload,
+        context,
+        queryset=music_models.Track.objects.all(),
+        serializer_class=serializers.TrackSerializer,
+    )
+
+
+@inbox.register({"type": "Update", "object.type": "Artist"})
+def inbox_update_artist(payload, context):
+    return handle_library_entry_update(
+        payload,
+        context,
+        queryset=music_models.Artist.objects.all(),
+        serializer_class=serializers.ArtistSerializer,
+    )
+
+
+@inbox.register({"type": "Update", "object.type": "Album"})
+def inbox_update_album(payload, context):
+    return handle_library_entry_update(
+        payload,
+        context,
+        queryset=music_models.Album.objects.all(),
+        serializer_class=serializers.AlbumSerializer,
+    )
+
+
+@outbox.register({"type": "Update", "object.type": "Track"})
+def outbox_update_track(context):
+    track = context["track"]
+    serializer = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.TrackSerializer(track).data}
+    )
+
+    yield {
+        "type": "Update",
+        "actor": actors.get_service_actor(),
+        "payload": with_recipients(
+            serializer.data,
+            to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
+        ),
+    }
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 0c5ce549796f9a7f03d11a3d475096bdff598ffa..666fde092fe31860449010b70ea63d770c600880 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -7,9 +7,11 @@ from django.core.paginator import Paginator
 from rest_framework import serializers
 
 from funkwhale_api.common import utils as funkwhale_utils
+from funkwhale_api.music import licenses
 from funkwhale_api.music import models as music_models
+from funkwhale_api.music import tasks as music_tasks
 
-from . import activity, contexts, jsonld, models, utils
+from . import activity, actors, contexts, jsonld, models, utils
 
 AP_CONTEXT = jsonld.get_default_context()
 
@@ -670,7 +672,7 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
             "first": jsonld.first_id(contexts.AS.first),
             "last": jsonld.first_id(contexts.AS.last),
             "next": jsonld.first_id(contexts.AS.next),
-            "prev": jsonld.first_id(contexts.AS.next),
+            "prev": jsonld.first_id(contexts.AS.prev),
             "partOf": jsonld.first_id(contexts.AS.partOf),
         }
 
@@ -731,6 +733,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
     "name": jsonld.first_val(contexts.AS.name),
     "published": jsonld.first_val(contexts.AS.published),
     "musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
+    "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
 }
 
 
@@ -739,9 +742,29 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
     published = serializers.DateTimeField()
     musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
     name = serializers.CharField(max_length=1000)
+    attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
+    updateable_fields = []
+
+    def update(self, instance, validated_data):
+        attributed_to_fid = validated_data.get("attributedTo")
+        if attributed_to_fid:
+            validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
+        updated_fields = funkwhale_utils.get_updated_fields(
+            self.updateable_fields, validated_data, instance
+        )
+        if updated_fields:
+            return music_tasks.update_library_entity(instance, updated_fields)
+
+        return instance
 
 
 class ArtistSerializer(MusicEntitySerializer):
+    updateable_fields = [
+        ("name", "name"),
+        ("musicbrainzId", "mbid"),
+        ("attributedTo", "attributed_to"),
+    ]
+
     class Meta:
         jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
 
@@ -752,6 +775,9 @@ class ArtistSerializer(MusicEntitySerializer):
             "name": instance.name,
             "published": instance.creation_date.isoformat(),
             "musicbrainzId": str(instance.mbid) if instance.mbid else None,
+            "attributedTo": instance.attributed_to.fid
+            if instance.attributed_to
+            else None,
         }
 
         if self.context.get("include_ap_context", self.parent is None):
@@ -765,6 +791,12 @@ class AlbumSerializer(MusicEntitySerializer):
     cover = LinkSerializer(
         allowed_mimetypes=["image/*"], allow_null=True, required=False
     )
+    updateable_fields = [
+        ("name", "title"),
+        ("musicbrainzId", "mbid"),
+        ("attributedTo", "attributed_to"),
+        ("released", "release_date"),
+    ]
 
     class Meta:
         jsonld_mapping = funkwhale_utils.concat_dicts(
@@ -791,6 +823,9 @@ class AlbumSerializer(MusicEntitySerializer):
                     instance.artist, context={"include_ap_context": False}
                 ).data
             ],
+            "attributedTo": instance.attributed_to.fid
+            if instance.attributed_to
+            else None,
         }
         if instance.cover:
             d["cover"] = {
@@ -812,6 +847,16 @@ class TrackSerializer(MusicEntitySerializer):
     license = serializers.URLField(allow_null=True, required=False)
     copyright = serializers.CharField(allow_null=True, required=False)
 
+    updateable_fields = [
+        ("name", "title"),
+        ("musicbrainzId", "mbid"),
+        ("attributedTo", "attributed_to"),
+        ("disc", "disc_number"),
+        ("position", "position"),
+        ("copyright", "copyright"),
+        ("license", "license"),
+    ]
+
     class Meta:
         jsonld_mapping = funkwhale_utils.concat_dicts(
             MUSIC_ENTITY_JSONLD_MAPPING,
@@ -846,6 +891,9 @@ class TrackSerializer(MusicEntitySerializer):
             "album": AlbumSerializer(
                 instance.album, context={"include_ap_context": False}
             ).data,
+            "attributedTo": instance.attributed_to.fid
+            if instance.attributed_to
+            else None,
         }
 
         if self.context.get("include_ap_context", self.parent is None):
@@ -855,13 +903,53 @@ class TrackSerializer(MusicEntitySerializer):
     def create(self, validated_data):
         from funkwhale_api.music import tasks as music_tasks
 
-        metadata = music_tasks.federation_audio_track_to_metadata(validated_data)
+        references = {}
+        actors_to_fetch = set()
+        actors_to_fetch.add(
+            funkwhale_utils.recursive_getattr(
+                validated_data, "attributedTo", permissive=True
+            )
+        )
+        actors_to_fetch.add(
+            funkwhale_utils.recursive_getattr(
+                validated_data, "album.attributedTo", permissive=True
+            )
+        )
+        artists = (
+            funkwhale_utils.recursive_getattr(
+                validated_data, "artists", permissive=True
+            )
+            or []
+        )
+        album_artists = (
+            funkwhale_utils.recursive_getattr(
+                validated_data, "album.artists", permissive=True
+            )
+            or []
+        )
+        for artist in artists + album_artists:
+            actors_to_fetch.add(artist.get("attributedTo"))
+
+        for url in actors_to_fetch:
+            if not url:
+                continue
+            references[url] = actors.get_actor(url)
+
+        metadata = music_tasks.federation_audio_track_to_metadata(
+            validated_data, references
+        )
+
         from_activity = self.context.get("activity")
         if from_activity:
             metadata["from_activity_id"] = from_activity.pk
         track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
         return track
 
+    def update(self, obj, validated_data):
+        if validated_data.get("license"):
+            validated_data["license"] = licenses.match(validated_data["license"])
+        return super().update(obj, validated_data)
+
 
 class UploadSerializer(jsonld.JsonLdSerializer):
     type = serializers.ChoiceField(choices=[contexts.AS.Audio])
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index cd2a91ccb1d72766493b8998189ea8bc58e654e4..2060ac13aa1d3efb4e1b1110d8587383cdb4a8ea 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -64,6 +64,12 @@ class ArtistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     class Meta:
         model = "music.Artist"
 
+    class Params:
+        attributed = factory.Trait(
+            attributed_to=factory.SubFactory(federation_factories.ActorFactory)
+        )
+        local = factory.Trait(fid=factory.Faker("federation_url", local=True))
+
 
 @registry.register
 class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@@ -79,6 +85,15 @@ class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     class Meta:
         model = "music.Album"
 
+    class Params:
+        attributed = factory.Trait(
+            attributed_to=factory.SubFactory(federation_factories.ActorFactory)
+        )
+
+        local = factory.Trait(
+            fid=factory.Faker("federation_url", local=True), artist__local=True
+        )
+
 
 @registry.register
 class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@@ -94,6 +109,15 @@ class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     class Meta:
         model = "music.Track"
 
+    class Params:
+        attributed = factory.Trait(
+            attributed_to=factory.SubFactory(federation_factories.ActorFactory)
+        )
+
+        local = factory.Trait(
+            fid=factory.Faker("federation_url", local=True), album__local=True
+        )
+
     @factory.post_generation
     def license(self, created, extracted, **kwargs):
         if not created:
diff --git a/api/funkwhale_api/music/migrations/0038_attributed_to.py b/api/funkwhale_api/music/migrations/0038_attributed_to.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1ac8cfd4c4f5c5106f483fa1da8ab11852c659b
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0038_attributed_to.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.1.7 on 2019-04-09 09:33
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("federation", "0017_auto_20190130_0926"),
+        ("music", "0037_auto_20190103_1757"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="artist",
+            name="attributed_to",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="attributed_artists",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AddField(
+            model_name="album",
+            name="attributed_to",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="attributed_albums",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AddField(
+            model_name="track",
+            name="attributed_to",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="attributed_tracks",
+                to="federation.Actor",
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index fb7af3adbf138c144685a9e5f7174b872383e231..b88a8daea381a33a50af9c74c6bfd4b67e61571b 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -114,6 +114,16 @@ class APIModelMixin(models.Model):
 
         return super().save(**kwargs)
 
+    @property
+    def is_local(self):
+        if not self.fid:
+            return True
+
+        d = settings.FEDERATION_HOSTNAME
+        return self.fid.startswith("http://{}/".format(d)) or self.fid.startswith(
+            "https://{}/".format(d)
+        )
+
 
 class License(models.Model):
     code = models.CharField(primary_key=True, max_length=100)
@@ -178,6 +188,16 @@ class Artist(APIModelMixin):
         "mbid": {"musicbrainz_field_name": "id"},
         "name": {"musicbrainz_field_name": "name"},
     }
+    # Music entities are attributed to actors, to validate that updates occur
+    # from an authorized account. On top of that, we consider the instance actor
+    # can update anything under it's own domain
+    attributed_to = models.ForeignKey(
+        "federation.Actor",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="attributed_artists",
+    )
     api = musicbrainz.api.artists
     objects = ArtistQuerySet.as_manager()
 
@@ -254,6 +274,16 @@ class Album(APIModelMixin):
     TYPE_CHOICES = (("album", "Album"),)
     type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
 
+    # Music entities are attributed to actors, to validate that updates occur
+    # from an authorized account. On top of that, we consider the instance actor
+    # can update anything under it's own domain
+    attributed_to = models.ForeignKey(
+        "federation.Actor",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="attributed_albums",
+    )
     api_includes = ["artist-credits", "recordings", "media", "release-groups"]
     api = musicbrainz.api.releases
     federation_namespace = "albums"
@@ -476,6 +506,16 @@ class Track(APIModelMixin):
         on_delete=models.DO_NOTHING,
         related_name="tracks",
     )
+    # Music entities are attributed to actors, to validate that updates occur
+    # from an authorized account. On top of that, we consider the instance actor
+    # can update anything under it's own domain
+    attributed_to = models.ForeignKey(
+        "federation.Actor",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="attributed_tracks",
+    )
     copyright = models.CharField(max_length=500, null=True, blank=True)
     federation_namespace = "tracks"
     musicbrainz_model = "recording"
diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py
index 4d78b8ea9c086649cd17dbc689a9a562e36c4bbb..fdbb7c11cd3c35fb8f2e9c1d5ca146c99d680e92 100644
--- a/api/funkwhale_api/music/mutations.py
+++ b/api/funkwhale_api/music/mutations.py
@@ -1,14 +1,15 @@
 from funkwhale_api.common import mutations
+from funkwhale_api.federation import routes
 
 from . import models
 
 
 def can_suggest(obj, actor):
-    return True
+    return obj.is_local
 
 
 def can_approve(obj, actor):
-    return actor.user and actor.user.get_permissions()["library"]
+    return obj.is_local and actor.user and actor.user.get_permissions()["library"]
 
 
 @mutations.registry.connect(
@@ -22,3 +23,8 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer):
     class Meta:
         model = models.Track
         fields = ["license", "title", "position", "copyright"]
+
+    def post_apply(self, obj, validated_data):
+        routes.outbox.dispatch(
+            {"type": "Update", "object": {"type": "Track"}}, context={"track": obj}
+        )
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 8a8c4c5c254844dcd6a49e2cedcecc2f4b8a6a1c..ee79938f350d0539904ea78107a7809fb826aec8 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -43,6 +43,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
         model = models.Album
         fields = (
             "id",
+            "fid",
             "mbid",
             "title",
             "artist",
@@ -51,6 +52,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
             "creation_date",
             "tracks_count",
             "is_playable",
+            "is_local",
         )
 
     def get_tracks_count(self, o):
@@ -68,13 +70,13 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.Artist
-        fields = ("id", "mbid", "name", "creation_date", "albums")
+        fields = ("id", "fid", "mbid", "name", "creation_date", "albums", "is_local")
 
 
 class ArtistSimpleSerializer(serializers.ModelSerializer):
     class Meta:
         model = models.Artist
-        fields = ("id", "mbid", "name", "creation_date")
+        fields = ("id", "fid", "mbid", "name", "creation_date", "is_local")
 
 
 class AlbumTrackSerializer(serializers.ModelSerializer):
@@ -87,6 +89,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
         model = models.Track
         fields = (
             "id",
+            "fid",
             "mbid",
             "title",
             "album",
@@ -99,6 +102,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
             "duration",
             "copyright",
             "license",
+            "is_local",
         )
 
     def get_uploads(self, obj):
@@ -125,6 +129,7 @@ class AlbumSerializer(serializers.ModelSerializer):
         model = models.Album
         fields = (
             "id",
+            "fid",
             "mbid",
             "title",
             "artist",
@@ -133,6 +138,7 @@ class AlbumSerializer(serializers.ModelSerializer):
             "cover",
             "creation_date",
             "is_playable",
+            "is_local",
         )
 
     def get_tracks(self, o):
@@ -156,12 +162,14 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
         model = models.Album
         fields = (
             "id",
+            "fid",
             "mbid",
             "title",
             "artist",
             "release_date",
             "cover",
             "creation_date",
+            "is_local",
         )
 
 
@@ -190,6 +198,7 @@ class TrackSerializer(serializers.ModelSerializer):
         model = models.Track
         fields = (
             "id",
+            "fid",
             "mbid",
             "title",
             "album",
@@ -202,6 +211,7 @@ class TrackSerializer(serializers.ModelSerializer):
             "listen_url",
             "copyright",
             "license",
+            "is_local",
         )
 
     def get_lyrics(self, obj):
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index 3f0d87e7a772a7248368030ca5fe4d860cf4ec65..8f314262987a531327ecae5f5455285bcd79af47 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -206,7 +206,9 @@ def process_upload(upload):
     )
     additional_data["upload_source"] = upload.source
     try:
-        track = get_track_from_import_metadata(final_metadata)
+        track = get_track_from_import_metadata(
+            final_metadata, attributed_to=upload.library.actor
+        )
     except UploadImportError as e:
         return fail_import(upload, e.code)
     except Exception:
@@ -282,7 +284,7 @@ def process_upload(upload):
         )
 
 
-def federation_audio_track_to_metadata(payload):
+def federation_audio_track_to_metadata(payload, references):
     """
     Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data,
     returns a correct metadata payload for use with get_track_from_import_metadata.
@@ -293,6 +295,7 @@ def federation_audio_track_to_metadata(payload):
         "disc_number": payload.get("disc"),
         "license": payload.get("license"),
         "copyright": payload.get("copyright"),
+        "attributed_to": references.get(payload.get("attributedTo")),
         "mbid": str(payload.get("musicbrainzId"))
         if payload.get("musicbrainzId")
         else None,
@@ -300,6 +303,7 @@ def federation_audio_track_to_metadata(payload):
             "title": payload["album"]["name"],
             "fdate": payload["album"]["published"],
             "fid": payload["album"]["id"],
+            "attributed_to": references.get(payload["album"].get("attributedTo")),
             "mbid": str(payload["album"]["musicbrainzId"])
             if payload["album"].get("musicbrainzId")
             else None,
@@ -309,6 +313,7 @@ def federation_audio_track_to_metadata(payload):
                     "fid": a["id"],
                     "name": a["name"],
                     "fdate": a["published"],
+                    "attributed_to": references.get(a.get("attributedTo")),
                     "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
                 }
                 for a in payload["album"]["artists"]
@@ -319,6 +324,7 @@ def federation_audio_track_to_metadata(payload):
                 "fid": a["id"],
                 "name": a["name"],
                 "fdate": a["published"],
+                "attributed_to": references.get(a.get("attributedTo")),
                 "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
             }
             for a in payload["artists"]
@@ -393,8 +399,8 @@ def sort_candidates(candidates, important_fields):
 
 
 @transaction.atomic
-def get_track_from_import_metadata(data, update_cover=False):
-    track = _get_track(data)
+def get_track_from_import_metadata(data, update_cover=False, attributed_to=None):
+    track = _get_track(data, attributed_to=attributed_to)
     if update_cover and track and not track.album.cover:
         update_album_cover(
             track.album,
@@ -404,7 +410,7 @@ def get_track_from_import_metadata(data, update_cover=False):
     return track
 
 
-def _get_track(data):
+def _get_track(data, attributed_to=None):
     track_uuid = getter(data, "funkwhale", "track", "uuid")
 
     if track_uuid:
@@ -458,6 +464,7 @@ def _get_track(data):
         "mbid": artist_mbid,
         "fid": artist_fid,
         "from_activity_id": from_activity_id,
+        "attributed_to": artist.get("attributed_to", attributed_to),
     }
     if artist.get("fdate"):
         defaults["creation_date"] = artist.get("fdate")
@@ -484,6 +491,7 @@ def _get_track(data):
             "mbid": album_artist_mbid,
             "fid": album_artist_fid,
             "from_activity_id": from_activity_id,
+            "attributed_to": album_artist.get("attributed_to", attributed_to),
         }
         if album_artist.get("fdate"):
             defaults["creation_date"] = album_artist.get("fdate")
@@ -511,6 +519,7 @@ def _get_track(data):
         "release_date": album.get("release_date"),
         "fid": album_fid,
         "from_activity_id": from_activity_id,
+        "attributed_to": album.get("attributed_to", attributed_to),
     }
     if album.get("fdate"):
         defaults["creation_date"] = album.get("fdate")
@@ -536,6 +545,7 @@ def _get_track(data):
         "disc_number": data.get("disc_number"),
         "fid": track_fid,
         "from_activity_id": from_activity_id,
+        "attributed_to": data.get("attributed_to", attributed_to),
         "license": licenses.match(data.get("license"), data.get("copyright")),
         "copyright": data.get("copyright"),
     }
@@ -613,3 +623,18 @@ def get_prunable_albums():
 
 def get_prunable_artists():
     return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True)
+
+
+def update_library_entity(obj, data):
+    """
+    Given an obj and some updated fields, will persist the changes on the obj
+    and also check if the entity need to be aliased with existing objs (i.e
+    if a mbid was added on the obj, and match another entity with the same mbid)
+    """
+    for key, value in data.items():
+        setattr(obj, key, value)
+
+    # Todo: handle integrity error on unique fields (such as MBID)
+    obj.save(update_fields=list(data.keys()))
+
+    return obj
diff --git a/api/tests/common/test_decorators.py b/api/tests/common/test_decorators.py
index 66e692585a5bca823c87cf2a8b5de5b5efa4d8ae..f8fef79045f744f965f16723dc071503148d7283 100644
--- a/api/tests/common/test_decorators.py
+++ b/api/tests/common/test_decorators.py
@@ -44,7 +44,7 @@ def test_mutations_route_create_success(factories, api_request, is_approved, moc
     on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
     user = factories["users.User"](permission_library=True)
     actor = user.create_actor()
-    track = factories["music.Track"](title="foo")
+    track = factories["music.Track"](title="foo", local=True)
     view = V.as_view({"post": "mutations"})
 
     request = api_request.post(
diff --git a/api/tests/common/test_mutations.py b/api/tests/common/test_mutations.py
index bb2a08500b5c45ab73043257f01b35954b400674..3c0d869a116fd832b9927e1b4c7c1da51b4a88e2 100644
--- a/api/tests/common/test_mutations.py
+++ b/api/tests/common/test_mutations.py
@@ -10,7 +10,7 @@ def mutations_registry():
     return mutations.Registry()
 
 
-def test_apply_mutation(mutations_registry):
+def test_apply_mutation(mutations_registry, db):
     class Obj:
         pass
 
diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py
index c5c4a8e239b9e24f1699b3d29b36e2d0b720e0f8..ea64ed9d2834f8d97aee7425fb223548ffab1b52 100644
--- a/api/tests/common/test_utils.py
+++ b/api/tests/common/test_utils.py
@@ -1,3 +1,5 @@
+import pytest
+
 from funkwhale_api.common import utils
 
 
@@ -42,3 +44,44 @@ def test_update_prefix(factories):
         old = n.fid
         n.refresh_from_db()
         assert n.fid == old.replace("http://", "https://")
+
+
+@pytest.mark.parametrize(
+    "conf, mock_args, data, expected",
+    [
+        (
+            ["field1", "field2"],
+            {"field1": "foo", "field2": "test"},
+            {"field1": "bar"},
+            {"field1": "bar"},
+        ),
+        (
+            ["field1", "field2"],
+            {"field1": "foo", "field2": "test"},
+            {"field1": "foo"},
+            {},
+        ),
+        (
+            ["field1", "field2"],
+            {"field1": "foo", "field2": "test"},
+            {"field1": "foo", "field2": "test"},
+            {},
+        ),
+        (
+            ["field1", "field2"],
+            {"field1": "foo", "field2": "test"},
+            {"field1": "bar", "field2": "test1"},
+            {"field1": "bar", "field2": "test1"},
+        ),
+        (
+            [("field1", "Hello"), ("field2", "World")],
+            {"Hello": "foo", "World": "test"},
+            {"field1": "bar", "field2": "test1"},
+            {"Hello": "bar", "World": "test1"},
+        ),
+    ],
+)
+def test_get_updated_fields(conf, mock_args, data, expected, mocker):
+    obj = mocker.Mock(**mock_args)
+
+    assert utils.get_updated_fields(conf, data, obj) == expected
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
index c69ac5d74aa390e8891cc0c5ed1a7d067c51bc24..aaeebbb87c594084bc404df077fb83b2e4a5bc2f 100644
--- a/api/tests/federation/test_activity.py
+++ b/api/tests/federation/test_activity.py
@@ -436,6 +436,53 @@ def test_prepare_deliveries_and_inbox_items(factories):
         assert inbox_item.type == "to"
 
 
+def test_prepare_deliveries_and_inbox_items_instances_with_followers(factories):
+
+    domain1 = factories["federation.Domain"](with_service_actor=True)
+    domain2 = factories["federation.Domain"](with_service_actor=True)
+    library = factories["music.Library"](actor__local=True)
+
+    factories["federation.LibraryFollow"](
+        target=library, actor__local=True, approved=True
+    ).actor
+    library_follower_remote = factories["federation.LibraryFollow"](
+        target=library, actor__domain=domain1, approved=True
+    ).actor
+
+    followed_actor = factories["federation.Actor"](local=True)
+    factories["federation.Follow"](
+        target=followed_actor, actor__local=True, approved=True
+    ).actor
+    actor_follower_remote = factories["federation.Follow"](
+        target=followed_actor, actor__domain=domain2, approved=True
+    ).actor
+
+    recipients = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
+
+    inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items(
+        recipients, "to"
+    )
+
+    expected_deliveries = sorted(
+        [
+            models.Delivery(
+                inbox_url=library_follower_remote.domain.service_actor.inbox_url
+            ),
+            models.Delivery(
+                inbox_url=actor_follower_remote.domain.service_actor.inbox_url
+            ),
+        ],
+        key=lambda v: v.inbox_url,
+    )
+    assert inbox_items == []
+    assert len(expected_deliveries) == len(deliveries)
+
+    for delivery, expected_delivery in zip(
+        sorted(deliveries, key=lambda v: v.inbox_url), expected_deliveries
+    ):
+        assert delivery.inbox_url == expected_delivery.inbox_url
+
+
 def test_should_rotate_actor_key(settings, cache, now):
     actor_id = 42
     settings.ACTOR_KEY_ROTATION_DELAY = 10
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index 6eeebd660ad12db40f7882ae753bd72acead92b2..68a4571421c6705eaf42c779728bd9b1b423dcb3 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -134,3 +134,33 @@ def test_actor_stats(factories):
     actor = factories["federation.Actor"]()
 
     assert actor.get_stats() == expected
+
+
+def test_actor_can_manage_false(mocker, factories):
+    obj = mocker.Mock()
+    actor = factories["federation.Actor"]()
+
+    assert actor.can_manage(obj) is False
+
+
+def test_actor_can_manage_attributed_to(mocker, factories):
+    actor = factories["federation.Actor"]()
+    obj = mocker.Mock(attributed_to_id=actor.pk)
+
+    assert actor.can_manage(obj) is True
+
+
+def test_actor_can_manage_domain_not_service_actor(mocker, factories):
+    actor = factories["federation.Actor"]()
+    obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
+
+    assert actor.can_manage(obj) is False
+
+
+def test_actor_can_manage_domain_service_actor(mocker, factories):
+    actor = factories["federation.Actor"]()
+    actor.domain.service_actor = actor
+    actor.domain.save()
+    obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
+
+    assert actor.can_manage(obj) is True
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index 438f45c222fe948b5160316b08bdbfafc0e835c9..10b58082941fce65fd49ff83c1c758f9d3da6887 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -1,6 +1,6 @@
 import pytest
 
-from funkwhale_api.federation import jsonld, routes, serializers
+from funkwhale_api.federation import actors, contexts, jsonld, routes, serializers
 
 
 @pytest.mark.parametrize(
@@ -13,6 +13,9 @@ from funkwhale_api.federation import jsonld, routes, serializers
         ({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
         ({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
         ({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow),
+        ({"type": "Update", "object.type": "Artist"}, routes.inbox_update_artist),
+        ({"type": "Update", "object.type": "Album"}, routes.inbox_update_album),
+        ({"type": "Update", "object.type": "Track"}, routes.inbox_update_track),
     ],
 )
 def test_inbox_routes(route, handler):
@@ -34,6 +37,7 @@ def test_inbox_routes(route, handler):
         ({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
         ({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
         ({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
+        ({"type": "Update", "object.type": "Track"}, routes.outbox_update_track),
     ],
 )
 def test_outbox_routes(route, handler):
@@ -405,3 +409,89 @@ def test_outbox_delete_follow_library(factories):
     assert activity["actor"] == follow.actor
     assert activity["object"] == follow
     assert activity["related_object"] == follow.target
+
+
+def test_handle_library_entry_update_can_manage(factories, mocker):
+    update_library_entity = mocker.patch(
+        "funkwhale_api.music.tasks.update_library_entity"
+    )
+    activity = factories["federation.Activity"]()
+    obj = factories["music.Artist"]()
+    actor = factories["federation.Actor"]()
+    mocker.patch.object(actor, "can_manage", return_value=False)
+    data = serializers.ArtistSerializer(obj).data
+    data["name"] = "New name"
+    payload = {"type": "Update", "actor": actor, "object": data}
+
+    routes.inbox_update_artist(
+        payload, context={"actor": actor, "raise_exception": True, "activity": activity}
+    )
+
+    update_library_entity.assert_not_called()
+
+
+def test_inbox_update_artist(factories, mocker):
+    update_library_entity = mocker.patch(
+        "funkwhale_api.music.tasks.update_library_entity"
+    )
+    activity = factories["federation.Activity"]()
+    obj = factories["music.Artist"](attributed=True)
+    actor = obj.attributed_to
+    data = serializers.ArtistSerializer(obj).data
+    data["name"] = "New name"
+    payload = {"type": "Update", "actor": actor, "object": data}
+
+    routes.inbox_update_artist(
+        payload, context={"actor": actor, "raise_exception": True, "activity": activity}
+    )
+
+    update_library_entity.assert_called_once_with(obj, {"name": "New name"})
+
+
+def test_inbox_update_album(factories, mocker):
+    update_library_entity = mocker.patch(
+        "funkwhale_api.music.tasks.update_library_entity"
+    )
+    activity = factories["federation.Activity"]()
+    obj = factories["music.Album"](attributed=True)
+    actor = obj.attributed_to
+    data = serializers.AlbumSerializer(obj).data
+    data["name"] = "New title"
+    payload = {"type": "Update", "actor": actor, "object": data}
+
+    routes.inbox_update_album(
+        payload, context={"actor": actor, "raise_exception": True, "activity": activity}
+    )
+
+    update_library_entity.assert_called_once_with(obj, {"title": "New title"})
+
+
+def test_inbox_update_track(factories, mocker):
+    update_library_entity = mocker.patch(
+        "funkwhale_api.music.tasks.update_library_entity"
+    )
+    activity = factories["federation.Activity"]()
+    obj = factories["music.Track"](attributed=True)
+    actor = obj.attributed_to
+    data = serializers.TrackSerializer(obj).data
+    data["name"] = "New title"
+    payload = {"type": "Update", "actor": actor, "object": data}
+
+    routes.inbox_update_track(
+        payload, context={"actor": actor, "raise_exception": True, "activity": activity}
+    )
+
+    update_library_entity.assert_called_once_with(obj, {"title": "New title"})
+
+
+def test_outbox_update_track(factories):
+    track = factories["music.Track"]()
+    activity = list(routes.outbox_update_track({"track": track}))[0]
+    expected = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.TrackSerializer(track).data}
+    ).data
+
+    expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}]
+
+    assert dict(activity["payload"]) == dict(expected)
+    assert activity["actor"] == actors.get_service_actor()
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index bde9128cbb2001ce2f4295346b9a53fddf814818..6872947f7f26e1421b30fa47c77a5d9b69ab74db 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -1,13 +1,23 @@
+import io
+import pytest
+import uuid
+
+from django.core.paginator import Paginator
+from django.utils import timezone
+
 from funkwhale_api.federation import keys
 from funkwhale_api.federation import jsonld
+from funkwhale_api.federation import models
 from funkwhale_api.federation import serializers
+from funkwhale_api.federation import utils
+from funkwhale_api.music import licenses
 
 
 def test_actor_serializer_from_ap(db):
     private, public = keys.get_key_pair()
     actor_url = "https://test.federation/actor"
     payload = {
-        "@context": jsonld.get_default_context(),
+        "@context": jsonld.get_default_context_fw(),
         "id": actor_url,
         "type": "Person",
         "outbox": "https://test.com/outbox",
@@ -47,3 +57,864 @@ def test_actor_serializer_from_ap(db):
     assert actor.private_key is None
     assert actor.public_key == payload["publicKey"]["publicKeyPem"]
     assert actor.domain_id == "test.federation"
+
+
+def test_actor_serializer_only_mandatory_field_from_ap(db):
+    payload = {
+        "@context": jsonld.get_default_context(),
+        "id": "https://test.federation/user",
+        "type": "Person",
+        "following": "https://test.federation/user/following",
+        "followers": "https://test.federation/user/followers",
+        "inbox": "https://test.federation/user/inbox",
+        "outbox": "https://test.federation/user/outbox",
+        "preferredUsername": "user",
+    }
+
+    serializer = serializers.ActorSerializer(data=payload)
+    assert serializer.is_valid(raise_exception=True)
+
+    actor = serializer.build()
+
+    assert actor.fid == payload["id"]
+    assert actor.inbox_url == payload["inbox"]
+    assert actor.outbox_url == payload["outbox"]
+    assert actor.followers_url == payload["followers"]
+    assert actor.following_url == payload["following"]
+    assert actor.preferred_username == payload["preferredUsername"]
+    assert actor.domain.pk == "test.federation"
+    assert actor.type == "Person"
+    assert actor.manually_approves_followers is None
+
+
+def test_actor_serializer_to_ap():
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "id": "https://test.federation/user",
+        "type": "Person",
+        "following": "https://test.federation/user/following",
+        "followers": "https://test.federation/user/followers",
+        "inbox": "https://test.federation/user/inbox",
+        "outbox": "https://test.federation/user/outbox",
+        "preferredUsername": "user",
+        "name": "Real User",
+        "summary": "Hello world",
+        "manuallyApprovesFollowers": False,
+        "publicKey": {
+            "id": "https://test.federation/user#main-key",
+            "owner": "https://test.federation/user",
+            "publicKeyPem": "yolo",
+        },
+        "endpoints": {"sharedInbox": "https://test.federation/inbox"},
+    }
+    ac = models.Actor(
+        fid=expected["id"],
+        inbox_url=expected["inbox"],
+        outbox_url=expected["outbox"],
+        shared_inbox_url=expected["endpoints"]["sharedInbox"],
+        followers_url=expected["followers"],
+        following_url=expected["following"],
+        public_key=expected["publicKey"]["publicKeyPem"],
+        preferred_username=expected["preferredUsername"],
+        name=expected["name"],
+        domain=models.Domain(pk="test.federation"),
+        summary=expected["summary"],
+        type="Person",
+        manually_approves_followers=False,
+    )
+    serializer = serializers.ActorSerializer(ac)
+
+    assert serializer.data == expected
+
+
+def test_webfinger_serializer():
+    expected = {
+        "subject": "acct:service@test.federation",
+        "links": [
+            {
+                "rel": "self",
+                "href": "https://test.federation/federation/instance/actor",
+                "type": "application/activity+json",
+            }
+        ],
+        "aliases": ["https://test.federation/federation/instance/actor"],
+    }
+    actor = models.Actor(
+        fid=expected["links"][0]["href"],
+        preferred_username="service",
+        domain=models.Domain(pk="test.federation"),
+    )
+    serializer = serializers.ActorWebfingerSerializer(actor)
+
+    assert serializer.data == expected
+
+
+def test_follow_serializer_to_ap(factories):
+    follow = factories["federation.Follow"](local=True)
+    serializer = serializers.FollowSerializer(follow)
+
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "id": follow.get_federation_id(),
+        "type": "Follow",
+        "actor": follow.actor.fid,
+        "object": follow.target.fid,
+    }
+
+    assert serializer.data == expected
+
+
+def test_follow_serializer_save(factories):
+    actor = factories["federation.Actor"]()
+    target = factories["federation.Actor"]()
+
+    data = {
+        "id": "https://test.follow",
+        "type": "Follow",
+        "actor": actor.fid,
+        "object": target.fid,
+    }
+    serializer = serializers.FollowSerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True)
+
+    follow = serializer.save()
+
+    assert follow.pk is not None
+    assert follow.actor == actor
+    assert follow.target == target
+    assert follow.approved is None
+
+
+def test_follow_serializer_save_validates_on_context(factories):
+    actor = factories["federation.Actor"]()
+    target = factories["federation.Actor"]()
+    impostor = factories["federation.Actor"]()
+
+    data = {
+        "id": "https://test.follow",
+        "type": "Follow",
+        "actor": actor.fid,
+        "object": target.fid,
+    }
+    serializer = serializers.FollowSerializer(
+        data=data, context={"follow_actor": impostor, "follow_target": impostor}
+    )
+
+    assert serializer.is_valid() is False
+
+    assert "actor" in serializer.errors
+    assert "object" in serializer.errors
+
+
+def test_accept_follow_serializer_representation(factories):
+    follow = factories["federation.Follow"](approved=None)
+
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "id": follow.get_federation_id() + "/accept",
+        "type": "Accept",
+        "actor": follow.target.fid,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(follow)
+
+    assert serializer.data == expected
+
+
+def test_accept_follow_serializer_save(factories):
+    follow = factories["federation.Follow"](approved=None)
+
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "id": follow.get_federation_id() + "/accept",
+        "type": "Accept",
+        "actor": follow.target.fid,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(data=data)
+    assert serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    follow.refresh_from_db()
+
+    assert follow.approved is True
+
+
+def test_accept_follow_serializer_validates_on_context(factories):
+    follow = factories["federation.Follow"](approved=None)
+    impostor = factories["federation.Actor"]()
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "id": follow.get_federation_id() + "/accept",
+        "type": "Accept",
+        "actor": impostor.url,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.AcceptFollowSerializer(
+        data=data, context={"follow_actor": impostor, "follow_target": impostor}
+    )
+
+    assert serializer.is_valid() is False
+    assert "actor" in serializer.errors["object"]
+    assert "object" in serializer.errors["object"]
+
+
+def test_undo_follow_serializer_representation(factories):
+    follow = factories["federation.Follow"](approved=True)
+
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "id": follow.get_federation_id() + "/undo",
+        "type": "Undo",
+        "actor": follow.actor.fid,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(follow)
+
+    assert serializer.data == expected
+
+
+def test_undo_follow_serializer_save(factories):
+    follow = factories["federation.Follow"](approved=True)
+
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "id": follow.get_federation_id() + "/undo",
+        "type": "Undo",
+        "actor": follow.actor.fid,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(data=data)
+    assert serializer.is_valid(raise_exception=True)
+    serializer.save()
+
+    with pytest.raises(models.Follow.DoesNotExist):
+        follow.refresh_from_db()
+
+
+def test_undo_follow_serializer_validates_on_context(factories):
+    follow = factories["federation.Follow"](approved=True)
+    impostor = factories["federation.Actor"]()
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "id": follow.get_federation_id() + "/undo",
+        "type": "Undo",
+        "actor": impostor.url,
+        "object": serializers.FollowSerializer(follow).data,
+    }
+
+    serializer = serializers.UndoFollowSerializer(
+        data=data, context={"follow_actor": impostor, "follow_target": impostor}
+    )
+
+    assert serializer.is_valid() is False
+    assert "actor" in serializer.errors["object"]
+    assert "object" in serializer.errors["object"]
+
+
+def test_paginated_collection_serializer(factories):
+    uploads = factories["music.Upload"].create_batch(size=5)
+    actor = factories["federation.Actor"](local=True)
+
+    conf = {
+        "id": "https://test.federation/test",
+        "items": uploads,
+        "item_serializer": serializers.UploadSerializer,
+        "actor": actor,
+        "page_size": 2,
+    }
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "type": "Collection",
+        "id": conf["id"],
+        "actor": actor.fid,
+        "totalItems": len(uploads),
+        "current": conf["id"] + "?page=1",
+        "last": conf["id"] + "?page=3",
+        "first": conf["id"] + "?page=1",
+    }
+
+    serializer = serializers.PaginatedCollectionSerializer(conf)
+
+    assert serializer.data == expected
+
+
+def test_paginated_collection_serializer_validation():
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "type": "Collection",
+        "id": "https://test.federation/test",
+        "totalItems": 5,
+        "actor": "http://test.actor",
+        "first": "https://test.federation/test?page=1",
+        "last": "https://test.federation/test?page=1",
+        "items": [],
+    }
+
+    serializer = serializers.PaginatedCollectionSerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True) is True
+    assert serializer.validated_data["totalItems"] == 5
+    assert serializer.validated_data["id"] == data["id"]
+    assert serializer.validated_data["actor"] == data["actor"]
+
+
+def test_collection_page_serializer_validation():
+    base = "https://test.federation/test"
+    data = {
+        "@context": jsonld.get_default_context(),
+        "type": "CollectionPage",
+        "id": base + "?page=2",
+        "totalItems": 5,
+        "actor": "https://test.actor",
+        "items": [],
+        "first": "https://test.federation/test?page=1",
+        "last": "https://test.federation/test?page=3",
+        "prev": base + "?page=1",
+        "next": base + "?page=3",
+        "partOf": base,
+    }
+
+    serializer = serializers.CollectionPageSerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True) is True
+    assert serializer.validated_data["totalItems"] == 5
+    assert serializer.validated_data["id"] == data["id"]
+    assert serializer.validated_data["actor"] == data["actor"]
+    assert serializer.validated_data["items"] == []
+    assert serializer.validated_data["prev"] == data["prev"]
+    assert serializer.validated_data["next"] == data["next"]
+    assert serializer.validated_data["partOf"] == data["partOf"]
+
+
+def test_collection_page_serializer_can_validate_child():
+    data = {
+        "@context": jsonld.get_default_context(),
+        "type": "CollectionPage",
+        "id": "https://test.page?page=2",
+        "actor": "https://test.actor",
+        "first": "https://test.page?page=1",
+        "last": "https://test.page?page=3",
+        "partOf": "https://test.page",
+        "totalItems": 1,
+        "items": [{"in": "valid"}],
+    }
+
+    serializer = serializers.CollectionPageSerializer(
+        data=data, context={"item_serializer": serializers.UploadSerializer}
+    )
+
+    # child are validated but not included in data if not valid
+    assert serializer.is_valid(raise_exception=True) is True
+    assert len(serializer.validated_data["items"]) == 0
+
+
+def test_collection_page_serializer(factories):
+    uploads = factories["music.Upload"].create_batch(size=5)
+    actor = factories["federation.Actor"](local=True)
+
+    conf = {
+        "id": "https://test.federation/test",
+        "item_serializer": serializers.UploadSerializer,
+        "actor": actor,
+        "page": Paginator(uploads, 2).page(2),
+    }
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "type": "CollectionPage",
+        "id": conf["id"] + "?page=2",
+        "actor": actor.fid,
+        "totalItems": len(uploads),
+        "partOf": conf["id"],
+        "prev": conf["id"] + "?page=1",
+        "next": conf["id"] + "?page=3",
+        "first": conf["id"] + "?page=1",
+        "last": conf["id"] + "?page=3",
+        "items": [
+            conf["item_serializer"](
+                i, context={"actor": actor, "include_ap_context": False}
+            ).data
+            for i in conf["page"].object_list
+        ],
+    }
+
+    serializer = serializers.CollectionPageSerializer(conf)
+
+    assert serializer.data == expected
+
+
+def test_music_library_serializer_to_ap(factories):
+    library = factories["music.Library"](privacy_level="everyone")
+    # pending, errored and skippednot included
+    factories["music.Upload"](import_status="pending")
+    factories["music.Upload"](import_status="errored")
+    factories["music.Upload"](import_status="finished")
+    serializer = serializers.LibrarySerializer(library)
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "audience": "https://www.w3.org/ns/activitystreams#Public",
+        "type": "Library",
+        "id": library.fid,
+        "name": library.name,
+        "summary": library.description,
+        "actor": library.actor.fid,
+        "totalItems": 0,
+        "current": library.fid + "?page=1",
+        "last": library.fid + "?page=1",
+        "first": library.fid + "?page=1",
+        "followers": library.followers_url,
+    }
+
+    assert serializer.data == expected
+
+
+def test_music_library_serializer_from_public(factories, mocker):
+    actor = factories["federation.Actor"]()
+    retrieve = mocker.patch(
+        "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
+    )
+    data = {
+        "@context": jsonld.get_default_context(),
+        "audience": "https://www.w3.org/ns/activitystreams#Public",
+        "name": "Hello",
+        "summary": "World",
+        "type": "Library",
+        "id": "https://library.id",
+        "followers": "https://library.id/followers",
+        "actor": actor.fid,
+        "totalItems": 12,
+        "first": "https://library.id?page=1",
+        "last": "https://library.id?page=2",
+    }
+    serializer = serializers.LibrarySerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True)
+
+    library = serializer.save()
+
+    assert library.actor == actor
+    assert library.fid == data["id"]
+    assert library.uploads_count == data["totalItems"]
+    assert library.privacy_level == "everyone"
+    assert library.name == "Hello"
+    assert library.description == "World"
+    assert library.followers_url == data["followers"]
+
+    retrieve.assert_called_once_with(
+        actor.fid,
+        actor=None,
+        queryset=actor.__class__,
+        serializer_class=serializers.ActorSerializer,
+    )
+
+
+def test_music_library_serializer_from_private(factories, mocker):
+    actor = factories["federation.Actor"]()
+    retrieve = mocker.patch(
+        "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
+    )
+    data = {
+        "@context": jsonld.get_default_context_fw(),
+        "audience": "",
+        "name": "Hello",
+        "summary": "World",
+        "type": "Library",
+        "id": "https://library.id",
+        "followers": "https://library.id/followers",
+        "actor": actor.fid,
+        "totalItems": 12,
+        "first": "https://library.id?page=1",
+        "last": "https://library.id?page=2",
+    }
+    serializer = serializers.LibrarySerializer(data=data)
+
+    assert serializer.is_valid(raise_exception=True)
+
+    library = serializer.save()
+
+    assert library.actor == actor
+    assert library.fid == data["id"]
+    assert library.uploads_count == data["totalItems"]
+    assert library.privacy_level == "me"
+    assert library.name == "Hello"
+    assert library.description == "World"
+    assert library.followers_url == data["followers"]
+    retrieve.assert_called_once_with(
+        actor.fid,
+        actor=None,
+        queryset=actor.__class__,
+        serializer_class=serializers.ActorSerializer,
+    )
+
+
+def test_activity_pub_artist_serializer_to_ap(factories):
+    artist = factories["music.Artist"](attributed=True)
+    expected = {
+        "@context": serializers.AP_CONTEXT,
+        "type": "Artist",
+        "id": artist.fid,
+        "name": artist.name,
+        "musicbrainzId": artist.mbid,
+        "published": artist.creation_date.isoformat(),
+        "attributedTo": artist.attributed_to.fid,
+    }
+    serializer = serializers.ArtistSerializer(artist)
+
+    assert serializer.data == expected
+
+
+def test_activity_pub_album_serializer_to_ap(factories):
+    album = factories["music.Album"](attributed=True)
+
+    expected = {
+        "@context": serializers.AP_CONTEXT,
+        "type": "Album",
+        "id": album.fid,
+        "name": album.title,
+        "cover": {
+            "type": "Link",
+            "mediaType": "image/jpeg",
+            "href": utils.full_url(album.cover.url),
+        },
+        "musicbrainzId": album.mbid,
+        "published": album.creation_date.isoformat(),
+        "released": album.release_date.isoformat(),
+        "artists": [
+            serializers.ArtistSerializer(
+                album.artist, context={"include_ap_context": False}
+            ).data
+        ],
+        "attributedTo": album.attributed_to.fid,
+    }
+    serializer = serializers.AlbumSerializer(album)
+
+    assert serializer.data == expected
+
+
+def test_activity_pub_track_serializer_to_ap(factories):
+    track = factories["music.Track"](
+        license="cc-by-4.0", copyright="test", disc_number=3, attributed=True
+    )
+    expected = {
+        "@context": serializers.AP_CONTEXT,
+        "published": track.creation_date.isoformat(),
+        "type": "Track",
+        "musicbrainzId": track.mbid,
+        "id": track.fid,
+        "name": track.title,
+        "position": track.position,
+        "disc": track.disc_number,
+        "license": track.license.conf["identifiers"][0],
+        "copyright": "test",
+        "artists": [
+            serializers.ArtistSerializer(
+                track.artist, context={"include_ap_context": False}
+            ).data
+        ],
+        "album": serializers.AlbumSerializer(
+            track.album, context={"include_ap_context": False}
+        ).data,
+        "attributedTo": track.attributed_to.fid,
+    }
+    serializer = serializers.TrackSerializer(track)
+
+    assert serializer.data == expected
+
+
+def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
+    track_attributed_to = factories["federation.Actor"]()
+    album_attributed_to = factories["federation.Actor"]()
+    album_artist_attributed_to = factories["federation.Actor"]()
+    artist_attributed_to = factories["federation.Actor"]()
+
+    activity = factories["federation.Activity"]()
+    published = timezone.now()
+    released = timezone.now().date()
+    data = {
+        "@context": jsonld.get_default_context(),
+        "type": "Track",
+        "id": "http://hello.track",
+        "published": published.isoformat(),
+        "musicbrainzId": str(uuid.uuid4()),
+        "name": "Black in back",
+        "position": 5,
+        "disc": 1,
+        "attributedTo": track_attributed_to.fid,
+        "album": {
+            "type": "Album",
+            "id": "http://hello.album",
+            "name": "Purple album",
+            "musicbrainzId": str(uuid.uuid4()),
+            "published": published.isoformat(),
+            "released": released.isoformat(),
+            "attributedTo": album_attributed_to.fid,
+            "cover": {
+                "type": "Link",
+                "href": "https://cover.image/test.png",
+                "mediaType": "image/png",
+            },
+            "artists": [
+                {
+                    "type": "Artist",
+                    "id": "http://hello.artist",
+                    "name": "John Smith",
+                    "musicbrainzId": str(uuid.uuid4()),
+                    "published": published.isoformat(),
+                    "attributedTo": album_artist_attributed_to.fid,
+                }
+            ],
+        },
+        "artists": [
+            {
+                "type": "Artist",
+                "id": "http://hello.trackartist",
+                "name": "Bob Smith",
+                "musicbrainzId": str(uuid.uuid4()),
+                "attributedTo": artist_attributed_to.fid,
+                "published": published.isoformat(),
+            }
+        ],
+    }
+    r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou"))
+    serializer = serializers.TrackSerializer(data=data, context={"activity": activity})
+    assert serializer.is_valid(raise_exception=True)
+
+    track = serializer.save()
+    album = track.album
+    artist = track.artist
+    album_artist = track.album.artist
+
+    assert track.from_activity == activity
+    assert track.fid == data["id"]
+    assert track.title == data["name"]
+    assert track.position == data["position"]
+    assert track.disc_number == data["disc"]
+    assert track.creation_date == published
+    assert track.attributed_to == track_attributed_to
+    assert str(track.mbid) == data["musicbrainzId"]
+
+    assert album.from_activity == activity
+    assert album.cover.read() == b"coucou"
+    assert album.cover.path.endswith(".png")
+    assert album.title == data["album"]["name"]
+    assert album.fid == data["album"]["id"]
+    assert str(album.mbid) == data["album"]["musicbrainzId"]
+    assert album.creation_date == published
+    assert album.release_date == released
+    assert album.attributed_to == album_attributed_to
+
+    assert artist.from_activity == activity
+    assert artist.name == data["artists"][0]["name"]
+    assert artist.fid == data["artists"][0]["id"]
+    assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
+    assert artist.creation_date == published
+    assert artist.attributed_to == artist_attributed_to
+
+    assert album_artist.from_activity == activity
+    assert album_artist.name == data["album"]["artists"][0]["name"]
+    assert album_artist.fid == data["album"]["artists"][0]["id"]
+    assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"]
+    assert album_artist.creation_date == published
+    assert album_artist.attributed_to == album_artist_attributed_to
+
+
+def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock):
+    activity = factories["federation.Activity"]()
+    library = factories["music.Library"]()
+
+    published = timezone.now()
+    updated = timezone.now()
+    released = timezone.now().date()
+    data = {
+        "@context": serializers.AP_CONTEXT,
+        "type": "Audio",
+        "id": "https://track.file",
+        "name": "Ignored",
+        "published": published.isoformat(),
+        "updated": updated.isoformat(),
+        "duration": 43,
+        "bitrate": 42,
+        "size": 66,
+        "url": {"href": "https://audio.file", "type": "Link", "mediaType": "audio/mp3"},
+        "library": library.fid,
+        "track": {
+            "type": "Track",
+            "id": "http://hello.track",
+            "published": published.isoformat(),
+            "musicbrainzId": str(uuid.uuid4()),
+            "name": "Black in back",
+            "position": 5,
+            "album": {
+                "type": "Album",
+                "id": "http://hello.album",
+                "name": "Purple album",
+                "musicbrainzId": str(uuid.uuid4()),
+                "published": published.isoformat(),
+                "released": released.isoformat(),
+                "cover": {
+                    "type": "Link",
+                    "href": "https://cover.image/test.png",
+                    "mediaType": "image/png",
+                },
+                "artists": [
+                    {
+                        "type": "Artist",
+                        "id": "http://hello.artist",
+                        "name": "John Smith",
+                        "musicbrainzId": str(uuid.uuid4()),
+                        "published": published.isoformat(),
+                    }
+                ],
+            },
+            "artists": [
+                {
+                    "type": "Artist",
+                    "id": "http://hello.trackartist",
+                    "name": "Bob Smith",
+                    "musicbrainzId": str(uuid.uuid4()),
+                    "published": published.isoformat(),
+                }
+            ],
+        },
+    }
+    r_mock.get(data["track"]["album"]["cover"]["href"], body=io.BytesIO(b"coucou"))
+
+    serializer = serializers.UploadSerializer(data=data, context={"activity": activity})
+    assert serializer.is_valid(raise_exception=True)
+    track_create = mocker.spy(serializers.TrackSerializer, "create")
+    upload = serializer.save()
+
+    assert upload.track.from_activity == activity
+    assert upload.from_activity == activity
+    assert track_create.call_count == 1
+    assert upload.fid == data["id"]
+    assert upload.track.fid == data["track"]["id"]
+    assert upload.duration == data["duration"]
+    assert upload.size == data["size"]
+    assert upload.bitrate == data["bitrate"]
+    assert upload.source == data["url"]["href"]
+    assert upload.mimetype == data["url"]["mediaType"]
+    assert upload.creation_date == published
+    assert upload.import_status == "finished"
+    assert upload.modification_date == updated
+
+
+def test_activity_pub_upload_serializer_validtes_library_actor(factories, mocker):
+    library = factories["music.Library"]()
+    usurpator = factories["federation.Actor"]()
+
+    serializer = serializers.UploadSerializer(data={}, context={"actor": usurpator})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        serializer.validate_library(library.fid)
+
+
+def test_activity_pub_audio_serializer_to_ap(factories):
+    upload = factories["music.Upload"](
+        mimetype="audio/mp3", bitrate=42, duration=43, size=44
+    )
+    expected = {
+        "@context": serializers.AP_CONTEXT,
+        "type": "Audio",
+        "id": upload.fid,
+        "name": upload.track.full_name,
+        "published": upload.creation_date.isoformat(),
+        "updated": upload.modification_date.isoformat(),
+        "duration": upload.duration,
+        "bitrate": upload.bitrate,
+        "size": upload.size,
+        "url": {
+            "href": utils.full_url(upload.listen_url),
+            "type": "Link",
+            "mediaType": "audio/mp3",
+        },
+        "library": upload.library.fid,
+        "track": serializers.TrackSerializer(
+            upload.track, context={"include_ap_context": False}
+        ).data,
+    }
+
+    serializer = serializers.UploadSerializer(upload)
+
+    assert serializer.data == expected
+
+
+def test_local_actor_serializer_to_ap(factories):
+    expected = {
+        "@context": jsonld.get_default_context(),
+        "id": "https://test.federation/user",
+        "type": "Person",
+        "following": "https://test.federation/user/following",
+        "followers": "https://test.federation/user/followers",
+        "inbox": "https://test.federation/user/inbox",
+        "outbox": "https://test.federation/user/outbox",
+        "preferredUsername": "user",
+        "name": "Real User",
+        "summary": "Hello world",
+        "manuallyApprovesFollowers": False,
+        "publicKey": {
+            "id": "https://test.federation/user#main-key",
+            "owner": "https://test.federation/user",
+            "publicKeyPem": "yolo",
+        },
+        "endpoints": {"sharedInbox": "https://test.federation/inbox"},
+    }
+    ac = models.Actor.objects.create(
+        fid=expected["id"],
+        inbox_url=expected["inbox"],
+        outbox_url=expected["outbox"],
+        shared_inbox_url=expected["endpoints"]["sharedInbox"],
+        followers_url=expected["followers"],
+        following_url=expected["following"],
+        public_key=expected["publicKey"]["publicKeyPem"],
+        preferred_username=expected["preferredUsername"],
+        name=expected["name"],
+        domain=models.Domain.objects.create(pk="test.federation"),
+        summary=expected["summary"],
+        type="Person",
+        manually_approves_followers=False,
+    )
+    user = factories["users.User"]()
+    user.actor = ac
+    user.save()
+    ac.refresh_from_db()
+    expected["icon"] = {
+        "type": "Image",
+        "mediaType": "image/jpeg",
+        "url": utils.full_url(user.avatar.crop["400x400"].url),
+    }
+    serializer = serializers.ActorSerializer(ac)
+
+    assert serializer.data == expected
+
+
+def test_activity_serializer_validate_recipients_empty(db):
+    s = serializers.BaseActivitySerializer()
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"to": []})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"cc": []})
+
+
+def test_track_serializer_update_license(factories):
+    licenses.load(licenses.LICENSES)
+
+    obj = factories["music.Track"](license=None)
+
+    serializer = serializers.TrackSerializer()
+    serializer.update(obj, {"license": "http://creativecommons.org/licenses/by/2.0/"})
+
+    obj.refresh_from_db()
+
+    assert obj.license_id == "cc-by-2.0"
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index cc73e85a3c64beba86da0e7c3ae58e459e7965d1..4446de7dd978a33686c699dae6e18f0248640437 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -533,3 +533,18 @@ def test_queryset_local_entities(factories, settings, factory):
     factories[factory](fid="https://noope/3")
 
     assert list(obj1.__class__.objects.local().order_by("id")) == [obj1, obj2]
+
+
+@pytest.mark.parametrize(
+    "federation_hostname, fid, expected",
+    [
+        ("test.domain", "http://test.domain/", True),
+        ("test.domain", None, True),
+        ("test.domain", "https://test.domain/", True),
+        ("test.otherdomain", "http://test.domain/", False),
+    ],
+)
+def test_api_model_mixin_is_local(federation_hostname, fid, expected, settings):
+    settings.FEDERATION_HOSTNAME = federation_hostname
+    obj = models.Track(fid=fid)
+    assert obj.is_local is expected
diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py
index bc9e81f8e3e79f3da52c6a1caad11e831cf560e0..a8a529798b23389e057eb0adfcb19c9326e12bca 100644
--- a/api/tests/music/test_mutations.py
+++ b/api/tests/music/test_mutations.py
@@ -56,3 +56,16 @@ def test_track_position_mutation(factories):
     track.refresh_from_db()
 
     assert track.position == 12
+
+
+def test_track_mutation_apply_outbox(factories, mocker):
+    dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
+    track = factories["music.Track"](position=4)
+    mutation = factories["common.Mutation"](
+        type="update", target=track, payload={"position": 12}
+    )
+    mutation.apply()
+
+    dispatch.assert_called_once_with(
+        {"type": "Update", "object": {"type": "Track"}}, context={"track": track}
+    )
diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py
index 155f998904dccadcf2915ab3890767d34eb33be0..e01b544512edef514cf2b156ef1b6f5eeb5dda8b 100644
--- a/api/tests/music/test_serializers.py
+++ b/api/tests/music/test_serializers.py
@@ -34,6 +34,7 @@ def test_artist_album_serializer(factories, to_api_date):
     album = album.__class__.objects.with_tracks_count().get(pk=album.pk)
     expected = {
         "id": album.id,
+        "fid": album.fid,
         "mbid": str(album.mbid),
         "title": album.title,
         "artist": album.artist.id,
@@ -47,6 +48,7 @@ def test_artist_album_serializer(factories, to_api_date):
             "small_square_crop": album.cover.crop["50x50"].url,
         },
         "release_date": to_api_date(album.release_date),
+        "is_local": album.is_local,
     }
     serializer = serializers.ArtistAlbumSerializer(album)
 
@@ -61,8 +63,10 @@ def test_artist_with_albums_serializer(factories, to_api_date):
 
     expected = {
         "id": artist.id,
+        "fid": artist.fid,
         "mbid": str(artist.mbid),
         "name": artist.name,
+        "is_local": artist.is_local,
         "creation_date": to_api_date(artist.creation_date),
         "albums": [serializers.ArtistAlbumSerializer(album).data],
     }
@@ -79,6 +83,7 @@ def test_album_track_serializer(factories, to_api_date):
 
     expected = {
         "id": track.id,
+        "fid": track.fid,
         "artist": serializers.ArtistSimpleSerializer(track.artist).data,
         "album": track.album.id,
         "mbid": str(track.mbid),
@@ -91,6 +96,7 @@ def test_album_track_serializer(factories, to_api_date):
         "duration": None,
         "license": track.license.code,
         "copyright": track.copyright,
+        "is_local": track.is_local,
     }
     serializer = serializers.AlbumTrackSerializer(track)
     assert serializer.data == expected
@@ -154,6 +160,7 @@ def test_album_serializer(factories, to_api_date):
     album = track1.album
     expected = {
         "id": album.id,
+        "fid": album.fid,
         "mbid": str(album.mbid),
         "title": album.title,
         "artist": serializers.ArtistSimpleSerializer(album.artist).data,
@@ -167,6 +174,7 @@ def test_album_serializer(factories, to_api_date):
         },
         "release_date": to_api_date(album.release_date),
         "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data,
+        "is_local": album.is_local,
     }
     serializer = serializers.AlbumSerializer(album)
 
@@ -181,6 +189,7 @@ def test_track_serializer(factories, to_api_date):
     setattr(track, "playable_uploads", [upload])
     expected = {
         "id": track.id,
+        "fid": track.fid,
         "artist": serializers.ArtistSimpleSerializer(track.artist).data,
         "album": serializers.TrackAlbumSerializer(track.album).data,
         "mbid": str(track.mbid),
@@ -193,6 +202,7 @@ def test_track_serializer(factories, to_api_date):
         "listen_url": track.listen_url,
         "license": upload.track.license.code,
         "copyright": upload.track.copyright,
+        "is_local": upload.track.is_local,
     }
     serializer = serializers.TrackSerializer(track)
     assert serializer.data == expected
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index d2bb3a90355c88e4ad30135d6cefaad472da3340..d897c1a5fa104b938cb7943e78381a8e73aa6c4d 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -42,9 +42,38 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
     assert track.album.release_date == datetime.date(2012, 8, 15)
     assert track.artist.name == metadata["artists"][0]["name"]
     assert track.artist.mbid is None
+    assert track.artist.attributed_to is None
     match_license.assert_called_once_with(metadata["license"], metadata["copyright"])
 
 
+def test_can_create_track_from_file_metadata_attributed_to(factories, mocker):
+    actor = factories["federation.Actor"]()
+    metadata = {
+        "title": "Test track",
+        "artists": [{"name": "Test artist"}],
+        "album": {"title": "Test album", "release_date": datetime.date(2012, 8, 15)},
+        "position": 4,
+        "disc_number": 2,
+        "copyright": "2018 Someone",
+    }
+
+    track = tasks.get_track_from_import_metadata(metadata, attributed_to=actor)
+
+    assert track.title == metadata["title"]
+    assert track.mbid is None
+    assert track.position == 4
+    assert track.disc_number == 2
+    assert track.copyright == metadata["copyright"]
+    assert track.attributed_to == actor
+    assert track.album.title == metadata["album"]["title"]
+    assert track.album.mbid is None
+    assert track.album.release_date == datetime.date(2012, 8, 15)
+    assert track.album.attributed_to == actor
+    assert track.artist.name == metadata["artists"][0]["name"]
+    assert track.artist.mbid is None
+    assert track.artist.attributed_to == actor
+
+
 def test_can_create_track_from_file_metadata_mbid(factories, mocker):
     metadata = {
         "title": "Test track",
@@ -229,6 +258,7 @@ def test_upload_import(now, factories, temp_signal, mocker):
     outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
     update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
     get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
+    get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
     track = factories["music.Track"](album__cover="")
     upload = factories["music.Upload"](
         track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}}
@@ -246,6 +276,10 @@ def test_upload_import(now, factories, temp_signal, mocker):
     update_album_cover.assert_called_once_with(
         upload.track.album, cover_data=get_picture.return_value, source=upload.source
     )
+    assert (
+        get_track_from_import_metadata.call_args[-1]["attributed_to"]
+        == upload.library.actor
+    )
     handler.assert_called_once_with(
         upload=upload,
         old_status="pending",
@@ -478,9 +512,15 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m
     )
 
 
-def test_federation_audio_track_to_metadata(now):
+def test_federation_audio_track_to_metadata(now, mocker):
     published = now
     released = now.date()
+    references = {
+        "http://track.attributed": mocker.Mock(),
+        "http://album.attributed": mocker.Mock(),
+        "http://album-artist.attributed": mocker.Mock(),
+        "http://artist.attributed": mocker.Mock(),
+    }
     payload = {
         "@context": jsonld.get_default_context(),
         "type": "Track",
@@ -492,6 +532,7 @@ def test_federation_audio_track_to_metadata(now):
         "published": published.isoformat(),
         "license": "http://creativecommons.org/licenses/by-sa/4.0/",
         "copyright": "2018 Someone",
+        "attributedTo": "http://track.attributed",
         "album": {
             "published": published.isoformat(),
             "type": "Album",
@@ -499,6 +540,7 @@ def test_federation_audio_track_to_metadata(now):
             "name": "Purple album",
             "musicbrainzId": str(uuid.uuid4()),
             "released": released.isoformat(),
+            "attributedTo": "http://album.attributed",
             "artists": [
                 {
                     "type": "Artist",
@@ -506,6 +548,7 @@ def test_federation_audio_track_to_metadata(now):
                     "id": "http://hello.artist",
                     "name": "John Smith",
                     "musicbrainzId": str(uuid.uuid4()),
+                    "attributedTo": "http://album-artist.attributed",
                 }
             ],
             "cover": {
@@ -521,6 +564,7 @@ def test_federation_audio_track_to_metadata(now):
                 "id": "http://hello.trackartist",
                 "name": "Bob Smith",
                 "musicbrainzId": str(uuid.uuid4()),
+                "attributedTo": "http://artist.attributed",
             }
         ],
     }
@@ -535,8 +579,10 @@ def test_federation_audio_track_to_metadata(now):
         "mbid": payload["musicbrainzId"],
         "fdate": serializer.validated_data["published"],
         "fid": payload["id"],
+        "attributed_to": references["http://track.attributed"],
         "album": {
             "title": payload["album"]["name"],
+            "attributed_to": references["http://album.attributed"],
             "release_date": released,
             "mbid": payload["album"]["musicbrainzId"],
             "fid": payload["album"]["id"],
@@ -546,6 +592,7 @@ def test_federation_audio_track_to_metadata(now):
                     "name": a["name"],
                     "mbid": a["musicbrainzId"],
                     "fid": a["id"],
+                    "attributed_to": references["http://album-artist.attributed"],
                     "fdate": serializer.validated_data["album"]["artists"][i][
                         "published"
                     ],
@@ -561,6 +608,7 @@ def test_federation_audio_track_to_metadata(now):
                 "mbid": a["musicbrainzId"],
                 "fid": a["id"],
                 "fdate": serializer.validated_data["artists"][i]["published"],
+                "attributed_to": references["http://artist.attributed"],
             }
             for i, a in enumerate(payload["artists"])
         ],
@@ -570,7 +618,9 @@ def test_federation_audio_track_to_metadata(now):
         },
     }
 
-    result = tasks.federation_audio_track_to_metadata(serializer.validated_data)
+    result = tasks.federation_audio_track_to_metadata(
+        serializer.validated_data, references
+    )
     assert result == expected
 
 
@@ -747,3 +797,14 @@ def test_get_prunable_artists(factories):
     factories["music.Track"](album__artist=non_prunable_album_artist)
 
     assert list(tasks.get_prunable_artists()) == [prunable_artist]
+
+
+def test_update_library_entity(factories, mocker):
+    artist = factories["music.Artist"]()
+    save = mocker.spy(artist, "save")
+
+    tasks.update_library_entity(artist, {"name": "Hello"})
+    save.assert_called_once_with(update_fields=["name"])
+
+    artist.refresh_from_db()
+    assert artist.name == "Hello"
diff --git a/docs/federation/index.rst b/docs/federation/index.rst
index e54de3fe4c720998a66e7281d7005843bf14ef14..3123455ff43d8f23c7b7e6b1112a48969de3e2f4 100644
--- a/docs/federation/index.rst
+++ b/docs/federation/index.rst
@@ -88,8 +88,7 @@ to posting an activity to an outbox, we create an object, with the proper payloa
 Receiving an activity from a remote actor in a local inbox is basically the same, but we skip step 2.
 
 Funkwhale does not support all activities, and we have a basic routing logic to handle
-specific activities, and discard unsupported ones. Unsupported activities are still
-received and stored though.
+specific activities, and discard unsupported ones.
 
 If a delivered activity matches one of our routes, a dedicated handler is called,
 which can trigger additional logic. For instance, if we receive a :ref:`activity-create` activity
@@ -102,6 +101,24 @@ Links:
 - `Delivery logic for activities <https://dev.funkwhale.audio/funkwhale/funkwhale/blob/develop/api/funkwhale_api/federation/tasks.py>`_
 
 
+.. _service-actor:
+
+Service actor
+-------------
+
+In some situations, we will send messages or authenticate our fetches using what we call
+the service actor. A service actor is an ActivityPub actor object that acts on behalf
+of a Funkwhale server.
+
+The actor id usually looks like ``https://yourdomain.com/federation/actors/service``, but
+the reliable way to determine it is to query the nodeinfo endpoint and use the value
+available in the ``metadata > actorId`` field.
+
+Funkwhale generally considers that the service actor has authority to send activities
+associated with any object on the same domain. For instance, the service actor
+could send a :ref:`activity-delete` activity linked to another users' library on the same domain.
+
+
 Supported activities
 --------------------
 
@@ -305,6 +322,59 @@ the audio library's actor are the same.
 
 If no local actor follows the audio's library, the activity will be discarded.
 
+.. _activity-update:
+
+
+Update
+^^^^^^
+
+Supported on
+************
+
+- :ref:`object-library` objects
+- :ref:`object-track` objects
+
+Example
+*******
+
+.. code-block:: json
+
+  {
+    "@context": [
+      "https://www.w3.org/ns/activitystreams",
+      "https://w3id.org/security/v1",
+      {}
+    ],
+    "to": [
+      "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
+    ],
+    "type": "Update",
+    "actor": "https://awesome.music/federation/actors/Bob",
+    "object": {}
+  }
+
+.. note::
+
+  Refer to :ref:`object-library` or :ref:`object-track` to see the structure of the ``object`` attribute.
+
+Internal logic
+**************
+
+When a :ref:`activity-update` is received with a :ref:`object-library` or :ref:`object-track` object,
+Funkwhale will try to update the local copy of the corresponding object in it's database.
+
+
+Checks
+******
+
+Checks vary depending of the type of object associated with the update.
+
+For :ref:`object-library` objects, we ensure the actor sending the message is the owner of the library.
+
+For musical entities such as :ref:`object-track`, we ensure the actor sending the message
+matches the :ref:`property-attributedTo` property declared on the local copy on the object,
+or the :ref:`service-actor`.
+
 .. _activity-delete:
 
 Delete
@@ -613,3 +683,19 @@ For :ref:`object-audio` url objects:
 
 - If the audio's library is public, audio file can be accessed without restriction
 - Otherwise, the HTTP request must be signed by an actor with an approved follow on the audio's library
+
+
+Properties
+----------
+
+.. _property-attributedTo:
+
+attributedTo
+------------
+
+Funkwhale will generally use the ``attributedTo`` property to communicate
+who is responsible for a given object. When an object has the ``attributedTo`` attribute,
+the associated actor has the permission to :ref:`activity-update`, :ref:`activity-delete` or
+more generally apply any kind of activity on the object.
+
+In addition, Funkwhale consider all the objects of a domain as attributed to its corresponding :ref:`service-actor`.
diff --git a/docs/swagger.yml b/docs/swagger.yml
index a60b233df732e90fc0632e5cea6db3a311fa46e8..2a4baeb8bec82465eb0b29630f1b0e350ee7f3ec 100644
--- a/docs/swagger.yml
+++ b/docs/swagger.yml
@@ -662,12 +662,20 @@ definitions:
         type: "integer"
         format: "int64"
         example: 42
+      fid:
+        type: string
+        format: uri
+        description: "The artist Federation ID (unique accross federation)"
       name:
         type: "string"
         example: "System of a Down"
       creation_date:
         type: "string"
         format: "date-time"
+      is_local:
+        type: "boolean"
+        description: "Indicates if the object was initally created locally or on another server"
+
   Artist:
     type: "object"
     allOf:
@@ -689,6 +697,10 @@ definitions:
         type: "integer"
         format: "int64"
         example: 16
+      fid:
+        type: string
+        format: uri
+        description: "The album Federation ID (unique accross federation)"
       artist:
         type: "integer"
         format: "int64"
@@ -708,6 +720,9 @@ definitions:
         type: "boolean"
       cover:
         $ref: "#/definitions/Image"
+      is_local:
+        type: "boolean"
+        description: "Indicates if the object was initally created locally or on another server"
 
   Album:
     type: "object"
@@ -819,6 +834,10 @@ definitions:
         type: "integer"
         format: "int64"
         example: 66
+      fid:
+        type: string
+        format: uri
+        description: "The track Federation ID (unique accross federation)"
       artist:
         type: "integer"
         format: "int64"
@@ -853,6 +872,9 @@ definitions:
         type: "string"
         description: "Identifier of the license that is linked to the track"
         example: "cc-by-nc-nd-4.0"
+      is_local:
+        type: "boolean"
+        description: "Indicates if the object was initally created locally or on another server"
 
   AlbumTrack:
     type: "object"
diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue
index 8d885e72a4401fc158e34bb506ce771b382d898e..cd4572f5b65990f8449f1881738eb9fd8b622786 100644
--- a/front/src/components/library/TrackBase.vue
+++ b/front/src/components/library/TrackBase.vue
@@ -65,6 +65,7 @@
             </modal>
           </template>
           <router-link
+            v-if="track.is_local"
             :to="{name: 'library.tracks.edit', params: {id: track.id }}"
             class="ui icon labeled button">
             <i class="edit icon"></i>
diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue
index 8438a97c699dd6c8a661c45c25cf54d0ab8618cc..08a3a4d3ef9a40030432763a66906a229d6be974 100644
--- a/front/src/components/library/TrackDetail.vue
+++ b/front/src/components/library/TrackDetail.vue
@@ -63,6 +63,16 @@
               <translate translate-context="*/*/*">N/A</translate>
             </td>
           </tr>
+          <tr>
+            <td>
+              <translate translate-context="Content/*/*/Noun">Federation ID</translate>
+            </td>
+            <td :title="track.fid">
+              <a :href="track.fid" target="_blank" rel="noopener noreferrer">
+                {{ track.fid|truncate(65)}}
+              </a>
+            </td>
+          </tr>
         </tbody>
       </table>
     </section>
diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue
index 945bae961029f80a29fa878baaefd9d2c91ea8e4..18e71e8fa62ea25884ff72819699c2dafbba591d 100644
--- a/front/src/components/library/TrackEdit.vue
+++ b/front/src/components/library/TrackEdit.vue
@@ -6,8 +6,11 @@
         <translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this track</translate>
         <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this track</translate>
       </h2>
+      <div class="ui message" v-if="!object.is_local">
+        <translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
+      </div>
       <edit-form
-        v-if="!isLoadingLicenses"
+        v-else-if="!isLoadingLicenses"
         :object-type="objectType"
         :object="object"
         :can-edit="canEdit"