diff --git a/src/api/serializers.py b/src/api/serializers.py
index 2ac02e23e81d31d04572c295787c421cc36da605..087a3fc22b3d6710493193198cc3394d31874d75 100644
--- a/src/api/serializers.py
+++ b/src/api/serializers.py
@@ -157,7 +157,7 @@ class AssemblySerializer(HubModelSerializer):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        cluster_qs = Assembly.objects.accessible_by_user(conference=self.conference, user=self.request_user).exclude(hierarchy=Assembly.Hierarchy.REGULAR)
+        cluster_qs = Assembly.objects.associated_with_user(conference=self.conference, user=self.request_user).exclude(hierarchy=Assembly.Hierarchy.REGULAR)
         self.fields['parent'].queryset = cluster_qs
 
 
diff --git a/src/api/views/mixins.py b/src/api/views/mixins.py
index 9c36dd5e222b261983aa23cfd98d2ebc9e67a14f..8cc93edfcafef1aae7d8a3dc5578cfd40ae7f8a9 100644
--- a/src/api/views/mixins.py
+++ b/src/api/views/mixins.py
@@ -56,7 +56,7 @@ class ConferenceSlugAssemblyMixin(ConferenceSlugMixin):
         if self._assembly is None:
             assembly_slug = self.request.resolver_match.kwargs['assembly']
             try:
-                self._assembly = Assembly.objects.accessible_by_user(self.request.user, self.conference).get(slug=assembly_slug)
+                self._assembly = Assembly.objects.associated_with_user(self.conference, user=self.request.user).get(slug=assembly_slug)
             except Assembly.DoesNotExist:
                 if issuing_token := self.kwargs.get('issuing_token', None):
                     try:
diff --git a/src/api/views/schedule.py b/src/api/views/schedule.py
index 3fd9bba054813a374fb2c50154be195a3fde42ba..d9c2c433d19ec4fcf9886839942d900244fd8676 100644
--- a/src/api/views/schedule.py
+++ b/src/api/views/schedule.py
@@ -127,7 +127,7 @@ class EventSchedule(ConferenceSlugMixin, APIView):
     permission_classes = [IsApiUserOrReadOnly]
 
     def get(self, request, pk, format=None, **kwargs):
-        event = Event.objects.accessible_by_user(conference=self.conference, user=self.request.user).get(pk=pk)
+        event = Event.objects.associated_with_user(conference=self.conference, user=self.request.user, show_public=True).get(pk=pk)
         return Response(ScheduleEncoder().encode_event(event, self.conference.timezone))
 
     def post(self, request, pk, format=None, **kwargs):
diff --git a/src/backoffice/tests/assemblies.py b/src/backoffice/tests/assemblies.py
index 17c524368bd7a678b91336b114f822b4ebed0bb7..78f644e461584a049c3ef6dcead6ad91a6cb56df 100644
--- a/src/backoffice/tests/assemblies.py
+++ b/src/backoffice/tests/assemblies.py
@@ -18,7 +18,7 @@ class AssemblyListViewTest(BackOfficeTestCase):
             al.save()
 
     def test_slugname_related_assemblies(self):
-        self.client.force_login(self.user)
+        self.client.force_login(self.staff)
         resp = self.client.get(reverse('backoffice:assemblieslist', kwargs={'variant': 'slugname'}))
         self.assertEqual(resp.context['data'][0][3], 'a2, a3')
         self.assertIn(b'a2, a3', resp.content)
diff --git a/src/backoffice/tests/base.py b/src/backoffice/tests/base.py
index 80bc46fd7b983e8fd98b51e60ce2e34f8e0c5d0f..3e308d4e87677388d1b363ef87e2fc5e827178cb 100644
--- a/src/backoffice/tests/base.py
+++ b/src/backoffice/tests/base.py
@@ -1,6 +1,7 @@
 import uuid
 from datetime import UTC, datetime
 
+from django.contrib.auth.models import Group
 from django.test import TestCase, override_settings
 
 from core.models import Conference, ConferenceMember, PlatformUser
@@ -10,6 +11,10 @@ TEST_CONF_ID = uuid.uuid4()
 
 @override_settings(SELECTED_CONFERENCE_ID=TEST_CONF_ID)
 class BackOfficeTestCase(TestCase):
+    fixtures = [
+        'bootstrap_auth_groups',
+    ]
+
     def setUp(self):
         self.conf = Conference(
             id=TEST_CONF_ID,
@@ -24,6 +29,9 @@ class BackOfficeTestCase(TestCase):
         self.user.save()
         self.conference_member = ConferenceMember(conference=self.conf, user=self.user)
         self.conference_member.save()
+        self.staff = PlatformUser.objects.create(username='test_staff', email='staff@where.test')
+        self.staff_cm = ConferenceMember.objects.create(conference=self.conf, user=self.staff, is_staff=True)
+        self.staff_cm.permission_groups.add(Group.objects.get(name='Assembly-Team'))
 
     def tearDown(self) -> None:
         self.client.logout()
diff --git a/src/backoffice/views/assemblies.py b/src/backoffice/views/assemblies.py
index 5d0e94b96cd7318fdcef0ac29f327012228ae2cd..bae1782e3d2ec3300503a5cc088ed2bc034c9499 100644
--- a/src/backoffice/views/assemblies.py
+++ b/src/backoffice/views/assemblies.py
@@ -448,7 +448,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
 
     def get(self, *args, **kwargs):
         candidates_qs = (
-            Assembly.objects.accessible_by_user(conference=self.conference, user=self.request.user)
+            Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user)
             .filter(hierarchy=Assembly.Hierarchy.REGULAR, parent=None)
             .exclude(state_assembly__in=[Assembly.State.NONE, Assembly.State.REJECTED, Assembly.State.HIDDEN, Assembly.State.PLANNED])
         )
@@ -481,7 +481,7 @@ class AssemblyEditLinksView(AssemblyMixin, View):
 
         if add_id is not None:
             try:
-                linkee = Assembly.objects.accessible_by_user(user=request.user, conference=self.conference).get(pk=add_id)
+                linkee = Assembly.objects.associated_with_user(user=request.user, conference=self.conference).get(pk=add_id)
             except Assembly.DoesNotExist:
                 messages.warning('404 +Assembly %s', add_id)
                 linkee = None
@@ -502,7 +502,7 @@ class AssemblyEditLinksView(AssemblyMixin, View):
 
         if remove_id is not None:
             try:
-                linkee = Assembly.objects.accessible_by_user(user=request.user, conference=self.conference).get(pk=remove_id)
+                linkee = Assembly.objects.associated_with_user(user=request.user, conference=self.conference).get(pk=remove_id)
             except Assembly.DoesNotExist:
                 messages.warning('404 -Assembly %s', remove_id)
                 linkee = None
@@ -524,7 +524,7 @@ class AssemblyEditLinksView(AssemblyMixin, View):
 
     def get(self, *args, **kwargs):
         candidates_qs = (
-            Assembly.objects.accessible_by_user(conference=self.conference, user=self.request.user)
+            Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user)
             .filter(hierarchy=Assembly.Hierarchy.REGULAR)
             .exclude(state_assembly__in=[Assembly.State.NONE, Assembly.State.PLANNED, Assembly.State.HIDDEN, Assembly.State.REJECTED])
         )
diff --git a/src/backoffice/views/assemblyteam.py b/src/backoffice/views/assemblyteam.py
index 4b29353b1cc69dd24ce697552438484638f2754f..2be313cf94632415f30a5adc0076864515b1af20 100644
--- a/src/backoffice/views/assemblyteam.py
+++ b/src/backoffice/views/assemblyteam.py
@@ -122,8 +122,7 @@ class AssembliesListMixin(AssemblyTeamMixin):
         # get the mode
         mode = (self.request.POST if self.request.method == 'POST' else self.request.GET).get('mode', self.default_assemblies_mode)
 
-        # not using .accessible_by_user() here as we're the Assembly Team anyway
-        qs = Assembly.objects.filter(conference=self.conference)
+        qs = Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user, staff_can_see=True)
         if mode == 'not_selected':
             pass
         elif self.active_page == 'assemblies':
diff --git a/src/core/fixtures/.gitignore b/src/core/fixtures/.gitignore
index 267d38517c98e1dfa6c06b0252b1248c2c63d232..445270a60b5e95ecf5ea791a678e6f725227ae09 100644
--- a/src/core/fixtures/.gitignore
+++ b/src/core/fixtures/.gitignore
@@ -1,2 +1,3 @@
 *.json
 !anhalter.json
+!bootstrap_*.json
diff --git a/src/core/fixtures/bootstrap_auth_groups.json b/src/core/fixtures/bootstrap_auth_groups.json
new file mode 100644
index 0000000000000000000000000000000000000000..364f79683d75958393f5fe0d62058a51167b8396
--- /dev/null
+++ b/src/core/fixtures/bootstrap_auth_groups.json
@@ -0,0 +1,125 @@
+[
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "Assembly-Team",
+            "permissions": [
+                [
+                    "assembly_team",
+                    "core",
+                    "conferencemember"
+                ]
+            ]
+        }
+    },
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "Karten-Verwaltung",
+            "permissions": [
+                [
+                    "map_edit",
+                    "core",
+                    "conferencemember"
+                ]
+            ]
+        }
+    },
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "Schedule Supervisor",
+            "permissions": [
+                [
+                    "scheduleadmin",
+                    "core",
+                    "conferencemember"
+                ],
+                [
+                    "add_schedulesource",
+                    "core",
+                    "schedulesource"
+                ],
+                [
+                    "change_schedulesource",
+                    "core",
+                    "schedulesource"
+                ],
+                [
+                    "delete_schedulesource",
+                    "core",
+                    "schedulesource"
+                ],
+                [
+                    "view_schedulesource",
+                    "core",
+                    "schedulesource"
+                ],
+                [
+                    "view_schedulesourceimport",
+                    "core",
+                    "schedulesourceimport"
+                ],
+                [
+                    "add_schedulesourcemapping",
+                    "core",
+                    "schedulesourcemapping"
+                ],
+                [
+                    "change_schedulesourcemapping",
+                    "core",
+                    "schedulesourcemapping"
+                ],
+                [
+                    "delete_schedulesourcemapping",
+                    "core",
+                    "schedulesourcemapping"
+                ],
+                [
+                    "view_schedulesourcemapping",
+                    "core",
+                    "schedulesourcemapping"
+                ]
+            ]
+        }
+    },
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "PL",
+            "permissions": [
+                [
+                    "static_pages",
+                    "core",
+                    "conferencemember"
+                ]
+            ]
+        }
+    },
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "Wiki Team",
+            "permissions": [
+                [
+                    "static_pages",
+                    "core",
+                    "conferencemember"
+                ]
+            ]
+        }
+    },
+    {
+        "model": "auth.group",
+        "fields": {
+            "name": "Moderation",
+            "permissions": [
+                [
+                    "moderation",
+                    "core",
+                    "conferencemember"
+                ]
+            ]
+        }
+    }
+]
diff --git a/src/core/models/assemblies.py b/src/core/models/assemblies.py
index 7e8139efa490eb36edf4637b6892b26b421b71d9..3ac1a0f64091c0614b7e320282e3a963494824af 100644
--- a/src/core/models/assemblies.py
+++ b/src/core/models/assemblies.py
@@ -19,6 +19,7 @@ from django.utils.safestring import mark_safe
 from django.utils.translation import gettext
 from django.utils.translation import gettext_lazy as _
 
+from core.models.base_managers import ConferenceManagerMixin
 from core.models.tags import TagItem
 from core.validators import FileSizeValidator, ImageDimensionValidator
 
@@ -31,85 +32,28 @@ from .tags import TaggedItemMixin
 from .users import PlatformUser
 
 
-class AssemblyManager(models.Manager):
-    def accessible_by_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
+class AssemblyManager(ConferenceManagerMixin['Assembly']):
+    staff_permissions = ['assembly_team']
+    assembly_filter = 'self'
 
-        qs = self.get_queryset()
-        if conference is not None:
-            qs = qs.filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            user = PlatformUser.get_anonymous_user()
-
-        # see if we can return all assemblies (i.e. the user is marked as "staff")
-        if user.is_authenticated:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # for everybody else, only show "public" assemblies
-        return qs.filter(state_assembly__in=self.model.PUBLIC_STATES)
-
-    def associated_to_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
+    def apply_public_filter(self, queryset: 'QuerySet[Assembly]', member: ConferenceMember | None = None) -> 'QuerySet[Assembly]':
+        return queryset.filter(Q(state_assembly__in=self.model.PUBLIC_STATES) | Q(state_channel__in=self.model.PUBLIC_STATES))
 
+    def associated_to_user(self, user: PlatformUser, conference: Conference) -> 'QuerySet[Assembly]':
         # guests cannot manage anything
         if user is None or not user.is_authenticated:
             return Assembly.objects.none()
-
-        # prepare query
-        qs = self.get_queryset()
-        if conference is not None:
-            qs = qs.filter(conference=conference)
-
-        # lookup via ConferenceMember
-        qs = qs.filter(members__member=user)
-
-        # but don't show "hidden" ones to the user
-        qs = qs.exclude(state_assembly__in=[Assembly.State.NONE], state_channel__in=[Assembly.State.NONE])
-
-        # finally return the resulting QuerySet
-        return qs
-
-    def conference_accessible(self, conference: Conference):
         return (
-            self.get_queryset()
-            .filter(conference=conference)
-            .filter(Q(state_assembly__in=self.model.PUBLIC_STATES) | Q(state_channel__in=self.model.PUBLIC_STATES))
+            self.associated_with_user(conference, user=user)
+            .filter(members__member=user)
+            .exclude(
+                Q(
+                    state_assembly__in=[Assembly.State.NONE, Assembly.State.HIDDEN],
+                    state_channel__in=[Assembly.State.NONE, Assembly.State.HIDDEN],
+                )
+            )
         )
 
-    def manageable_by_user(self, user: PlatformUser, conference: Conference, staff_can_manage=True):
-        assert user is not None
-
-        # guests cannot manage anything
-        if user is None or not user.is_authenticated:
-            return Assembly.objects.none()
-
-        # prepare query
-        qs = self.get_queryset()
-        if conference is not None:
-            qs = qs.filter(conference=conference)
-
-        # see if we can return all assemblies (i.e. the user is marked as "staff")
-        if staff_can_manage:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff and member.has_perm('core.assembly_team'):
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # lookup via AssemblyMember
-        qs = qs.filter(members__member=user, members__can_manage_assembly=True)
-
-        # finally return the resulting QuerySet
-        return qs
-
 
 def get_banner_file_name(instance: 'Assembly', filename: str):
     return str(Path(str(instance.id)).joinpath('banner', filename))
@@ -561,33 +505,12 @@ class Assembly(TaggedItemMixin, models.Model):
         self.logentries.create(user=user, kind=kind, comment=message, changes=changes)
 
 
-class AssemblyLinkManager(models.Manager):
-    def accessible_by_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
+class AssemblyLinkManager(ConferenceManagerMixin['AssemblyLink']):
+    staff_permissions = ['assembly_team']
+    conference_filter = 'a__conference'
 
-        qs = self.get_queryset()
-        if conference is not None:
-            qs = qs.filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            user = PlatformUser.get_anonymous_user()
-
-        # for global staff apply no more limits
-        if user.is_staff or user.is_superuser:
-            return qs
-
-        # see if we can return all links (i.e. the user is marked as "staff")
-        if user.is_authenticated:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # for everybody else, only show "public" links
-        return qs.filter(Type__in=self.model.PUBLIC_TYPES)
+    def apply_public_filter(self, queryset: 'QuerySet[AssemblyLink]', member: ConferenceMember) -> 'QuerySet[AssemblyLink]':
+        return queryset.filter(Type__in=self.model.PUBLIC_TYPES)
 
 
 class AssemblyLink(models.Model):
@@ -626,65 +549,13 @@ class AssemblyLink(models.Model):
         return s
 
 
-class AssemblyMemberManager(models.Manager):
-    def conference_accessible(self, conference: 'Conference', member: ConferenceMember | None = None) -> QuerySet['AssemblyMember']:
-        return self.get_queryset().filter(assembly__conference=conference).filter(show_public=True)
-
-    def accessible_by_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
-
-        qs = self.get_queryset()
-        if conference is not None:
-            qs = qs.filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            user = PlatformUser.get_anonymous_user()
-
-        # for global staff apply no more limits
-        if user.is_staff or user.is_superuser:
-            return qs
-
-        # see if we can return all roles (i.e. the user is marked as "staff")
-        if user.is_authenticated:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # for everybody else, only show "public" roles
-        return qs.filter(show_public=True)
-
-    def manageable_by_user_for_assembly(self, user: PlatformUser, assembly: Assembly):
-        assert user is not None and isinstance(user, PlatformUser)
-        assert assembly is not None and isinstance(assembly, Assembly)
-
-        if not user.is_authenticated:
-            return AssemblyMember.objects.none()
-
-        qs = AssemblyMember.objects.filter(assembly=assembly)
-
-        # for global staff apply no more limits
-        if user.is_staff or user.is_superuser:
-            return qs
-
-        # see if the user is marked as "staff" on the conference
-        try:
-            member = ConferenceMember.objects.get(conference=assembly.conference, user=user)
-            if member.is_staff:
-                return qs
-        except ConferenceMember.DoesNotExist:
-            pass
-
-        # check that the user has management role in the assembly
-        user_is_member = AssemblyMember.objects.filter(assembly=assembly, member=user, can_manage_assembly=True).exists()
-
-        if not user_is_member:
-            return AssemblyMember.objects.none()
+class AssemblyMemberManager(ConferenceManagerMixin['AssemblyMember']):
+    staff_permissions = ['assembly_team']
+    conference_filter = 'assembly__conference'
+    assembly_filter = 'assembly'
 
-        return qs
+    def apply_public_filter(self, queryset: 'QuerySet[AssemblyMember]', member: ConferenceMember | None = None) -> 'QuerySet[AssemblyMember]':
+        return queryset.filter(show_public=True)
 
 
 class AssemblyMember(models.Model):
diff --git a/src/core/models/badges.py b/src/core/models/badges.py
index 345845c0f7211c73f32be24b07973ceede59e269..07e949411f6f6c18ecc71e7ee565237d638b058e 100644
--- a/src/core/models/badges.py
+++ b/src/core/models/badges.py
@@ -77,7 +77,7 @@ class BadgeManager(ConferenceManagerMixin['Badge']):
         if not user.is_authenticated:
             return qs.filter(state=Badge.State.PUBLIC)
 
-        manageable = Assembly.objects.manageable_by_user(user, conference, staff_can_manage)
+        manageable = Assembly.objects.manageable_by_user(conference, user=user, staff_can_manage=staff_can_manage)
         return qs.filter(Q(state=Badge.State.PUBLIC) | Q(users__user=user) | Q(issuing_assembly__in=manageable))
 
 
diff --git a/src/core/models/base_managers.py b/src/core/models/base_managers.py
index 960e93f718b762ed2e1946ae106465e96718be30..651ba1c3bf0315b2a57647d3c30a82cb5bf2a8fc 100644
--- a/src/core/models/base_managers.py
+++ b/src/core/models/base_managers.py
@@ -5,7 +5,6 @@ from django.core.exceptions import ImproperlyConfigured
 from django.db import models
 from django.db.models import Model, QuerySet
 
-from core.models.assemblies import Assembly, AssemblyMember
 from core.models.conference import ConferenceMember
 
 if TYPE_CHECKING:
@@ -75,6 +74,9 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]):
         else:
             prefix = f'{self.assembly_filter}__'
             queryset = queryset.select_related(self.assembly_filter)
+
+        from core.models.assemblies import Assembly, AssemblyMember  # pylint: disable=C0415 # Circular import avoidance
+
         allowed_members = AssemblyMember.objects.filter(member=member.user)
         allowed_members = allowed_members.filter(can_manage_assembly=True) if only_manageable else allowed_members
         queryset = queryset.filter(**{f'{prefix}members__in': allowed_members}).exclude(**{f'{prefix}state_assembly__in': [Assembly.State.HIDDEN]})
@@ -167,6 +169,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]):
         member: ConferenceMember | None = None,
         only_manageable: bool | None = None,
         staff_can_manage: bool = True,
+        show_public: bool = False,
     ) -> QuerySet[_ModelType]:
         if not user and not member:
             raise ValueError('Either user or member must be given')
@@ -177,19 +180,18 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]):
         member = self.get_member(conference, user, member)
         qs = self.get_conference_queryset(conference)
         if not isinstance(member, ConferenceMember):
-            return qs.none()
+            return self.conference_accessible(conference=conference, member=member) if show_public else qs.none()
         if member.is_staff and staff_can_manage:
-            if self.staff_permissions is None or (self.staff_permissions and member.has_staff_permission(self.staff_permissions)):
+            if self.staff_permissions is None or (self.staff_permissions and member.has_staff_permission(*self.staff_permissions)):
                 return qs
-        return (
-            qs.filter(
-                id__in=self.apply_assembly_rights_filter(qs, member, only_manageable=only_manageable)
-                .values('id')
-                .union(self.apply_self_organized_rights_filter(qs, member, only_manageable=only_manageable).values('id'))
-            )
-            .select_related(*self.select_related_fields or [])
-            .prefetch_related(*self.prefetch_related_fields or [])
+        id_list = (
+            self.apply_assembly_rights_filter(qs, member, only_manageable=only_manageable)
+            .values('id')
+            .union(self.apply_self_organized_rights_filter(qs, member, only_manageable=only_manageable).values('id'))
         )
+        if show_public:
+            id_list = id_list.union(self.apply_public_filter(qs, member).values('id'))
+        return qs.filter(id__in=id_list).select_related(*self.select_related_fields or []).prefetch_related(*self.prefetch_related_fields or [])
 
     def associated_with_user(
         self,
@@ -198,6 +200,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]):
         user: 'PlatformUser | None' = None,
         member: ConferenceMember | None = None,
         staff_can_see: bool = True,
+        show_public: bool = False,
     ) -> QuerySet[_ModelType]:
         """Returns a queryset of objects that are accessible by the given user.
 
@@ -221,6 +224,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]):
             member=member,
             only_manageable=False,
             staff_can_manage=staff_can_see,
+            show_public=show_public,
         )
 
     def manageable_by_user(
diff --git a/src/core/models/events.py b/src/core/models/events.py
index 2ae99c5c7132ea747d1cbc0c66d991261aad8e9d..bc1439f10660859c8d9f5911f2003e29d0e558ae 100644
--- a/src/core/models/events.py
+++ b/src/core/models/events.py
@@ -1,7 +1,7 @@
 import logging
 import random
 from datetime import timedelta
-from re import compile
+from re import compile as re_compile
 from typing import Any
 from uuid import UUID, uuid4
 
@@ -11,7 +11,7 @@ from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.db import models
-from django.db.models import Q
+from django.db.models import Q, QuerySet
 from django.forms import DurationField
 from django.utils import timezone
 from django.utils.dateparse import parse_datetime
@@ -20,19 +20,18 @@ from django.utils.text import slugify
 from django.utils.timezone import is_aware, make_aware
 from django.utils.translation import gettext_lazy as _
 
-from core.models.tags import TagItem
+from core.fields import ConferenceReference
+from core.markdown import compile_translated_markdown_fields, store_relationships
+from core.models.assemblies import Assembly
+from core.models.base_managers import ConferenceManagerMixin
+from core.models.conference import Conference, ConferenceMember, ConferenceTrack
+from core.models.rooms import Room
+from core.models.shared import BackendMixin
+from core.models.tags import TaggedItemMixin, TagItem
+from core.models.users import PlatformUser
+from core.utils import str2bool, str2timedelta
 
-from ..fields import ConferenceReference
-from ..markdown import compile_translated_markdown_fields, store_relationships
-from ..utils import str2bool, str2timedelta
-from .assemblies import Assembly
-from .conference import Conference, ConferenceMember, ConferenceTrack
-from .rooms import Room
-from .shared import BackendMixin
-from .tags import TaggedItemMixin
-from .users import PlatformUser
-
-SIMPLE_TIME_RE = compile(r'(.*\s)?(\d+:\d+)$')
+SIMPLE_TIME_RE = re_compile(r'(.*\s)?(\d+:\d+)$')
 
 logger = logging.getLogger(__name__)
 
@@ -60,76 +59,21 @@ class EventDurationField(models.DurationField):
         return super().formfield(**defaults)
 
 
-class EventManager(models.Manager):
-    def accessible_by_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
-        assert conference is not None
-
-        qs = self.get_queryset().filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            user = PlatformUser.get_anonymous_user()
-
-        # for global staff apply no more limits
-        if user.is_staff or user.is_superuser:
-            return qs
-
-        # see if we can return all events (i.e. the user is marked as "staff")
-        if user.is_authenticated:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # for everybody else, only show public events
-        return qs.filter(is_public=True)
-
-    def conference_accessible(self, conference: Conference):
-        return (
-            self.get_queryset()
-            .filter(conference=conference, is_public=True)
-            .filter(
-                Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES)
-                | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES)
-                | Q(kind=Event.Kind.SELF_ORGANIZED)
-            )
-        )
-
-    def manageable_by_user(self, user: PlatformUser, conference: Conference, staff_can_manage: bool = True):
-        assert user is not None
-        assert conference is not None
-
-        qs = self.get_queryset().filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            return qs.none()
+class EventManager(ConferenceManagerMixin['Event']):
+    staff_permissions = ['assembly_team', 'scheduleadmin']
+    conference_filter = 'assembly__conference'
+    assembly_filter = 'assembly'
 
-        # for global staff apply no more limits
-        if staff_can_manage and (user.is_staff or user.is_superuser):
-            return qs
-
-        # see if we can return all events (i.e. the user is marked as "staff")
-        if staff_can_manage:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
+    def apply_public_filter(self, queryset: 'QuerySet[Event]', member: ConferenceMember | None = None) -> 'QuerySet[Event]':
+        return queryset.filter(
+            (Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES) | Q(kind=Event.Kind.SELF_ORGANIZED))
+            & Q(is_public=True)
+        )
 
-        # for everybody else, only show public events
-        manageable_assemblies_qs = Assembly.objects.manageable_by_user(conference=conference, user=user).filter(
-            members__member=user, members__can_manage_assembly=True
+    def apply_manage_filter(self, queryset: 'QuerySet[Event]', member: ConferenceMember) -> 'QuerySet[Event]':
+        return queryset.filter(
+            Q(assembly__members__member=member, assembly__members__can_manage_assembly=True) | Q(owner=member.user, kind=Event.Kind.SELF_ORGANIZED)
         )
-        # TODO: remove work around when implementing ConferenceManagerMixin
-        # Filter out events belonging to the SoS Assembly
-        if not staff_can_manage:
-            manageable_assemblies_qs.exclude(id=conference.self_organized_sessions_assembly_id)
-        return qs.filter(Q(kind=Event.Kind.SELF_ORGANIZED, owner=user) | Q(assembly_id__in=manageable_assemblies_qs.values_list('id', flat=True)))
 
 
 class Event(TaggedItemMixin, BackendMixin, models.Model):
diff --git a/src/core/models/rooms.py b/src/core/models/rooms.py
index 4a336b213300456a8528dceb136d8c9ede3f4963..c1c137845ba9350d4fb3557fe1e1fc106f15e3ad 100644
--- a/src/core/models/rooms.py
+++ b/src/core/models/rooms.py
@@ -10,21 +10,20 @@ from django.contrib.postgres.fields import DateTimeRangeField
 from django.core.exceptions import ValidationError
 from django.core.validators import URLValidator
 from django.db import models
-from django.db.models import Q
+from django.db.models import Q, QuerySet
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 
+from core.fields import ConferenceReference
+from core.markdown import compile_translated_markdown_fields, store_relationships
+from core.models.assemblies import Assembly
+from core.models.base_managers import ConferenceManagerMixin
+from core.models.conference import Conference, ConferenceMember
+from core.models.shared import BackendMixin
 from core.models.tags import TagItem
+from core.utils import str2bool
 from core.validators import FileSizeValidator, ImageDimensionValidator
 
-from ..fields import ConferenceReference
-from ..markdown import compile_translated_markdown_fields, store_relationships
-from ..models.conference import Conference, ConferenceMember
-from ..models.users import PlatformUser
-from ..utils import str2bool
-from .assemblies import Assembly
-from .shared import BackendMixin
-
 
 class RoomType(models.TextChoices):
     LECTURE_HALL = 'lecturehall', _('Room__type-lecturehall')
@@ -39,39 +38,14 @@ class RoomType(models.TextChoices):
     OTHER = 'other', _('Room__type-other')
 
 
-class RoomManager(models.Manager):
-    def accessible_by_user(self, user: PlatformUser, conference: Conference):
-        assert user is not None
-        assert conference is not None
-
-        qs = self.get_queryset().filter(conference=conference)
-
-        # crude hack because Django makes it that hard to customize AnonymousUser
-        if user is None or not user.is_authenticated:
-            user = PlatformUser.get_anonymous_user()
+class RoomManager(ConferenceManagerMixin['Room']):
+    staff_permissions = ['assembly_team']
+    conference_filter = 'assembly__conference'
+    assembly_filter = 'assembly'
 
-        # for global staff apply no more limits
-        if user.is_staff or user.is_superuser:
-            return qs
-
-        # see if we can return all events (i.e. the user is marked as "staff")
-        if user.is_authenticated:
-            try:
-                member = ConferenceMember.objects.get(conference=conference, user=user)
-                if member.is_staff:
-                    return qs
-            except ConferenceMember.DoesNotExist:
-                pass
-
-        # for everybody else, only show public events
-        return qs.filter(blocked=False)
-
-    def conference_accessible(self, conference: Conference):
-        return (
-            self.get_queryset()
-            .filter(conference=conference, blocked=False)
-            .filter(Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES))
-            .exclude(room_type=RoomType.PROJECT)
+    def apply_public_filter(self, queryset: 'QuerySet[Room]', member: ConferenceMember | None = None) -> 'QuerySet[Room]':
+        return queryset.filter(blocked=False).filter(
+            Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES)
         )
 
     def assignable_in_timeframe(self, conference: Conference, start: datetime, end: datetime, assembly: Optional[Assembly] = None, is_sos: bool = False):
@@ -471,6 +445,15 @@ class RoomShare(models.Model):
             raise ValidationError(errors)
 
 
+class RoomLinkManager(ConferenceManagerMixin['RoomLink']):
+    staff_permissions = ['assembly_team']
+    conference_filter = 'room__assembly__conference'
+    assembly_filter = 'room__assembly'
+
+    def apply_manage_filter(self, queryset: 'QuerySet[RoomLink]', member: ConferenceMember) -> 'QuerySet[RoomLink]':
+        return queryset.filter(Q(assembly__members__member=member, assembly__members__can_manage_assembly=True))
+
+
 class RoomLink(models.Model):
     class LinkType(models.TextChoices):
         WEBSITE = 'website', _('RoomLink__type-website')
@@ -482,6 +465,8 @@ class RoomLink(models.Model):
         VIDEO = 'video', _('RoomLink__type-video')
         AUDIO = 'audio', _('RoomLink__type-audio')
 
+    objects = RoomLinkManager()
+
     URL_LINKTYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO]
     """All LinkTypes which require the link to be a valid URL. The notable exception is media_ccc_de where only a global id is required."""
 
diff --git a/src/core/tests/assemblies.py b/src/core/tests/assemblies.py
index c8d4b5b6b33f45720ea66daa76dd0f0b98a4b905..be70aabad8f3ac39e86666e1e272694b3a65aaa6 100644
--- a/src/core/tests/assemblies.py
+++ b/src/core/tests/assemblies.py
@@ -1,12 +1,13 @@
+from django.contrib.auth.models import Group, Permission
 from django.core import mail
 from django.test import TestCase, override_settings
 
-from ..models.assemblies import Assembly
+from ..models.assemblies import Assembly, AssemblyMember
 from ..models.conference import Conference, ConferenceMember
 from ..models.users import PlatformUser, UserCommunicationChannel
 
 
-class AssembliesTests(TestCase):
+class AssembliesTestsMixin(TestCase):
     def setUp(self):
         self.conference = Conference(slug='foo', name='Foo Conference', mail_footer='This is serious business w/ legal stuff.')
         self.conference.save()
@@ -27,6 +28,7 @@ class AssembliesTests(TestCase):
         )
         self.user_mgmt2 = PlatformUser(username='manager2')
         self.user_mgmt2.save()
+        self.user_mgmt2.user_permissions.add(Permission.objects.get(codename='assembly_team'))
         self.user_mgmt2.communication_channels.create(
             channel=UserCommunicationChannel.Channel.MAIL,
             address='notifications2@unittest.local',
@@ -49,6 +51,7 @@ class AssembliesTests(TestCase):
             is_verified=True,
             use_for_notifications=False,
         )
+        self.user_anon = PlatformUser.get_anonymous_user()
         ConferenceMember(conference=self.conference, user=self.user_mgmt).save()
         ConferenceMember(conference=self.conference, user=self.user_mgmt2).save()
         ConferenceMember(conference=self.conference, user=self.user_participant).save()
@@ -57,11 +60,13 @@ class AssembliesTests(TestCase):
         self.assembly1 = Assembly(conference=self.conference, slug='fnord', name='Fnord Assembly')
         self.assembly1.save()
         self.assembly1.members.create(member=self.user_mgmt, can_manage_assembly=True)
-        self.assembly1.members.create(member=self.user_participant, can_manage_assembly=False)
+        self.assembly1.members.create(member=self.user_participant, can_manage_assembly=False, show_public=True)
         self.assembly2 = Assembly(conference=self.conference, slug='foo', name='Foo Bar')
         self.assembly2.members.create(member=self.user_mgmt2, can_manage_assembly=True)
         self.assembly2.save()
 
+
+class AssembliesTests(AssembliesTestsMixin):
     @override_settings(SUPPORT_HTML_MAILS=True)
     def test_mail_plainhtml(self):
         self.assembly2.send_mail_to_managers(
@@ -105,3 +110,63 @@ class AssembliesTests(TestCase):
         self.assertIn('https://events.ccc.de/', the_mail.body)
         self.assertIn(self.assembly1.slug, the_mail.alternatives[0][0])
         self.assertIn('https://events.ccc.de/', the_mail.alternatives[0][0])
+
+
+class AssembliesObjectManagerTests(AssembliesTestsMixin):
+    fixtures = [
+        'bootstrap_auth_groups',
+    ]
+
+    def setUp(self):
+        super().setUp()
+
+        self.conference2 = Conference(slug='bar', name='Bar Conference')
+        self.conference2.save()
+        self.assembly3 = Assembly(conference=self.conference2, slug='fnord', name='Fnord Assembly')
+        self.assembly3.save()
+        self.user_a3_mgmt = PlatformUser(username='manager3')
+        self.user_a3_mgmt.save()
+        ConferenceMember(conference=self.conference2, user=self.user_a3_mgmt).save()
+        self.assembly3.members.create(member=self.user_a3_mgmt, can_manage_assembly=True, show_public=True)
+        self.user_a3_part = PlatformUser(username='participant3')
+        self.user_a3_part.save()
+        ConferenceMember(conference=self.conference2, user=self.user_a3_part).save()
+        self.assembly3.members.create(member=self.user_a3_part, can_manage_assembly=False, show_public=True)
+
+        self.assembly_group = Group.objects.get(name='Assembly-Team')
+
+        self.user_conf_staff = PlatformUser(username='conf_staff')
+        self.user_conf_staff.save()
+        self.cm_conf_staff = ConferenceMember(
+            conference=self.conference,
+            user=self.user_conf_staff,
+            is_staff=True,
+        )
+        self.cm_conf_staff.save()
+        self.cm_conf_staff.permission_groups.add(self.assembly_group)
+
+        self.public_members = list(AssemblyMember.objects.filter(show_public=True))
+
+    def test_associated_with_user(self):
+        self.assertListEqual(
+            list(AssemblyMember.objects.associated_with_user(user=self.user_conf_staff, conference=self.conference)),
+            list(AssemblyMember.objects.filter(assembly__conference=self.conference)),
+        )
+        self.assertListEqual(
+            list(AssemblyMember.objects.associated_with_user(user=self.user_conf_staff, conference=self.conference, staff_can_see=False)),
+            [],
+        )
+        self.assertListEqual(
+            list(AssemblyMember.objects.associated_with_user(user=self.user_visitor, conference=self.conference)),
+            [],
+        )
+
+    def test_conference_accessible(self):
+        self.assertListEqual(
+            list(AssemblyMember.objects.conference_accessible(conference=self.conference)),
+            list(AssemblyMember.objects.filter(assembly__conference=self.conference, show_public=True)),
+        )
+        self.assertListEqual(
+            list(AssemblyMember.objects.conference_accessible(conference=self.conference)),
+            list(AssemblyMember.objects.filter(assembly__conference=self.conference, show_public=True)),
+        )
diff --git a/src/plainui/views/events.py b/src/plainui/views/events.py
index 0b1535e385925ebe14240a34e57923300e1e82a0..962b253a153d235c85a991bc585d796081dc9d20 100644
--- a/src/plainui/views/events.py
+++ b/src/plainui/views/events.py
@@ -188,7 +188,7 @@ class SosList(ConferenceRequiredMixin, FilteredListView):
         context['events_upcoming'] = event_filter(self.request.user, self.conf, kinds=[Event.Kind.SELF_ORGANIZED], calendar_mode=False, upcoming=True)
         context['events'] = event_filter(self.request.user, self.conf, kinds=[Event.Kind.SELF_ORGANIZED], calendar_mode=False)
         context['is_favorite_events'] = session_get_favorite_events(self.request.session, self.request.user)
-        context['manageable_events'] = Event.objects.manageable_by_user(self.request.user, self.conf).only('id')
+        context['manageable_events'] = Event.objects.manageable_by_user(self.conf, user=self.request.user).only('id')
 
         context['report_info'] = {'enabled': True}