diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py
index 29a48d1517db6661fafd7ddd81bc0f975faa2d5a..24c7905093be9974a267b4ac2dfdbf97a18c1508 100644
--- a/aleksis/core/admin.py
+++ b/aleksis/core/admin.py
@@ -1,5 +1,6 @@
 from django.contrib import admin
 
+from .mixins import BaseModelAdmin
 from .models import (
     Group,
     Person,
@@ -12,7 +13,12 @@ from .models import (
     CustomMenuItem,
 )
 
-admin.site.register(Person)
+
+class PersonAdmin(BaseModelAdmin):
+    pass
+
+
+admin.site.register(Person, PersonAdmin)
 admin.site.register(Group)
 admin.site.register(School)
 admin.site.register(SchoolTerm)
@@ -25,7 +31,7 @@ class AnnouncementRecipientInline(admin.StackedInline):
     model = AnnouncementRecipient
 
 
-class AnnouncementAdmin(admin.ModelAdmin):
+class AnnouncementAdmin(BaseModelAdmin):
     inlines = [
         AnnouncementRecipientInline,
     ]
diff --git a/aleksis/core/decorators.py b/aleksis/core/decorators.py
deleted file mode 100644
index 1c884a123c8a4b2a60e4616e4c928064e4a7123e..0000000000000000000000000000000000000000
--- a/aleksis/core/decorators.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from typing import Callable
-
-from django.contrib.auth.decorators import login_required, user_passes_test
-
-from .util.core_helpers import has_person
-
-
-def admin_required(function: Callable = None) -> Callable:
-    actual_decorator = user_passes_test(lambda u: u.is_active and u.is_superuser)
-    return actual_decorator(function)
-
-
-def person_required(function: Callable = None) -> Callable:
-    """ Requires a logged-in user which is linked to a person. """
-
-    actual_decorator = user_passes_test(has_person)
-    return actual_decorator(login_required(function))
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index f04baef7eb3b0f4b560aeaa919f29d8222751564..33cb12c3bd734f6ffd21c72ac29dfc9feb1a7254 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -62,8 +62,7 @@ MENUS = {
             "url": "#",
             "icon": "security",
             "validators": [
-                "menu_generator.validators.is_authenticated",
-                "menu_generator.validators.is_superuser",
+                ("aleksis.core.util.predicates.permission_validator", "core.view_admin_menu"),
             ],
             "submenu": [
                 {
@@ -71,8 +70,7 @@ MENUS = {
                     "url": "announcements",
                     "icon": "announcement",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
-                        "menu_generator.validators.is_superuser",
+                        ("aleksis.core.util.predicates.permission_validator", "core.view_announcements"),
                     ],
                 },
                 {
@@ -80,8 +78,7 @@ MENUS = {
                     "url": "data_management",
                     "icon": "view_list",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
-                        "menu_generator.validators.is_superuser",
+                        ("aleksis.core.util.predicates.permission_validator", "core.manage_data"),
                     ],
                 },
                 {
@@ -89,8 +86,7 @@ MENUS = {
                     "url": "system_status",
                     "icon": "power_settings_new",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
-                        "menu_generator.validators.is_superuser",
+                        ("aleksis.core.util.predicates.permission_validator", "core.view_system_status"),
                     ],
                 },
                 {
@@ -98,8 +94,7 @@ MENUS = {
                     "url": "impersonate-list",
                     "icon": "people",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
-                        "menu_generator.validators.is_superuser",
+                        ("aleksis.core.util.predicates.permission_validator", "core.impersonate"),
                     ],
                 },
                 {
@@ -107,8 +102,7 @@ MENUS = {
                     "url": "school_management",
                     "icon": "school",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
-                        "menu_generator.validators.is_superuser",
+                        ("aleksis.core.util.predicates.permission_validator", "core.manage_school"),
                     ],
                 },
                 {
@@ -116,7 +110,6 @@ MENUS = {
                     "url": "admin:index",
                     "icon": "settings",
                     "validators": [
-                        "menu_generator.validators.is_authenticated",
                         "menu_generator.validators.is_superuser",
                     ],
                 },
@@ -127,22 +120,23 @@ MENUS = {
             "url": "#",
             "icon": "people",
             "root": True,
-            "validators": [
-                "menu_generator.validators.is_authenticated",
-                "aleksis.core.util.core_helpers.has_person",
-            ],
+            "validators": [("aleksis.core.util.predicates.permission_validator", "core.view_people_menu")],
             "submenu": [
                 {
                     "name": _("Persons"),
                     "url": "persons",
                     "icon": "person",
-                    "validators": ["menu_generator.validators.is_authenticated"],
+                    "validators": [
+                        ("aleksis.core.util.predicates.permission_validator", "core.view_persons")
+                    ],
                 },
                 {
                     "name": _("Groups"),
                     "url": "groups",
                     "icon": "group",
-                    "validators": ["menu_generator.validators.is_authenticated"],
+                    "validators": [
+                        ("aleksis.core.util.predicates.permission_validator", "core.view_groups")
+                    ],
                 },
                 {
                     "name": _("Persons and accounts"),
diff --git a/aleksis/core/migrations/0023_add_permissions_person.py b/aleksis/core/migrations/0023_add_permissions_person.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f73d8d5e7d450ea0afca7ba836a3ee08088a0bf
--- /dev/null
+++ b/aleksis/core/migrations/0023_add_permissions_person.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.0.5 on 2020-04-18 12:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0022_group_types'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='person',
+            options={'ordering': ['last_name', 'first_name'], 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_personal_details', 'Can view personal details'), ('view_person_groups', 'Can view persons groups')), 'verbose_name': 'Person', 'verbose_name_plural': 'Persons'},
+        ),
+    ]
diff --git a/aleksis/core/migrations/0024_globalpermissions.py b/aleksis/core/migrations/0024_globalpermissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..b127523a981c0e847340c10c5aca6210d0c94dc1
--- /dev/null
+++ b/aleksis/core/migrations/0024_globalpermissions.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.0.5 on 2020-04-19 10:30
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0023_add_permissions_person'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='GlobalPermissions',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict, editable=False)),
+            ],
+            options={
+                'permissions': (('view_system_status', 'Can view system status'), ('link_persons_accounts', 'Can link persons to accounts'), ('manage_data', 'Can manage data'), ('impersonate', 'Can impersonate')),
+                'managed': False,
+            },
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 574cf185cc2350e3e7b45f7723c8e9313a631790..15b5c0c67c21a3cf473eda2b1c8637db849125b2 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -7,8 +7,10 @@ from django.db.models import QuerySet
 from django.forms.models import ModelFormMetaclass, ModelForm
 
 from easyaudit.models import CRUDEvent
+from guardian.admin import GuardedModelAdmin
 from jsonstore.fields import JSONField, JSONFieldMixin
 from material.base import LayoutNode, Layout
+from rules.contrib.admin import ObjectPermissionsModelAdmin
 
 
 class CRUDMixin(models.Model):
@@ -233,3 +235,7 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass):
 
         cls.base_layout.append(node)
         cls.layout = Layout(*cls.base_layout)
+
+
+class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin):
+    pass
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 8d9991f03e26a70c965e597acf05a1d189f65767..0ea49774c79d08bcd15d8635fec71b3831e99cdd 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -95,6 +95,13 @@ class Person(ExtensibleModel):
         ordering = ["last_name", "first_name"]
         verbose_name = _("Person")
         verbose_name_plural = _("Persons")
+        permissions = (
+            ("view_address", _("Can view address")),
+            ("view_contact_details", _("Can view contact details")),
+            ("view_photo", _("Can view photo")),
+            ("view_person_groups", _("Can view persons groups")),
+            ("view_personal_details", _("Can view personal details")),
+        )
 
     icon_ = "person"
 
@@ -566,3 +573,15 @@ class GroupType(ExtensibleModel):
     class Meta:
         verbose_name = _("Group type")
         verbose_name_plural = _("Group types")
+
+
+class GlobalPermissions(ExtensibleModel):
+    class Meta:
+        managed = False
+        permissions = (
+            ("view_system_status", _("Can view system status")),
+            ("link_persons_accounts", _("Can link persons to accounts")),
+            ("manage_data", _("Can manage data")),
+            ("impersonate", _("Can impersonate")),
+            ("search", _("Can use search")),
+        )
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
new file mode 100644
index 0000000000000000000000000000000000000000..234d6f80159442633f3e45fbc753fe7ad39de6c4
--- /dev/null
+++ b/aleksis/core/rules.py
@@ -0,0 +1,139 @@
+from rules import add_perm, always_allow
+
+from .models import Person, Group, Announcement
+from .util.predicates import (
+    has_person,
+    has_global_perm,
+    has_any_object,
+    is_current_person,
+    has_object_perm,
+)
+
+
+add_perm("core", always_allow)
+
+# View dashboard
+add_perm("core.view_dashboard", has_person)
+
+# Use search
+search_predicate = has_person & has_global_perm("core.search")
+add_perm("core.search", search_predicate)
+
+# View persons
+view_persons_predicate = has_person & (
+    has_global_perm("core.view_person") | has_any_object("core.view_person", Person)
+)
+add_perm("core.view_persons", view_persons_predicate)
+
+# View person
+view_person_predicate = has_person & (
+    has_global_perm("core.view_person") | has_object_perm("core.view_person") | is_current_person
+)
+add_perm("core.view_person", view_person_predicate)
+
+# View person address
+view_address_predicate = has_person & (
+    has_global_perm("core.view_address") | has_object_perm("core.view_address") | is_current_person
+)
+add_perm("core.view_address", view_address_predicate)
+
+# View person contact details
+view_contact_details_predicate = has_person & (
+    has_global_perm("core.view_contact_details") | has_object_perm("core.view_contact_details") | is_current_person
+)
+add_perm("core.view_contact_details", view_contact_details_predicate)
+
+# View person photo
+view_photo_predicate = has_person & (
+    has_global_perm("core.view_photo") | has_object_perm("core.view_photo") | is_current_person
+)
+add_perm("core.view_photo", view_photo_predicate)
+
+# View persons groups
+view_groups_predicate = has_person & (
+    has_global_perm("core.view_person_groups") | has_object_perm("core.view_person_groups") | is_current_person
+)
+add_perm("core.view_person_groups", view_groups_predicate)
+
+# Edit person
+edit_person_predicate = has_person & (
+    has_global_perm("core.change_person") | has_object_perm("core.change_person")
+)
+add_perm("core.edit_person", edit_person_predicate)
+
+# Link persons with accounts
+link_persons_accounts_predicate = has_person & has_global_perm("core.link_persons_accounts")
+add_perm("core.link_persons_accounts", link_persons_accounts_predicate)
+
+# View groups
+view_groups_predicate = has_person & (
+    has_global_perm("core.view_group") | has_any_object("core.view_group", Group)
+)
+add_perm("core.view_groups", view_groups_predicate)
+
+# View group
+view_group_predicate = has_person & (
+    has_global_perm("core.view_group") | has_object_perm("core.view_group")
+)
+add_perm("core.view_group", view_group_predicate)
+
+# Edit group
+edit_group_predicate = has_person & (
+    has_global_perm("core.change_group") | has_object_perm("core.change_group")
+)
+add_perm("core.edit_group", edit_group_predicate)
+
+# Edit school information
+edit_school_information_predicate = has_person & has_global_perm("core.change_school")
+add_perm("core.edit_school_information", edit_school_information_predicate)
+
+# Edit school term
+edit_schoolterm_predicate = has_person & has_global_perm("core.change_schoolterm")
+add_perm("core.edit_schoolterm", edit_schoolterm_predicate)
+
+# Manage school
+manage_school_predicate = edit_school_information_predicate | edit_schoolterm_predicate
+add_perm("core.manage_school", manage_school_predicate)
+
+# Manage data
+manage_data_predicate = has_person & has_global_perm("core.manage_data")
+add_perm("core.manage_data", manage_data_predicate)
+
+# View announcements
+view_announcements_predicate = has_person & (
+    has_global_perm("core.view_announcement") | has_any_object("core.view_announcement", Announcement)
+)
+add_perm("core.view_announcements", view_announcements_predicate)
+
+# Create or edit announcement
+create_or_edit_announcement_predicate = has_person & (
+    has_global_perm("core.add_announcement") & (has_global_perm("core.change_announcement") | has_object_perm("core.change_announcement"))
+)
+add_perm("core.create_or_edit_announcement", create_or_edit_announcement_predicate)
+
+# Delete announcement
+delete_announcement_predicate = has_person & (
+    has_global_perm("core.delete_announcement") | has_object_perm("core.delete_announcement")
+)
+add_perm("core.delete_announcement", delete_announcement_predicate)
+
+# Use impersonate
+impersonate_predicate = has_person & has_global_perm("core.impersonate")
+add_perm("core.impersonate", impersonate_predicate)
+
+# View system status
+view_system_status_predicate = has_person & has_global_perm("core.view_system_status")
+add_perm("core.view_system_status", view_system_status_predicate)
+
+# View people menu (persons + objects)
+add_perm("core.view_people_menu", has_person & (view_persons_predicate | view_groups_predicate))
+
+# View admin menu
+view_admin_menu_predicate = has_person & (manage_data_predicate | manage_school_predicate | impersonate_predicate | view_system_status_predicate | view_announcements_predicate)
+add_perm("core.view_admin_menu", view_admin_menu_predicate)
+
+# View person personal details
+view_personal_details_predicate = has_person & (
+    has_global_perm("core.view_personal_details") | has_object_perm("core.view_personal_details") | is_current_person
+)
+add_perm("core.view_personal_details", view_personal_details_predicate)
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 81c00f57599429b58494b7e38fb5a6f5651fdbb1..eb24f076df6a414052e2e5a29d9dfbf8af5c7083 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -30,6 +30,8 @@ _settings = LazySettings(
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
+SILENCED_SYSTEM_CHECKS = []
+
 # SECURITY WARNING: keep the secret key used in production secret!
 SECRET_KEY = _settings.get("secret_key", "DoNotUseInProduction")
 
@@ -55,6 +57,8 @@ INSTALLED_APPS = [
     "django.contrib.sites",
     "django.contrib.staticfiles",
     "django.contrib.humanize",
+    "guardian",
+    "rules.apps.AutodiscoverRulesConfig",
     "haystack",
     "polymorphic",
     "django_global_request",
@@ -575,6 +579,16 @@ LOGGING = {
     },
 }
 
+# Rules and permissions
+
+GUARDIAN_RAISE_403 = True
+ANONYMOUS_USER_NAME = None
+
+SILENCED_SYSTEM_CHECKS.append("guardian.W001")
+
+# Append authentication backends
+AUTHENTICATION_BACKENDS.append("rules.permissions.ObjectPermissionBackend")
+
 HAYSTACK_BACKEND_SHORT = _settings.get("search.backend", "simple")
 
 if HAYSTACK_BACKEND_SHORT == "simple":
diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html
index fe536557737d05a04c93d9bfbedfcc63f1bbe60b..1aaaf85ca133c0657e8589597bda13f0cdb38de2 100644
--- a/aleksis/core/templates/core/base.html
+++ b/aleksis/core/templates/core/base.html
@@ -1,7 +1,7 @@
 {# -*- engine:django -*- #}
 
 
-{% load i18n menu_generator static sass_tags any_js pwa %}
+{% load i18n menu_generator static sass_tags any_js pwa rules %}
 
 
 <!DOCTYPE html>
@@ -71,7 +71,8 @@
         </object>
       </a>
     </li>
-    {% if user.is_authenticated %}
+    {% has_perm 'core.search' user as search %}
+    {% if search %}
       <li class="search">
         <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete">
           <div class="search-wrapper">
diff --git a/aleksis/core/templates/core/person_full.html b/aleksis/core/templates/core/person_full.html
index 8f9ceed60aa7ca1a99bde9e3682f5988d2f29f75..cf584d8ae87e203936c1c077a6a4345c06661cb9 100644
--- a/aleksis/core/templates/core/person_full.html
+++ b/aleksis/core/templates/core/person_full.html
@@ -2,24 +2,29 @@
 
 {% extends "core/base.html" %}
 
-{% load i18n static cropping %}
+{% load i18n static cropping rules %}
 {% load render_table from django_tables2 %}
 
 {% block browser_title %}{{ person.first_name }} {{ person.last_name }}{% endblock %}
 
 {% block content %}
   <h4>{{ person.first_name }} {{ person.last_name }}</h4>
-  <p>
-    <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light">
-      <i class="material-icons left">edit</i>
-      {% trans "Edit" %}
-    </a>
-  </p>
+
+  {% has_perm 'core.edit_person' user person as can_change_person %}
+  {% if can_change_person %}
+    <p>
+      <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light">
+        <i class="material-icons left">edit</i>
+        {% trans "Edit" %}
+      </a>
+    </p>
+  {% endif %}
 
   <h5>{% blocktrans %}Contact details{% endblocktrans %}</h5>
   <div class="row">
     <div class="col s12 m4">
-      {% if person.photo %}
+      {% has_perm 'core.view_photo' user person as can_view_photo %}
+      {% if person.photo and can_view_photo %}
         <img class="person-img" src="{% cropped_thumbnail person 'photo_cropping' max_size='300x400' %}"
              alt="{{ person.first_name }} {{ person.last_name }}"/>
       {% else %}
@@ -46,36 +51,48 @@
           </td>
           <td colspan="3">{{ person.get_sex_display }}</td>
         </tr>
-        <tr>
-          <td>
-            <i class="material-icons small">home</i>
-          </td>
-          <td colspan="2">{{ person.street }} {{ person.housenumber }}</td>
-          <td colspan="2">{{ person.postal_code }} {{ person.place }}</td>
-        </tr>
-        <tr>
-          <td>
-            <i class="material-icons small">phone</i>
-          </td>
-          <td>{{ person.phone_number }}</td>
-          <td>{{ person.mobile_number }}</td>
-        </tr>
-        <tr>
-          <td>
-            <i class="material-icons small">email</i>
-          </td>
-          <td colspan="3">{{ person.email }}</td>
-        </tr>
-        <tr>
-          <td>
-            <i class="material-icons small">cake</i>
-          </td>
-          <td colspan="3">{{ person.date_of_birth|date }}</td>
-        </tr>
+        {% has_perm 'core.view_address' user person as can_view_address %}
+        {% if can_view_address %}
+          <tr>
+            <td>
+              <i class="material-icons small">home</i>
+            </td>
+            <td colspan="2">{{ person.street }} {{ person.housenumber }}</td>
+            <td colspan="2">{{ person.postal_code }} {{ person.place }}</td>
+          </tr>
+        {% endif %}
+        {% has_perm 'core.view_contact_details' user person as can_view_contact_details %}
+        {% if can_view_contact_details %}
+          <tr>
+            <td>
+              <i class="material-icons small">phone</i>
+            </td>
+            <td>{{ person.phone_number }}</td>
+            <td>{{ person.mobile_number }}</td>
+          </tr>
+          <tr>
+            <td>
+              <i class="material-icons small">email</i>
+            </td>
+            <td colspan="3">{{ person.email }}</td>
+          </tr>
+        {% endif %}
+        {% has_perm 'core.view_personal_details' user person as can_view_personal_details %}
+        {% if can_view_personal_details %}
+          <tr>
+            <td>
+              <i class="material-icons small">cake</i>
+            </td>
+            <td colspan="3">{{ person.date_of_birth|date }}</td>
+          </tr>
+        {% endif %}
       </table>
     </div>
   </div>
 
-  <h5>{% blocktrans %}Groups{% endblocktrans %}</h5>
-  {% render_table groups_table %}
+  {% has_perm 'core.view_person_groups' user person as can_view_groups %}
+  {% if can_view_groups %}
+    <h5>{% blocktrans %}Groups{% endblocktrans %}</h5>
+    {% render_table groups_table %}
+  {% endif %}
 {% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 5de76472c91da9243edfa13c57c5ce29a7782e08..6c1648ee402020e33e5f37230b0730174a55e39f 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -9,6 +9,7 @@ from django.views.i18n import JavaScriptCatalog
 import calendarweek.django
 import debug_toolbar
 from django_js_reverse.views import urls_js
+from rules.contrib.views import permission_required
 from two_factor.urls import urlpatterns as tf_urls
 
 from . import views
@@ -41,7 +42,7 @@ urlpatterns = [
     path("announcement/edit/<int:pk>/", views.announcement_form, name="edit_announcement"),
     path("announcement/delete/<int:pk>/", views.delete_announcement, name="delete_announcement"),
     path("search/searchbar/", views.searchbar_snippets, name="searchbar_snippets"),
-    path("search/", include("haystack.urls")),
+    path("search/", views.PermissionSearchView(), name="haystack_search"),
     path("maintenance-mode/", include("maintenance_mode.urls")),
     path("impersonate/", include("impersonate.urls")),
     path("__i18n__/", include("django.conf.urls.i18n")),
diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py
new file mode 100644
index 0000000000000000000000000000000000000000..3fb1b64e98ee26f29b2b72014ccf66bc2c39e53d
--- /dev/null
+++ b/aleksis/core/util/predicates.py
@@ -0,0 +1,86 @@
+from django.contrib.auth.backends import ModelBackend
+from django.contrib.auth.models import User
+from django.db.models import Model
+from django.http import HttpRequest
+from guardian.backends import ObjectPermissionBackend
+from guardian.shortcuts import get_objects_for_user
+from rules import predicate
+
+from .core_helpers import has_person as has_person_helper
+
+# 1. Global permissions (view all, add, change all, delete all)
+# 2. Object permissions (view, change, delete)
+# 3. Rules
+
+
+def permission_validator(request: HttpRequest, perm: str) -> bool:
+    """ Checks whether the request user has a permission """
+
+    if request.user:
+        return request.user.has_perm(perm)
+    return False
+
+
+def check_global_permission(user: User, perm: str) -> bool:
+    """ Checks whether a user has a global permission """
+
+    return ModelBackend().has_perm(user, perm)
+
+
+def check_object_permission(user: User, perm: str, obj: Model) -> bool:
+    """ Checks whether a user has a permission on a object """
+
+    return ObjectPermissionBackend().has_perm(user, perm, obj)
+
+
+def has_global_perm(perm: str):
+    """ Builds predicate which checks whether a user has a global permission """
+
+    name = "has_global_perm:{}".format(perm)
+
+    @predicate(name)
+    def fn(user: User) -> bool:
+        return check_global_permission(user, perm)
+
+    return fn
+
+
+def has_object_perm(perm: str):
+    """ Builds predicate which checks whether a user has a permission on a object """
+
+    name = "has_global_perm:{}".format(perm)
+
+    @predicate(name)
+    def fn(user: User, obj: Model) -> bool:
+        if not obj:
+            return False
+        return check_object_permission(user, perm, obj)
+
+    return fn
+
+
+def has_any_object(perm: str, klass):
+    """ Build predicate which checks whether a user has access to objects with the provided permission """
+
+    name = "has_any_object:{}".format(perm)
+
+    @predicate(name)
+    def fn(user: User) -> bool:
+        objs = get_objects_for_user(user, perm, klass)
+        return len(objs) > 0
+
+    return fn
+
+
+@predicate
+def has_person(user: User) -> bool:
+    """ Predicate which checks whether a user has a linked person """
+
+    return has_person_helper(user)
+
+
+@predicate
+def is_current_person(user: User, obj: Model) -> bool:
+    """ Predicate which checks if the provided object is the person linked to the user object """
+
+    return user.person == obj
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index f3eae71612f3f0c488437b3a7ccb33fb4e214f0d..6c4fad570d22c05d3b057fa6f8b24e2c8e1dee8a 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -2,17 +2,19 @@ from importlib import import_module
 from typing import Optional
 
 from django.apps import apps
-from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import PermissionDenied
 from django.http import Http404, HttpRequest, HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.translation import gettext_lazy as _
 
 from django_tables2 import RequestConfig
+from guardian.shortcuts import get_objects_for_user
 from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
+from haystack.views import SearchView
+from rules.contrib.views import permission_required
 
-from .decorators import admin_required, person_required
 from .forms import (
     EditGroupForm,
     EditPersonForm,
@@ -27,7 +29,7 @@ from .util import messages
 from .util.apps import AppConfig
 
 
-@person_required
+@permission_required("core.view_dashboard")
 def index(request: HttpRequest) -> HttpResponse:
     context = {}
 
@@ -63,12 +65,14 @@ def about(request):
     return render(request, "core/about.html", context)
 
 
-@login_required
+@permission_required("core.view_persons")
 def persons(request: HttpRequest) -> HttpResponse:
     context = {}
 
     # Get all persons
-    persons = Person.objects.filter(is_active=True)
+    persons = get_objects_for_user(
+        request.user, "core.view_person", Person.objects.filter(is_active=True)
+    )
 
     # Build table
     persons_table = PersonsTable(persons)
@@ -78,19 +82,19 @@ def persons(request: HttpRequest) -> HttpResponse:
     return render(request, "core/persons.html", context)
 
 
-@person_required
+def get_person_by_pk(request, id_: Optional[int] = None):
+    if id_:
+        return get_object_or_404(Person, pk=id_)
+    else:
+        return request.user.person
+
+
+@permission_required("core.view_person", fn=get_person_by_pk)
 def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse:
     context = {}
 
     # Get person and check access
-    try:
-        if id_ is None:
-            person = request.user.person
-        else:
-            person = Person.objects.get(pk=id_)
-    except Person.DoesNotExist as e:
-        # Turn not-found object into a 404 error
-        raise Http404 from e
+    person = get_person_by_pk(request, id_)
 
     context["person"] = person
 
@@ -105,16 +109,15 @@ def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse:
     return render(request, "core/person_full.html", context)
 
 
-@login_required
+def get_group_by_pk(request: HttpRequest, id_: int) -> Group:
+    return get_object_or_404(Group, pk=id_)
+
+
+@permission_required("core.view_group", fn=get_group_by_pk)
 def group(request: HttpRequest, id_: int) -> HttpResponse:
     context = {}
 
-    # Get group and check if it exist
-    try:
-        group = Group.objects.get(pk=id_)
-    except Group.DoesNotExist as e:
-        # Turn not-found object into a 404 error
-        raise Http404 from e
+    group = get_group_by_pk(request, id_)
 
     context["group"] = group
 
@@ -140,12 +143,12 @@ def group(request: HttpRequest, id_: int) -> HttpResponse:
     return render(request, "core/group_full.html", context)
 
 
-@login_required
+@permission_required("core.view_groups")
 def groups(request: HttpRequest) -> HttpResponse:
     context = {}
 
     # Get all groups
-    groups = Group.objects.all()
+    groups = get_objects_for_user(request.user, "core.view_group", Group)
 
     # Build table
     groups_table = GroupsTable(groups)
@@ -155,7 +158,7 @@ def groups(request: HttpRequest) -> HttpResponse:
     return render(request, "core/groups.html", context)
 
 
-@admin_required
+@permission_required("core.link_persons_accounts")
 def persons_accounts(request: HttpRequest) -> HttpResponse:
     context = {}
 
@@ -171,11 +174,15 @@ def persons_accounts(request: HttpRequest) -> HttpResponse:
     return render(request, "core/persons_accounts.html", context)
 
 
-@admin_required
+def get_person_by_id(request: HttpRequest, id_:int):
+    return get_object_or_404(Person, id=id_)
+
+
+@permission_required("core.edit_person", fn=get_person_by_id)
 def edit_person(request: HttpRequest, id_: int) -> HttpResponse:
     context = {}
 
-    person = get_object_or_404(Person, id=id_)
+    person = get_person_by_id(request, id_)
 
     edit_person_form = EditPersonForm(request.POST or None, request.FILES or None, instance=person)
 
@@ -193,15 +200,22 @@ def edit_person(request: HttpRequest, id_: int) -> HttpResponse:
     return render(request, "core/edit_person.html", context)
 
 
-@admin_required
+def get_group_by_id(request: HttpRequest, id_: Optional[int] = None):
+    if id_:
+        return get_object_or_404(Group, id=id_)
+    else:
+        return None
+
+
+@permission_required("core.edit_group", fn=get_group_by_id)
 def edit_group(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse:
     context = {}
 
+    group = get_group_by_id(request, id_)
+
     if id_:
-        group = get_object_or_404(Group, id=id_)
         edit_group_form = EditGroupForm(request.POST or None, instance=group)
     else:
-        group = None
         edit_group_form = EditGroupForm(request.POST or None)
 
     if request.method == "POST":
@@ -217,26 +231,26 @@ def edit_group(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse:
     return render(request, "core/edit_group.html", context)
 
 
-@admin_required
+@permission_required("core.manage_data")
 def data_management(request: HttpRequest) -> HttpResponse:
     context = {}
     return render(request, "core/data_management.html", context)
 
 
-@admin_required
+@permission_required("core.view_system_status")
 def system_status(request: HttpRequest) -> HttpResponse:
     context = {}
 
     return render(request, "core/system_status.html", context)
 
 
-@admin_required
+@permission_required("core.manage_school")
 def school_management(request: HttpRequest) -> HttpResponse:
     context = {}
     return render(request, "core/school_management.html", context)
 
 
-@admin_required
+@permission_required("core.edit_school_information")
 def edit_school(request: HttpRequest) -> HttpResponse:
     context = {}
 
@@ -257,7 +271,7 @@ def edit_school(request: HttpRequest) -> HttpResponse:
     return render(request, "core/edit_school.html", context)
 
 
-@admin_required
+@permission_required("core.edit_schoolterm")
 def edit_schoolterm(request: HttpRequest) -> HttpResponse:
     context = {}
 
@@ -290,7 +304,7 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse:
     return redirect("index")
 
 
-@admin_required
+@permission_required("core.view_announcements")
 def announcements(request: HttpRequest) -> HttpResponse:
     context = {}
 
@@ -301,12 +315,18 @@ def announcements(request: HttpRequest) -> HttpResponse:
     return render(request, "core/announcement/list.html", context)
 
 
-@admin_required
+def get_announcement_by_pk(request: HttpRequest, pk: Optional[int] = None):
+    if pk:
+        return get_object_or_404(Announcement, pk=pk)
+    return None
+
+
+@permission_required("core.create_or_edit_announcement", fn=get_announcement_by_pk)
 def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpResponse:
     context = {}
 
     if pk:
-        announcement = get_object_or_404(Announcement, pk=pk)
+        announcement = get_announcement_by_pk(request, pk)
         form = AnnouncementForm(
             request.POST or None,
             instance=announcement
@@ -328,17 +348,17 @@ def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpRes
     return render(request, "core/announcement/form.html", context)
 
 
-@admin_required
+@permission_required("core.delete_announcement", fn=get_announcement_by_pk)
 def delete_announcement(request: HttpRequest, pk: int) -> HttpResponse:
     if request.method == "POST":
-        announcement = get_object_or_404(Announcement, pk=pk)
+        announcement = get_announcement_by_pk(request, pk)
         announcement.delete()
         messages.success(request, _("The announcement has been deleted."))
 
     return redirect("announcements")
 
 
-@login_required
+@permission_required("core.search")
 def searchbar_snippets(request: HttpRequest) -> HttpResponse:
     query = request.GET.get('q', '')
     limit = int(request.GET.get('limit', '5'))
@@ -347,3 +367,13 @@ def searchbar_snippets(request: HttpRequest) -> HttpResponse:
     context = {"results": results}
 
     return render(request, "search/searchbar_snippets.html", context)
+
+
+class PermissionSearchView(PermissionRequiredMixin, SearchView):
+    permission_required = "core.search"
+
+    def create_response(self):
+        context = self.get_context()
+        if not self.has_permission():
+            return self.handle_no_permission()
+        return render(self.request, self.template, context)
diff --git a/poetry.lock b/poetry.lock
index 6893b47254efa4705507f421317319d5a99d8e70..4fe739a06e68f6620a5c073574d11a128a9599e0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -382,6 +382,17 @@ version = "2.2.0"
 [package.dependencies]
 Django = ">=1.8"
 
+[[package]]
+category = "main"
+description = "Django utility for a memoization decorator that uses the Django cache framework."
+name = "django-cache-memoize"
+optional = false
+python-versions = "*"
+version = "0.1.6"
+
+[package.extras]
+dev = ["flake8", "tox", "twine", "therapist", "black"]
+
 [[package]]
 category = "main"
 description = "Database-backed Periodic Tasks."
@@ -617,17 +628,6 @@ version = "1.6.3"
 [package.dependencies]
 six = "*"
 
-[[package]]
-category = "main"
-description = "An implementation of memoization technique for Django."
-name = "django-memoize"
-optional = false
-python-versions = "*"
-version = "2.3.0"
-
-[package.dependencies]
-django = "*"
-
 [[package]]
 category = "main"
 description = "A straightforward menu generator for Django"
@@ -1912,7 +1912,7 @@ description = "A collection of helpers and mock objects for unit tests and doc t
 name = "testfixtures"
 optional = false
 python-versions = "*"
-version = "6.14.0"
+version = "6.14.1"
 
 [package.extras]
 build = ["setuptools-git", "wheel", "twine"]
@@ -2073,7 +2073,7 @@ celery = ["Celery", "django-celery-results", "django-celery-beat", "django-celer
 ldap = ["django-auth-ldap"]
 
 [metadata]
-content-hash = "afd2f4b69870b913a740e8247dbe3618d40e0b51294203c5e883b9f72427bd71"
+content-hash = "0f4a5dd1371431d369bd5d1ef0dd49286afb38daae4a1019ff085b097a38a560"
 python-versions = "^3.7"
 
 [metadata.files]
@@ -2227,6 +2227,10 @@ django-bulk-update = [
     {file = "django-bulk-update-2.2.0.tar.gz", hash = "sha256:5ab7ce8a65eac26d19143cc189c0f041d5c03b9d1b290ca240dc4f3d6aaeb337"},
     {file = "django_bulk_update-2.2.0-py2.py3-none-any.whl", hash = "sha256:49a403392ae05ea872494d74fb3dfa3515f8df5c07cc277c3dc94724c0ee6985"},
 ]
+django-cache-memoize = [
+    {file = "django-cache-memoize-0.1.6.tar.gz", hash = "sha256:7f271be70b11155929ee8a4a2b5f53c9fb46b9befa1b546caffa3298e6ac8f7d"},
+    {file = "django_cache_memoize-0.1.6-py2.py3-none-any.whl", hash = "sha256:d239e8c37734b0a70b74f94fa33b180b3b0c82c3784beb21209bb4ab64a3e6fb"},
+]
 django-celery-beat = [
     {file = "django-celery-beat-2.0.0.tar.gz", hash = "sha256:fdf1255eecfbeb770c6521fe3e69989dfc6373cd5a7f0fe62038d37f80f47e48"},
     {file = "django_celery_beat-2.0.0-py2.py3-none-any.whl", hash = "sha256:fe0b2a1b31d4a6234fea4b31986ddfd4644a48fab216ce1843f3ed0ddd2e9097"},
@@ -2306,9 +2310,6 @@ django-material = [
     {file = "django-material-1.6.3.tar.gz", hash = "sha256:f8758afe1beabc16a3c54f5437c7fea15946b7d068eedd89c97d57a363793950"},
     {file = "django_material-1.6.3-py2.py3-none-any.whl", hash = "sha256:502dc88c2f61f190fdc401666e83b47da00cbda98477af6ed8b7d43944ce6407"},
 ]
-django-memoize = [
-    {file = "django-memoize-2.3.0.tar.gz", hash = "sha256:85decffbef7d38ffc569dc96527f598e6677bbc01ce29adf722b051da7efd4be"},
-]
 django-menu-generator = [
     {file = "django-menu-generator-1.0.4.tar.gz", hash = "sha256:ce71a5055c16933c8aff64fb36c21e5cf8b6d505733aceed1252f8b99369a378"},
 ]
@@ -2417,6 +2418,8 @@ flake8-builtins = [
 ]
 flake8-django = [
     {file = "flake8-django-0.0.4.tar.gz", hash = "sha256:7329ec2e2b8b194e8109639c534359014c79df4d50b14f4b85b8395edc5d6760"},
+    {file = "flake8_django-0.0.4-py3.5.egg", hash = "sha256:ca66462724acbcf241d29edec201dac40c05cc27ae118b5abb8d74066681402f"},
+    {file = "flake8_django-0.0.4-py3.7.egg", hash = "sha256:29721a4976f784921b140752234447af1192c4e4f989d0db4e2d9f7f7915fa86"},
 ]
 flake8-docstrings = [
     {file = "flake8-docstrings-1.5.0.tar.gz", hash = "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717"},
@@ -2485,6 +2488,7 @@ libsass = [
     {file = "libsass-0.19.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:81a013a4c2a614927fd1ef7a386eddabbba695cbb02defe8f31cf495106e974c"},
     {file = "libsass-0.19.4-cp35-cp35m-win32.whl", hash = "sha256:fcb7ab4dc81889e5fc99cafbc2017bc76996f9992fc6b175f7a80edac61d71df"},
     {file = "libsass-0.19.4-cp35-cp35m-win_amd64.whl", hash = "sha256:fc5f8336750f76f1bfae82f7e9e89ae71438d26fc4597e3ab4c05ca8fcd41d8a"},
+    {file = "libsass-0.19.4-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:53f87116e7441827878bd79bbad8debac23e1930423f61ab8d837ec4a4c36e0c"},
     {file = "libsass-0.19.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9b59afa0d755089c4165516400a39a289b796b5612eeef5736ab7a1ebf96a67c"},
     {file = "libsass-0.19.4-cp36-cp36m-win32.whl", hash = "sha256:c93df526eeef90b1ea4799c1d33b6cd5aea3e9f4633738fb95c1287c13e6b404"},
     {file = "libsass-0.19.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0fd8b4337b3b101c6e6afda9112cc0dc4bacb9133b59d75d65968c7317aa3272"},
@@ -2492,6 +2496,7 @@ libsass = [
     {file = "libsass-0.19.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:338e9ae066bf1fde874e335324d5355c52d2081d978b4f74fc59536564b35b08"},
     {file = "libsass-0.19.4-cp37-cp37m-win32.whl", hash = "sha256:e318f06f06847ff49b1f8d086ac9ebce1e63404f7ea329adab92f4f16ba0e00e"},
     {file = "libsass-0.19.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a7e685466448c9b1bf98243339793978f654a1151eb5c975f09b83c7a226f4c1"},
+    {file = "libsass-0.19.4-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0fb4399f7bbecab7b181f2c2d82c3a0ba2916bf9169714b96e425355a5b23b9f"},
     {file = "libsass-0.19.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6a51393d75f6e3c812785b0fa0b7d67c54258c28011921f204643b55f7355ec0"},
     {file = "libsass-0.19.4.tar.gz", hash = "sha256:8b5b6d1a7c4ea1d954e0982b04474cc076286493f6af2d0a13c2e950fbe0be95"},
 ]
@@ -2862,8 +2867,8 @@ termcolor = [
     {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"},
 ]
 testfixtures = [
-    {file = "testfixtures-6.14.0-py2.py3-none-any.whl", hash = "sha256:799144b3cbef7b072452d9c36cbd024fef415ab42924b96aad49dfd9c763de66"},
-    {file = "testfixtures-6.14.0.tar.gz", hash = "sha256:cdfc3d73cb6d3d4dc3c67af84d912e86bf117d30ae25f02fe823382ef99383d2"},
+    {file = "testfixtures-6.14.1-py2.py3-none-any.whl", hash = "sha256:30566e24a1b34e4d3f8c13abf62557d01eeb4480bcb8f1745467bfb0d415a7d9"},
+    {file = "testfixtures-6.14.1.tar.gz", hash = "sha256:58d2b3146d93bc5ddb0cd24e0ccacb13e29bdb61e5c81235c58f7b8ee4470366"},
 ]
 "testing.common.database" = [
     {file = "testing.common.database-2.0.3-py2.py3-none-any.whl", hash = "sha256:e3ed492bf480a87f271f74c53b262caf5d85c8bc09989a8f534fa2283ec52492"},
diff --git a/pyproject.toml b/pyproject.toml
index d07c890ad188c3a08f15095e07c9378125ece20e..b0e9c84ee4820832417255a8f290c762fe270206 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,6 +67,8 @@ django-polymorphic = "^2.1.2"
 django-otp = "0.7.5"
 django-colorfield = "^0.2.1"
 django-bleach = "^0.6.1"
+django-guardian = "^2.2.0"
+rules = "^2.2"
 django-cache-memoize = "^0.1.6"
 django-haystack = {version="3.0b1", allows-prereleases = true}
 celery-haystack = {version="^0.3.1", optional=true}