From b41e80a65521e5ecaf77e29e8c0da4c6ee4bb9a0 Mon Sep 17 00:00:00 2001
From: Martin Juhas <511735@mail.muni.cz>
Date: Wed, 10 Apr 2024 11:48:34 +0200
Subject: [PATCH] Merge aai-dev to main

---
 .gitignore                                    |   1 -
 aai/__init__.py                               |   0
 aai/admin.py                                  |   3 +
 aai/apps.py                                   |   9 +
 aai/backend.py                                |  28 ++
 aai/management/__init__.py                    |   0
 aai/management/commands/__init__.py           |   1 +
 aai/management/commands/assignperms.py        |  78 ++++
 aai/migrations/0001_initial.py                |  24 ++
 aai/migrations/__init__.py                    |   0
 aai/models.py                                 | 114 ++++++
 aai/schema/__init__.py                        |   1 +
 aai/schema/mutation.py                        |  88 +++++
 aai/tests/aai_utils_tests.py                  | 353 +++++++++++++++++
 aai/tests/encryption_tests.py                 |   1 +
 aai/utils.py                                  | 355 ++++++++++++++++++
 aai/views.py                                  |   3 +
 common_lib/graphql/__init__.py                |   4 +
 common_lib/graphql/api/__init__.py            |   2 +-
 common_lib/graphql/api/utils.py               |   4 +
 common_lib/graphql/graphql_api_t_case.py      |  13 +-
 common_lib/graphql/mutations.py               |  53 +++
 common_lib/schema_types.py                    |  67 ++++
 exercise/lib/log_manager.py                   |  80 +++-
 .../migrations/0009_auto_20240227_1813.py     |  37 ++
 ...o_20240227_1813_0010_auto_20240221_1426.py |  14 +
 exercise/models.py                            |  13 +
 exercise/schema/mutation.py                   |  19 +
 exercise/schema/query.py                      |  32 +-
 exercise/serializers.py                       |  66 +++-
 exercise/subscription.py                      |   3 +
 exercise/tests/create_exercise_tests.py       |   9 +-
 exercise/views.py                             |  17 +-
 .../migrations/0011_auto_20240227_1813.py     |  57 +++
 ...o_20240227_1813_0012_auto_20240227_1222.py |  14 +
 exercise_definition/models/models.py          |  41 ++
 exercise_definition/views.py                  |   7 +
 running_exercise/lib/email_client.py          |  23 +-
 running_exercise/lib/inject_selector.py       |   7 +-
 running_exercise/lib/team_action_handler.py   |  11 +-
 .../migrations/0006_auto_20240227_1813.py     |  37 ++
 ...o_20240227_1813_0007_auto_20240227_1222.py |  14 +
 .../migrations/0009_auto_20240320_1646.py     |  39 ++
 .../migrations/0010_emailthread_exercise.py   |  21 ++
 .../0011_alter_emailthread_exercise.py        |  20 +
 running_exercise/models.py                    |  49 ++-
 running_exercise/schema/mutation.py           |  44 ++-
 running_exercise/schema/query.py              |  82 +++-
 running_exercise/serializers.py               |  47 ++-
 running_exercise/subscription.py              |  31 +-
 running_exercise/tests/email_tests.py         |  54 ++-
 running_exercise/tests/serializers_tests.py   | 132 +++++++
 running_exercise/views.py                     |   6 +
 ttxbackend/schema.py                          |   8 +-
 ttxbackend/settings.py                        |  21 +-
 ttxbackend/urls.py                            |   1 +
 user/__init__.py                              |   0
 user/admin.py                                 |   5 +
 user/apps.py                                  |  21 ++
 user/email/email_sender.py                    |  38 ++
 user/graphql_inputs.py                        |  31 ++
 user/lib/__init__.py                          |   2 +
 user/lib/user_uploader.py                     |  90 +++++
 user/lib/user_validator.py                    | 163 ++++++++
 user/migrations/0001_initial.py               | 117 ++++++
 user/migrations/0002_auto_20240320_1646.py    |  36 ++
 user/migrations/0003_alter_user_id.py         |  19 +
 user/migrations/0004_auto_20240402_1408.py    |  31 ++
 user/migrations/0005_auto_20240402_1922.py    |  26 ++
 user/migrations/0006_auto_20240405_1401.py    |  25 ++
 user/migrations/0007_auto_20240405_1518.py    |  22 ++
 user/migrations/__init__.py                   |   0
 user/models.py                                | 238 ++++++++++++
 user/schema/__init__.py                       |   2 +
 user/schema/mutation.py                       | 237 ++++++++++++
 user/schema/query.py                          |  73 ++++
 user/schema/validators.py                     | 149 ++++++++
 user/templates/welcome_email.html             |   8 +
 user/tests/__init__.py                        |   0
 user/tests/csv_onboarding_tests.py            | 208 ++++++++++
 user/tests/graphql_api_tests.py               | 289 ++++++++++++++
 user/tests/mutation_validators_tests.py       | 127 +++++++
 user/urls.py                                  |  11 +
 user/views.py                                 |  50 +++
 84 files changed, 4193 insertions(+), 83 deletions(-)
 create mode 100644 aai/__init__.py
 create mode 100644 aai/admin.py
 create mode 100644 aai/apps.py
 create mode 100644 aai/backend.py
 create mode 100644 aai/management/__init__.py
 create mode 100644 aai/management/commands/__init__.py
 create mode 100644 aai/management/commands/assignperms.py
 create mode 100644 aai/migrations/0001_initial.py
 create mode 100644 aai/migrations/__init__.py
 create mode 100644 aai/models.py
 create mode 100644 aai/schema/__init__.py
 create mode 100644 aai/schema/mutation.py
 create mode 100644 aai/tests/aai_utils_tests.py
 create mode 100644 aai/tests/encryption_tests.py
 create mode 100644 aai/utils.py
 create mode 100644 aai/views.py
 create mode 100644 exercise/migrations/0009_auto_20240227_1813.py
 create mode 100644 exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py
 create mode 100644 exercise_definition/migrations/0011_auto_20240227_1813.py
 create mode 100644 exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py
 create mode 100644 running_exercise/migrations/0006_auto_20240227_1813.py
 create mode 100644 running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py
 create mode 100644 running_exercise/migrations/0009_auto_20240320_1646.py
 create mode 100644 running_exercise/migrations/0010_emailthread_exercise.py
 create mode 100644 running_exercise/migrations/0011_alter_emailthread_exercise.py
 create mode 100644 running_exercise/tests/serializers_tests.py
 create mode 100644 user/__init__.py
 create mode 100644 user/admin.py
 create mode 100644 user/apps.py
 create mode 100644 user/email/email_sender.py
 create mode 100644 user/graphql_inputs.py
 create mode 100644 user/lib/__init__.py
 create mode 100644 user/lib/user_uploader.py
 create mode 100644 user/lib/user_validator.py
 create mode 100644 user/migrations/0001_initial.py
 create mode 100644 user/migrations/0002_auto_20240320_1646.py
 create mode 100644 user/migrations/0003_alter_user_id.py
 create mode 100644 user/migrations/0004_auto_20240402_1408.py
 create mode 100644 user/migrations/0005_auto_20240402_1922.py
 create mode 100644 user/migrations/0006_auto_20240405_1401.py
 create mode 100644 user/migrations/0007_auto_20240405_1518.py
 create mode 100644 user/migrations/__init__.py
 create mode 100644 user/models.py
 create mode 100644 user/schema/__init__.py
 create mode 100644 user/schema/mutation.py
 create mode 100644 user/schema/query.py
 create mode 100644 user/schema/validators.py
 create mode 100644 user/templates/welcome_email.html
 create mode 100644 user/tests/__init__.py
 create mode 100644 user/tests/csv_onboarding_tests.py
 create mode 100644 user/tests/graphql_api_tests.py
 create mode 100644 user/tests/mutation_validators_tests.py
 create mode 100644 user/urls.py
 create mode 100644 user/views.py

diff --git a/.gitignore b/.gitignore
index cc802dec..0eaf6a04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,3 @@ data
 
 # export/import
 export_import
-logs.jsonl
diff --git a/aai/__init__.py b/aai/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/aai/admin.py b/aai/admin.py
new file mode 100644
index 00000000..8c38f3f3
--- /dev/null
+++ b/aai/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/aai/apps.py b/aai/apps.py
new file mode 100644
index 00000000..bd5f7be7
--- /dev/null
+++ b/aai/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class AaiConfig(AppConfig):
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "aai"
+
+    def ready(self):
+        import aai.management.commands.assignperms
diff --git a/aai/backend.py b/aai/backend.py
new file mode 100644
index 00000000..527a23f0
--- /dev/null
+++ b/aai/backend.py
@@ -0,0 +1,28 @@
+from django.contrib.auth.backends import ModelBackend
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import AuthenticationFailed
+
+UserModel = get_user_model()
+
+
+class CustomAuthBackend(ModelBackend):
+    def authenticate(self, request, username=None, password=None, **kwargs):
+        if username is None:
+            username = kwargs.get(UserModel.USERNAME_FIELD)
+        if username is None or password is None:
+            raise AuthenticationFailed("Username or password is missing")
+        try:
+            user = UserModel._default_manager.get_by_natural_key(username)
+        except UserModel.DoesNotExist:
+            # Run the default password hasher once to reduce the timing
+            # difference between an existing and a nonexistent user.
+            UserModel().set_password(password)
+            raise AuthenticationFailed("Incorrect username or password")
+        else:
+            if not user.check_password(password):
+                raise AuthenticationFailed("Incorrect username or password")
+
+            if not self.user_can_authenticate(user):
+                return AuthenticationFailed("Your account has been blocked")
+
+            return user
diff --git a/aai/management/__init__.py b/aai/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/aai/management/commands/__init__.py b/aai/management/commands/__init__.py
new file mode 100644
index 00000000..87f235c0
--- /dev/null
+++ b/aai/management/commands/__init__.py
@@ -0,0 +1 @@
+default_app_config = "aai.apps.AaiConfig"
diff --git a/aai/management/commands/assignperms.py b/aai/management/commands/assignperms.py
new file mode 100644
index 00000000..35c77c42
--- /dev/null
+++ b/aai/management/commands/assignperms.py
@@ -0,0 +1,78 @@
+from typing import Any
+import sys
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth.models import Permission, Group
+from django.db.models.signals import post_migrate
+from django.dispatch import receiver
+
+from aai.models import UserGroup, Perms
+
+migrated_apps = set()
+required_migrations = {"user", "aai", "django.contrib.auth"}
+DONE = False
+
+
+# Due to unit tests pipeline this has to be temporarly automatic
+@receiver(post_migrate)
+def assign_perms_after_migration(sender, **kwargs):
+    global DONE, migrated_apps, required_migrations
+    if DONE:
+        return
+
+    migrated_apps.add(sender.name)
+    if required_migrations.issubset(migrated_apps):
+        assign_perms()
+        DONE = True
+
+
+def assign_perms():
+    # Initialize groups
+    trainee, _ = Group.objects.get_or_create(name=UserGroup.TRAINEE)
+    instructor, _ = Group.objects.get_or_create(name=UserGroup.INSTRUCTOR)
+    _, _ = Group.objects.get_or_create(name=UserGroup.ADMIN)
+
+    #### trainee permissions assignemnt ####
+    trainee_perm_names = [
+        Perms.view_exercise.codename,
+        Perms.use_tool.codename,
+        Perms.send_email.codename,
+        Perms.view_trainee_info.codename,
+        Perms.view_email_info.codename,
+        Perms.view_email.codename,
+        Perms.manipulate_file.codename,
+    ]
+    trainee_perms = Permission.objects.filter(codename__in=trainee_perm_names)
+    trainee.permissions.add(*trainee_perms)
+    instructor.permissions.add(*trainee_perms)
+    trainee.save()
+
+    #### instructor permissions assignment ####
+    instructor_perm_names = [
+        Perms.update_exercise.codename,
+        Perms.update_definition.codename,
+        Perms.view_definition.codename,
+        Perms.view_category.codename,
+        Perms.view_milestone.codename,
+        Perms.send_injectselection.codename,
+        Perms.view_extendtool.codename,
+        Perms.view_injectselection.codename,
+        Perms.view_analytics.codename,
+        Perms.update_userassignment.codename,
+        Perms.view_user.codename,
+        Perms.update_user.codename,
+    ]
+    instructor_perms = Permission.objects.filter(
+        codename__in=instructor_perm_names
+    )
+    instructor.permissions.add(*instructor_perms)
+    instructor.save()
+
+    sys.stderr.write("Predefined groups and permissions have been set in DB\n")
+
+
+class Command(BaseCommand):
+    help = "Populate database with groups and assigns permissions"
+
+    def handle(self, *args: Any, **options: Any):
+        assign_perms()
diff --git a/aai/migrations/0001_initial.py b/aai/migrations/0001_initial.py
new file mode 100644
index 00000000..8f35be44
--- /dev/null
+++ b/aai/migrations/0001_initial.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.24 on 2024-02-27 18:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Perms',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+            ],
+            options={
+                'permissions': [('update_exercise', 'Can access instructor tools for exercise manipulation'), ('view_exercise', 'Can view exercise and their info'), ('update_definition', 'Can add/delete/change definition'), ('view_definition', 'Can view definition'), ('view_category', 'Can view inject categories'), ('view_milestone', 'Can view milestones'), ('use_tool', 'Can use tool of the exercise'), ('send_injectselection', 'Can pick and send inject selection'), ('send_email', 'Can send email and execute email related operations'), ('view_trainee_info', 'Can view exercise info intedned to trainees (roles, tools...)'), ('view_extendtool', 'Can view extend tool (with responses)'), ('view_injectselection', 'Can view inject selecion options'), ('view_email_info', 'Can view info related to emails (contacts, addresses...)'), ('view_email', 'Can view email bodies and threads'), ('view_analytics', 'Can view data needed for analytics dashboard'), ('update_userassignment', 'Can (un)assign user to exercise or team'), ('view_user', 'Can view users in database'), ('manipulate_file', 'Can upload and download files during exercise'), ('update_user', 'Can add/remove/change user')],
+                'default_permissions': (),
+            },
+        ),
+    ]
diff --git a/aai/migrations/__init__.py b/aai/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/aai/models.py b/aai/models.py
new file mode 100644
index 00000000..d08619b0
--- /dev/null
+++ b/aai/models.py
@@ -0,0 +1,114 @@
+from typing import Optional
+from enum import Enum
+
+from django.db import models
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.auth.models import Group
+
+
+class UserGroup(str, Enum):
+    TRAINEE = "trainee"
+    INSTRUCTOR = "instructor"
+    ADMIN = "admin"
+
+    @staticmethod
+    def from_str(group: str) -> Optional["UserGroup"]:
+        lowered_group = group.lower()
+        if lowered_group == "trainee" or lowered_group == "t":
+            return UserGroup.TRAINEE
+        elif lowered_group == "instructor" or lowered_group == "i":
+            return UserGroup.INSTRUCTOR
+        elif lowered_group == "admin" or lowered_group == "a":
+            return UserGroup.ADMIN
+        return None
+
+    def __eq__(self, other):
+        if isinstance(other, Group):
+            return self.value == other.name
+        return super().__eq__(other)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __hash__(self) -> int:
+        return super().__hash__()
+
+
+class NameHandler:
+    full_name: str
+    codename: str
+
+    def __init__(self, full_name):
+        self.full_name = full_name
+        self.codename = self.full_name.split(".")[1]
+
+    def __get__(self, instance, owner):
+        return self
+
+
+class Perms(models.Model):
+    update_exercise = NameHandler("aai.update_exercise")
+    view_exercise = NameHandler("aai.view_exercise")
+    update_definition = NameHandler("aai.update_definition")
+    view_definition = NameHandler("aai.view_definition")
+    view_category = NameHandler("aai.view_category")
+    view_milestone = NameHandler("aai.view_milestone")
+    use_tool = NameHandler("aai.use_tool")
+    send_injectselection = NameHandler("aai.send_injectselection")
+    send_email = NameHandler("aai.send_email")
+    view_trainee_info = NameHandler("aai.view_trainee_info")
+    view_extendtool = NameHandler("aai.view_extendtool")
+    view_injectselection = NameHandler("aai.view_injectselection")
+    view_email_info = NameHandler("aai.view_email_info")
+    view_email = NameHandler("aai.view_email")
+    view_analytics = NameHandler("aai.view_analytics")
+    update_userassignment = NameHandler("aai.update_userassignment")
+    view_user = NameHandler("aai.view_user")
+    manipulate_file = NameHandler("aai.manipulate_file")
+    update_user = NameHandler("aai.update_user")
+
+    class Meta:
+        default_permissions = ()
+        permissions = [
+            (
+                "update_exercise",
+                "Can access instructor tools for exercise manipulation",
+            ),
+            ("view_exercise", "Can view exercise and their info"),
+            ("update_definition", "Can add/delete/change definition"),
+            ("view_definition", "Can view definition"),
+            ("view_category", "Can view inject categories"),
+            ("view_milestone", "Can view milestones"),
+            ("use_tool", "Can use tool of the exercise"),
+            ("send_injectselection", "Can pick and send inject selection"),
+            (
+                "send_email",
+                "Can send email and execute email related operations",
+            ),
+            (
+                "view_trainee_info",
+                "Can view exercise info intedned to trainees (roles, tools...)",
+            ),
+            ("view_extendtool", "Can view extend tool (with responses)"),
+            ("view_injectselection", "Can view inject selecion options"),
+            (
+                "view_email_info",
+                "Can view info related to emails (contacts, addresses...)",
+            ),
+            ("view_email", "Can view email bodies and threads"),
+            ("view_analytics", "Can view data needed for analytics dashboard"),
+            (
+                "update_userassignment",
+                "Can (un)assign user to exercise or team",
+            ),
+            ("view_user", "Can view users in database"),
+            (
+                "manipulate_file",
+                "Can upload and download files during exercise",
+            ),
+            ("update_user", "Can add/remove/change user"),
+        ]
+
+    @classmethod
+    def content_type(cls):
+        return ContentType.objects.get_for_model(Perms)
diff --git a/aai/schema/__init__.py b/aai/schema/__init__.py
new file mode 100644
index 00000000..4cd53dec
--- /dev/null
+++ b/aai/schema/__init__.py
@@ -0,0 +1 @@
+from .mutation import Mutation
diff --git a/aai/schema/mutation.py b/aai/schema/mutation.py
new file mode 100644
index 00000000..ef12726c
--- /dev/null
+++ b/aai/schema/mutation.py
@@ -0,0 +1,88 @@
+import graphene
+from typing import Optional
+
+from django.contrib.auth import authenticate, logout, login
+from django.conf import settings
+
+from common_lib.schema_types import UserType
+from rest_framework.exceptions import AuthenticationFailed
+from common_lib.exceptions import ValidationError
+from user.email.email_sender import send_password_change_notification
+
+
+class LoginMutation(graphene.Mutation):
+    class Arguments:
+        username = graphene.String(required=True)
+        password = graphene.String(required=True)
+
+    user = graphene.Field(UserType)
+
+    @classmethod
+    def mutate(
+        cls,
+        root,
+        info,
+        username: str,
+        password: str,
+    ) -> graphene.Mutation:
+        # raises exception when fail
+        user = authenticate(username=username, password=password)
+        login(info.context, user)
+        # TODO: add logging
+        # TODO: implement attempts limiter
+        return LoginMutation(user=user)
+
+
+class LogoutMutation(graphene.Mutation):
+    logged_out = graphene.Boolean()
+
+    @classmethod
+    def mutate(cls, root, info) -> graphene.Mutation:
+        logout(info.context)
+        return LogoutMutation(logged_out=True)
+
+
+class PasswordChange(graphene.Mutation):
+    class Arguments:
+        old_password = graphene.String(required=True)
+        new_password = graphene.String(required=True)
+        new_password_repeat = graphene.String(required=True)
+
+    password_changed = graphene.Boolean()
+
+    @classmethod
+    def mutate(
+        cls,
+        root,
+        info,
+        old_password: str,
+        new_password: str,
+        new_password_repeat: str,
+    ) -> graphene.Mutation:
+        user = info.context.user
+
+        if not user.check_password(old_password):
+            raise AuthenticationFailed("Old password does not match")
+
+        if new_password != new_password_repeat:
+            raise ValidationError("New password does not match")
+
+        if new_password == old_password:
+            raise ValidationError(
+                "New password must be different from old password"
+            )
+        user.set_password(new_password)
+        user.save()
+        send_password_change_notification(user)
+        # TODO: add logging
+        return PasswordChange(password_changed=True)
+
+
+class Mutation(graphene.ObjectType):
+    login = LoginMutation.Field(description="Mutation for logging in an user")
+    logout = LogoutMutation.Field(
+        description="Mutation for logging out an user"
+    )
+    password_change = PasswordChange.Field(
+        description="Mutation for changing a user's password"
+    )
diff --git a/aai/tests/aai_utils_tests.py b/aai/tests/aai_utils_tests.py
new file mode 100644
index 00000000..03853fe2
--- /dev/null
+++ b/aai/tests/aai_utils_tests.py
@@ -0,0 +1,353 @@
+import os
+import shutil
+
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.core.exceptions import PermissionDenied
+from rest_framework.test import APIRequestFactory
+from django.contrib.auth.models import AnonymousUser, Permission
+
+from user.models import User
+from common_lib.test_utils import (
+    internal_create_exercise,
+    internal_upload_definition,
+)
+from aai.utils import (
+    protected,
+    team_protected,
+    exercise_protected,
+    definition_protected,
+    thread_protected,
+    log_protected,
+    visibility_protected,
+    input_object_protected,
+)
+from aai.models import Perms
+from running_exercise.models import (
+    ToolDetails,
+    Tool,
+    Content,
+    ActionLog,
+    LogType,
+    EmailThread,
+    EmailParticipant,
+)
+
+TEST_DATA_STORAGE = "aai_tests_test_data"
+
+
+@override_settings(
+    DATA_STORAGE=TEST_DATA_STORAGE,
+    FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"),
+)
+class AaiUtilsTests(TestCase):
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(settings.DATA_STORAGE)
+        super().tearDownClass()
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.definition = internal_upload_definition("base_definition")
+        cls.exercise = internal_create_exercise(cls.definition.id, 3)
+        cls.exercise2 = internal_create_exercise(cls.definition.id, 3)
+
+        cls.trainee1 = User.objects.create_user(
+            username="trainee1@mail.com", password="password"
+        )
+        cls.trainee1_1 = User.objects.create_user(
+            username="trainee1_1@mail.com", password="password"
+        )
+        cls.trainee2 = User.objects.create_user(
+            username="trainee2@mail.com", password="password"
+        )
+        cls.trainee3 = User.objects.create_user(
+            username="trainee3@mail.com", password="password"
+        )
+        cls.instructor = User.objects.create_staffuser(
+            username="instructor@mail.com", password="password"
+        )
+        cls.instructor2 = User.objects.create_staffuser(
+            username="instructor2@mail.com", password="password"
+        )
+        cls.admin = User.objects.create_superuser(
+            username="admin@mail.com", password="password"
+        )
+
+        cls.factory = APIRequestFactory()
+        cls.assignUsers()
+        cls.assignPerms()
+        cls.createData()
+
+    @classmethod
+    def assignUsers(cls):
+        # Assign trainees to teams
+        cls.team1 = cls.exercise.teams.all()[0]
+        cls.team2 = cls.exercise.teams.all()[1]
+        cls.team3 = cls.exercise.teams.all()[2]
+
+        cls.trainee1.teams.add(cls.team1)
+        cls.trainee2.teams.add(cls.team1)
+        cls.trainee3.teams.add(cls.team3)
+
+        # Assign instructor of exercise
+        cls.instructor.exercises.add(cls.exercise)
+
+        # Assign definition to the instructor
+        cls.instructor.definitions.add(cls.definition)
+
+    @classmethod
+    def assignPerms(cls):
+        p = Permission.objects.get(codename=Perms.view_exercise.codename)
+        cls.trainee1.group.permissions.add(p)
+        cls.instructor.group.permissions.add(p)
+
+        p = Permission.objects.get(codename=Perms.update_exercise.codename)
+        cls.instructor.group.permissions.add(p)
+
+    @classmethod
+    def createData(cls):
+        # create action log
+        tool: Tool = cls.definition.tools.first()
+        content = Content.objects.create(raw=tool.default_response, rendered="")
+        details = ToolDetails.objects.create(
+            tool=tool,
+            argument="argument",
+            content=content,
+            user=cls.trainee1,
+        )
+        cls.log = ActionLog.objects.create(
+            type=LogType.get_type(details),
+            team=cls.team1,
+            details=details,
+        )
+
+        # create thread
+        cls.participant = EmailParticipant.objects.create(
+            exercise=cls.exercise, address="team1@mail.com", team=cls.team1
+        )
+
+        cls.thread = EmailThread.objects.create(
+            subject="Test thread",
+            exercise=cls.exercise,
+        )
+        cls.thread.participants.add(cls.participant)
+
+    def dummy(self, request):
+        return 0
+
+    def team_dummy(self, request, team_id):
+        return 0
+
+    def exercise_dummy(self, request, exercise_id):
+        return 0
+
+    def definition_dummy(self, request, definition_id):
+        return 0
+
+    def log_dummy(self, request, log_id):
+        return 0
+
+    def thread_dummy(self, request, thread_id):
+        return 0
+
+    def visibility_dummy(self, request, visible_only):
+        return 0
+
+    def test_protected_admin(self):
+        decorated = protected("aai.non_existent")(
+            self.dummy
+        )  # admin has access
+        request = self.factory.request()
+
+        request.user = self.trainee1
+        with self.assertRaises(PermissionDenied):
+            decorated(request)
+
+        request.user = self.instructor
+        with self.assertRaises(PermissionDenied):
+            decorated(request)
+
+        request.user = self.admin
+        self.assertEqual(decorated(request), 0)
+
+    def test_protected_instructor(self):
+        decorated = protected(Perms.update_exercise.full_name)(self.dummy)
+        request = self.factory.request()
+
+        request.user = self.trainee1
+        with self.assertRaises(PermissionDenied):
+            decorated(request)
+
+        request.user = self.instructor
+        self.assertEqual(decorated(request), 0)
+
+        request.user = self.admin
+        self.assertEqual(decorated(request), 0)
+
+    def test_protected_trainee(self):
+        decorated = protected(Perms.view_exercise.full_name)(self.dummy)
+        request = self.factory.request()
+
+        request.user = self.trainee1
+        self.assertEqual(decorated(request), 0)
+
+        request.user = self.instructor
+        self.assertEqual(decorated(request), 0)
+
+        request.user = self.admin
+        self.assertEqual(decorated(request), 0)
+
+    def test_protected_anonoymous(self):
+        decorated = protected(Perms.view_exercise.full_name)(self.dummy)
+        request = self.factory.request()
+
+        request.user = AnonymousUser()
+        with self.assertRaises(PermissionDenied):
+            decorated(request)
+
+    def test_team_protected(self):
+        request = self.factory.request()
+        decorated = team_protected(self.team_dummy)
+
+        request.user = self.trainee1
+        self.assertEqual(decorated(request, team_id=self.team1.id), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, team_id=self.team2.id)
+
+        request.user = self.trainee2
+        self.assertEqual(decorated(request, team_id=self.team1.id), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, team_id=self.team2.id)
+
+        request.user = self.trainee3
+        self.assertEqual(decorated(request, team_id=self.team3.id), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, team_id=self.team2.id)
+
+        request.user = AnonymousUser()
+        with self.assertRaises(PermissionDenied):
+            decorated(request, team_id=self.team2.id)
+
+    def test_exercise_protected(self):
+        request = self.factory.request()
+        decorated = exercise_protected(self.exercise_dummy)
+
+        request.user = self.instructor
+        self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, exercise_id=self.exercise2.id)
+
+        request.user = self.admin
+        self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0)
+        self.assertEqual(decorated(request, exercise_id=self.exercise2.id), 0)
+
+        request.user = self.trainee1
+        self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, exercise_id=self.exercise2.id)
+
+        request.user = AnonymousUser()
+        with self.assertRaises(PermissionDenied):
+            decorated(request, team_id=self.team2.id)
+
+    def test_definition_protected(self):
+        request = self.factory.request()
+        decorated = definition_protected(self.definition_dummy)
+
+        request.user = self.instructor
+        self.assertEqual(
+            decorated(request, definition_id=self.definition.id), 0
+        )  # instructor with access to definition
+
+        request.user = self.instructor2
+        # instructor without access to definition
+        with self.assertRaises(PermissionDenied):
+            decorated(request, definition_id=self.definition.id)
+
+        request.user = self.admin
+        # admin should see all definition
+        self.assertEqual(
+            decorated(request, definition_id=self.definition.id), 0
+        )
+
+        request.user = self.trainee1
+        # trainee should't see the definition
+        with self.assertRaises(PermissionDenied):
+            decorated(request, definition_id=self.definition.id)
+
+    def test_log_protected(self):
+        request = self.factory.request()
+        decorated = log_protected(self.log_dummy)
+
+        request.user = self.trainee1
+        # trainee1 created log -> can access it
+        self.assertEqual(decorated(request, log_id=self.log.id), 0)
+
+        request.user = self.trainee2
+        # trainee1_1 is in the same team as trainee1 who created the log thus
+        # can read the log as well
+        self.assertEqual(decorated(request, log_id=self.log.id), 0)
+
+        request.user = self.instructor
+        # instructor is assigned to the exercise where log was created thus can access it
+        self.assertEqual(decorated(request, log_id=self.log.id), 0)
+
+        request.user = self.admin
+        # admin can see all logs
+        self.assertEqual(decorated(request, log_id=self.log.id), 0)
+
+        request.user = self.trainee3
+        # trainee3 belongs to different team of the exercise thus can't read other teams logs
+        with self.assertRaises(PermissionDenied):
+            decorated(request, log_id=self.log.id)
+
+        request.user = self.instructor2
+        # instructor2 is not assigned to the exercise thus can't access the log
+        with self.assertRaises(PermissionDenied):
+            decorated(request, log_id=self.log.id)
+
+    def test_thread_protected(self):
+        request = self.factory.request()
+        decorated = thread_protected(self.thread_dummy)
+
+        request.user = self.trainee1
+        # trainee1 created thread thus can access it
+        self.assertEqual(decorated(request, thread_id=self.thread.id), 0)
+
+        request.user = self.trainee2
+        # trainee2 is in the same team as trainee1 thus can access it
+        self.assertEqual(decorated(request, thread_id=self.thread.id), 0)
+
+        request.user = self.instructor
+        # instructor is assigned to the exercise thus can access all threads of exercise
+        self.assertEqual(decorated(request, thread_id=self.thread.id), 0)
+
+        request.user = self.admin
+        # admin can access all thread
+        self.assertEqual(decorated(request, thread_id=self.thread.id), 0)
+
+        request.user = self.instructor2
+        # instructor2 is not assigned to the exercise where thread belongs thus cant access it
+        with self.assertRaises(PermissionDenied):
+            decorated(request, thread_id=self.thread.id)
+
+        request.user = self.trainee3
+        # trainee3 does not belong to any participating teams of the thread
+        with self.assertRaises(PermissionDenied):
+            decorated(request, thread_id=self.thread.id)
+
+    def test_visible_protected(self):
+        request = self.factory.request()
+        decorated = visibility_protected(self.visibility_dummy)
+
+        request.user = self.instructor
+        # instructor can see both visible only and all data
+        self.assertEqual(decorated(request, visible_only=False), 0)
+        self.assertEqual(decorated(request, visible_only=True), 0)
+
+        request.user = self.trainee1
+        # trainee can see only visible_only=True data
+        self.assertEqual(decorated(request, visible_only=True), 0)
+        with self.assertRaises(PermissionDenied):
+            decorated(request, visible_only=False)
diff --git a/aai/tests/encryption_tests.py b/aai/tests/encryption_tests.py
new file mode 100644
index 00000000..249de7c6
--- /dev/null
+++ b/aai/tests/encryption_tests.py
@@ -0,0 +1 @@
+# TODO after FE tests compatibility with current implementation
diff --git a/aai/utils.py b/aai/utils.py
new file mode 100644
index 00000000..bd4b73e3
--- /dev/null
+++ b/aai/utils.py
@@ -0,0 +1,355 @@
+from typing import Tuple
+from graphene import ResolveInfo
+from rest_framework.request import HttpRequest, Request
+from typing import Union
+
+from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import PermissionDenied
+
+from user.models import User
+from aai.models import UserGroup
+from exercise.models import Team, Exercise
+from running_exercise.models import EmailThread, ActionLog, EmailParticipant
+from common_lib.utils import get_model, InputObject
+from running_exercise.graphql_inputs import (
+    UseToolInput,
+    SendEmailInput,
+    SelectTeamInjectInput,
+)
+from exercise.graphql_inputs import CreateExerciseInput
+
+
+def _get_user(*args, **kwargs) -> Union[AnonymousUser, User]:
+    """
+    Gets user object from known request types.
+
+    Args:
+        *args - non-keyworded arguments of request
+        **kwargs - keyword arguments of request
+    Returns:
+        Instance of User if request is authenticated else instance of AnonymousUser
+    """
+    if len(args) == 0:
+        return AnonymousUser
+
+    request = args[-1]  # get request
+
+    if isinstance(request, ResolveInfo):
+        return request.context.user
+
+    if isinstance(request, HttpRequest) or isinstance(request, Request):
+        return request.user
+    return AnonymousUser
+
+
+def _checked_get_user(*args, **kwargs) -> User:
+    """
+    Helper function for retrieving user of the request with anonymity check.
+
+    Returns:
+        Instance of User or raises an exception when user is anonymous.
+    """
+    user = _get_user(*args, **kwargs)
+    if user is None or user.is_anonymous:
+        raise PermissionDenied(
+            "You have no access to this team specific endpoint."
+        )
+    return user
+
+
+def _retrieve_param_id(param_name: str, *args, **kwargs) -> Tuple[User, int]:
+    """
+    Helper function for getting user and given id parameter. Checks whether
+    both user and id parameter were successfully retrieved. Otherwise raises
+    PermissionDenied exception.
+    """
+    user = _checked_get_user(*args, **kwargs)
+    param_id = kwargs.get(param_name, None)
+    if param_id is None:
+        raise PermissionDenied("Permission denied")
+    return user, int(param_id)
+
+
+def _get_param_id_from_input_obj(
+    input_obj: InputObject, *args, **kwargs
+) -> int:
+    """
+    Helper function for retrieving id parameter from the current input objects.
+
+    Returns:
+        Id parameter needed for authorization from input object.
+    """
+    id_parameter = None
+    if isinstance(input_obj, (SelectTeamInjectInput, UseToolInput)):
+        id_parameter = input_obj.team_id
+
+    elif isinstance(input_obj, SendEmailInput):
+        thread = get_model(EmailThread, id=input_obj.thread_id)
+        id_parameter = EmailParticipant.objects.get(
+            address=input_obj.sender_address, exercise_id=thread.exercise_id
+        ).team_id
+
+    elif isinstance(input_obj, CreateExerciseInput):
+        id_parameter = input_obj.definition_id
+
+    if id_parameter is None:
+        raise ValueError("Unexpected input object argument")
+    return int(id_parameter)
+
+
+def _check_team_presence(user: User, team_id: int) -> bool:
+    """
+    Helper function for checking whether user is in the team or user is instructor
+    of the exercise where team belongs.
+    """
+    if user.teams.filter(id=team_id).exists():
+        # user is in team which he wants to get data of
+        return True
+    exercises = user.exercises.all()
+    # user is instructor of the exercise where given team belongs
+    return Team.objects.filter(exercise__in=exercises, id=team_id).exists()
+
+
+def _check_exercise_presence(user: User, exercise_id) -> bool:
+    """
+    Helper function for checking whether user is the instructor of the exercise
+    or belongs to some of the team of the exercise.
+    """
+    if user.exercises.filter(id=exercise_id).exists():
+        # user is instructor of the exercise
+        return True
+    # user is trainee of the exercise
+    return user.teams.filter(exercise_id=exercise_id).exists()
+
+
+def _check_definition_presence(user: User, definition_id) -> bool:
+    """
+    Helper function for checking whether user has access to definition.
+    """
+    # user is maintainer of the definition
+    return user.definitions.filter(id=definition_id).exists()
+
+
+def protected(required_permission: str):
+    """
+    Decorator that checks whether user of request has required permission.
+    If not, PermissionDenied exception is raised.
+
+    Args:
+        required_permission: name of the permission in form "app_label.codename"
+    """
+
+    def decorator(func):
+        def wrapper(*args, **kwargs):
+            user = _get_user(*args, **kwargs)
+            if user.is_anonymous:  # protected allows only authenticated users
+                raise PermissionDenied("Permission denied")
+
+            if user.has_perm(required_permission):
+                return func(*args, **kwargs)
+            raise PermissionDenied("Permission denied")
+
+        return wrapper
+
+    return decorator
+
+
+def team_protected(func):
+    """
+    Decorator that checks whether user of the request is in the team stated in
+    request (or is instructor of the exercise containing the team).
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "team_id" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user, team_id = _retrieve_param_id("team_id", *args, **kwargs)
+        if user.is_superuser:
+            return func(*args, **kwargs)
+
+        if _check_team_presence(user, team_id):
+            return func(*args, **kwargs)
+        raise PermissionDenied("Permission denied")
+
+    return wrapper
+
+
+def exercise_protected(func):
+    """
+    Decorator that checks whether user of the request is instructor of the
+    exercise stated in request or trainee in the team of the exercise.
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "exercise_id" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user, exercise_id = _retrieve_param_id("exercise_id", *args, **kwargs)
+        if user.is_superuser:
+            return func(*args, **kwargs)
+
+        if _check_exercise_presence(user, exercise_id):
+            return func(*args, **kwargs)
+        raise PermissionDenied("Permission denied")
+
+    return wrapper
+
+
+def definition_protected(func):
+    """
+    Decorator that checks whether user of the request is maintainer of the
+    definition stated in request.
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "definition_id" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user, definition_id = _retrieve_param_id(
+            "definition_id", *args, **kwargs
+        )
+        if user.is_superuser:
+            return func(*args, **kwargs)
+
+        if _check_definition_presence(user, definition_id):
+            return func(*args, **kwargs)
+        raise PermissionDenied("Permission denied")
+
+    return wrapper
+
+
+def log_protected(func):
+    """
+    Decorator that checks whether user of the request is in the team where log
+    belongs or is instructor of the exercise containing the team of the log.
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "log_id" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user, log_id = _retrieve_param_id("log_id", *args, **kwargs)
+        if user.is_superuser:
+            return func(*args, **kwargs)
+
+        team_id = int(get_model(ActionLog, id=log_id).team_id)
+        if _check_team_presence(user, team_id):
+            return func(*args, **kwargs)
+        raise PermissionDenied("Permission denied")
+
+    return wrapper
+
+
+def _input_object_protection(
+    user: User, param_id: int, input_obj: InputObject
+) -> bool:
+    """
+    Helper function for checking authorization restrications on input objects.
+    Returns:
+        True if one of the condition to access the data is met otherwise False.
+    """
+    if isinstance(
+        input_obj, (UseToolInput, SelectTeamInjectInput, SendEmailInput)
+    ):
+        if isinstance(input_obj, UseToolInput):
+            exercise = get_model(Exercise, teams__in=[param_id])
+            # check if user has access to given tool (is in exercise
+            # containing the tool)
+            if not exercise.definition.tools.filter(id=param_id).exists():
+                return False
+
+        if _check_team_presence(user, param_id):
+            return True
+
+    elif isinstance(input_obj, CreateExerciseInput):
+        return _check_definition_presence(user, param_id)
+    return False
+
+
+def input_object_protected(obj_name: str):
+    """
+    Decorator for checking InputObject types evaluates whether user has access
+    to requested data based on the data from InputObject type.
+    Input objects MUST HAVE parmater with name given in "obj_name" parameter to
+    work properly.
+    Args:
+        obj_name: name of keyword argument of the input object of the resolver
+    """
+
+    def decorator(func):
+        def wrapper(*args, **kwargs):
+            user = _checked_get_user(*args, **kwargs)
+            if user.is_superuser:
+                return func(*args, **kwargs)
+
+            input_obj = kwargs.get(obj_name, None)
+            if input_obj is None:
+                raise PermissionDenied("Permission denied")
+
+            # create_exercise_input: definition_id
+            # other input objects: team_id
+            param_id = _get_param_id_from_input_obj(input_obj, *args, **kwargs)
+
+            if _input_object_protection(user, param_id, input_obj):
+                return func(*args, **kwargs)
+            raise PermissionDenied("Permission denied")
+
+        return wrapper
+
+    return decorator
+
+
+def thread_protected(func):
+    """
+    Decorator that checks whether user of the request is in one of the teams
+    of the email thread or is instructor of the exercise containing the
+    email thread.
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "thread_id" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user, thread_id = _retrieve_param_id("thread_id", *args, **kwargs)
+        if user.is_superuser:
+            return func(*args, **kwargs)
+
+        thread = get_model(EmailThread, id=thread_id)
+
+        if user.exercises.filter(id=thread.exercise_id).exists():
+            # user is instructor of the exercise with the thread
+            return func(*args, **kwargs)
+
+        participant_teams = thread.participants.exclude(
+            team_id=None
+        ).values_list("team_id", flat=True)
+        if user.teams.filter(id__in=participant_teams).exists():
+            # user is trainee of the team participating in the thread
+            return func(*args, **kwargs)
+
+        raise PermissionDenied("Permission denied")
+
+    return wrapper
+
+
+def visibility_protected(func):
+    """
+    Decorator that checks whether user of the request can ask for team non-visible
+    information (visible_only set to False means all information should be retrieved
+    and it is not allowed to TRAINEE).
+    The function resolving the request, where this decorator is used,
+    MUST HAVE "visible_only" parameter to work properly.
+    If no condition is met, PermissionDenied exception is raised.
+    """
+
+    def wrapper(*args, **kwargs):
+        user = _checked_get_user(*args, **kwargs)
+        visible_only = kwargs.get("visible_only", None)
+
+        if not visible_only and user.group == UserGroup.TRAINEE:
+            # TRAINEE trying to obtain trainee-non-visible data
+            raise PermissionDenied("Permission denied")
+        return func(*args, **kwargs)
+
+    return wrapper
diff --git a/aai/views.py b/aai/views.py
new file mode 100644
index 00000000..91ea44a2
--- /dev/null
+++ b/aai/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/common_lib/graphql/__init__.py b/common_lib/graphql/__init__.py
index 3ea832ee..64ad6796 100644
--- a/common_lib/graphql/__init__.py
+++ b/common_lib/graphql/__init__.py
@@ -18,5 +18,9 @@ from .mutations import (
     DeleteExerciseAction,
     StopExerciseAction,
     DeleteDefinitionAction,
+    AssignUsersToTeamAction,
+    RemoveUsersFromTeamAction,
+    AddDefinitionAccessAction,
+    RemoveDefinitionAccessAction,
 )
 from .graphql_api_t_case import GraphQLApiTestCase
diff --git a/common_lib/graphql/api/__init__.py b/common_lib/graphql/api/__init__.py
index 25907cf6..82e4bf52 100644
--- a/common_lib/graphql/api/__init__.py
+++ b/common_lib/graphql/api/__init__.py
@@ -1,4 +1,4 @@
 from .api_action import APIAction
 from .graphql_query import GraphQLQuery
 from .response_field import ResponseField
-from .utils import id_argument
+from .utils import id_argument, id_list_argument
diff --git a/common_lib/graphql/api/utils.py b/common_lib/graphql/api/utils.py
index 527df72e..96b3c758 100644
--- a/common_lib/graphql/api/utils.py
+++ b/common_lib/graphql/api/utils.py
@@ -3,3 +3,7 @@ from typing import Tuple
 
 def id_argument(name: str) -> Tuple[str, str]:
     return f"{name}Id", "ID!"
+
+
+def id_list_argument(name: str) -> Tuple[str, str]:
+    return f"{name}Ids", "[ID]!"
diff --git a/common_lib/graphql/graphql_api_t_case.py b/common_lib/graphql/graphql_api_t_case.py
index 6b6e9cc5..1fea4ce2 100644
--- a/common_lib/graphql/graphql_api_t_case.py
+++ b/common_lib/graphql/graphql_api_t_case.py
@@ -1,9 +1,10 @@
 import json
-from typing import List, Any, Dict
+from typing import List, Any, Dict, Optional
 
 from graphene_django.utils import GraphQLTestCase, camelize
 
 from common_lib.graphql.api import APIAction
+from user.models import User
 
 
 class GraphQLApiTestCase(GraphQLTestCase):
@@ -20,7 +21,12 @@ class GraphQLApiTestCase(GraphQLTestCase):
     def setUp(self):
         self._variables = {}
 
-    def run_action(self, action: APIAction, should_throw: bool = False):
+    def run_action(
+        self,
+        action: APIAction,
+        should_throw: bool = False,
+        user: Optional[User] = None,
+    ):
         """
         Run a single graphql query.
         `should_throw` is a flag that determines whether the query should result in an error or not.
@@ -33,6 +39,9 @@ class GraphQLApiTestCase(GraphQLTestCase):
         ResponseField specified on the action.
         """
         action.validate()
+        if user is not None:
+            self.client.force_login(user)
+
         result = self.query(
             action.query_string(),
             variables=camelize(action.get_variables()),
diff --git a/common_lib/graphql/mutations.py b/common_lib/graphql/mutations.py
index 2273fece..6358ad57 100644
--- a/common_lib/graphql/mutations.py
+++ b/common_lib/graphql/mutations.py
@@ -5,6 +5,7 @@ from common_lib.graphql.api import (
     APIAction,
     GraphQLQuery,
     id_argument,
+    id_list_argument,
     ResponseField,
 )
 from common_lib.utils import create_input
@@ -168,3 +169,55 @@ class DeleteDefinitionAction(APIAction):
             [id_argument("definition")],
             Fragments.operation_done(),
         )
+
+
+class AssignUsersToTeamAction(APIAction):
+    query_name = "assignUsersToTeam"
+
+    def __init__(self, variables: Dict[str, Any]):
+        self.variables = variables
+        self.query = GraphQLQuery(
+            "mutation",
+            self.query_name,
+            [id_list_argument("user"), id_argument("team")],
+            Fragments.operation_done(),
+        )
+
+
+class RemoveUsersFromTeamAction(APIAction):
+    query_name = "removeUsersFromTeam"
+
+    def __init__(self, variables: Dict[str, Any]):
+        self.variables = variables
+        self.query = GraphQLQuery(
+            "mutation",
+            self.query_name,
+            [id_list_argument("user"), id_argument("team")],
+            Fragments.operation_done(),
+        )
+
+
+class AddDefinitionAccessAction(APIAction):
+    query_name = "addDefinitionAccess"
+
+    def __init__(self, variables: Dict[str, Any]):
+        self.variables = variables
+        self.query = GraphQLQuery(
+            "mutation",
+            self.query_name,
+            [id_list_argument("user"), id_argument("definition")],
+            Fragments.operation_done(),
+        )
+
+
+class RemoveDefinitionAccessAction(APIAction):
+    query_name = "removeDefinitionAccess"
+
+    def __init__(self, variables: Dict[str, Any]):
+        self.variables = variables
+        self.query = GraphQLQuery(
+            "mutation",
+            self.query_name,
+            [id_list_argument("user"), id_argument("definition")],
+            Fragments.operation_done(),
+        )
diff --git a/common_lib/schema_types.py b/common_lib/schema_types.py
index 105a8f72..c7ce1c00 100644
--- a/common_lib/schema_types.py
+++ b/common_lib/schema_types.py
@@ -30,6 +30,28 @@ from running_exercise.models import (
     CustomInjectDetails,
     LogType,
 )
+from user.models import User, Tag, Group
+
+
+class RestrictedUser(DjangoObjectType):
+    class Meta:
+        model = User
+        exclude = ("definitions", "exercises", "teams")
+
+
+class RestrictedExercise(DjangoObjectType):
+    id = graphene.ID(source="id")
+    uuid = graphene.UUID(source="uuid")
+
+    class Meta:
+        model = Exercise
+        fields = ()
+
+
+class RestrictedTeam(DjangoObjectType):
+    class Meta:
+        model = Team
+        fields = ("id", "exercise", "name", "role", "email_address")
 
 
 class ExerciseDefinitionType(graphene.ObjectType):
@@ -51,6 +73,7 @@ class DefinitionType(DjangoObjectType):
         exclude = ["milestones"]
 
     tools = graphene.List(graphene.NonNull(ToolType), required=True)
+    user_set = graphene.List(RestrictedUser)
 
 
 class DefinitionRoleType(DjangoObjectType):
@@ -64,12 +87,15 @@ class ExerciseType(DjangoObjectType):
 
     # change the definition field type to hide some definition fields
     definition = graphene.Field(ExerciseDefinitionType)
+    user_set = graphene.Field(RestrictedUser)
 
 
 class TeamType(DjangoObjectType):
     class Meta:
         model = Team
 
+    user_set = graphene.List(RestrictedUser)
+
 
 class ContentType(DjangoObjectType):
     class Meta:
@@ -90,6 +116,8 @@ class ToolDetailsType(DjangoObjectType):
     class Meta:
         model = ToolDetails
 
+    user = graphene.Field(RestrictedUser)
+
 
 class InjectDetailsType(DjangoObjectType):
     class Meta:
@@ -100,11 +128,15 @@ class CustomInjectDetailsType(DjangoObjectType):
     class Meta:
         model = CustomInjectDetails
 
+    user = graphene.Field(RestrictedUser)
+
 
 class EmailType(DjangoObjectType):
     class Meta:
         model = Email
 
+    user = graphene.Field(RestrictedUser)
+
 
 class ActionLogDetails(graphene.Union):
     class Meta:
@@ -223,6 +255,41 @@ class GrapheneConfig(graphene.ObjectType):
     custom_email_suffix = graphene.String()
 
 
+class UserType(DjangoObjectType):
+    definitions = graphene.List(ExerciseDefinitionType)
+    exercises = graphene.List(RestrictedExercise)
+    teams = graphene.List(RestrictedTeam)
+    group = graphene.String()
+
+    class Meta:
+        model = User
+        exclude = ["password"]
+
+    def resolve_definitions(self, info):
+        return self.definitions.all()
+
+    def resolve_exercises(self, info):
+        return self.exercises.all()
+
+    def resolve_teams(self, info):
+        return self.teams.all()
+
+    def resolve_group(self, info):
+        return self.group
+
+
+class TagType(DjangoObjectType):
+    class Meta:
+        model = Tag
+        exclude_fields = ("user_set",)
+
+
+class GroupType(DjangoObjectType):
+    class Meta:
+        model = Group
+        exclude_fields = ("user_set",)
+
+
 class ExerciseEventTypeEnum(models.TextChoices):
     CREATE = "create"
     MODIFY = "modify"
diff --git a/exercise/lib/log_manager.py b/exercise/lib/log_manager.py
index 4a4077cf..931d8b43 100644
--- a/exercise/lib/log_manager.py
+++ b/exercise/lib/log_manager.py
@@ -1,7 +1,7 @@
 import json
 import os
 import shutil
-from typing import TextIO, TypeVar, Type, Collection
+from typing import TextIO, TypeVar, Type, Collection, Dict, Optional
 
 from django.conf import settings
 from django.db.models import Model
@@ -16,6 +16,7 @@ from exercise.serializers import (
     TeamSerializer,
     TeamInjectStateSerializer,
     ExerciseSerializer,
+    InstructorSerializer,
 )
 from exercise_definition.models import Feature, FileInfo
 from exercise_definition.serializers import (
@@ -40,12 +41,17 @@ ModelType = TypeVar("ModelType", bound=Model)
 
 
 def _serialize_data(
-    out: TextIO, serializer: Type[SerializerType], data: Collection[ModelType]
+    out: TextIO,
+    serializer: Type[SerializerType],
+    data: Collection[ModelType],
+    context: Optional[Dict[str, object]] = None,
 ) -> None:
     model_count = len(data)
     for i, model in enumerate(data):
         out.write(
-            json.dumps(serializer(model).data, indent=None, ensure_ascii=False)
+            json.dumps(
+                serializer(model, context=context).data, ensure_ascii=False
+            )
         )
         if i + 1 < model_count:
             out.write("\n")
@@ -55,9 +61,13 @@ class TeamLogSerializer:
     team: Team
     team_log_path: str
     team_upload_path: str
+    context: Dict[str, object]
 
-    def __init__(self, team: Team, log_path: str) -> None:
+    def __init__(
+        self, team: Team, log_path: str, context: Dict[str, object]
+    ) -> None:
         self.team = team
+        self.context = context
 
         self.team_log_path = os.path.join(log_path, f"team-{team.id}")
         os.makedirs(self.team_log_path)
@@ -81,7 +91,10 @@ class TeamLogSerializer:
             os.path.join(self.team_log_path, "action_logs.jsonl"), "w"
         ) as action_logs_file:
             _serialize_data(
-                action_logs_file, ActionLogSerializer, self.team.logs.all()
+                action_logs_file,
+                ActionLogSerializer,
+                self.team.logs.all(),
+                self.context,
             )
 
     def serialize_milestone_states(self) -> None:
@@ -102,7 +115,9 @@ class TeamLogSerializer:
         with open(
             os.path.join(self.team_log_path, "emails.jsonl"), "w"
         ) as emails_file:
-            _serialize_data(emails_file, EmailThreadSerializer, email_threads)
+            _serialize_data(
+                emails_file, EmailThreadSerializer, email_threads, self.context
+            )
 
     def serialize_categories(self) -> None:
         with open(
@@ -129,10 +144,21 @@ class TeamLogSerializer:
 class ExerciseLogSerializer:
     exercise: Exercise
     log_path: str
+    anonymize: bool
+    analytics_ids: Dict[str, int]
+    context: Dict[str, object]
 
-    def __init__(self, exercise: Exercise, log_path: str) -> None:
+    def __init__(
+        self, exercise: Exercise, log_path: str, anonymize: bool
+    ) -> None:
         self.exercise = exercise
         self.log_path = log_path
+        self.anonymize = anonymize
+        self.analytics_ids = self.generate_analytics_ids()
+        self.context = {
+            "anonymize": self.anonymize,
+            "analytics_ids": self.analytics_ids,
+        }
 
     def serialize_all(self) -> None:
         self.serialize_exercise()
@@ -145,6 +171,7 @@ class ExerciseLogSerializer:
             self.serialize_email_participants()
 
         self.serialize_teams(with_emails)
+        self.serialize_instructors()
         self.serialize_file_infos()
         self.copy_definition_files()
 
@@ -196,12 +223,28 @@ class ExerciseLogSerializer:
 
     def serialize_teams(self, with_emails: bool) -> None:
         with open(os.path.join(self.log_path, "teams.jsonl"), "w") as teams:
-            _serialize_data(teams, TeamSerializer, self.exercise.teams.all())
+            _serialize_data(
+                teams,
+                TeamSerializer,
+                self.exercise.teams.all(),
+                context=self.context,
+            )
 
         for team in self.exercise.teams.all():
-            serializer = TeamLogSerializer(team, self.log_path)
+            serializer = TeamLogSerializer(team, self.log_path, self.context)
             serializer.serialize_all(with_emails)
 
+    def serialize_instructors(self) -> None:
+        with open(
+            os.path.join(self.log_path, "instructors.jsonl"), "w"
+        ) as instructors:
+            _serialize_data(
+                instructors,
+                InstructorSerializer,
+                [self.exercise],
+                context=self.context,
+            )
+
     def serialize_file_infos(self) -> None:
         with open(
             os.path.join(self.log_path, "file_infos.jsonl"), "w"
@@ -226,17 +269,32 @@ class ExerciseLogSerializer:
                 os.path.join(definition_files_path, str(file_info.id)),
             )
 
+    def generate_analytics_ids(self) -> Dict[str, int]:
+        user_to_analytics_id: Dict[str, int] = {}
+        analytics_id = 1
+
+        for team in self.exercise.teams.all():
+            for user in team.user_set.all():
+                user_to_analytics_id[user.username] = analytics_id
+                analytics_id += 1
+
+        for instructor in self.exercise.instructors.all():
+            user_to_analytics_id[instructor.user.username] = analytics_id
+            analytics_id += 1
+
+        return user_to_analytics_id
+
 
 class LogManager:
     log_path: str
     archive_path: str
     exercise_log_serializer: ExerciseLogSerializer
 
-    def __init__(self, exercise_id: int) -> None:
+    def __init__(self, exercise_id: int, anonymize: bool) -> None:
         exercise = get_model(Exercise, id=exercise_id)
         self.log_path = os.path.join(settings.LOG_STORAGE, "tmp", "logs")
         self.exercise_log_serializer = ExerciseLogSerializer(
-            exercise, self.log_path
+            exercise, self.log_path, anonymize
         )
         self.archive_path = os.path.join(
             settings.LOG_STORAGE,
diff --git a/exercise/migrations/0009_auto_20240227_1813.py b/exercise/migrations/0009_auto_20240227_1813.py
new file mode 100644
index 00000000..08086b38
--- /dev/null
+++ b/exercise/migrations/0009_auto_20240227_1813.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.24 on 2024-02-27 18:13
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise', '0008_rename_elapsed_time_exercise_elapsed_s'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='emailparticipant',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='exercise',
+            options={'default_permissions': (), 'ordering': ['id']},
+        ),
+        migrations.AlterModelOptions(
+            name='injectcategory',
+            options={'default_permissions': (), 'ordering': ['category__time']},
+        ),
+        migrations.AlterModelOptions(
+            name='milestonestate',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='team',
+            options={'default_permissions': (), 'ordering': ['id']},
+        ),
+        migrations.AlterModelOptions(
+            name='teamstate',
+            options={'default_permissions': ()},
+        ),
+    ]
diff --git a/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py b/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py
new file mode 100644
index 00000000..94a383fc
--- /dev/null
+++ b/exercise/migrations/0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.24 on 2024-03-07 12:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise', '0009_auto_20240227_1813'),
+        ('exercise', '0010_auto_20240221_1426'),
+    ]
+
+    operations = [
+    ]
diff --git a/exercise/models.py b/exercise/models.py
index 8b6452bd..ed77f457 100644
--- a/exercise/models.py
+++ b/exercise/models.py
@@ -33,6 +33,7 @@ class Exercise(models.Model):
 
     class Meta:
         ordering = ["id"]
+        default_permissions = ()
 
     def is_time_reached(self, minute: int) -> bool:
         return self.elapsed_s - minute * 60 >= 0
@@ -43,6 +44,9 @@ class TeamState(models.Model):
         Exercise, on_delete=models.CASCADE, related_name="team_states"
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class Team(models.Model):
     exercise = models.ForeignKey(
@@ -59,6 +63,7 @@ class Team(models.Model):
 
     class Meta:
         ordering = ["id"]
+        default_permissions = ()
 
 
 class MilestoneState(models.Model):
@@ -71,6 +76,9 @@ class MilestoneState(models.Model):
     reached = models.BooleanField(default=False)
     timestamp_reached = models.DateTimeField(null=True, blank=True)
 
+    class Meta:
+        default_permissions = ()
+
 
 class MilestoneStateHistory(models.Model):
     milestone_state = models.ForeignKey(
@@ -83,6 +91,7 @@ class MilestoneStateHistory(models.Model):
 
     class Meta:
         ordering = ["timestamp_from"]
+        default_permissions = ()
 
 
 class TeamInjectState(models.Model):
@@ -108,6 +117,7 @@ class TeamInjectState(models.Model):
 
     class Meta:
         ordering = ["inject__time"]
+        default_permissions = ()
 
 
 class EmailParticipant(models.Model):
@@ -130,6 +140,9 @@ class EmailParticipant(models.Model):
         blank=True,
     )
 
+    class Meta:
+        default_permissions = ()
+
     def is_definition(self) -> bool:
         return self.definition_address_id is not None
 
diff --git a/exercise/schema/mutation.py b/exercise/schema/mutation.py
index bfca640c..55650120 100644
--- a/exercise/schema/mutation.py
+++ b/exercise/schema/mutation.py
@@ -4,6 +4,15 @@ from common_lib.schema_types import ExerciseType
 from exercise.graphql_inputs import CreateExerciseInput
 from exercise.lib.exercise_manager import ExerciseManager
 from exercise_definition.lib.definition_manager import DefinitionManager
+from aai.models import Perms
+from aai.utils import (
+    protected,
+    exercise_protected,
+    definition_protected,
+    input_object_protected,
+)
+from user.schema.validators import validate_instructor_assigning
+from user.models import InstructorOfExercise
 
 
 class CreateExerciseMutation(graphene.Mutation):
@@ -15,6 +24,8 @@ class CreateExerciseMutation(graphene.Mutation):
         )
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @input_object_protected("create_exercise_input")
     def mutate(
         cls,
         root,
@@ -22,6 +33,10 @@ class CreateExerciseMutation(graphene.Mutation):
         create_exercise_input: CreateExerciseInput,
     ) -> graphene.Mutation:
         exercise = ExerciseManager.create_exercise(create_exercise_input)
+
+        user = validate_instructor_assigning([info.context.user.id], exercise)
+        InstructorOfExercise.objects.create(user=user[0], exercise=exercise)
+
         return CreateExerciseMutation(exercise=exercise)
 
 
@@ -32,6 +47,8 @@ class DeleteExerciseMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @exercise_protected
     def mutate(cls, root, info, exercise_id: str):
         ExerciseManager.delete_exercise(int(exercise_id))
         return DeleteExerciseMutation(operation_done=True)
@@ -44,6 +61,8 @@ class DeleteDefinitionMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.update_definition.full_name)
+    @definition_protected
     def mutate(cls, root, info, definition_id: str):
         DefinitionManager.delete_definition(int(definition_id))
         return DeleteDefinitionMutation(operation_done=True)
diff --git a/exercise/schema/query.py b/exercise/schema/query.py
index 1a16c085..2ceca7a9 100644
--- a/exercise/schema/query.py
+++ b/exercise/schema/query.py
@@ -19,6 +19,8 @@ from exercise_definition.models import (
     FileInfo,
     Channel,
 )
+from aai.models import Perms, UserGroup
+from aai.utils import protected, exercise_protected, definition_protected
 
 
 class Query(graphene.ObjectType):
@@ -91,6 +93,7 @@ class Query(graphene.ObjectType):
         description="Retrieve all channels for an exercise",
     )
 
+    @protected(Perms.view_exercise.full_name)
     def resolve_exercises(
         self,
         info,
@@ -99,7 +102,15 @@ class Query(graphene.ObjectType):
         limit: Optional[int] = None,
         skip: Optional[int] = None,
     ) -> List[Exercise]:
-        exercises = Exercise.objects.all()
+        user = info.context.user
+        if user.group == UserGroup.TRAINEE:
+            exercises = Exercise.objects.filter(teams__users__user=user)
+        elif user.group == UserGroup.INSTRUCTOR:
+            exercises = user.exercises.all()
+        elif user.group == UserGroup.ADMIN:
+            exercises = Exercise.objects.all()
+        else:
+            return Exercise.objects.none()
 
         if running is not None:
             exercises = exercises.filter(running=running)
@@ -115,15 +126,28 @@ class Query(graphene.ObjectType):
 
         return exercises
 
+    @protected(Perms.view_exercise.full_name)
+    @exercise_protected
     def resolve_exercise_id(self, info, exercise_id: str) -> Optional[Exercise]:
         return Exercise.objects.filter(id=exercise_id).first()
 
+    @protected(Perms.view_definition.full_name)
     def resolve_definitions(self, info) -> List[Definition]:
-        return Definition.objects.all()
-
+        user = info.context.user
+        if user.group == UserGroup.INSTRUCTOR:
+            return Definition.objects.filter(maintainers__user=user)
+        elif user.group == UserGroup.ADMIN:
+            return Definition.objects.all()
+        else:  # UserGroup.TRAINEE and unknown groups can't see definitions
+            return []
+
+    @protected(Perms.view_definition.full_name)
+    @definition_protected
     def resolve_definition(self, info, definition_id: str) -> Definition:
         return get_model(Definition, id=definition_id)
 
+    @protected(Perms.view_category.full_name)
+    @exercise_protected
     def resolve_auto_injects(self, info, exercise_id: str) -> List[Inject]:
         exercise = get_model(
             Exercise,
@@ -134,6 +158,8 @@ class Query(graphene.ObjectType):
             definition_id=exercise.definition.id, auto=True
         )
 
+    @protected(Perms.view_milestone.full_name)
+    @exercise_protected
     def resolve_milestones(self, info, exercise_id: str) -> List[Milestone]:
         exercise = get_model(
             Exercise,
diff --git a/exercise/serializers.py b/exercise/serializers.py
index c1b8ae7e..9a83b103 100644
--- a/exercise/serializers.py
+++ b/exercise/serializers.py
@@ -1,5 +1,12 @@
+from typing import List, Dict
+
 from rest_framework.fields import CharField
-from rest_framework.serializers import ModelSerializer, IntegerField
+from rest_framework.serializers import (
+    ModelSerializer,
+    IntegerField,
+    EmailField,
+    SerializerMethodField,
+)
 
 from exercise.models import (
     EmailParticipant,
@@ -9,6 +16,12 @@ from exercise.models import (
     Exercise,
 )
 from exercise_definition.serializers import EmailAddressSerializer
+from user.models import User
+
+
+def _serialize_users(users: List[User], context: Dict[str, object]):
+    serializer = UserSerializer(users, many=True, context=context)
+    return serializer.data
 
 
 class ExerciseSerializer(ModelSerializer):
@@ -24,12 +37,61 @@ class ExerciseSerializer(ModelSerializer):
         ]
 
 
+class UserSerializer(ModelSerializer):
+    username = EmailField(required=False, allow_null=True)
+    analytics_id = SerializerMethodField()
+
+    class Meta:
+        model = User
+        fields = ("username", "analytics_id")
+
+    def get_analytics_id(self, instance) -> int:
+        analytics_ids = self.context.get("analytics_ids", None)
+        if analytics_ids is None:
+            raise ValueError("No analytics_ids provided to UserSerializer")
+
+        return analytics_ids[instance.username]
+
+    def to_representation(self, instance):
+        anonymize = self.context.get("anonymize", False)
+        data = super().to_representation(instance)
+
+        if anonymize:
+            data["username"] = None
+
+        return data
+
+
+class InstructorSerializer(ModelSerializer):
+    instructors = SerializerMethodField()
+
+    class Meta:
+        model = Exercise
+        fields = ["instructors"]
+
+    def get_instructors(self, obj):
+        users = [instr.user for instr in obj.instructors.all()]
+        return _serialize_users(users, self.context)
+
+
 class TeamSerializer(ModelSerializer):
     team_id = IntegerField(source="id")
+    users = SerializerMethodField()
 
     class Meta:
         model = Team
-        fields = ["team_id", "name", "role", "exercise_id", "finish_time"]
+        fields = [
+            "team_id",
+            "name",
+            "role",
+            "exercise_id",
+            "finish_time",
+            "users",
+        ]
+
+    def get_users(self, obj):
+        users = obj.user_set.all()
+        return _serialize_users(users, self.context)
 
 
 class ParticipantSerializer(ModelSerializer):
diff --git a/exercise/subscription.py b/exercise/subscription.py
index 71da72a3..433a42d0 100644
--- a/exercise/subscription.py
+++ b/exercise/subscription.py
@@ -3,6 +3,8 @@ import graphene
 from common_lib.schema_types import ExerciseEventTypeEnum
 
 from common_lib.schema_types import ExerciseType
+from aai.models import Perms
+from aai.utils import protected
 
 NOTIFICATION_QUEUE_LIMIT = 64
 
@@ -14,6 +16,7 @@ class ExercisesSubscription(channels_graphql_ws.Subscription):
     event_type = graphene.Field(graphene.Enum.from_enum(ExerciseEventTypeEnum))
 
     @staticmethod
+    @protected(Perms.view_exercise.full_name)
     def subscribe(root, info):
         return ["all"]
 
diff --git a/exercise/tests/create_exercise_tests.py b/exercise/tests/create_exercise_tests.py
index 310ddb21..70c8ac7f 100644
--- a/exercise/tests/create_exercise_tests.py
+++ b/exercise/tests/create_exercise_tests.py
@@ -12,6 +12,7 @@ from common_lib.test_utils import internal_upload_definition
 from common_lib.utils import get_model
 from exercise.models import Exercise
 from exercise_definition.models import Definition
+from user.models import User
 
 TEST_DATA_STORAGE = "create_exercise_test_data"
 
@@ -28,6 +29,12 @@ class CreateExerciseTestCase(GraphQLApiTestCase):
     def setUpTestData(cls):
         cls.roles_definition = internal_upload_definition("roles_definition")
         cls.emails_definition = internal_upload_definition("emails_definition")
+        cls.instructor = User.objects.create_staffuser(
+            username="ins@mail.com", password="ins"
+        )
+        # Add definition access to instructor so he can use them in exercise creation
+        cls.instructor.definitions.add(cls.roles_definition)
+        cls.instructor.definitions.add(cls.emails_definition)
 
     @classmethod
     def tearDownClass(cls):
@@ -37,7 +44,7 @@ class CreateExerciseTestCase(GraphQLApiTestCase):
         super().tearDownClass()
 
     def create_exercise(self, action: CreateExerciseAction) -> Exercise:
-        self.run_action(action)
+        self.run_action(action, user=self.instructor)
         exercise_id = action.result["exercise"]["id"]
         return get_model(Exercise, id=exercise_id)
 
diff --git a/exercise/views.py b/exercise/views.py
index 2523dea1..094f1f1c 100644
--- a/exercise/views.py
+++ b/exercise/views.py
@@ -14,22 +14,37 @@ from common_lib.responses import (
 )
 from exercise.lib.export_import import export_database, import_database
 from exercise.lib.log_manager import LogManager
+from aai.utils import protected, exercise_protected
+from aai.models import Perms
 
 
 class RetrieveExerciseLogsView(APIView):
     @swagger_auto_schema(
+        manual_parameters=[
+            openapi.Parameter(
+                name="anonymize",
+                in_=openapi.IN_QUERY,
+                type=openapi.TYPE_BOOLEAN,
+                description="Set to true for user anonymization otherwise leave empty",
+            )
+        ],
         responses={
             200: file_response("Exported exercise logs"),
             500: ERROR_RESPONSE,
         },
     )
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def get(self, request, *args, **kwargs):
         """
         Get logs of all teams in a game.
         """
         exercise_id = self.kwargs.get("exercise_id")
+        anonymize = request.GET.get("anonymize") is not None
         force = request.GET.get("force") is not None
-        archive_path = LogManager(int(exercise_id)).create_exercise_logs(force)
+        archive_path = LogManager(
+            exercise_id=int(exercise_id), anonymize=anonymize
+        ).create_exercise_logs(force)
         return FileResponse(open(archive_path, "rb"), as_attachment=True)
 
 
diff --git a/exercise_definition/migrations/0011_auto_20240227_1813.py b/exercise_definition/migrations/0011_auto_20240227_1813.py
new file mode 100644
index 00000000..1cf77150
--- /dev/null
+++ b/exercise_definition/migrations/0011_auto_20240227_1813.py
@@ -0,0 +1,57 @@
+# Generated by Django 3.2.24 on 2024-02-27 18:13
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise_definition', '0010_inject_subject'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='category',
+            options={'default_permissions': (), 'ordering': ['time']},
+        ),
+        migrations.AlterModelOptions(
+            name='config',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='definition',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='emailaddress',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='emailtemplate',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='enabledfeatures',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='inject',
+            options={'default_permissions': (), 'ordering': ['name']},
+        ),
+        migrations.AlterModelOptions(
+            name='milestone',
+            options={'default_permissions': (), 'ordering': ['id']},
+        ),
+        migrations.AlterModelOptions(
+            name='response',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='role',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='tool',
+            options={'default_permissions': ()},
+        ),
+    ]
diff --git a/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py b/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py
new file mode 100644
index 00000000..4ad5529c
--- /dev/null
+++ b/exercise_definition/migrations/0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.24 on 2024-03-07 12:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise_definition', '0011_auto_20240227_1813'),
+        ('exercise_definition', '0012_auto_20240227_1222'),
+    ]
+
+    operations = [
+    ]
diff --git a/exercise_definition/models/models.py b/exercise_definition/models/models.py
index 389311b6..c02dd562 100644
--- a/exercise_definition/models/models.py
+++ b/exercise_definition/models/models.py
@@ -37,6 +37,9 @@ class Definition(models.Model):
     name = models.CharField(max_length=100)
     version = models.CharField(max_length=20)
 
+    class Meta:
+        default_permissions = ()
+
 
 class FileInfo(models.Model):
     id = models.UUIDField(
@@ -54,6 +57,9 @@ class FileInfo(models.Model):
         null=True,
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class Content(models.Model):
     raw = models.CharField(max_length=2000)
@@ -66,6 +72,9 @@ class Content(models.Model):
         blank=True,
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class Config(models.Model):
     exercise_duration = models.IntegerField()
@@ -74,6 +83,9 @@ class Config(models.Model):
         Definition, on_delete=models.CASCADE, null=True, blank=True
     )
 
+    class Meta:
+        default_permissions = ()
+
     def has_enabled(self, feature: Feature) -> bool:
         return self.enabled_features.filter(feature=feature).first() is not None
 
@@ -84,6 +96,9 @@ class EnabledFeatures(models.Model):
     )
     feature = models.CharField(max_length=100, choices=Feature.choices)
 
+    class Meta:
+        default_permissions = ()
+
 
 class Control(models.Model):
     milestone_condition = models.CharField(max_length=200)
@@ -91,6 +106,9 @@ class Control(models.Model):
     deactivate_milestone = models.CharField(max_length=200)
     roles = models.CharField(max_length=200)
 
+    class Meta:
+        default_permissions = ()
+
 
 class Milestone(models.Model):
     name = models.CharField(max_length=500)
@@ -104,6 +122,7 @@ class Milestone(models.Model):
 
     class Meta:
         ordering = ["id"]
+        default_permissions = ()
 
 
 class EmailAddress(models.Model):
@@ -124,6 +143,7 @@ class EmailAddress(models.Model):
                 fields=["address", "definition_id"], name="unique_address"
             )
         ]
+        default_permissions = ()
 
 
 class EmailTemplate(models.Model):
@@ -138,10 +158,16 @@ class EmailTemplate(models.Model):
         EmailAddress, on_delete=models.CASCADE, related_name="templates"
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class Overlay(models.Model):
     duration = models.IntegerField()
 
+    class Meta:
+        default_permissions = ()
+
 
 class InjectTypes(models.TextChoices):
     INFO = "info"
@@ -170,6 +196,7 @@ class InfoAlternative(models.Model):
 
     class Meta:
         ordering = ["name"]
+        default_permissions = ()
 
 
 class EmailAlternative(models.Model):
@@ -196,6 +223,7 @@ class EmailAlternative(models.Model):
 
     class Meta:
         ordering = ["name"]
+        default_permissions = ()
 
 
 Alternative = Union["InfoAlternative", "EmailAlternative"]
@@ -214,6 +242,7 @@ class Inject(models.Model):
 
     class Meta:
         ordering = ["time"]
+        default_permissions = ()
 
     @property
     def alternatives(self):
@@ -232,6 +261,9 @@ class Tool(models.Model):
         Definition, on_delete=models.CASCADE, related_name="tools"
     )
 
+    class Meta:
+        default_permissions = ()
+
     def find_responses(self, argument: str) -> List["Response"]:
         responses = []
         for response in self.responses.all():
@@ -273,6 +305,9 @@ class Response(models.Model):
         Tool, on_delete=models.CASCADE, related_name="responses"
     )
 
+    class Meta:
+        default_permissions = ()
+
     def __eq__(self, other):
         if not isinstance(other, Response):
             return False
@@ -296,6 +331,9 @@ class Role(models.Model):
         Definition, on_delete=models.CASCADE, related_name="roles"
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class Channel(models.Model):
     name = models.CharField(max_length=200)
@@ -303,3 +341,6 @@ class Channel(models.Model):
     definition = models.ForeignKey(
         Definition, on_delete=models.CASCADE, related_name="channels"
     )
+
+    class Meta:
+        default_permissions = ()
diff --git a/exercise_definition/views.py b/exercise_definition/views.py
index 7bd968c9..960c175c 100644
--- a/exercise_definition/views.py
+++ b/exercise_definition/views.py
@@ -8,6 +8,9 @@ from rest_framework.views import APIView
 from common_lib.exceptions import ApiException
 from common_lib.responses import success_json_response, ERROR_RESPONSE
 from exercise_definition.lib import DefinitionUploader
+from aai.models import Perms
+from aai.utils import protected
+from user.models import DefinitionAccess
 
 
 class UploadDefinitionView(APIView):
@@ -35,6 +38,7 @@ class UploadDefinitionView(APIView):
             500: ERROR_RESPONSE,
         },
     )
+    @protected(Perms.update_definition.full_name)
     def post(self, request: Request, *args, **kwargs):
         """Upload an exercise definition file and check it for correctness."""
         uploaded_file = request.FILES.get("file")
@@ -48,6 +52,9 @@ class UploadDefinitionView(APIView):
             raise ApiException("Invalid file type, must be zip")
 
         definition = DefinitionUploader(uploaded_file).upload(definition_name)
+        DefinitionAccess.objects.create(
+            user=request.user, definition=definition
+        )
         return Response(
             {
                 "status": "ok",
diff --git a/running_exercise/lib/email_client.py b/running_exercise/lib/email_client.py
index 6dfda3b7..357855e0 100644
--- a/running_exercise/lib/email_client.py
+++ b/running_exercise/lib/email_client.py
@@ -25,6 +25,7 @@ from running_exercise.models import (
     Email,
     ActionLog,
 )
+from user.models import User
 
 
 def _update_thread_milestones(thread: EmailThread, control: Optional[Control]):
@@ -65,14 +66,13 @@ def send_email(
 
 
 def _validate_participants(
-    participant_addresses: List[str],
+    participant_addresses: List[str], exercise: Exercise
 ) -> List[EmailParticipant]:
     if len(set(participant_addresses)) < 2:
         raise RunningExerciseOperationException(
             "A thread must have at least 2 participants"
         )
 
-    exercise = get_model(Exercise, running=True)
     email_between_teams = exercise.config.has_enabled(
         Feature.EMAIL_BETWEEN_TEAMS
     )
@@ -137,11 +137,14 @@ def _activate_thread_definition_milestones(
 class EmailClient:
     @staticmethod
     def create_thread(
-        participant_addresses: List[str], subject: str
+        participant_addresses: List[str], subject: str, exercise_id: str
     ) -> EmailThread:
-        participants = _validate_participants(participant_addresses)
+        exercise = get_model(Exercise, id=exercise_id)
+        participants = _validate_participants(participant_addresses, exercise)
 
-        new_thread = EmailThread.objects.create(subject=subject)
+        new_thread = EmailThread.objects.create(
+            subject=subject, exercise=exercise
+        )
         new_thread.participants.add(*participants)
 
         SubscriptionHandler.broadcast_email_thread(
@@ -150,7 +153,9 @@ class EmailClient:
         return new_thread
 
     @staticmethod
-    def send_email(send_email_input: SendEmailInput) -> List[ActionLog]:
+    def send_email(
+        send_email_input: SendEmailInput, user: Optional[User] = None
+    ) -> List[ActionLog]:
         thread = get_model(EmailThread, id=int(send_email_input.thread_id))
         sender = get_model(
             EmailParticipant,
@@ -183,7 +188,7 @@ class EmailClient:
         )
 
         email = Email.objects.create(
-            sender=sender, thread=thread, content=content
+            sender=sender, thread=thread, content=content, user=user
         )
         return send_email(email=email, channel=channel)
 
@@ -229,7 +234,9 @@ class EmailClient:
             return thread
 
         return EmailClient.create_thread(
-            [participant.address for participant in participants], subject
+            [participant.address for participant in participants],
+            subject,
+            team.exercise.id,
         )
 
     @staticmethod
diff --git a/running_exercise/lib/inject_selector.py b/running_exercise/lib/inject_selector.py
index c66c1f83..d1df40e5 100644
--- a/running_exercise/lib/inject_selector.py
+++ b/running_exercise/lib/inject_selector.py
@@ -24,13 +24,17 @@ from running_exercise.models import (
     CustomInjectDetails,
     Email,
 )
+from user.models import User
 
 CUSTOM_INJECT_SELECTION_ID = "-1"
 
 
 class InjectSelector:
     @staticmethod
-    def select_inject(select_team_inject_input: SelectTeamInjectInput):
+    def select_inject(
+        select_team_inject_input: SelectTeamInjectInput,
+        user: Optional[User] = None,
+    ):
         team = get_model(Team, id=select_team_inject_input.team_id)
         if select_team_inject_input.file_id != "":
             file_info = get_model(FileInfo, id=select_team_inject_input.file_id)
@@ -108,6 +112,7 @@ class InjectSelector:
             else:
                 details = CustomInjectDetails.objects.create(
                     content=content,
+                    user=user,
                 )
             for _ in range(select_team_inject_input.repeat + 1):
                 create_action_log(team, details, channel)
diff --git a/running_exercise/lib/team_action_handler.py b/running_exercise/lib/team_action_handler.py
index e8888663..b1a2fa9d 100644
--- a/running_exercise/lib/team_action_handler.py
+++ b/running_exercise/lib/team_action_handler.py
@@ -26,6 +26,7 @@ from running_exercise.lib.utils import (
 from running_exercise.models import (
     ToolDetails,
 )
+from user.models import User
 
 
 def _parse_selected_tool(team: Team, tool_id: int) -> Tool:
@@ -57,7 +58,11 @@ def _find_chosen_response(
 
 
 def _handle_chosen_response(
-    tool: Tool, response: Optional[Response], team: Team, argument: str
+    tool: Tool,
+    response: Optional[Response],
+    team: Team,
+    argument: str,
+    user: Optional[User] = None,
 ) -> None:
     if response is not None:
         update_milestones(team, response.control)
@@ -70,6 +75,7 @@ def _handle_chosen_response(
         tool=tool,
         argument=argument,
         content=content,
+        user=user,
     )
 
     channel = get_model(
@@ -81,7 +87,7 @@ def _handle_chosen_response(
 class TeamActionHandler:
     @staticmethod
     def perform_action(
-        use_tool_input: UseToolInput,
+        use_tool_input: UseToolInput, user: Optional[User] = None
     ) -> None:
         team = get_model(Team, id=int(use_tool_input.team_id))
         if not team.exercise.running:
@@ -100,4 +106,5 @@ class TeamActionHandler:
             chosen_response,
             team,
             tool_argument,
+            user,
         )
diff --git a/running_exercise/migrations/0006_auto_20240227_1813.py b/running_exercise/migrations/0006_auto_20240227_1813.py
new file mode 100644
index 00000000..bce240e3
--- /dev/null
+++ b/running_exercise/migrations/0006_auto_20240227_1813.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.24 on 2024-02-27 18:13
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("running_exercise", "0005_rename_description_emailthread_subject"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="actionlog",
+            options={"default_permissions": (), "ordering": ["id"]},
+        ),
+        migrations.AlterModelOptions(
+            name="email",
+            options={"default_permissions": ()},
+        ),
+        migrations.AlterModelOptions(
+            name="emailthread",
+            options={"default_permissions": ()},
+        ),
+        migrations.AlterModelOptions(
+            name="injectoption",
+            options={"default_permissions": (), "ordering": ["id"]},
+        ),
+        migrations.AlterModelOptions(
+            name="injectselection",
+            options={"default_permissions": (), "ordering": ["id"]},
+        ),
+        migrations.AlterModelOptions(
+            name="threadparticipant",
+            options={"default_permissions": ()},
+        ),
+    ]
diff --git a/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py b/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py
new file mode 100644
index 00000000..0461423b
--- /dev/null
+++ b/running_exercise/migrations/0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.24 on 2024-03-07 12:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('running_exercise', '0006_auto_20240227_1813'),
+        ('running_exercise', '0007_auto_20240227_1222'),
+    ]
+
+    operations = [
+    ]
diff --git a/running_exercise/migrations/0009_auto_20240320_1646.py b/running_exercise/migrations/0009_auto_20240320_1646.py
new file mode 100644
index 00000000..b996407c
--- /dev/null
+++ b/running_exercise/migrations/0009_auto_20240320_1646.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.24 on 2024-03-20 16:46
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('running_exercise', '0008_merge_0006_auto_20240227_1813_0007_auto_20240227_1222'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='actionlog',
+            options={'default_permissions': ()},
+        ),
+        migrations.AlterModelOptions(
+            name='teamfile',
+            options={'default_permissions': ()},
+        ),
+        migrations.AddField(
+            model_name='custominjectdetails',
+            name='user',
+            field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='email',
+            name='user',
+            field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AddField(
+            model_name='tooldetails',
+            name='user',
+            field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/running_exercise/migrations/0010_emailthread_exercise.py b/running_exercise/migrations/0010_emailthread_exercise.py
new file mode 100644
index 00000000..d10eb773
--- /dev/null
+++ b/running_exercise/migrations/0010_emailthread_exercise.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.24 on 2024-03-28 16:08
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'),
+        ('running_exercise', '0009_auto_20240320_1646'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='emailthread',
+            name='exercise',
+            field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='exercise', to='exercise.exercise'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/running_exercise/migrations/0011_alter_emailthread_exercise.py b/running_exercise/migrations/0011_alter_emailthread_exercise.py
new file mode 100644
index 00000000..ad90aa50
--- /dev/null
+++ b/running_exercise/migrations/0011_alter_emailthread_exercise.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.24 on 2024-04-05 13:45
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'),
+        ('running_exercise', '0010_emailthread_exercise'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='emailthread',
+            name='exercise',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='threads', to='exercise.exercise'),
+        ),
+    ]
diff --git a/running_exercise/models.py b/running_exercise/models.py
index cab2db99..cf0f9f35 100644
--- a/running_exercise/models.py
+++ b/running_exercise/models.py
@@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 
-from exercise.models import Team, EmailParticipant, TeamInjectState
+from exercise.models import Team, EmailParticipant, TeamInjectState, Exercise
 from exercise_definition.models import (
     Tool,
     FileInfo,
@@ -14,6 +14,7 @@ from exercise_definition.models import (
     Channel,
     Overlay,
 )
+from user.models import User
 
 
 class ToolDetails(models.Model):
@@ -28,6 +29,16 @@ class ToolDetails(models.Model):
         on_delete=models.CASCADE,
         related_name="+",
     )
+    user = models.ForeignKey(
+        User,
+        on_delete=models.SET_NULL,
+        related_name="+",
+        null=True,
+        default=None,
+    )
+
+    class Meta:
+        default_permissions = ()
 
 
 class InjectDetails(models.Model):
@@ -47,6 +58,9 @@ class InjectDetails(models.Model):
         null=True,
     )
 
+    class Meta:
+        default_permissions = ()
+
 
 class CustomInjectDetails(models.Model):
     content = models.ForeignKey(
@@ -54,6 +68,16 @@ class CustomInjectDetails(models.Model):
         on_delete=models.CASCADE,
         related_name="+",
     )
+    user = models.ForeignKey(
+        User,
+        on_delete=models.SET_NULL,
+        related_name="+",
+        null=True,
+        default=None,
+    )
+
+    class Meta:
+        default_permissions = ()
 
 
 DetailsUnion = Union[ToolDetails, InjectDetails, CustomInjectDetails, "Email"]
@@ -93,6 +117,9 @@ class ActionLog(models.Model):
     details_id = models.PositiveIntegerField()
     details = GenericForeignKey("content_type", "details_id")
 
+    class Meta:
+        default_permissions = ()
+
 
 class EmailThread(models.Model):
     subject = models.CharField(max_length=300)
@@ -103,6 +130,12 @@ class EmailThread(models.Model):
         related_name="threads",
     )
     timestamp = models.DateTimeField(auto_now_add=True)
+    exercise = models.ForeignKey(
+        Exercise, on_delete=models.CASCADE, related_name="threads"
+    )
+
+    class Meta:
+        default_permissions = ()
 
     def get_definition_participant(self) -> Optional[EmailParticipant]:
         return self.participants.filter(
@@ -128,6 +161,16 @@ class Email(models.Model):
         blank=True,
         null=True,
     )
+    user = models.ForeignKey(
+        User,
+        on_delete=models.SET_NULL,
+        related_name="+",
+        null=True,
+        default=None,
+    )
+
+    class Meta:
+        default_permissions = ()
 
 
 class ThreadParticipant(models.Model):
@@ -145,6 +188,7 @@ class ThreadParticipant(models.Model):
                 fields=["email_participant", "email_thread"],
             )
         ]
+        default_permissions = ()
 
 
 class InjectSelection(models.Model):
@@ -159,6 +203,7 @@ class InjectSelection(models.Model):
 
     class Meta:
         ordering = ["id"]
+        default_permissions = ()
 
 
 class InjectOption(models.Model):
@@ -176,6 +221,7 @@ class InjectOption(models.Model):
 
     class Meta:
         ordering = ["id"]
+        default_permissions = ()
 
 
 class TeamFile(models.Model):
@@ -193,3 +239,4 @@ class TeamFile(models.Model):
                 fields=["team", "file_info"],
             )
         ]
+        default_permissions = ()
diff --git a/running_exercise/schema/mutation.py b/running_exercise/schema/mutation.py
index 7237738f..9df9072e 100644
--- a/running_exercise/schema/mutation.py
+++ b/running_exercise/schema/mutation.py
@@ -16,6 +16,13 @@ from running_exercise.lib.inject_selector import (
 )
 from running_exercise.lib.milestone_handler import instructor_modify_milestone
 from running_exercise.lib.team_action_handler import TeamActionHandler
+from aai.models import Perms
+from aai.utils import (
+    protected,
+    exercise_protected,
+    team_protected,
+    input_object_protected,
+)
 
 
 class UseToolMutation(graphene.Mutation):
@@ -25,13 +32,15 @@ class UseToolMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.use_tool.full_name)
+    @input_object_protected("use_tool_input")
     def mutate(
         cls,
         root,
         info,
         use_tool_input: UseToolInput,
     ) -> graphene.Mutation:
-        TeamActionHandler.perform_action(use_tool_input)
+        TeamActionHandler.perform_action(use_tool_input, info.context.user)
         return UseToolMutation(operation_done=True)
 
 
@@ -44,10 +53,14 @@ class SelectTeamInjectOptionMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.send_injectselection.full_name)
+    @input_object_protected("select_team_inject_input")
     def mutate(
         cls, root, info, select_team_inject_input: SelectTeamInjectInput
     ) -> graphene.Mutation:
-        InjectSelector.select_inject(select_team_inject_input)
+        InjectSelector.select_inject(
+            select_team_inject_input, info.context.user
+        )
         return SelectTeamInjectOptionMutation(operation_done=True)
 
 
@@ -65,9 +78,20 @@ class CreateThreadMutation(graphene.Mutation):
     thread = graphene.Field(EmailThreadType)
 
     @classmethod
-    def mutate(cls, root, info, participant_addresses: List[str], subject: str):
+    @protected(Perms.send_email.full_name)
+    @exercise_protected
+    def mutate(
+        cls,
+        root,
+        info,
+        participant_addresses: List[str],
+        subject: str,
+        exercise_id: str,
+    ):
         return CreateThreadMutation(
-            thread=EmailClient.create_thread(participant_addresses, subject)
+            thread=EmailClient.create_thread(
+                participant_addresses, subject, exercise_id
+            )
         )
 
 
@@ -78,13 +102,15 @@ class SendEmailMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.send_email.full_name)
+    @input_object_protected("send_email_input")
     def mutate(
         cls,
         root,
         info,
         send_email_input: SendEmailInput,
     ):
-        EmailClient.send_email(send_email_input)
+        EmailClient.send_email(send_email_input, info.context.user)
         return SendEmailMutation(operation_done=True)
 
 
@@ -96,6 +122,8 @@ class MoveExerciseTimeMutation(graphene.Mutation):
     exercise = graphene.Field(ExerciseType)
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @exercise_protected
     def mutate(cls, root, info, exercise_id: str, time_diff: int):
         exercise = ExerciseLoop.move_time(int(exercise_id), time_diff)
         return MoveExerciseTimeMutation(exercise=exercise)
@@ -108,6 +136,8 @@ class StartExerciseMutation(graphene.Mutation):
     exercise = graphene.Field(ExerciseType)
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @exercise_protected
     def mutate(cls, root, info, exercise_id: str):
         exercise = ExerciseLoop.start(int(exercise_id))
         return StartExerciseMutation(exercise=exercise)
@@ -120,6 +150,8 @@ class StopExerciseMutation(graphene.Mutation):
     exercise = graphene.Field(ExerciseType)
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @exercise_protected
     def mutate(cls, root, info, exercise_id: str):
         exercise = ExerciseLoop.stop(int(exercise_id))
         return StopExerciseMutation(exercise=exercise)
@@ -138,6 +170,8 @@ class ModifyMilestoneMutation(graphene.Mutation):
     operation_done = graphene.Boolean()
 
     @classmethod
+    @protected(Perms.update_exercise.full_name)
+    @team_protected
     def mutate(cls, root, info, team_id: str, milestone: str, activate: bool):
         instructor_modify_milestone(
             team_id=int(team_id), milestone=milestone, activate=activate
diff --git a/running_exercise/schema/query.py b/running_exercise/schema/query.py
index a18abe70..310314be 100644
--- a/running_exercise/schema/query.py
+++ b/running_exercise/schema/query.py
@@ -3,6 +3,15 @@ from typing import List
 import graphene
 from django.db.models import QuerySet
 
+from aai.models import Perms
+from aai.utils import (
+    protected,
+    exercise_protected,
+    team_protected,
+    log_protected,
+    thread_protected,
+    visibility_protected,
+)
 from common_lib.exceptions import RunningExerciseOperationException
 from common_lib.schema_types import (
     ActionLogType,
@@ -28,12 +37,7 @@ from running_exercise.lib.email_client import EmailClient
 from running_exercise.lib.exercise_loop import ExerciseLoop
 from running_exercise.lib.milestone_handler import get_milestone_states
 from running_exercise.lib.utils import get_running_exercise
-from running_exercise.models import (
-    ActionLog,
-    InjectSelection,
-    EmailThread,
-    ThreadParticipant,
-)
+from running_exercise.models import ActionLog, InjectSelection, EmailThread
 
 
 class Query(graphene.ObjectType):
@@ -77,7 +81,8 @@ class Query(graphene.ObjectType):
         team_id=graphene.ID(required=True),
         visible_only=graphene.Boolean(
             required=False,
-            description="Not required, defaults to false. When true only milestones visible to the team are retrieved",
+            description="""Not required, defaults to true returns only milestones that are visible to the team.
+            If set to false then all milestones (even not visible to team) are sent""",
         ),
         description="Retrieve all milestones for the specific team filtered by team visibility",
     )
@@ -90,7 +95,8 @@ class Query(graphene.ObjectType):
         EmailParticipantType,
         visible_only=graphene.Boolean(
             required=False,
-            description="Not required, defaults to false. If set to true then only email contacts that are visible to the team are retrieved",
+            description="""Not required, defaults to true returns only milestones that are visible to the team.
+            If set to false then all milestones (even not visible to team) are sent""",
         ),
         description="Retrieve all email contacts for the running exercise",
     )
@@ -171,15 +177,20 @@ class Query(graphene.ObjectType):
         description="Retrieve all tools for the specific exercise. Useful for analytics.",
     )
 
+    @protected(Perms.view_trainee_info.full_name)
+    @team_protected
     def resolve_team(self, info, team_id: str) -> Team:
         return get_model(Team, id=int(team_id))
 
+    @protected(Perms.view_trainee_info.full_name)
     def resolve_team_roles(self, info) -> List[str]:
         running_exercise = get_running_exercise()
         if running_exercise is None:
             raise RunningExerciseOperationException("No exercise is running.")
         return [team.role for team in running_exercise.teams.all()]
 
+    @protected(Perms.view_trainee_info.full_name)
+    @team_protected
     def resolve_team_tools(self, info, team_id: str) -> List[Tool]:
         team = get_model(Team, id=int(team_id))
 
@@ -189,6 +200,8 @@ class Query(graphene.ObjectType):
             if has_role(team.role, tool.roles.split(" "))
         ]
 
+    @protected(Perms.view_extendtool.full_name)
+    @team_protected
     def resolve_extended_team_tools(self, info, team_id: str) -> List[Tool]:
         team = get_model(Team, id=int(team_id))
 
@@ -198,9 +211,13 @@ class Query(graphene.ObjectType):
             if has_role(team.role, tool.roles.split(" "))
         ]
 
+    @protected(Perms.view_trainee_info.full_name)
+    @log_protected
     def resolve_action_log(self, info, log_id: str) -> ActionLog:
         return get_model(ActionLog, id=int(log_id))
 
+    @protected(Perms.view_trainee_info.full_name)
+    @team_protected
     def resolve_team_action_logs(
         self, info, team_id: str
     ) -> QuerySet[ActionLog]:
@@ -211,55 +228,77 @@ class Query(graphene.ObjectType):
     ) -> QuerySet[ActionLog]:
         return ActionLog.objects.filter(team_id=team_id, channel_id=channel_id)
 
+    @protected(Perms.view_milestone.full_name)
+    @team_protected
+    @visibility_protected
     def resolve_team_milestones(
-        self, info, team_id: str, visible_only: bool = False
+        self, info, team_id: str, visible_only: bool = True
     ) -> List[MilestoneState]:
         return get_milestone_states(int(team_id), visible_only)
 
+    @protected(Perms.view_injectselection.full_name)
+    @team_protected
     def resolve_team_inject_selections(
         self, info, team_id: str
     ) -> QuerySet[InjectSelection]:
         return InjectSelection.objects.filter(team_id=team_id)
 
+    @protected(Perms.view_email_info.full_name)
+    @visibility_protected
     def resolve_email_contacts(
-        self, info, visible_only: bool = False
+        self, info, visible_only: bool = True
     ) -> QuerySet[EmailParticipant]:
         return EmailClient.get_contacts(visible_only)
 
+    @protected(Perms.view_email_info.full_name)
     def resolve_email_contact(
         self, info, participant_id: str
     ) -> EmailParticipant:
         return get_model(EmailParticipant, id=int(participant_id))
 
+    @protected(Perms.view_email.full_name)
+    @team_protected
     def resolve_email_threads(
         self, info, team_id: str
     ) -> QuerySet[EmailThread]:
         return EmailClient.get_threads(int(team_id))
 
+    @protected(Perms.view_email.full_name)
+    @thread_protected
     def resolve_email_thread(self, info, thread_id: str) -> EmailThread:
         return get_model(EmailThread, id=int(thread_id))
 
+    @protected(Perms.view_email_info.full_name)
+    @thread_protected
     def resolve_email_addresses(self, info, thread_id: str) -> List[str]:
         return EmailClient.get_instructor_email_addresses(int(thread_id))
 
+    @protected(Perms.view_email_info.full_name)
+    @team_protected
     def resolve_team_email_participant(
         self, info, team_id: str
     ) -> EmailParticipant:
         return EmailClient.get_team_participant(int(team_id))
 
+    @protected(Perms.view_injectselection.full_name)
+    @exercise_protected
     def resolve_email_templates(
         self, info, exercise_id: str, email_addresses: List[str]
     ) -> List[EmailTemplate]:
         return EmailClient.get_email_templates(exercise_id, email_addresses)
 
+    @protected(Perms.view_injectselection.full_name)
+    @thread_protected
     def resolve_thread_templates(
         self, info, thread_id: int
     ) -> List[EmailTemplate]:
         return EmailClient.get_thread_templates(int(thread_id))
 
+    @protected(Perms.view_injectselection.full_name)
     def resolve_thread_template(self, info, template_id: str) -> EmailTemplate:
         return get_model(EmailTemplate, id=int(template_id))
 
+    @protected(Perms.view_exercise.full_name)
     def resolve_exercise_time_left(self, info) -> int:
         running_exercise = get_running_exercise()
         if not running_exercise:
@@ -271,6 +310,8 @@ class Query(graphene.ObjectType):
             )
         return max(0, exercise_duration_s - int(running_exercise.elapsed_s))
 
+    @protected(Perms.view_exercise.full_name)  # TODO: decide if correct?
+    @exercise_protected
     def resolve_exercise_config(self, info, exercise_id: str) -> GrapheneConfig:
         exercise = get_model(Exercise, id=int(exercise_id))
 
@@ -285,9 +326,13 @@ class Query(graphene.ObjectType):
             custom_email_suffix=config.custom_email_suffix,
         )
 
+    @protected(Perms.view_exercise.full_name)
+    @exercise_protected  # TODO: leave exercise_protected?
     def resolve_exercise_loop_running(self, info, exercise_id: str) -> bool:
         return ExerciseLoop.is_running(int(exercise_id))
 
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def resolve_analytics_milestones(
         self, info, exercise_id: str
     ) -> QuerySet[MilestoneState]:
@@ -295,6 +340,8 @@ class Query(graphene.ObjectType):
             team_state__exercise_id=int(exercise_id)
         )
 
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def resolve_analytics_action_logs(
         self, info, exercise_id: str
     ) -> QuerySet[ActionLog]:
@@ -302,18 +349,17 @@ class Query(graphene.ObjectType):
 
         return ActionLog.objects.filter(team__in=exercise.teams.all())
 
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def resolve_analytics_email_threads(
         self, info, exercise_id: str
     ) -> QuerySet[EmailThread]:
-        exercise = get_model(Exercise, id=int(exercise_id))
-        # TODO: add exercise to EmailThread for simpler filtering
-        thread_ids = ThreadParticipant.objects.filter(
-            email_participant__exercise=exercise
-        ).values_list("email_thread_id", flat=True)
-        return EmailThread.objects.filter(id__in=thread_ids).prefetch_related(
-            "emails"
-        )
+        return EmailThread.objects.filter(
+            exercise_id=exercise_id
+        ).prefetch_related("emails")
 
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def resolve_exercise_tools(self, info, exercise_id: str) -> List[Tool]:
         exercise = get_model(Exercise, id=int(exercise_id))
 
diff --git a/running_exercise/serializers.py b/running_exercise/serializers.py
index 3143475d..248d7e4c 100644
--- a/running_exercise/serializers.py
+++ b/running_exercise/serializers.py
@@ -1,6 +1,12 @@
+from typing import Optional
+
 from rest_framework.fields import CharField
 from rest_framework.relations import RelatedField
-from rest_framework.serializers import ModelSerializer, IntegerField
+from rest_framework.serializers import (
+    ModelSerializer,
+    IntegerField,
+    SerializerMethodField,
+)
 
 from exercise_definition.serializers import (
     ContentSerializer,
@@ -18,6 +24,7 @@ from running_exercise.models import (
 class EmailSerializer(ModelSerializer):
     email_id = IntegerField(source="id")
     content = ContentSerializer()
+    user_analytics_id = SerializerMethodField()
 
     class Meta:
         model = Email
@@ -26,8 +33,18 @@ class EmailSerializer(ModelSerializer):
             "thread_id",
             "sender_id",
             "timestamp",
+            "content",
+            "user_analytics_id",
         ]
 
+    def get_user_analytics_id(self, instance) -> Optional[int]:
+        if instance.user is None:
+            return None
+        analytics_ids = self.context.get("analytics_ids", None)
+        if analytics_ids is None:
+            raise ValueError("Missing analytics_ids mapping in EmailSerializer")
+        return analytics_ids[instance.user.username]
+
 
 class EmailThreadSerializer(ModelSerializer):
     thread_id = IntegerField(source="id")
@@ -46,10 +63,19 @@ class EmailThreadSerializer(ModelSerializer):
 
 class ToolDetailsSerializer(ModelSerializer):
     content = ContentSerializer()
+    user_analytics_id = SerializerMethodField()
 
     class Meta:
         model = ToolDetails
-        fields = ["tool_id", "argument", "content"]
+        fields = ["tool_id", "argument", "content", "user_analytics_id"]
+
+    def get_user_analytics_id(self, instance) -> int:
+        analytics_ids = self.context.get("analytics_ids", None)
+        if analytics_ids is None:
+            raise ValueError(
+                "No analytics_ids provided to ToolDetailsSerializer"
+            )
+        return analytics_ids[instance.user.username]
 
 
 class InjectDetailsSerializer(ModelSerializer):
@@ -62,22 +88,29 @@ class InjectDetailsSerializer(ModelSerializer):
 
 class CustomInjectDetailsSerializer(ModelSerializer):
     content = ContentSerializer()
+    user_analytics_id = SerializerMethodField()
 
     class Meta:
         model = CustomInjectDetails
-        fields = ["content", "sender"]
+        fields = ["content", "sender", "user_analytics_id"]
+
+    def get_user_analytics_id(self, instance) -> int:
+        analytics_ids = self.context.get("analytics_ids", None)
+        return analytics_ids[instance.user.username]
 
 
 class DetailsRelatedField(RelatedField):
     def to_representation(self, value):
         if isinstance(value, ToolDetails):
-            serializer = ToolDetailsSerializer(value)
+            serializer = ToolDetailsSerializer(value, context=self.context)
         elif isinstance(value, InjectDetails):
-            serializer = InjectDetailsSerializer(value)
+            serializer = InjectDetailsSerializer(value, context=self.context)
         elif isinstance(value, CustomInjectDetails):
-            serializer = CustomInjectDetailsSerializer(value)
+            serializer = CustomInjectDetailsSerializer(
+                value, context=self.context
+            )
         elif isinstance(value, Email):
-            serializer = EmailSerializer(value)
+            serializer = EmailSerializer(value, context=self.context)
         else:
             raise Exception("Unknown details type")
 
diff --git a/running_exercise/subscription.py b/running_exercise/subscription.py
index 48c38869..70fd7acf 100644
--- a/running_exercise/subscription.py
+++ b/running_exercise/subscription.py
@@ -9,6 +9,13 @@ from common_lib.schema_types import (
 )
 from common_lib.utils import get_model, get_subscription_group
 from exercise.models import Team, Exercise
+from aai.models import Perms
+from aai.utils import (
+    protected,
+    exercise_protected,
+    team_protected,
+    visibility_protected,
+)
 
 NOTIFICATION_QUEUE_LIMIT = 64
 
@@ -22,6 +29,8 @@ class ExerciseLoopRunningSubscription(channels_graphql_ws.Subscription):
         exercise_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_exercise.full_name)
+    @exercise_protected
     def subscribe(root, info, exercise_id: str):
         get_model(Exercise, id=int(exercise_id))
         return [exercise_id]
@@ -40,6 +49,8 @@ class ActionLogsSubscription(channels_graphql_ws.Subscription):
         team_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_trainee_info.full_name)
+    @team_protected
     def subscribe(root, info, team_id: str):
         get_model(Team, id=int(team_id))
         return [team_id]
@@ -57,16 +68,20 @@ class MilestonesSubscription(channels_graphql_ws.Subscription):
         team_id = graphene.ID(required=True)
         visible_only = graphene.Boolean(
             required=False,
-            description="Not required, defaults to false. If set to true then only milestones that are visible to the team are sent",
+            description="""Not required, defaults to true returns only milestones that are visible to the team.
+            If set to false then all milestones (even not visible to team) are sent""",
         )
 
     @staticmethod
-    def subscribe(root, info, team_id: str, visible_only: bool = False):
+    @protected(Perms.view_milestone.full_name)
+    @team_protected
+    @visibility_protected
+    def subscribe(root, info, team_id: str, visible_only: bool = True):
         get_model(Team, id=int(team_id))
         return ["all", get_subscription_group(int(team_id), visible_only)]
 
     @staticmethod
-    def publish(payload, info, team_id: str, visible_only: bool = False):
+    def publish(payload, info, team_id: str, visible_only: bool = True):
         return MilestonesSubscription(milestones=payload)
 
 
@@ -78,6 +93,8 @@ class InjectSelectionsSubscription(channels_graphql_ws.Subscription):
         team_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.send_injectselection.full_name)
+    @team_protected
     def subscribe(root, info, team_id: str):
         get_model(Team, id=int(team_id))
         return [team_id]
@@ -95,6 +112,8 @@ class EmailThreadSubscription(channels_graphql_ws.Subscription):
         team_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_email.full_name)
+    @team_protected
     def subscribe(root, info, team_id: str):
         get_model(Team, id=int(team_id))
         return [team_id]
@@ -112,6 +131,8 @@ class AnalyticsMilestonesSubscription(channels_graphql_ws.Subscription):
         exercise_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def subscribe(root, info, exercise_id: str):
         get_model(Exercise, id=int(exercise_id))
         return [exercise_id]
@@ -129,6 +150,8 @@ class AnalyticsActionLogsSubscription(channels_graphql_ws.Subscription):
         exercise_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def subscribe(root, info, exercise_id: str):
         get_model(Exercise, id=int(exercise_id))
         return [exercise_id]
@@ -146,6 +169,8 @@ class AnalyticsEmailThreadSubscription(channels_graphql_ws.Subscription):
         exercise_id = graphene.ID()
 
     @staticmethod
+    @protected(Perms.view_analytics.full_name)
+    @exercise_protected
     def subscribe(root, info, exercise_id: str):
         get_model(Exercise, id=int(exercise_id))
         return [exercise_id]
diff --git a/running_exercise/tests/email_tests.py b/running_exercise/tests/email_tests.py
index 0d925b66..5b20b1de 100644
--- a/running_exercise/tests/email_tests.py
+++ b/running_exercise/tests/email_tests.py
@@ -124,27 +124,37 @@ class EmailTests(TestCase):
         ]
 
         with self.assertRaises(RunningExerciseOperationException) as _:
-            EmailClient.create_thread(participants, self.subject)
+            EmailClient.create_thread(
+                participants, self.subject, str(self.exercise.id)
+            )
 
         participants.append("nonexistentaddress@mail.com")
         with self.assertRaises(ModelNotFoundException) as _:
-            EmailClient.create_thread(participants, self.subject)
+            EmailClient.create_thread(
+                participants, self.subject, str(self.exercise.id)
+            )
 
         # multiple definition addresses
         participants[1] = "test@mail.ex"
         with self.assertRaises(RunningExerciseOperationException) as _:
-            EmailClient.create_thread(participants, self.subject)
+            EmailClient.create_thread(
+                participants, self.subject, str(self.exercise.id)
+            )
 
         _disable_feature(self.exercise, Feature.EMAIL_BETWEEN_TEAMS)
         participants[1] = "team-1@mail.com"
         participants.append("team-2@mail.com")
         with self.assertRaises(RunningExerciseOperationException) as _:
-            EmailClient.create_thread(participants, self.subject)
+            EmailClient.create_thread(
+                participants, self.subject, str(self.exercise.id)
+            )
 
     def test_create_thread(self):
         participants = ["team-1@mail.com", "test@mail.ex"]
 
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         self._compare_expected_participants(
             participants, thread.participants.all()
         )
@@ -155,7 +165,9 @@ class EmailTests(TestCase):
 
         # test for email between teams
         participants.append("team-2@mail.com")
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         self._compare_expected_participants(
             participants, thread.participants.all()
         )
@@ -167,7 +179,9 @@ class EmailTests(TestCase):
         # test for thread only between teams
         # remove definition address
         participants = ["team-1@mail.com", "team-2@mail.com"]
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         self._compare_expected_participants(
             participants, thread.participants.all()
         )
@@ -189,7 +203,11 @@ class EmailTests(TestCase):
         threads = []
         for participants in participant_combinations:
             for subject in subjects:
-                threads.append(EmailClient.create_thread(participants, subject))
+                threads.append(
+                    EmailClient.create_thread(
+                        participants, subject, str(self.exercise.id)
+                    )
+                )
 
         # 1) get an existing thread
         participants = participant_combinations[1]
@@ -240,7 +258,9 @@ class EmailTests(TestCase):
             )
 
         participants = ["team-1@mail.com", "team-2@mail.com"]
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
 
         with self.assertRaises(ModelNotFoundException) as _:
             EmailClient.send_email(
@@ -253,7 +273,9 @@ class EmailTests(TestCase):
 
     def test_instructor_email(self):
         participants = ["test@mail.ex", "team-1@mail.com", "team-2@mail.com"]
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         milestone = self.definition.milestones.get(name="evaluation_great")
         expected_sender = self.participants["test@mail.ex"]
         for team in self.exercise.teams.all():
@@ -304,7 +326,9 @@ class EmailTests(TestCase):
             )
 
         participants = ["doe@mail.ex", "team-1@mail.com"]
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         invalid_participant = self.participants["team-2@mail.com"]
 
         with self.assertRaises(ModelNotFoundException) as _:
@@ -317,7 +341,9 @@ class EmailTests(TestCase):
             )
 
         participants = ["team-1@mail.com", "team-2@mail.com"]
-        thread = EmailClient.create_thread(participants, self.subject)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
         invalid_participant = self.participants["team-3@mail.com"]
         with self.assertRaises(ModelNotFoundException) as _:
             EmailClient.send_email(
@@ -333,8 +359,10 @@ class EmailTests(TestCase):
         milestone = self.definition.milestones.get(name="email_doe")
         team_sender = self.participants["team-1@mail.com"]
         definition_sender = self.participants["doe@mail.ex"]
-        thread = EmailClient.create_thread(participants, self.subject)
         email_channel = self.definition.channels.get(type=InjectTypes.EMAIL)
+        thread = EmailClient.create_thread(
+            participants, self.subject, str(self.exercise.id)
+        )
 
         # check that milestones from the definition address were not activated on thread create
         self.assertFalse(is_milestone_reached(team_sender.team, milestone))
diff --git a/running_exercise/tests/serializers_tests.py b/running_exercise/tests/serializers_tests.py
new file mode 100644
index 00000000..4d5fa20a
--- /dev/null
+++ b/running_exercise/tests/serializers_tests.py
@@ -0,0 +1,132 @@
+from typing import List, Dict
+import os
+import shutil
+
+from django.test import TestCase, override_settings
+
+from django.conf import settings
+from exercise.serializers import (
+    UserSerializer,
+    TeamSerializer,
+    InstructorSerializer,
+)
+from common_lib.test_utils import (
+    internal_create_exercise,
+    internal_upload_definition,
+)
+from user.models import User, UserInTeam, InstructorOfExercise
+from exercise.models import Exercise, Team
+
+TEST_DATA_STORAGE = "running_exercise_tests_test_data"
+
+
+@override_settings(
+    DATA_STORAGE=TEST_DATA_STORAGE,
+    FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"),
+)
+class SerializerTest(TestCase):
+    exercise: Exercise
+    trainees: List[User]
+    instructors: List[User]
+    teams: List[Team]
+    analytics_ids: Dict[str, object]
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(settings.DATA_STORAGE)
+        super().tearDownClass()
+
+    @classmethod
+    def setUpTestData(cls):
+        num_of_teams = 3
+
+        definition = internal_upload_definition("base_definition")
+        cls.exercise = internal_create_exercise(
+            definition_id=definition.id, team_count=num_of_teams
+        )
+        cls.trainees, cls.instructors = [], []
+        cls.analytics_ids = {}
+        cls.teams = cls.exercise.teams.all()
+
+        trainees_count = 6
+        for i in range(trainees_count):
+            user = User.objects.create_user(
+                username=f"u{i}@mail.com", password=f"u"
+            )
+            cls.trainees.append(user)
+            UserInTeam(user=user, team=cls.teams[i % num_of_teams]).save()
+            cls.analytics_ids[user.username] = i
+
+        for i in range(2):
+            user = User.objects.create_staffuser(
+                username=f"i{i}@mail.com", password="i"
+            )
+            cls.instructors.append(user)
+            InstructorOfExercise(user=user, exercise=cls.exercise).save()
+            cls.analytics_ids[user.username] = i + trainees_count
+
+    def _user_serializer_check(self, users: List[User], anonymize: bool):
+        context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize}
+
+        for user in users:
+            data = UserSerializer(user, context=context).data
+            self.assertEqual(
+                data["username"], None if anonymize else user.username
+            )
+            self.assertEqual(
+                data["analytics_id"], self.analytics_ids[user.username]
+            )
+
+    def test_user_serializer_anonymized(self):
+        self._user_serializer_check(self.trainees, True)
+        self._user_serializer_check(self.instructors, True)
+
+    def test_user_serializer_nonanonymized(self):
+        self._user_serializer_check(self.trainees, False)
+        self._user_serializer_check(self.instructors, False)
+
+    def _team_serializer_check(self, anonymize: bool):
+        context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize}
+        for team in self.teams:
+            data = TeamSerializer(team, context=context).data
+            for i, user in enumerate(team.user_set.all()):
+                self.assertEqual(
+                    data["users"][i]["username"],
+                    None if anonymize else user.username,
+                )
+                self.assertEqual(
+                    data["users"][i]["analytics_id"],
+                    self.analytics_ids[user.username],
+                )
+
+    def test_team_serializer(self):
+        self._team_serializer_check(True)
+        self._team_serializer_check(False)
+
+    def _check_instructor_serializer(self, anonymize: True):
+        context = {"analytics_ids": self.analytics_ids, "anonymize": anonymize}
+        data = InstructorSerializer(self.exercise, context=context).data
+
+        for i, user in enumerate(self.instructors):
+            self.assertEqual(
+                data["instructors"][i]["username"],
+                None if anonymize else user.username,
+            )
+            self.assertEqual(
+                data["instructors"][i]["analytics_id"],
+                self.analytics_ids[user.username],
+            )
+
+    def test_instructor_serializer(self):
+        self._check_instructor_serializer(True)
+        self._check_instructor_serializer(False)
+
+    def test_team_serializer_invalid(self):
+        # raises exception due to the missing analytics_ids mapping
+        with self.assertRaises(ValueError):
+            UserSerializer(self.trainees[0]).data
+
+    def test_instructor_serializer_invalid(self):
+        # raises exception due to the missing analytics_ids mapping
+        with self.assertRaises(ValueError):
+            InstructorSerializer(self.exercise).data
diff --git a/running_exercise/views.py b/running_exercise/views.py
index 8f693b44..c44b1130 100644
--- a/running_exercise/views.py
+++ b/running_exercise/views.py
@@ -15,6 +15,8 @@ from running_exercise.lib.file_handler import (
     upload_file,
     get_uploaded_file,
 )
+from aai.models import Perms
+from aai.utils import protected, team_protected
 
 
 class GetFileView(APIView):
@@ -26,6 +28,8 @@ class GetFileView(APIView):
             500: ERROR_RESPONSE,
         },
     )
+    @protected(Perms.manipulate_file.full_name)
+    @team_protected
     def get(self, request, *args, **kwargs):
         """
         Get the requested file. If the exercise is running and parameter 'instructor' is not specified,
@@ -66,6 +70,8 @@ class UploadFileView(APIView):
             500: ERROR_RESPONSE,
         },
     )
+    @protected(Perms.manipulate_file.full_name)
+    @team_protected
     def post(self, request, *args, **kwargs):
         """Upload a file as a team."""
         file = request.FILES.get("file")
diff --git a/ttxbackend/schema.py b/ttxbackend/schema.py
index f44346b5..a23ff69e 100644
--- a/ttxbackend/schema.py
+++ b/ttxbackend/schema.py
@@ -15,16 +15,20 @@ from running_exercise.schema import (
     Query as RunningExerciseQuery,
     Mutation as RunningExerciseMutation,
 )
+from aai.schema import Mutation as AaiMutation
+from user.schema import Query as UserQuery, Mutation as UserMutation
 from running_exercise.subscription import (
     Subscription as RunningExerciseSubscription,
 )
 
 
-class Query(ExerciseQuery, RunningExerciseQuery):
+class Query(ExerciseQuery, RunningExerciseQuery, UserQuery):
     pass
 
 
-class Mutation(ExerciseMutation, RunningExerciseMutation):
+class Mutation(
+    ExerciseMutation, RunningExerciseMutation, AaiMutation, UserMutation
+):
     pass
 
 
diff --git a/ttxbackend/settings.py b/ttxbackend/settings.py
index 4d87a66d..64e1b01c 100644
--- a/ttxbackend/settings.py
+++ b/ttxbackend/settings.py
@@ -53,6 +53,8 @@ INSTALLED_APPS = [
     "exercise_definition",
     "exercise",
     "running_exercise",
+    "user",
+    "aai",
     "django_nose",
 ]
 
@@ -186,7 +188,24 @@ with open(
 ) as def_changelog:
     DEFINITION_VERSION = def_changelog.readline().lstrip("# ").rstrip()
 
-# TODO: update to use KNOWN_HOST (for use this for allowing frontend sending cookies)
+
+# Authentication
+AUTH_USER_MODEL = "user.User"
+SESSION_COOKIE_HTTPONLY = True
+CSRF_USE_SESSIONS = True
+SESSION_COOKIE_AGE = 24 * 60 * 60  # Session validity period in seconds
+AUTHENTICATION_BACKENDS = ["aai.backend.CustomAuthBackend"]
+
+# Email client
+EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = os.environ.get("EMAIL_HOST") or "relay.fi.muni.cz"
+EMAIL_PORT = 465
+EMAIL_HOST_USER = ""
+EMAIL_HOST_PASSWORD = ""
+EMAIL_USE_SSL = True
+EMAIL_SENDER = os.environ.get("EMAIL_SENDER") or "inject@fi.muni.cz"
+
+# TODO: update to use modified KNOWN_HOST (needed for allowing frontend sending cookies)
 CORS_ALLOW_ALL_ORIGINS = True
 CORS_ALLOW_CREDENTIALS = True
 
diff --git a/ttxbackend/urls.py b/ttxbackend/urls.py
index 0ce2a8d3..7554a07f 100644
--- a/ttxbackend/urls.py
+++ b/ttxbackend/urls.py
@@ -55,6 +55,7 @@ urlpatterns = [
     path("", include("exercise_definition.urls")),
     path("", include("running_exercise.urls")),
     path("", include("exercise.urls")),
+    path("", include("user.urls")),
 ]
 
 # Prefixing urls
diff --git a/user/__init__.py b/user/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/user/admin.py b/user/admin.py
new file mode 100644
index 00000000..54c0d68a
--- /dev/null
+++ b/user/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+
+from user.models import User
+
+admin.site.register(User)
diff --git a/user/apps.py b/user/apps.py
new file mode 100644
index 00000000..1289c12e
--- /dev/null
+++ b/user/apps.py
@@ -0,0 +1,21 @@
+from django.apps import AppConfig
+from json import JSONEncoder
+from uuid import UUID
+
+
+class UserConfig(AppConfig):
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "user"
+
+
+old_default = JSONEncoder.default
+
+
+# python json encoder can't handle UUID type objects
+def new_default(self, obj):
+    if isinstance(obj, UUID):
+        return str(obj)
+    return old_default(self, obj)
+
+
+JSONEncoder.default = new_default  # type: ignore
diff --git a/user/email/email_sender.py b/user/email/email_sender.py
new file mode 100644
index 00000000..26c05015
--- /dev/null
+++ b/user/email/email_sender.py
@@ -0,0 +1,38 @@
+from typing import List, Tuple
+
+from django.core.mail import get_connection, EmailMultiAlternatives, send_mail
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
+
+from user.models import User
+from ttxbackend.settings import EMAIL_SENDER
+
+
+def send_credentials(new_users: List[Tuple[User, str]]):
+    subject = "Welcome to INJECT!"
+    messages = []
+
+    for user, psswd in new_users:
+        html_content = render_to_string(
+            "welcome_email.html",
+            {"email": user.username, "password": psswd},
+        )
+        text_content = strip_tags(html_content)
+        message = EmailMultiAlternatives(
+            subject, text_content, EMAIL_SENDER, [user.username]
+        )
+        message.attach_alternative(html_content, "text/html")
+        messages.append(message)
+
+    connection = get_connection(fail_silently=True)
+    connection.send_messages(messages)
+
+
+def send_password_change_notification(user: User):
+    # TODO: create html template
+    send_mail(
+        subject="INJECT - password change",
+        message="Your password on the INJECT platform was changed",
+        from_email=EMAIL_SENDER,
+        recipient_list=[user.username],
+    )
diff --git a/user/graphql_inputs.py b/user/graphql_inputs.py
new file mode 100644
index 00000000..de0ac9c7
--- /dev/null
+++ b/user/graphql_inputs.py
@@ -0,0 +1,31 @@
+import graphene
+
+
+class ChangeUserBase:
+    user_id = graphene.UUID(required=True)
+    group = graphene.String(required=False, default_value=None)
+    active = graphene.Boolean(required=False, default_value=None)
+
+
+class ChangeUserType(ChangeUserBase, graphene.ObjectType):
+    pass
+
+
+class ChangeUserInput(ChangeUserBase, graphene.InputObjectType):
+    pass
+
+
+class FilterUsersBase:
+    active = graphene.Boolean(required=False, default_value=None)
+    group = graphene.List(graphene.String, required=False, default_value=None)
+    tags = graphene.List(graphene.String, required=False, default_value=None)
+    limit = graphene.Int(required=False, default_value=None)
+    skip = graphene.Int(required=False, default_value=None)
+
+
+class FilterUsersType(FilterUsersBase, graphene.ObjectType):
+    pass
+
+
+class FilterUsersInput(FilterUsersBase, graphene.InputObjectType):
+    pass
diff --git a/user/lib/__init__.py b/user/lib/__init__.py
new file mode 100644
index 00000000..1c00b4f8
--- /dev/null
+++ b/user/lib/__init__.py
@@ -0,0 +1,2 @@
+from .user_uploader import UserUploader
+from .user_validator import UserValidator
diff --git a/user/lib/user_uploader.py b/user/lib/user_uploader.py
new file mode 100644
index 00000000..c61ff4e6
--- /dev/null
+++ b/user/lib/user_uploader.py
@@ -0,0 +1,90 @@
+import csv
+import io
+from typing import Tuple, Optional, List, Callable
+
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.utils.crypto import get_random_string
+from django.contrib.auth.models import Group
+from rest_framework.response import Response
+
+from user.lib.user_validator import UserValidator, ResultAction
+from user.models import User, Tag, UserTag
+from aai.models import UserGroup
+from user.email.email_sender import send_credentials
+
+
+TAG_DELIMITER = "|"
+
+
+def _group_to_create_func(group: UserGroup) -> Callable:
+    if group == UserGroup.TRAINEE:
+        return User.objects.create_user
+
+    elif group == UserGroup.INSTRUCTOR:
+        return User.objects.create_staffuser
+
+    elif group == UserGroup.ADMIN:
+        return User.objects.create_superuser
+
+    raise ValueError(f"Unexpected UserGroup ({group}) in _group_to_create_func")
+
+
+def _create_and_tag_user(
+    username: str, group: UserGroup, tags: Optional[str], action: ResultAction
+) -> Optional[Tuple[User, str]]:
+    if action == ResultAction.CREATE_AND_TAG_USER:
+        password = get_random_string(length=15)
+        user = _group_to_create_func(group)(
+            username=username,
+            password=password,
+        )
+        _tag_user(user, tags)
+        # password needs to be retained in open form until it's sent to user
+        return user, password
+
+    if action == ResultAction.TAG_EXISTING_USER:
+        user = User.objects.get(username=username)
+        _tag_user(user, tags)
+
+    return None
+
+
+def _tag_user(user: User, tags: Optional[str]):
+    if tags is None or tags.strip() == "":
+        return
+
+    tag_db_list = [
+        Tag.objects.get_or_create(name=tag.strip())
+        for tag in tags.split(TAG_DELIMITER)
+    ]
+    UserTag.objects.bulk_create_ignore_conflicts(
+        UserTag(user=user, tag=tag) for tag in tag_db_list
+    )
+
+
+class UserUploader:
+    @staticmethod
+    def validate_and_upload(
+        uploaded_file: InMemoryUploadedFile, admin_initiator: bool
+    ) -> Response:
+        file = uploaded_file.read().decode("utf-8")
+        reader = csv.DictReader(
+            io.StringIO(file), fieldnames=["username", "group", "tags"]
+        )
+        user_data_list = [
+            (row["username"], row["group"], row["tags"]) for row in reader
+        ]
+        validator = UserValidator(user_data_list, admin_initiator)
+        valid_users = validator.validate()
+
+        if not validator.is_success():
+            return validator.result_handler.create_response()
+
+        created_users: List[Tuple[User, str]] = []
+        for username, group, tags, action in valid_users:
+            new_user_data = _create_and_tag_user(username, group, tags, action)
+            if new_user_data is not None:
+                created_users.append(new_user_data)
+
+        send_credentials(created_users)
+        return validator.result_handler.create_response()
diff --git a/user/lib/user_validator.py b/user/lib/user_validator.py
new file mode 100644
index 00000000..1ec8692e
--- /dev/null
+++ b/user/lib/user_validator.py
@@ -0,0 +1,163 @@
+from typing import List, Tuple
+import re
+from enum import Enum
+
+from rest_framework.response import Response
+from rest_framework import status
+from django.contrib.auth.models import Group
+
+from aai.models import UserGroup
+from user.models import User, EMAIL_REGEX
+
+Username = str
+Group_str = str
+Tags = str
+UserRawData = Tuple[Username, Group_str, Tags]
+UserValidatedData = Tuple[Username, UserGroup, Tags, "ResultAction"]
+
+
+class ResultAction(Enum):
+    CREATE_AND_TAG_USER = 0
+    TAG_EXISTING_USER = 1
+    SKIP = 2
+    ERROR = 3
+
+
+class UserValidationResultHandler:
+    def __init__(self):
+        self.errors = []
+        self.warnings = []
+
+    def add_error(self, error_msg: str):
+        self.errors.append(error_msg + "\n")
+
+    def add_warning(self, warning_msg: str):
+        self.warnings.append(warning_msg + "\n")
+
+    def _build_final_message(self) -> str:
+        msg = ""
+        if self.errors:
+            msg += "Errors:\n" + "".join(self.errors) + 20 * "-" + "\n"
+            msg += "!In order to successfully finish users creation correct all errors!\n"
+        if self.warnings:
+            if self.errors:
+                msg += 20 * "-"  # add separator between errors and warnings
+            msg += "Warnings:\n" + "".join(self.warnings) + "\n"
+        return msg
+
+    def is_success(self) -> bool:
+        return len(self.errors) == 0
+
+    def create_response(self) -> Response:
+        if self.errors:
+            return Response(
+                {
+                    "status": "error",
+                    "detail": "Unsuccessful users creation.\n"
+                    + self._build_final_message(),
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+        if self.warnings:
+            return Response(
+                {
+                    "status": "ok",
+                    "detail": "Users were created.\n"
+                    + self._build_final_message(),
+                }
+            )
+        return Response(
+            {"status": "ok", "detail": "All users were successfully created.\n"}
+        )
+
+
+class UserValidator:
+    def __init__(self, new_users: List[UserRawData], admin_initiator: bool):
+        self.new_users = new_users
+        self.result_handler = UserValidationResultHandler()
+        self.admin_initiator = admin_initiator
+        self.current_usernames = set(
+            User.objects.all().values_list("username", flat=True)
+        )
+
+    def _validate_username(
+        self, username: str, used_usernames: List[str]
+    ) -> ResultAction:
+        result = ResultAction.CREATE_AND_TAG_USER
+        if username in self.current_usernames:
+            self.result_handler.add_warning(
+                f"User with username: {username} already exists. Skipping creation..."
+            )
+            result = ResultAction.TAG_EXISTING_USER
+
+        if username in used_usernames:
+            self.result_handler.add_warning(
+                f"User with username: {username} is more than once in a csv."
+                + "Using only first occurrence for user creation, skipping all other creations..."
+            )
+            result = ResultAction.SKIP
+
+        if not (re.fullmatch(EMAIL_REGEX, username)):
+            self.result_handler.add_error(
+                f"User username:{username} has invalid format."
+            )
+            result = ResultAction.ERROR
+        return result
+
+    def _validate_group(self, username: str, group: str) -> bool:
+        create = True
+        if group is None or group == "":
+            # For convenience of users "Empty" is taken as TRAINEE and thus valid
+            return True
+
+        if UserGroup.from_str(group) is None:
+            self.result_handler.add_error(
+                f"User with username:{username} has definined invalid role: {group}."
+            )
+            create = False
+
+        elif (
+            UserGroup.from_str(group) == UserGroup.ADMIN
+            and not self.admin_initiator
+        ):
+            self.result_handler.add_error(
+                f"Cannot assign admin authorization group to user: {username} "
+                + f"because you are not admin."
+            )
+            create = False
+        return create
+
+    def is_success(self) -> bool:
+        return self.result_handler.is_success()
+
+    def validate(self) -> List[UserValidatedData]:
+        used_usernames: List[str] = []
+        valid_users: List[UserValidatedData] = []
+
+        for username, group, tags in self.new_users:
+            group_valid = False
+            username = username.strip() if username is not None else None
+            group = group.strip() if group is not None else None
+
+            result_action = self._validate_username(username, used_usernames)
+
+            if self._validate_group(username, group):
+                group_valid = True
+
+            if group_valid and result_action not in (
+                ResultAction.SKIP,
+                ResultAction.ERROR,
+            ):
+                typed_group = (
+                    UserGroup.from_str(group)
+                    if group is not None and group != ""
+                    else UserGroup.TRAINEE
+                )
+                assert (
+                    typed_group is not None
+                )  # validation was success -> can't be None
+                valid_users.append((username, typed_group, tags, result_action))
+
+            used_usernames.append(username)
+
+        return valid_users
diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py
new file mode 100644
index 00000000..21f7664d
--- /dev/null
+++ b/user/migrations/0001_initial.py
@@ -0,0 +1,117 @@
+# Generated by Django 3.2.24 on 2024-03-01 11:17
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import user.models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('exercise', '0009_auto_20240227_1813'),
+        ('auth', '0012_alter_user_first_name_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='User',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('password', models.CharField(max_length=128, verbose_name='password')),
+                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+                ('email', models.EmailField(max_length=254, unique=True)),
+                ('username', models.CharField(blank=True, max_length=150, null=True, unique=True)),
+                ('first_name', models.CharField(blank=True, max_length=150, null=True)),
+                ('last_name', models.CharField(blank=True, max_length=150, null=True)),
+                ('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
+                ('is_staff', models.BooleanField(default=False)),
+                ('is_superuser', models.BooleanField(default=False)),
+                ('is_active', models.BooleanField(default=True)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+            managers=[
+                ('objects', user.models.UserManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Tag',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=20)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+        ),
+        migrations.CreateModel(
+            name='UserTag',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='user.tag')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+        ),
+        migrations.CreateModel(
+            name='UserInTeam',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise.team')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+        ),
+        migrations.CreateModel(
+            name='InstructorOfExercise',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise.exercise')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='exercises',
+            field=models.ManyToManyField(through='user.InstructorOfExercise', to='exercise.Exercise'),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='group',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='auth.group'),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='tags',
+            field=models.ManyToManyField(through='user.UserTag', to='user.Tag'),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='teams',
+            field=models.ManyToManyField(through='user.UserInTeam', to='exercise.Team'),
+        ),
+        migrations.AddConstraint(
+            model_name='usertag',
+            constraint=models.UniqueConstraint(fields=('user', 'tag'), name='unique_user_tag'),
+        ),
+        migrations.AddConstraint(
+            model_name='userinteam',
+            constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_user_team'),
+        ),
+        migrations.AddConstraint(
+            model_name='instructorofexercise',
+            constraint=models.UniqueConstraint(fields=('user', 'exercise'), name='unique_user_exercise'),
+        ),
+    ]
diff --git a/user/migrations/0002_auto_20240320_1646.py b/user/migrations/0002_auto_20240320_1646.py
new file mode 100644
index 00000000..39e56e7d
--- /dev/null
+++ b/user/migrations/0002_auto_20240320_1646.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.2.24 on 2024-03-20 16:46
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise', '0011_merge_0009_auto_20240227_1813_0010_auto_20240221_1426'),
+        ('user', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='instructorofexercise',
+            name='exercise',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instructors', to='exercise.exercise'),
+        ),
+        migrations.AlterField(
+            model_name='instructorofexercise',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_exercises', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='userinteam',
+            name='team',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='exercise.team'),
+        ),
+        migrations.AlterField(
+            model_name='userinteam',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_teams', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/user/migrations/0003_alter_user_id.py b/user/migrations/0003_alter_user_id.py
new file mode 100644
index 00000000..90ec0ed3
--- /dev/null
+++ b/user/migrations/0003_alter_user_id.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.24 on 2024-03-22 15:05
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('user', '0002_auto_20240320_1646'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='user',
+            name='id',
+            field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True),
+        ),
+    ]
diff --git a/user/migrations/0004_auto_20240402_1408.py b/user/migrations/0004_auto_20240402_1408.py
new file mode 100644
index 00000000..8cfe058c
--- /dev/null
+++ b/user/migrations/0004_auto_20240402_1408.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.24 on 2024-04-02 14:08
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise_definition', '0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222'),
+        ('user', '0003_alter_user_id'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DefinitionAccess',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='maintainers', to='exercise_definition.definition')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='definitions', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'default_permissions': (),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='definitionaccess',
+            constraint=models.UniqueConstraint(fields=('user', 'definition'), name='unique_user_definition'),
+        ),
+    ]
diff --git a/user/migrations/0005_auto_20240402_1922.py b/user/migrations/0005_auto_20240402_1922.py
new file mode 100644
index 00000000..a117db47
--- /dev/null
+++ b/user/migrations/0005_auto_20240402_1922.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.24 on 2024-04-02 19:22
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('exercise_definition', '0013_merge_0011_auto_20240227_1813_0012_auto_20240227_1222'),
+        ('user', '0004_auto_20240402_1408'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='definitions',
+            field=models.ManyToManyField(through='user.DefinitionAccess', to='exercise_definition.Definition'),
+        ),
+        migrations.AlterField(
+            model_name='definitionaccess',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/user/migrations/0006_auto_20240405_1401.py b/user/migrations/0006_auto_20240405_1401.py
new file mode 100644
index 00000000..7acf5a97
--- /dev/null
+++ b/user/migrations/0006_auto_20240405_1401.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.24 on 2024-04-05 14:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('user', '0005_auto_20240402_1922'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='instructorofexercise',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='userinteam',
+            name='user',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/user/migrations/0007_auto_20240405_1518.py b/user/migrations/0007_auto_20240405_1518.py
new file mode 100644
index 00000000..59b551f5
--- /dev/null
+++ b/user/migrations/0007_auto_20240405_1518.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.24 on 2024-04-05 15:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('user', '0006_auto_20240405_1401'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='user',
+            name='email',
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='username',
+            field=models.CharField(max_length=150, unique=True),
+        ),
+    ]
diff --git a/user/migrations/__init__.py b/user/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/user/models.py b/user/models.py
new file mode 100644
index 00000000..5a57fa85
--- /dev/null
+++ b/user/models.py
@@ -0,0 +1,238 @@
+import re
+import uuid
+
+from django.db import models, transaction
+from django.apps import apps
+from django.utils import timezone
+
+from django.contrib.auth.models import BaseUserManager, AbstractBaseUser, Group
+from django.contrib.auth.hashers import make_password
+from django.db.models.signals import post_save, m2m_changed
+from django.dispatch import receiver
+
+from aai.models import UserGroup
+from exercise.models import Team, Exercise, Definition
+
+EMAIL_REGEX = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b"
+PERM_CACHE = "_perm_cache"
+
+
+class UserManager(BaseUserManager):
+    use_in_migrations = True
+
+    def _create_user(self, username, password, group=None, **extra_fields):
+        """
+        Create and save a user with the given username and password.
+        """
+        if not username:
+            raise ValueError("The given username must be set")
+        if not re.fullmatch(EMAIL_REGEX, username):
+            raise ValueError(
+                f"The given username: {username} has incorrect format"
+            )
+
+        username = self.normalize_email(username)
+        user = self.model(username=username, **extra_fields)
+        user.password = make_password(password)
+        user.save(
+            using=self._db
+        )  # user must have assigned id when adding group
+
+        if group is not None:
+            group, _ = Group.objects.get_or_create(name=group)
+            user.group = group
+            user.save()
+
+        return user
+
+    def create_superuser(self, username, password, **extra_fields):
+        extra_fields.setdefault("is_staff", True)
+        extra_fields.setdefault("is_superuser", True)
+        return self._create_user(
+            username, password, UserGroup.ADMIN, **extra_fields
+        )
+
+    def create_staffuser(self, username, password, **extra_fields):
+        extra_fields.setdefault("is_staff", True)
+        extra_fields.setdefault("is_superuser", False)
+        return self._create_user(
+            username, password, UserGroup.INSTRUCTOR, **extra_fields
+        )
+
+    def create_user(self, username, password, **extra_fields):
+        extra_fields.setdefault("is_staff", False)
+        extra_fields.setdefault("is_superuser", False)
+        return self._create_user(
+            username, password, UserGroup.TRAINEE, **extra_fields
+        )
+
+
+class TagManager(models.Manager):
+    def get_or_create(self, name):
+        existing = self.get_queryset().filter(name=name).first()
+
+        if existing:
+            return existing
+        return self.create(name=name)
+
+
+class UserTagManager(models.Manager):
+    def get_or_create_ignore_duplicates(self, **kwargs):
+        user_id = kwargs.pop("user_id", None)
+        tag_id = kwargs.pop("tag_id", None)
+        if user_id is not None and tag_id is not None:
+            existing_pair = UserTag.objects.filter(
+                user_id=user_id, tag_id=tag_id
+            ).first()
+            if existing_pair:
+                return existing_pair
+
+            # Create a new UserTag instance
+            with transaction.atomic():
+                user_tag = UserTag(**kwargs)
+                user_tag.save()
+                return user_tag
+
+        return None
+
+    def bulk_create_ignore_conflicts(self, objs, batch_size=None):
+        return self.bulk_create(
+            objs, batch_size=batch_size, ignore_conflicts=True
+        )
+
+
+class User(AbstractBaseUser):
+    id = models.UUIDField(
+        primary_key=True,
+        default=uuid.uuid4,
+        unique=True,
+        editable=False,
+    )
+    username = models.CharField(max_length=150, unique=True, blank=False)
+    first_name = models.CharField(max_length=150, blank=True, null=True)
+    last_name = models.CharField(max_length=150, blank=True, null=True)
+    date_joined = models.DateTimeField(default=timezone.now)
+    group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True)
+    is_staff = models.BooleanField(default=False)
+    is_superuser = models.BooleanField(default=False)
+    is_active = models.BooleanField(default=True)
+    teams = models.ManyToManyField(Team, through="UserInTeam")
+    exercises = models.ManyToManyField(Exercise, through="InstructorOfExercise")
+    definitions = models.ManyToManyField(Definition, through="DefinitionAccess")
+    tags = models.ManyToManyField("Tag", through="UserTag")
+
+    objects = UserManager()
+
+    USERNAME_FIELD = "username"
+
+    class Meta:
+        default_permissions = ()
+
+    def get_username(self):
+        return getattr(self, self.USERNAME_FIELD)
+
+    def __str__(self):
+        return self.get_username()
+
+    def has_perm(self, perm):
+        if self.is_superuser:
+            return True
+
+        if not self.is_active or self.is_anonymous or self.group is None:
+            return False
+
+        if not hasattr(self, PERM_CACHE):
+            perms = self.group.permissions.all().values_list(
+                "content_type__app_label", "codename"
+            )
+            setattr(
+                self,
+                PERM_CACHE,
+                {f"{ct}.{name}" for ct, name in perms},
+            )
+        return perm in getattr(self, PERM_CACHE)
+
+    @classmethod
+    def invalidate_cache(cls):
+        for user in cls.objects.all():
+            if hasattr(user, PERM_CACHE):
+                delattr(user, PERM_CACHE)
+
+
+@receiver(post_save, sender=Group)
+@receiver(m2m_changed, sender=Group.permissions.through)
+def _user_perm_cache_invalidator(signal, instance, **kwargs):
+    User.invalidate_cache()
+
+
+class Tag(models.Model):
+    name = models.CharField(max_length=20, blank=False, null=False)
+
+    objects = TagManager()
+
+    class Meta:
+        default_permissions = ()
+
+
+class UserInTeam(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
+    team = models.ForeignKey(
+        Team, on_delete=models.CASCADE, related_name="users"
+    )
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                name="unique_user_team",
+                fields=["user", "team"],
+            )
+        ]
+        default_permissions = ()
+
+
+class InstructorOfExercise(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
+    exercise = models.ForeignKey(
+        Exercise, on_delete=models.CASCADE, related_name="instructors"
+    )
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                name="unique_user_exercise",
+                fields=["user", "exercise"],
+            )
+        ]
+        default_permissions = ()
+
+
+class DefinitionAccess(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
+    definition = models.ForeignKey(
+        Definition, on_delete=models.CASCADE, related_name="maintainers"
+    )
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                name="unique_user_definition",
+                fields=["user", "definition"],
+            )
+        ]
+        default_permissions = ()
+
+
+class UserTag(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
+    tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name="+")
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                name="unique_user_tag",
+                fields=["user", "tag"],
+            )
+        ]
+        default_permissions = ()
+
+    objects = UserTagManager()
diff --git a/user/schema/__init__.py b/user/schema/__init__.py
new file mode 100644
index 00000000..78d2dbab
--- /dev/null
+++ b/user/schema/__init__.py
@@ -0,0 +1,2 @@
+from user.schema.mutation import Mutation
+from user.schema.query import Query
diff --git a/user/schema/mutation.py b/user/schema/mutation.py
new file mode 100644
index 00000000..c75ebec3
--- /dev/null
+++ b/user/schema/mutation.py
@@ -0,0 +1,237 @@
+import graphene
+from typing import List
+
+from django.contrib.auth.models import Group
+
+from user.models import UserInTeam, InstructorOfExercise, DefinitionAccess, User
+from exercise.models import Team, Exercise, Definition
+from user.schema.validators import (
+    validate_team_assigning,
+    validate_team_removing,
+    validate_instructor_assigning,
+    validate_instructor_removing,
+    validate_definition_access_assinging,
+    validate_definition_access_removing,
+)
+from common_lib.utils import get_model
+from common_lib.schema_types import UserType
+from aai.models import Perms
+from aai.utils import (
+    protected,
+    team_protected,
+    exercise_protected,
+    definition_protected,
+)
+from user.graphql_inputs import ChangeUserInput
+
+
+class AssignUsersToTeamMutation(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the users to be assigned to team",
+        )
+        team_id = graphene.ID(
+            required=True,
+            description="ID of the team to which users are being assigned",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_userassignment.full_name)
+    @team_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], team_id: str
+    ) -> graphene.Mutation:
+        team = get_model(Team, id=team_id)
+        users = validate_team_assigning(user_ids, team)
+        UserInTeam.objects.bulk_create(
+            UserInTeam(user=user, team=team) for user in users
+        )
+        return AssignUsersToTeamMutation(operation_done=True)
+
+
+class RemoveUsersFromTeamMutation(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the users to be removed from a team",
+        )
+        team_id = graphene.ID(
+            required=True,
+            description="ID of the team from which users are being removed",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_userassignment.full_name)
+    @team_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], team_id: str
+    ) -> graphene.Mutation:
+        team = get_model(Team, id=team_id)
+        users = validate_team_removing(user_ids, team)
+        UserInTeam.objects.filter(user__in=users, team=team).delete()
+        return RemoveUsersFromTeamMutation(operation_done=True)
+
+
+class AssignInstructorsToExercise(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the instructors (users) to be assigned to an exercise",
+        )
+        exercise_id = graphene.ID(
+            required=True,
+            description="ID of the exercise to which instructors (users) are being assigned",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_userassignment.full_name)
+    @exercise_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], exercise_id: str
+    ) -> graphene.Mutation:
+        exercise = get_model(Exercise, id=exercise_id)
+        users = validate_instructor_assigning(user_ids, exercise)
+        InstructorOfExercise.objects.bulk_create(
+            InstructorOfExercise(user=user, exercise=exercise) for user in users
+        )
+        return AssignInstructorsToExercise(operation_done=True)
+
+
+class RemoveInstructorsFromExerciseMutation(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the users to be removed from a team",
+        )
+        exercise_id = graphene.ID(
+            required=True,
+            description="ID of the exercise from which instructors (users) are being removed",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_userassignment.full_name)
+    @exercise_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], exercise_id: str
+    ) -> graphene.Mutation:
+        exercise = get_model(Exercise, id=exercise_id)
+        users = validate_instructor_removing(user_ids, exercise)
+        InstructorOfExercise.objects.filter(
+            user__in=users, exercise=exercise
+        ).delete()
+        return RemoveUsersFromTeamMutation(operation_done=True)
+
+
+class AddDefinitionAccessMutation(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the instructors (users) to be granted access to definition",
+        )
+        definition_id = graphene.ID(
+            required=True,
+            description="ID of the definition to which instructors (users) are granted access",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_definition.full_name)
+    @definition_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], definition_id: str
+    ) -> graphene.Mutation:
+        definition = get_model(Definition, id=int(definition_id))
+        users = validate_definition_access_assinging(user_ids)
+        DefinitionAccess.objects.bulk_create(
+            DefinitionAccess(user=user, definition=definition) for user in users
+        )
+        return AssignInstructorsToExercise(operation_done=True)
+
+
+class RemoveDefinitionAccessMutation(graphene.Mutation):
+    class Arguments:
+        user_ids = graphene.List(
+            graphene.ID,
+            required=True,
+            description="IDs of the users to be removed from a definition access",
+        )
+        definition_id = graphene.ID(
+            required=True,
+            description="ID of the definition from which instructors (users) are being removed",
+        )
+
+    operation_done = graphene.Boolean()
+
+    @classmethod
+    @protected(Perms.update_definition.full_name)
+    @definition_protected
+    def mutate(
+        cls, root, info, user_ids: List[str], definition_id: str
+    ) -> graphene.Mutation:
+        definition = get_model(Definition, id=int(definition_id))
+        users = validate_definition_access_removing(user_ids, definition)
+        DefinitionAccess.objects.filter(
+            user__in=users, definition=definition
+        ).delete()
+        return RemoveDefinitionAccessMutation(operation_done=True)
+
+
+class ChangeUserDataMutation(graphene.Mutation):
+    user = graphene.Field(UserType)
+
+    class Arguments:
+        change_user_input = graphene.Argument(ChangeUserInput, required=True)
+
+    @classmethod
+    @protected(Perms.update_user)
+    def mutate(
+        cls, root, info, change_user_input: ChangeUserInput
+    ) -> graphene.Mutation:
+        user = get_model(User, id=change_user_input.user_id)
+        if change_user_input.group is not None:
+            user.group = Group.objects.get(name=change_user_input.group)
+
+        if change_user_input.active is not None:
+            user.is_active = change_user_input.active
+
+        user.save()
+        return ChangeUserDataMutation(user=user)
+
+
+class Mutation(graphene.ObjectType):
+    assign_users_to_team = AssignUsersToTeamMutation.Field(
+        description="Mutation for assigning users to the specific team of the exercise"
+    )
+    remove_users_from_team = RemoveUsersFromTeamMutation.Field(
+        description="Mutation for removing users from the specific team of the exercise"
+    )
+    assign_instructors_to_exercise = AssignInstructorsToExercise.Field(
+        description="Mutation for assigning instructors (users) to the exercise"
+    )
+    remove_instructors_from_exercise = RemoveInstructorsFromExerciseMutation.Field(
+        description="Mutation for removing instructors (users) from the exercise"
+    )
+    add_definition_access = AddDefinitionAccessMutation.Field(
+        description="Mutation for granting access to the specific definition"
+    )
+    remove_definition_access = RemoveDefinitionAccessMutation.Field(
+        description="Mutation for removing access of instructors (users) to the definition"
+    )
+    change_user_data = ChangeUserDataMutation.Field(
+        description="Mutation for changing user data"
+    )
diff --git a/user/schema/query.py b/user/schema/query.py
new file mode 100644
index 00000000..9a8b70ec
--- /dev/null
+++ b/user/schema/query.py
@@ -0,0 +1,73 @@
+from typing import Optional, List
+
+import graphene
+from django.contrib.auth.models import Group
+
+from common_lib.schema_types import UserType, TagType, GroupType
+from common_lib.utils import get_model
+from user.models import User, Tag
+from aai.models import Perms
+from aai.utils import protected
+from user.graphql_inputs import FilterUsersInput
+
+
+class Query(graphene.ObjectType):
+    who_am_i = graphene.Field(
+        UserType,
+        description="Retrieve data of the currently logged-in user of the request",
+    )
+    users = graphene.List(
+        UserType,
+        filter_users_input=FilterUsersInput(required=False),
+        description="Retrieve all users with filtering options",
+    )
+    user = graphene.Field(UserType, user_id=graphene.UUID())
+    tags = graphene.List(
+        TagType, description="Retrieve all tags (for filtering)"
+    )
+    groups = graphene.List(
+        GroupType, description="Retrieve all groups (for filtering)"
+    )
+
+    def resolve_who_am_i(self, info) -> Optional[User]:
+        if info.context.user.is_anonymous:
+            return None
+        return info.context.user
+
+    @protected(Perms.view_user.full_name)
+    def resolve_users(
+        self, info, filter_users_input: Optional[FilterUsersInput] = None
+    ) -> List[User]:
+        if filter_users_input is None:
+            return User.objects.all()
+
+        users = User.objects.all()
+
+        if filter_users_input.active is not None:
+            users = users.filter(active=filter_users_input.active)
+
+        if filter_users_input.group is not None:
+            users = users.filter(group__name=filter_users_input.group)
+
+        if filter_users_input.tags is not None:
+            for tag in filter_users_input.tags:
+                users = users.filter(tags__name=tag)
+
+        if filter_users_input.skip is not None:
+            users = users[filter_users_input.skip :]
+
+        if filter_users_input.limit is not None:
+            users = users[: filter_users_input.limit]
+        return users
+
+    @protected(Perms.view_user.full_name)
+    def resolve_user(self, info, user_id: str) -> User:
+        return get_model(User, id=user_id)
+
+    @protected(Perms.view_user)
+    def resolve_tags(self, info) -> List[Tag]:
+        return Tag.objects.all()
+
+    # TODO: for now leave open to anyone
+    def resolve_groups(self, info) -> List[Group]:
+        return Group.objects.all()
diff --git a/user/schema/validators.py b/user/schema/validators.py
new file mode 100644
index 00000000..69d75ad4
--- /dev/null
+++ b/user/schema/validators.py
@@ -0,0 +1,149 @@
+from typing import List, Type, TypeVar, Union
+
+from django.db.models import Model
+
+from user.models import UserInTeam, User, InstructorOfExercise, DefinitionAccess
+from exercise.models import Team, Exercise, Definition
+from aai.models import UserGroup
+
+ModelType = TypeVar("ModelType", bound=Model)
+
+
+def _check_nonexistent_users(user_ids: List[str]) -> List[User]:
+    present_users = User.objects.filter(id__in=user_ids)
+    if len(present_users) == len(user_ids):
+        return present_users
+
+    invalid_ids = [
+        user_id
+        for user_id in user_ids
+        if user_id not in present_users.values_list("id", flat=True)
+    ]
+    raise ValueError(f"Users with ids:{invalid_ids} don't exist")
+
+
+def _check_group(users: List[User], required_groups: List[UserGroup]):
+    invalid_group_users = [
+        user
+        for user in users
+        if user.group is None
+        or not set([user.group.name]).intersection(set(required_groups))
+    ]
+    if not invalid_group_users:
+        return
+
+    msg_format_users = __create_users_msg_format(invalid_group_users)
+    raise ValueError(
+        f"Users {msg_format_users} don't have one of the groups:{required_groups}. "
+        f"If you want to assign them, set them one of the metioned group."
+    )
+
+
+def _check_presence(
+    model_type: Type[ModelType],
+    users: List[User],
+    assigned_to: Union[Exercise, Team, Definition],
+    **kwargs,
+):
+    assigned_users = model_type.objects.filter(**kwargs)
+    if len(assigned_users) != len(users):
+        invalid_users = [user for user in users if user not in assigned_users]
+        msg_format_users = __create_users_msg_format(invalid_users)
+        raise ValueError(
+            f"Users {msg_format_users} are not assigned to {assigned_to} "
+            f"thus can't be deleted"
+        )
+
+
+def __create_users_msg_format(users: List[User]) -> str:
+    invalid_group_users = [
+        ("(" + user.username + ", id: " + str(user.id) + ")") for user in users
+    ]
+    return ", ".join(invalid_group_users)
+
+
+def validate_team_assigning(user_ids: List[str], team: Team) -> List[User]:
+    users = _check_nonexistent_users(user_ids)
+
+    # check whether users are already in a team belonging to the same exercise
+    teams_of_exercise = team.exercise.teams.all()
+    assigned_to_team = UserInTeam.objects.filter(
+        user__in=users, team__in=teams_of_exercise
+    )
+    if assigned_to_team.exists():
+        assigned_users = list(
+            assigned_to_team.values_list("user__username", flat=True)
+        )
+        raise ValueError(
+            f"Users {assigned_users} already assigned "
+            f"to a team of the same exercise"
+        )
+
+    _check_group(users, [UserGroup.TRAINEE])
+    return users
+
+
+def validate_team_removing(user_ids: List[str], team: Team) -> List[User]:
+    users = _check_nonexistent_users(user_ids)
+    _check_presence(
+        UserInTeam,
+        users,
+        team,
+        user_id__in=user_ids,
+        team_id=team.id,
+    )
+    return users
+
+
+def validate_instructor_assigning(
+    user_ids: List[str], exercise: Exercise
+) -> List[User]:
+    users = _check_nonexistent_users(user_ids)
+
+    # check for users already assigned to the given exercise
+    already_assigned = InstructorOfExercise.objects.filter(
+        user__in=users, exercise=exercise
+    )
+    if already_assigned.exists():
+        invalid_users = [invalid.user for invalid in already_assigned]
+        msg_format_users = __create_users_msg_format(invalid_users)
+        raise ValueError(
+            f"Users {msg_format_users} were already assigned to exercise (id:{exercise.id})"
+        )
+
+    _check_group(users, [UserGroup.INSTRUCTOR, UserGroup.ADMIN])
+    return users
+
+
+def validate_instructor_removing(
+    user_ids: List[str], exercise: Exercise
+) -> List[User]:
+    users = _check_nonexistent_users(user_ids)
+    _check_presence(
+        InstructorOfExercise,
+        users,
+        exercise,
+        user_id__in=user_ids,
+        exercise_id=exercise.id,
+    )
+    return users
+
+
+def validate_definition_access_assinging(user_ids: List[str]) -> List[User]:
+    users = _check_nonexistent_users(user_ids)
+    _check_group(users, [UserGroup.INSTRUCTOR, UserGroup.ADMIN])
+    return users
+
+
+def validate_definition_access_removing(
+    user_ids: List[str], definition: Definition
+):
+    users = _check_nonexistent_users(user_ids)
+    _check_presence(
+        DefinitionAccess,
+        users,
+        definition,
+        user_id__in=user_ids,
+        definition_id=definition.id,
+    )
+    return users
diff --git a/user/templates/welcome_email.html b/user/templates/welcome_email.html
new file mode 100644
index 00000000..ad87c340
--- /dev/null
+++ b/user/templates/welcome_email.html
@@ -0,0 +1,8 @@
+<html>
+<body>
+Hello!
+Your account was activated on INJECT.<br>
+Your <strong>login credentials</strong> are:<br>
+email: {{email}}<br>
+password: {{password}}<br>
+</body>
\ No newline at end of file
diff --git a/user/tests/__init__.py b/user/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/user/tests/csv_onboarding_tests.py b/user/tests/csv_onboarding_tests.py
new file mode 100644
index 00000000..008659c4
--- /dev/null
+++ b/user/tests/csv_onboarding_tests.py
@@ -0,0 +1,208 @@
+import io
+from typing import List
+
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.test import TestCase
+from rest_framework.response import Response
+
+from user.lib.user_uploader import UserUploader, TAG_DELIMITER
+from aai.models import UserGroup
+from user.models import User, Tag
+
+
+class UserTests(TestCase):
+    def create_file(self, data: List[str]) -> InMemoryUploadedFile:
+        data = "\n".join(data)
+        data = data.encode("utf-8")
+        byte_io = io.BytesIO(data)
+        uploaded_file = InMemoryUploadedFile(
+            file=byte_io,
+            field_name="test_data",
+            name="test_data.csv",
+            content_type="text/csv",
+            size=None,
+            charset=None,
+        )
+        return uploaded_file
+
+    def test_valid_data(self):
+        data = [
+            "a@example.com,TRAINEE,",
+            "b@example.com, INSTRUCTOR",
+            "c@uni.mail.com,ADMIN ",
+            "d@uni.mail.com, trainee,",
+            "e@mail.com,instructor",
+            "f@mail.com,admin,",
+            "g@mail.com, t ",
+            "h@mail.com,i ",
+            "i@mail.com,a,",
+            "j@mail.com,TraIneE ",
+            "k@uni.mail.sk,",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(
+            response.data["status"], "ok", msg=response.data["detail"]
+        )
+
+        for line in data:  # check all users are created
+            username = line.split(",")[0].strip()
+            user = User.objects.get(username=username)
+            self.assertIsNotNone(user)
+
+    def test_user_exists_warning(self):
+        data = [
+            "a@example.com,TRAINEE",
+            "b@example.com, INSTRUCTOR",
+            "c@uni.mail.com,admin",
+            "d@uni.mail.com,Trainee",
+        ]
+        uploaded_file = self.create_file(data)
+
+        User.objects.create_user(username="a@example.com", password="a")
+        User.objects.create_user(username="b@uni.mail.com", password="b")
+
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(response.data["status"], "ok")
+        self.assertIn(
+            "Warning", response.data["detail"], msg=response.data["detail"]
+        )
+
+    def test_repeated_user_in_csv_warning(self):
+        data = [
+            "a@example.com,TRAINEE",
+            "b@example.com, INSTRUCTOR",
+            "c@mail.com,admin",
+            "a@example.com,Trainee",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(response.data["status"], "ok")
+        self.assertIn(
+            "Warning", response.data["detail"], msg=response.data["detail"]
+        )
+
+    def test_invalid_username_error(self):
+        data = [
+            "aexample.com,TRAINEE",
+            "b@example.com, INSTRUCTOR",
+            "cmail.com,admin",
+            "a@example.com,Trainee",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(response.data["status"], "error")
+        self.assertIn(
+            "Error", response.data["detail"], msg=response.data["detail"]
+        )
+
+        data = [
+            "a@,TRAINEE",
+            "b@example.com, INSTRUCTOR",
+            "cmail.com,admin",
+            "a@example.com,Trainee",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(response.data["status"], "error")
+        self.assertIn(
+            "Error", response.data["detail"], msg=response.data["detail"]
+        )
+
+    def test_invalid_group_error(self):
+        data = [
+            "a@example.com,tra",
+            "b@example.com, ins",
+            "c@mail.com,ad",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.ADMIN
+        )
+        self.assertEqual(response.data["status"], "error")
+        self.assertIn(
+            "Error", response.data["detail"], msg=response.data["detail"]
+        )
+
+    def test_invalid_group_hierarchy_error(self):
+        data = [
+            "c@mail.com,ADMIN",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, False
+        )
+        self.assertEqual(response.data["status"], "error")
+        self.assertIn(
+            "Error", response.data["detail"], msg=response.data["detail"]
+        )
+
+    def test_create_users_with_tag(self):
+        existing_user = User.objects.create_user(
+            username="userZ@mail.com", password="p"
+        )
+
+        data = [
+            "userX@mail.com,trainee,CYBER-EX",
+            "userY@mail.com, instructor,CYBER-EX",
+            "userZ@mail.com,trainee,CYBER-EX",
+        ]
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.INSTRUCTOR
+        )
+
+        self.assertEqual(
+            response.data["status"], "ok", msg=response.data["detail"]
+        )
+        tag = Tag.objects.filter(name="CYBER-EX").first()
+        self.assertIsNotNone(tag)
+
+        for line in data:  # check all users are created and have the tag
+            username = line.split(",")[0].strip()
+            user = User.objects.get(username=username)
+            self.assertIsNotNone(user)
+            self.assertTrue(tag in user.tags.all())
+
+        # check if already existing user got the Tag as well
+        self.assertTrue(tag in existing_user.tags.all())
+
+    def test_create_users_with_several_tags(self):
+        tags1 = TAG_DELIMITER.join(["INJECT", "team1", "morning"])
+        tags2 = TAG_DELIMITER.join(["INJECT", "team1", "afternoon"])
+        tags3 = TAG_DELIMITER.join(["INJECT", "team2", "morning"])
+        data = [
+            "userA@mail.com,trainee," + tags1,
+            "userB@mail.com, instructor," + tags2,
+            "userC@mail.com,trainee," + tags3,
+        ]
+
+        uploaded_file = self.create_file(data)
+        response: Response = UserUploader.validate_and_upload(
+            uploaded_file, UserGroup.INSTRUCTOR
+        )
+        self.assertEqual(
+            response.data["status"], "ok", msg=response.data["detail"]
+        )
+
+        for line in data:  # check all users were created
+            username = line.split(",")[0].strip()
+            tags = line.split(",")[2]
+            user = User.objects.get(username=username)
+            self.assertIsNotNone(user)
+
+            for tag in tags.split(
+                TAG_DELIMITER
+            ):  # check all tags were created and assigned
+                tag_db = Tag.objects.get(name=tag)
+                self.assertTrue(tag_db in user.tags.all())
diff --git a/user/tests/graphql_api_tests.py b/user/tests/graphql_api_tests.py
new file mode 100644
index 00000000..acbce55f
--- /dev/null
+++ b/user/tests/graphql_api_tests.py
@@ -0,0 +1,289 @@
+import os
+import shutil
+import uuid
+
+from django.test import override_settings
+
+from django.conf import settings
+from common_lib.graphql import (
+    GraphQLApiTestCase,
+    AssignUsersToTeamAction,
+    RemoveUsersFromTeamAction,
+    AddDefinitionAccessAction,
+    RemoveDefinitionAccessAction,
+)
+from common_lib.test_utils import (
+    internal_create_exercise,
+    internal_upload_definition,
+)
+from user.models import User, UserInTeam, InstructorOfExercise, DefinitionAccess
+from exercise.models import Exercise, Team, Definition
+
+
+TEST_DATA_STORAGE = "user_tests_test_data"
+
+
+@override_settings(
+    DATA_STORAGE=TEST_DATA_STORAGE,
+    FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"),
+)
+class UserGraphQLApiTests(GraphQLApiTestCase):
+    exercise: Exercise
+    definition: Definition
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(settings.DATA_STORAGE)
+        super().tearDownClass()
+
+    @classmethod
+    def setUpTestData(cls):
+        UserInTeam.objects.all().delete()
+
+        Team.objects.all().delete()
+        cls.definition = internal_upload_definition("base_definition")
+        cls.exercise = internal_create_exercise(cls.definition.id, 3)
+        cls.user1 = User.objects.create_user(
+            username="user1@mail.com", password="user1"
+        )
+        cls.user2 = User.objects.create_user(
+            username="user2@mail.com", password="user2"
+        )
+        cls.user3 = User.objects.create_user(
+            username="user3@mail.com", password="user3"
+        )
+        cls.instructor = User.objects.create_staffuser(
+            username="i@mail.com", password="i"
+        )
+        InstructorOfExercise.objects.create(
+            user=cls.instructor, exercise=cls.exercise
+        ).save()
+        DefinitionAccess.objects.create(
+            user=cls.instructor, definition=cls.definition
+        )
+        cls.instructor2 = User.objects.create_staffuser(
+            username="i2@mail.com", password="i2"
+        )
+        cls.admin = User.objects.create_superuser(
+            username="admin@mail.com", password="admin"
+        )
+        cls.teams = cls.exercise.teams.all()
+        cls.user_ids = [cls.user1.id, cls.user2.id, cls.user3.id]
+        cls.team = cls.teams[0]
+
+    def test_invalid_assign_user_to_team_single(self):
+        # Non-existing user case
+        action = AssignUsersToTeamAction(
+            {"user_ids": [0], "team_id": self.team.id},
+        )
+        self.run_action(action, should_throw=True)
+
+        # Non-existing team case
+        action.variables["user_ids"] = [self.user1.id]
+        action.variables["team_id"] = len(self.teams) + 2
+        self.run_action(action, should_throw=True)
+
+        # User already assigned in the team case
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        action.variables["user_ids"] = [self.user1.id]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+        # User with invalid group
+        action.variables["user_ids"] = [self.instructor.id]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+    def test_invalid_assign_user_to_team_multiple(self):
+        # Non-existing user case
+        action = AssignUsersToTeamAction(
+            {"user_ids": [1, 2, 13], "team_id": self.team.id},
+        )
+        self.run_action(action, should_throw=True)
+
+        # Non-existing team case
+        action.variables["user_ids"] = self.user_ids
+        action.variables["team_id"] = len(self.teams) + 2
+        self.run_action(action, should_throw=True)
+
+        # Users already assigned in the team case
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        UserInTeam.objects.create(user=self.user2, team=self.team)
+        action.variables["user_ids"] = self.user_ids
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+        # User with invalid group
+        action.variables["user_ids"] = [self.instructor.id, self.instructor2.id]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+    def test_valid_assign_user_to_team_single(self):
+        action = AssignUsersToTeamAction(
+            {"user_ids": [self.user1.id], "team_id": self.team.id},
+        )
+        self.run_action(action, user=self.instructor)
+        self.assertTrue(
+            UserInTeam.objects.filter(user=self.user1, team=self.team).exists()
+        )
+
+    def test_valid_assign_user_to_team_multiple(self):
+        action = AssignUsersToTeamAction(
+            {"user_ids": self.user_ids, "team_id": self.team.id},
+        )
+        self.run_action(action, user=self.instructor)
+        self.assertTrue(
+            UserInTeam.objects.filter(
+                user_id__in=self.user_ids, team=self.team
+            ).count()
+            == 3
+        )
+
+    def test_invalid_remove_user_from_team_single(self):
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        # Non-existing team case
+        action = RemoveUsersFromTeamAction(
+            {"user_ids": self.user1.id, "team_id": len(self.teams) + 2},
+        )
+        self.run_action(action, should_throw=True)
+
+        # Non-existing user case
+        action.variables["user_ids"] = [13]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+        # Users are not assigned in the team case (user2 is not assigned)
+        action.variables["user_ids"] = [self.user2.id]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+    def test_invalid_remove_user_from_team_multiple(self):
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        UserInTeam.objects.create(user=self.user2, team=self.team)
+        # Non-existing team case
+        action = RemoveUsersFromTeamAction(
+            {"user_ids": self.user_ids, "team_id": len(self.teams) + 2},
+        )
+        self.run_action(action, should_throw=True)
+
+        # Non-existing user case
+        action.variables["user_ids"] = [1, 2, 13]
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+        # Users are not assigned in the team case (user3 is not assigned)
+        action.variables["user_ids"] = self.user_ids
+        action.variables["team_id"] = self.team.id
+        self.run_action(action, should_throw=True)
+
+    def test_valid_remove_user_from_team_single(self):
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        action = RemoveUsersFromTeamAction(
+            {"user_ids": [self.user1.id], "team_id": self.team.id},
+        )
+        self.run_action(action, user=self.instructor)
+        self.assertFalse(
+            UserInTeam.objects.filter(
+                user_id=self.user1.id, team=self.team
+            ).exists()
+        )
+
+    def test_valid_remove_user_from_team_multiple(self):
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        UserInTeam.objects.create(user=self.user2, team=self.team)
+        UserInTeam.objects.create(user=self.user3, team=self.team)
+        action = RemoveUsersFromTeamAction(
+            {"user_ids": self.user_ids, "team_id": self.team.id},
+        )
+        self.run_action(action, user=self.instructor)
+        self.assertFalse(
+            UserInTeam.objects.filter(
+                user_id__in=self.user_ids, team=self.team
+            ).exists()
+        )
+
+    def test_valid_definition_access_assigning(self):
+        action = AddDefinitionAccessAction(
+            {
+                "user_ids": [self.instructor2.id],
+                "definition_id": self.definition.id,
+            }
+        )
+        # instructor has access to the definition thus can assign others
+        self.run_action(action, user=self.instructor)
+        self.assertTrue(
+            DefinitionAccess.objects.filter(
+                user=self.instructor2, definition=self.definition
+            )
+        )
+        DefinitionAccess.objects.filter(
+            user=self.instructor2, definition=self.definition
+        ).delete()
+
+        # admin can access all definitions (does not need definition access)
+        self.run_action(action, user=self.admin)
+        self.assertTrue(
+            DefinitionAccess.objects.filter(
+                user=self.instructor2, definition=self.definition
+            )
+        )
+        DefinitionAccess.objects.filter(
+            user=self.instructor2, definition=self.definition
+        ).delete()
+
+    def test_invalid_definition_access_assigning(self):
+        action = AddDefinitionAccessAction(
+            {
+                "user_ids": [self.instructor2.id],
+                "definition_id": self.definition.id,
+            }
+        )
+        # trainee can't assign users to definition
+        self.run_action(action, should_throw=True, user=self.user1)
+
+        # trying to assing non-existent user
+        action.variables["user_ids"] = [uuid.uuid4()]
+        self.run_action(action, should_throw=True, user=self.instructor)
+
+        # trying to assign non-existent definition
+        action.variables["definition_id"] = 420
+        self.run_action(action, should_throw=True, user=self.instructor)
+
+    def test_valid_definition_access_removing(self):
+        action = RemoveDefinitionAccessAction(
+            {
+                "user_ids": [self.instructor2.id],
+                "definition_id": self.definition.id,
+            }
+        )
+        DefinitionAccess.objects.create(
+            user=self.instructor2, definition=self.definition
+        )
+
+        # instructor with definition access can remove other instructors
+        self.run_action(action, user=self.instructor)
+
+        action.variables["user_ids"] = [self.instructor.id]
+        # admin can remove instructors from any definition access
+        self.run_action(action, user=self.admin)
+
+        DefinitionAccess.objects.create(
+            user=self.instructor, definition=self.definition
+        )
+
+    def test_invalid_definition_access_removing(self):
+        action = RemoveDefinitionAccessAction(
+            {
+                "user_ids": [self.instructor2.id],
+                "definition_id": self.definition.id,
+            }
+        )
+        # trying to remove user not assigned to definition
+        self.run_action(action, should_throw=True, user=self.instructor)
+
+        action.variables["user_ids"] = [self.instructor.id]
+        # trainee can't remove users from definition
+        self.run_action(action, should_throw=True, user=self.user1)
+
+        # instructor without definition access can't remove others
+        self.run_action(action, should_throw=True, user=self.instructor2)
diff --git a/user/tests/mutation_validators_tests.py b/user/tests/mutation_validators_tests.py
new file mode 100644
index 00000000..f4dac319
--- /dev/null
+++ b/user/tests/mutation_validators_tests.py
@@ -0,0 +1,127 @@
+import uuid
+import os
+import shutil
+
+from django.test import TestCase, override_settings
+
+from django.conf import settings
+from user.models import User, UserInTeam, InstructorOfExercise
+from aai.models import UserGroup
+from exercise.models import Exercise, Team
+from user.schema.validators import (
+    _check_nonexistent_users,
+    _check_group,
+    _check_presence,
+)
+from common_lib.test_utils import (
+    internal_create_exercise,
+    internal_upload_definition,
+)
+from common_lib.exceptions import ModelNotFoundException
+from common_lib.utils import get_model
+
+TEST_DATA_STORAGE = "user_tests_test_data"
+
+
+@override_settings(
+    DATA_STORAGE=TEST_DATA_STORAGE,
+    FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"),
+)
+class UserMutationValidatorsTest(TestCase):
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(settings.DATA_STORAGE)
+        super().tearDownClass()
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user1 = User.objects.create_user(
+            username="u1@mail.com", password="u1"
+        )
+        cls.user2 = User.objects.create_user(
+            username="u2@mail.com", password="u2"
+        )
+        cls.user3 = User.objects.create_user(
+            username="u3@mail.com", password="u3"
+        )
+        cls.instructor = User.objects.create_staffuser(
+            username="i@mail.com", password="i"
+        )
+        cls.instructor2 = User.objects.create_staffuser(
+            username="i2@mail.com", password="i2"
+        )
+        cls.definition = internal_upload_definition("base_definition")
+        cls.exercise = internal_create_exercise(cls.definition.id, 3)
+        cls.team = cls.exercise.teams.all().first()
+
+    def test_nonexistent_users_checker(self):
+        self.assertIsNotNone(
+            _check_nonexistent_users([self.user1.id, self.user2.id])
+        )
+        with self.assertRaises(ValueError):
+            _check_nonexistent_users([uuid.uuid4(), uuid.uuid4()])
+
+    def test_existance_checker(self):
+        self.assertIsNotNone(get_model(Exercise, id=self.exercise.id))
+        nonexistent_exercise = str(Exercise.objects.all().count() + 1)
+        with self.assertRaises(ModelNotFoundException):
+            get_model(Exercise, id=nonexistent_exercise)
+
+        self.assertIsNotNone(
+            get_model(Team, id=self.exercise.teams.all().first().id)
+        )
+        with self.assertRaises(ModelNotFoundException):
+            get_model(Team, id="10")
+
+    def test_group_checker(self):
+        self.assertIsNone(_check_group([self.user1], [UserGroup.TRAINEE]))
+        with self.assertRaises(ValueError):
+            _check_group([self.user1], [UserGroup.INSTRUCTOR])
+
+        self.assertIsNone(
+            _check_group([self.instructor], [UserGroup.INSTRUCTOR])
+        )
+        with self.assertRaises(ValueError):
+            _check_group([self.instructor], [UserGroup.ADMIN])
+
+    def test_presence_checker(self):
+        UserInTeam.objects.create(user=self.user1, team=self.team)
+        _check_presence(
+            UserInTeam,
+            [self.user1],
+            self.team,
+            user_id__in=[self.user1.id],
+            team_id=self.team.id,
+        )
+        team2 = self.exercise.teams.all()[1]
+
+        # user1 was not assigned to team 2
+        with self.assertRaises(ValueError):
+            _check_presence(
+                UserInTeam,
+                [self.user1],
+                team2,
+                user_id__in=[self.user1.id],
+                team_id=team2.id,
+            )
+
+        InstructorOfExercise.objects.create(
+            user=self.instructor, exercise=self.exercise
+        )
+        _check_presence(
+            InstructorOfExercise,
+            [self.instructor],
+            self.exercise,
+            user_id__in=[self.instructor.id],
+            exercise_id=self.exercise.id,
+        )
+
+        # instructor2 was not assigned to exercise
+        with self.assertRaises(ValueError):
+            _check_presence(
+                InstructorOfExercise,
+                [self.instructor2],
+                self.exercise,
+                user_id__in=[self.instructor2.id],
+                exercise_id=self.exercise.id,
+            )
diff --git a/user/urls.py b/user/urls.py
new file mode 100644
index 00000000..8f8e7d64
--- /dev/null
+++ b/user/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from user import views
+
+urlpatterns = [
+    path(
+        "user/create-users",
+        views.UploadUserFile.as_view(),
+        name="upload-users",
+    ),
+]
diff --git a/user/views.py b/user/views.py
new file mode 100644
index 00000000..bbd663e2
--- /dev/null
+++ b/user/views.py
@@ -0,0 +1,50 @@
+from drf_yasg2 import openapi
+from drf_yasg2.utils import swagger_auto_schema
+from rest_framework import parsers
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common_lib.exceptions import ApiException
+from aai.utils import protected
+from aai.models import Perms
+from user.lib import UserUploader
+
+
+class UploadUserFile(APIView):
+    parser_classes = [parsers.MultiPartParser]
+
+    @swagger_auto_schema(
+        manual_parameters=[
+            openapi.Parameter(
+                name="file",
+                in_=openapi.IN_FORM,
+                type=openapi.TYPE_FILE,
+                description="User list in CSV format",
+            ),
+        ]
+    )
+    @protected(Perms.update_user.full_name)
+    def post(self, request: Request, *args, **kwargs):
+        """
+        Uploads a new users file and checks for its correctness.
+        In consequence User objects are created based on the content of the file
+        if valid, else error response is created.
+        """
+        uploaded_file = request.FILES.get("file")
+        if not uploaded_file:
+            raise ApiException("A csv file must be uploaded")
+
+        if uploaded_file.content_type not in [
+            "text/csv",
+        ]:
+            raise ApiException("File has to be in csv format")
+
+        if request.user.is_anonymous:  # with @protected should never happen
+            raise ValueError(
+                "Not logged in user or user without role called UploadUserFile"
+            )
+
+        return UserUploader.validate_and_upload(
+            uploaded_file=uploaded_file,
+            admin_initiator=request.user.is_superuser,
+        )
-- 
GitLab