diff --git a/.github/docker/docker-compose.opencast.yml b/.github/docker/docker-compose.opencast.yml
new file mode 100644
index 0000000000000000000000000000000000000000..99bf5fbf5175e0ff31e27b981ab6ba61c1e8d703
--- /dev/null
+++ b/.github/docker/docker-compose.opencast.yml
@@ -0,0 +1,42 @@
+services:
+  opencast_opensearch:
+    image: opensearchproject/opensearch:1
+    ports:
+      - "9200:9200"
+    environment:
+      discovery.type: single-node
+      bootstrap.memory_lock: 'true'
+      OPENSEARCH_JAVA_OPTS: -Xms128m -Xmx512m
+      DISABLE_INSTALL_DEMO_CONFIG: 'true'
+      DISABLE_SECURITY_PLUGIN: 'true'
+    volumes:
+      - opencast_opensearch:/usr/share/opensearch/data
+
+  opencast:
+    image: quay.io/opencast/allinone:16.6
+    network_mode: host
+    environment:
+      ORG_OPENCASTPROJECT_SERVER_URL: http://127.0.0.1:8081
+      ORG_OPENCASTPROJECT_DOWNLOAD_URL: http://127.0.0.1:8081/static
+      ORG_OPENCASTPROJECT_SECURITY_ADMIN_USER: admin
+      ORG_OPENCASTPROJECT_SECURITY_ADMIN_PASS: opencast
+      ORG_OPENCASTPROJECT_SECURITY_DIGEST_USER: opencast_system_account
+      ORG_OPENCASTPROJECT_SECURITY_DIGEST_PASS: CHANGE_ME
+      ELASTICSEARCH_SERVER_HOST: localhost
+    volumes:
+      - opencast_data:/data
+      - ./opencast/etc/opencast/security/mh_default_org.xml:/opencast/etc/security/mh_default_org.xml
+      - ./opencast/etc/opencast/org.opencastproject.kernel.security.OAuthConsumerDetailsService.cfg:/opencast/etc/org.opencastproject.kernel.security.OAuthConsumerDetailsService.cfg
+      - ./opencast/etc/opencast/org.opencastproject.plugin.impl.PluginManagerImpl.cfg:/opencast/etc/org.opencastproject.plugin.impl.PluginManagerImpl.cfg
+      - ./opencast/etc/opencast/org.opencastproject.security.lti.LtiLaunchAuthenticationHandler.cfg:/opencast/etc/org.opencastproject.security.lti.LtiLaunchAuthenticationHandler.cfg
+      - ./opencast/etc/opencast/org.opencastproject.userdirectory.studip-default.cfg:/opencast/etc/org.opencastproject.userdirectory.studip-default.cfg
+
+  opencast_nginx:
+    image: nginx:1.24
+    network_mode: host
+    volumes:
+      - ./opencast/etc/nginx/nginx.conf:/etc/nginx/nginx.conf
+
+volumes:
+  opencast_opensearch: {}
+  opencast_data: {}
\ No newline at end of file
diff --git a/.github/docker/docker-compose.studip.yml b/.github/docker/docker-compose.studip.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5dc589128843eb5a0d09fe01c63e0557132c7c49
--- /dev/null
+++ b/.github/docker/docker-compose.studip.yml
@@ -0,0 +1,43 @@
+services:
+  studip_db:
+    image: mariadb:10.4
+    volumes:
+      - studip_db_data:/var/lib/mysql
+    ports:
+      - "3306:3306"
+    command: mysqld --sql_mode=""
+    restart: always
+    environment:
+      MYSQL_RANDOM_ROOT_PASSWORD: 1
+      MYSQL_DATABASE: studip_db
+      MYSQL_USER: studip_user
+      MYSQL_PASSWORD: studip_password
+  studip:
+    image: studip/studip:5.4
+    network_mode: host
+    depends_on:
+      - studip_db
+    volumes:
+      - studip_data:/var/www/studip/data
+      - ../..:/var/www/studip/public/plugins_packages/elan-ev/OpencastV3
+    restart: always
+    environment:
+      MYSQL_DATABASE: studip_db
+      MYSQL_USER: studip_user
+      MYSQL_PASSWORD: studip_password
+      MYSQL_HOST: 127.0.0.1
+      STUDIP_MAIL_TRANSPORT: debug
+
+      # Use automigrate to migrate your instance on startup
+      AUTO_MIGRATE: 1
+
+      # Use proxy url OR autoproxy if run behind a proxy
+      # PROXY_URL: https://studip.example.com/
+      # AUTO_PROXY: 1
+
+      # Demo data for your studip instance
+      DEMO_DATA: 1
+
+volumes:
+  studip_data: {}
+  studip_db_data: {}
diff --git a/.github/docker/docker-compose.yml b/.github/docker/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6f6b9ae93fa6a7ecf7aac56e5bfe74b2b305c7c2
--- /dev/null
+++ b/.github/docker/docker-compose.yml
@@ -0,0 +1,3 @@
+include:
+  - docker-compose.opencast.yml
+  - docker-compose.studip.yml
diff --git a/.github/docker/oc.sql b/.github/docker/oc.sql
new file mode 100644
index 0000000000000000000000000000000000000000..2455dec35b57856e25d4022a8ec74962854732d6
--- /dev/null
+++ b/.github/docker/oc.sql
@@ -0,0 +1,174 @@
+SET FOREIGN_KEY_CHECKS=0;
+
+REPLACE INTO `oc_config`
+    (`id`, `service_url`, `service_user`, `service_password`, `service_version`, `settings`) VALUES
+    (1,	'http://127.0.0.1:8081', 'admin', 'opencast', '16.6', '{\"lti_consumerkey\":\"CONSUMERKEY\",\"lti_consumersecret\":\"CONSUMERSECRET\"}');
+
+
+REPLACE INTO `oc_endpoints` (`config_id`, `service_url`, `service_type`) VALUES
+(1,	'http://127.0.0.1:8081/api/events',	'apievents'),
+(1,    'http://127.0.0.1:8081/api/playlists', 'apiplaylists'),
+(1,	'http://127.0.0.1:8081/api/series',	'apiseries'),
+(1,	'http://127.0.0.1:8081/api/workflows',	'apiworkflows'),
+(1,	'http://127.0.0.1:8081/capture-admin',	'capture-admin'),
+(1,	'http://127.0.0.1:8081/ingest',	'ingest'),
+(1,    'http://127.0.0.1:8081/play', 'play'),
+(1,	'http://127.0.0.1:8081/recordings',	'recordings'),
+(1,	'http://127.0.0.1:8081/search',	'search'),
+(1,	'http://127.0.0.1:8081/series',	'series'),
+(1,	'http://127.0.0.1:8081/services',	'services'),
+(1,	'http://127.0.0.1:8081/upload',	'upload'),
+(1,	'http://127.0.0.1:8081/workflow',	'workflow');
+
+
+REPLACE INTO `config_values` (`field`, `range_id`, `value`, `mkdate`, `chdate`, `comment`) VALUES
+('OPENCAST_API_TOKEN',	'studip',	'mytoken1234abcdef',	1693295334,	1693295334,	'');
+
+
+REPLACE INTO `config_values` (`field`, `range_id`, `value`, `mkdate`, `chdate`, `comment`) VALUES
+('OPENCAST_DEFAULT_SERVER ',	'studip',	'1',	1693295334,	1693295334,	'');
+
+REPLACE INTO `roles_plugins` (`roleid`, `pluginid`) VALUES
+(7,	29);
+
+
+REPLACE INTO `auth_user_md5` (`user_id`, `username`, `password`, `perms`, `Vorname`, `Nachname`, `Email`, `validation_key`, `auth_plugin`, `locked`, `lock_comment`, `locked_by`, `visible`) VALUES
+('fad0229f8b0573cda5fbdf5fcfa89362', 'simple_autor', 0x24326124303824756C6A4D587969786C71376939634D6539775841364F526C612E466C6E46743370762F45754E5150356C58516A775070634442462E, 'autor', 'Simple', 'Autor', 'test@studip.de', '', 'standard', 0, NULL, NULL, 'unknown');
+
+UPDATE auth_user_md5 SET visible = 'always' WHERE 1;
+
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', '205f3efb7997a0fc9755da2b535038da', 1);
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', '6235c46eb9e962866ebdceece739ace5', 1);
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', '76ed43ef286fb55cf9e41beadb484a9f', 1);
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', '7e81ec247c151c02ffd479511e24cc03', 1);
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', 'e7a0a84b161f3e8c09b4a0a2e8a58147', 1);
+REPLACE INTO config_values (field, range_id, value) VALUES ('TERMS_ACCEPTED', 'fad0229f8b0573cda5fbdf5fcfa89362', 1);
+
+-- add videos so foreign keys are working
+# REPLACE INTO `oc_video` (`id`, `config_id`, `episode`, `available`, `duration`) VALUES
+# (1,	1,	'ID-goat',	1,	NULL),
+# (2,	1,	'ID-weitsprung',	1,	NULL),
+# (3,	1,	'ID-nasa-earth-4k',	1,	NULL),
+# (4,	1,	'ID-strong-river-flowing-down-the-green-forest',	1,	NULL),
+# (5,	1,	'ID-marguerite',	1,	NULL),
+# (6,	1,	'ID-espresso-video',	1,	NULL),
+# (7,	1,	'ID-westerberg',	1,	NULL),
+# (8,	1,	'ID-cats',	1,	NULL),
+# (9,	1,	'ID-spring',	1,	NULL),
+# (10,	1,	'ID-dog-rose',	1,	NULL),
+# (11,	1,	'ID-nasa-rocket-booster',	1,	NULL),
+# (12,	1,	'ID-was-ist-chaos',	1,	NULL),
+# (13,	1,	'ID-3d-print',	1,	NULL),
+# (14,	1,	'ID-perseverance-arrives-at-mars',	1,	NULL),
+# (15,	1,	'ID-pendulum-with-spring-damper',	1,	NULL),
+# (16,	1,	'ID-coffee-run',	1,	NULL),
+# (17,	1,	'ID-lavender',	1,	NULL),
+# (18,	1,	'ID-subtitle-demo',	1,	NULL),
+# (19,	1,	'ID-about-opencast',	1,	NULL),
+# (20,	1,	'ID-dual-stream-demo',	1,	NULL);
+
+
+# REPLACE INTO `oc_video_sync`
+#     VALUES (1,1,'scheduled','2023-11-10 11:06:02',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (2,2,'scheduled','2023-11-10 11:06:02',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (3,3,'scheduled','2023-11-10 11:06:02',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (4,4,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (5,5,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (6,6,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (7,7,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (8,8,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (9,9,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (10,10,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (11,11,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (12,12,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (13,13,'scheduled','2023-11-10 11:06:03',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (14,14,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (15,15,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (16,16,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (17,17,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (18,18,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (19,19,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00'),
+#     (20,20,'scheduled','2023-11-10 11:06:04',NULL,'0000-00-00 00:00:00','0000-00-00 00:00:00');
+
+-- allow test_dozent access to videos
+# REPLACE INTO oc_video_user_perms
+#     (video_id, user_id, perm) VALUES
+# (1, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (2, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (3, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (4, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (5, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (6, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (7, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (8, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (9, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (10, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (11, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (12, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (13, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (14, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (15, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (16, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (17, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (18, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (19, '205f3efb7997a0fc9755da2b535038da', 'owner'),
+# (20, '205f3efb7997a0fc9755da2b535038da', 'owner');
+
+-- activate plugin in course
+REPLACE INTO tools_activated
+    (range_id, range_type, plugin_id, position, metadata, mkdate, chdate) VALUES
+('a07535cf2f8a72df33c12ddfa4b53dde', 'course', 29, 11, '[]', 1699267230, 1699267230);
+
+-- add videos to course playlist
+# REPLACE INTO oc_playlist
+#     (id, token, config_id, service_playlist_id, title, visibility, chdate, mkdate, sort_order, allow_download) VALUES
+#     (1, 'fce2a63c', 1, 'studip-playlist', '12345 Test Lehrveranstaltung (WS 2023/2024)', NULL, '2023-11-10 12:50:57', '2023-11-10 12:50:57', 'created_desc', NULL);
+
+# REPLACE INTO `oc_playlist_seminar` (`id`, `playlist_id`, `seminar_id`, `is_default`, `visibility`) VALUES
+#     (1,	1,	'a07535cf2f8a72df33c12ddfa4b53dde',	1,	'visible');
+
+# REPLACE INTO oc_playlist_video
+#     (playlist_id, video_id, `order`) VALUES
+# (1, 1, 0),
+# (1, 2, 0),
+# (1, 3, 0),
+# (1, 4, 0),
+# (1, 5, 0),
+# (1, 6, 0),
+# (1, 7, 0),
+# (1, 8, 0),
+# (1, 9, 0),
+# (1, 10, 0),
+# (1, 11, 0),
+# (1, 12, 0),
+# (1, 13, 0),
+# (1, 14, 0),
+# (1, 15, 0),
+# (1, 16, 0),
+# (1, 17, 0),
+# (1, 18, 0),
+# (1, 19, 0),
+# (1, 20, 0);
+
+
+REPLACE INTO `oc_workflow` (`id`, `config_id`, `name`, `tag`, `displayname`) VALUES
+(1,	1,	'delete',	'delete',	'Delete'),
+(2,	1,	'duplicate-event',	'archive',	'Duplicate Event'),
+(3,	1,	'fast',	'schedule',	'Fast Testing Workflow'),
+(4,	1,	'fast',	'upload',	'Fast Testing Workflow'),
+(5,	1,	'schedule-and-upload',	'schedule',	'Process upon upload and schedule'),
+(6,	1,	'schedule-and-upload',	'upload',	'Process upon upload and schedule'),
+(7,	1,	'publish',	'archive',	'Publish'),
+(8,	1,	'publish',	'editor',	'Publish'),
+(9,	1,	'republish-metadata',	'archive',	'Republish metadata'),
+(10,	1,	'retract',	'archive',	'Retract');
+
+
+REPLACE INTO `oc_workflow_config` (`id`, `config_id`, `used_for`, `workflow_id`) VALUES
+(1,	1,	'schedule',	5),
+(2,	1,	'upload',	6),
+(3,	1,	'studio',	6),
+(4,	1,	'delete',	1),
+(5,	1,	'subtitles',	9);
+
+SET FOREIGN_KEY_CHECKS=1;
diff --git a/.github/docker/opencast/etc/nginx/nginx.conf b/.github/docker/opencast/etc/nginx/nginx.conf
new file mode 100644
index 0000000000000000000000000000000000000000..b24c3f3a1c002603817d73a737af46cd580a679a
--- /dev/null
+++ b/.github/docker/opencast/etc/nginx/nginx.conf
@@ -0,0 +1,79 @@
+
+user  nginx;
+worker_processes  auto;
+
+error_log  /var/log/nginx/error.log notice;
+pid        /var/run/nginx.pid;
+
+
+events {
+    worker_connections  1024;
+}
+
+
+http {
+    include       /etc/nginx/mime.types;
+    default_type  application/octet-stream;
+
+    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
+                      '$status $body_bytes_sent "$http_referer" '
+                      '"$http_user_agent" "$http_x_forwarded_for"';
+
+    access_log  /var/log/nginx/access.log  main;
+
+    sendfile        on;
+    #tcp_nopush     on;
+
+    keepalive_timeout  65;
+
+    #gzip  on;
+
+    #include /etc/nginx/conf.d/*.conf;
+    
+    # Do not send the nginx version number in error pages and Server header
+    server_tokens off;
+    
+    server {
+    
+        listen 8081;
+        
+        # Only send the shortened referrer to a foreign origin, full referrer
+        # to a local host
+        # https://infosec.mozilla.org/guidelines/web_security#referrer-policy
+        add_header Referrer-Policy strict-origin-when-cross-origin;
+        
+        # Basic open CORS for everyone
+        add_header Access-Control-Allow-Origin $http_origin always;
+        add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
+        add_header Access-Control-Allow-Credentials true always;
+        add_header Access-Control-Allow-Headers 'Origin,Content-Type,Accept,Authorization' always;
+        
+        # Always respond with 200 to OPTIONS requests as browsers do not accept
+        # non-200 responses to CORS preflight requests.
+        if ($request_method = OPTIONS) {
+            return 200;
+        }
+        
+        # Accept large ingests
+        client_max_body_size 0;
+    
+        location / {
+        
+            proxy_set_header        Host $host:8081;
+            proxy_set_header        X-Real-IP $remote_addr;
+            proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header        X-Forwarded-Proto $scheme;
+        
+            proxy_pass http://127.0.0.1:8080;
+            
+            proxy_cookie_path / "/; HTTPOnly; Secure";
+            
+            
+            # Do not buffer responses
+            proxy_buffering         off;
+
+            # Do not buffer requests
+            proxy_request_buffering off;
+        }
+    }
+}
diff --git a/.github/docker/opencast/etc/opencast/org.opencastproject.kernel.security.OAuthConsumerDetailsService.cfg b/.github/docker/opencast/etc/opencast/org.opencastproject.kernel.security.OAuthConsumerDetailsService.cfg
new file mode 100755
index 0000000000000000000000000000000000000000..d9c8230f9978506cdf2a3d6971acf640102f12d9
--- /dev/null
+++ b/.github/docker/opencast/etc/opencast/org.opencastproject.kernel.security.OAuthConsumerDetailsService.cfg
@@ -0,0 +1,8 @@
+# OAuth consumer consisting of name, key and secret.
+#
+# Multiple OAuth consumers can be configured, by incrementing the counter. The list is read
+# sequentially incrementing the counter. If you miss any numbers it will stop looking for
+# further consumers.
+oauth.consumer.name.1=CONSUMERNAME
+oauth.consumer.key.1=CONSUMERKEY
+oauth.consumer.secret.1=CONSUMERSECRET
diff --git a/.github/docker/opencast/etc/opencast/org.opencastproject.plugin.impl.PluginManagerImpl.cfg b/.github/docker/opencast/etc/opencast/org.opencastproject.plugin.impl.PluginManagerImpl.cfg
new file mode 100755
index 0000000000000000000000000000000000000000..81901d4d6ca89515580d3acf65080f9e41698ab4
--- /dev/null
+++ b/.github/docker/opencast/etc/opencast/org.opencastproject.plugin.impl.PluginManagerImpl.cfg
@@ -0,0 +1,22 @@
+###
+# Opencast Plugins
+#
+# This configuration allows you to turn additional functionality of Opencast off and on.
+# Plugins can be enabled at runtime.
+##
+
+# List of available plugins
+opencast-plugin-admin-ng                    = off
+opencast-plugin-legacy-annotation           = off
+opencast-plugin-transcription-services      = off
+opencast-plugin-userdirectory-brightspace   = off
+opencast-plugin-userdirectory-canvas        = off
+opencast-plugin-userdirectory-moodle        = off
+opencast-plugin-userdirectory-sakai         = off
+opencast-plugin-userdirectory-studip        = on
+opencast-plugin-usertracking                = off
+
+# Enables Karaf's verbose feature activateion logs.
+# Note that Karaf writes these to stdout, not to the logger.
+# Default: false
+#verbose = false
diff --git a/.github/docker/opencast/etc/opencast/org.opencastproject.security.lti.LtiLaunchAuthenticationHandler.cfg b/.github/docker/opencast/etc/opencast/org.opencastproject.security.lti.LtiLaunchAuthenticationHandler.cfg
new file mode 100755
index 0000000000000000000000000000000000000000..8f3723c689e39878e106d4fba60365ada386c15c
--- /dev/null
+++ b/.github/docker/opencast/etc/opencast/org.opencastproject.security.lti.LtiLaunchAuthenticationHandler.cfg
@@ -0,0 +1,98 @@
+# OAuth consumer keys with should be highly trusted.
+#
+# By default OAuth consumer are regarded as untrusted and a user authenticating via such
+# systems receives a rewritten username in the form of "lti:{ltiConsumerGUID}:{ltiUserID}".
+# This user is regarded as a new user temporarily existing for the duration of the session.
+# Opencast roles associated with the original user will not be attached to this user.
+#
+# Usernames of users authenticating via highly trusted systems will not be rewritten except
+# for the cases configured in the additional options below.
+#
+# Note that marking a consumer key as highly trusted can be a security risk. If the usernames of sensitive Opencast
+# users are not blacklisted, the LMS administrator could create LMS users with the same username and use LTI to grant
+# that user access to Opencast. In the default configuration, that includes the `admin` and `opencast_system_account`
+# users.
+#
+# Multiple consumer keys can be configured, by incrementing the counter. The list is read
+# sequentially incrementing the counter. If you miss any numbers it will stop looking for
+# further consumer keys.
+#lti.oauth.highly_trusted_consumer_key.1=CONSUMERKEY
+
+# Allow the Opencast system administrator user to authenticate as such via LTI.
+#
+# Note that this user may still authenticate via LTI, but the username will be rewritten,
+# even if a trusted OAuth consumer key is used.
+#
+# Note that this option does not apply to custom users having the ROLE_ADMIN. Use the
+# blacklist below instead.
+#
+# Default: false
+#lti.allow_system_administrator=false
+
+# Allow the Opencast digest user to authenticate as such via LTI.
+#
+# Note that this user may still authenticate via LTI, but the username will be rewritten,
+# even if a trusted OAuth consumer key is used.
+#
+# Default: false
+#lti.allow_digest_user=false
+
+# A blacklist of users not allowed to authenticate via LTI as themselves.
+#
+# Note that these users may still authenticate via LTI, but their username will be rewritten,
+# even if a trusted OAuth consumer key is used.
+#
+# Multiple users can be configured, by incrementing the counter. The list is read sequentially
+# incrementing the counter. If you miss any numbers it will stop looking for further users.
+#
+# Default: no blacklisted users
+#lti.blacklist.user.1=
+
+# Determines whether a JpaUserReference should be created on LTI User Login.
+# This persists the LTI Users in the database, giving them the ability to create long running tasks like ingesting a video.
+#
+# Default: true
+lti.create_jpa_user_reference = false
+
+# Determines which LTI roles should be persisted in the database on LTI user logins.
+# The "lti.create_jpa_user_reference" config key has to be "true", otherwise this config key will be ignored.
+# The value can be a list of LTI roles identifying users to be persisted or the special value * causing all users to be persisted.
+# The value is not case sensitive.
+#
+# Examples:
+#  - Persist only instructors:
+#    lti.create_jpa_user_reference.roles = instructor
+#  - Persist only instructors and administrators:
+#    lti.create_jpa_user_reference.roles = instructor, administrator
+#  - Persist all users:
+#    lti.create_jpa_user_reference.roles = *
+#
+# Default: *
+#
+# lti.create_jpa_user_reference.roles = *
+
+# Add Custom Roles to users who has the role with custom_role_name
+# This configuration key is a list, to add additional custom roles increment the lti.custom_role_name.# number,
+# the role will only be added if it has matching lti.custom_roles.# roles configuration
+# It also has support for regex patterns for example 'ims\/lis\/.*' will match all roles that start with ims/list/
+# Default: empty no custom roles
+
+lti.custom_role_name.1=Instructor
+
+# The lti.custom_roles.# configuration key must have matching lti.custom_role_name.# key.
+# This Role set is an example for a user which can open the editor for an event and upload videos via opencast studio.
+lti.custom_roles.1=ROLE_STUDIO,ROLE_UI_EVENTS_DETAILS_COMMENTS_CREATE,ROLE_UI_EVENTS_DETAILS_COMMENTS_DELETE,ROLE_UI_EVENTS_DETAILS_COMMENTS_EDIT,ROLE_UI_EVENTS_DETAILS_COMMENTS_REPLY,ROLE_UI_EVENTS_DETAILS_COMMENTS_RESOLVE,ROLE_UI_EVENTS_DETAILS_COMMENTS_VIEW,ROLE_UI_EVENTS_DETAILS_MEDIA_VIEW,ROLE_UI_EVENTS_DETAILS_METADATA_EDIT,ROLE_UI_EVENTS_DETAILS_METADATA_VIEW,ROLE_UI_EVENTS_DETAILS_VIEW,ROLE_UI_EVENTS_EDITOR_EDIT,ROLE_UI_EVENTS_EDITOR_VIEW,ROLE_CAPTURE_AGENT,ROLE_API_EVENTS_TRACK_EDIT,ROLE_API_WORKFLOW_INSTANCE_CREATE
+
+# Prefix for LTI context based roles based on OAuth consumer keys.
+# The LTI context (e.g. the course identifier) is used to generate context roles like “12345_Learner”.
+# If multiple LTI consumers are used, this can clash, causing users from one consumer to get access to content from
+# another consumer. The prefix can be used to prevent this by generating context roles like “PREFIX1_123_Learner” and
+# “PREFIX2_123_Learner” instead.
+#
+# Context roles may not start with “ROLE_”. Avoid using that as a prefix.
+#
+# Default: No prefix
+#
+#lti.consumer_role_prefix.CONSUMERKEY0 = STUDIP_
+#lti.consumer_role_prefix.CONSUMERKEY1 = MOODLE_
+#lti.consumer_role_prefix.CONSUMERKEY2 = ILIAS_
diff --git a/.github/docker/opencast/etc/opencast/org.opencastproject.userdirectory.studip-default.cfg b/.github/docker/opencast/etc/opencast/org.opencastproject.userdirectory.studip-default.cfg
new file mode 100755
index 0000000000000000000000000000000000000000..f02a7cc829f77290d2dd3055980223dba2dff485
--- /dev/null
+++ b/.github/docker/opencast/etc/opencast/org.opencastproject.userdirectory.studip-default.cfg
@@ -0,0 +1,20 @@
+# Stud.IP UserDirectoryProvider configuration
+
+# This is an optional plugin not enabled by default.
+# Enable this plugin in:
+#   org.opencastproject.plugin.impl.PluginManagerImpl.cfg
+
+# The organization for this provider
+org.opencastproject.userdirectory.studip.org=mh_default_org
+
+# The URL and token for the Studip REST webservice
+org.opencastproject.userdirectory.studip.url=http://localhost/plugins.php/opencastv3/api/
+org.opencastproject.userdirectory.studip.token=mytoken1234abcdef
+
+# The maximum number of users to cache
+# Default: 1000
+#org.opencastproject.userdirectory.studip.cache.size=1000
+
+# The maximum number of minutes to cache a user
+# Default: 60
+org.opencastproject.userdirectory.studip.cache.expiration=1
diff --git a/.github/docker/opencast/etc/opencast/security/mh_default_org.xml b/.github/docker/opencast/etc/opencast/security/mh_default_org.xml
new file mode 100755
index 0000000000000000000000000000000000000000..e28c7c6d7d361f9336ff535c53d29f3192901313
--- /dev/null
+++ b/.github/docker/opencast/etc/opencast/security/mh_default_org.xml
@@ -0,0 +1,919 @@
+<beans xmlns="http://www.springframework.org/schema/beans"
+       xmlns:sec="http://www.springframework.org/schema/security"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:osgi="http://www.springframework.org/schema/osgi"
+       xmlns:util="http://www.springframework.org/schema/util"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans
+                           http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/util
+                           http://www.springframework.org/schema/util/spring-util-3.1.xsd
+                           http://www.springframework.org/schema/osgi
+                           http://www.springframework.org/schema/osgi/spring-osgi.xsd
+                           http://www.springframework.org/schema/security
+                           http://www.springframework.org/schema/security/spring-security-3.1.xsd">
+
+  <!-- ######################################## -->
+  <!-- # Open and unsecured url patterns      # -->
+  <!-- ######################################## -->
+
+  <sec:http pattern="/favicon.ico" security="none" />
+
+  <sec:http create-session="ifRequired" servlet-api-provision="true" realm="Opencast"
+            entry-point-ref="opencastEntryPoint">
+
+    <sec:access-denied-handler error-page="/403.html"/>
+
+    <!-- ################ -->
+    <!-- # URL SECURITY # -->
+    <!-- ################ -->
+
+    <!-- Allow anonymous access to the login form -->
+    <sec:intercept-url pattern="/" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/login.html" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/sysinfo/bundles/version" method="GET" access="ROLE_ANONYMOUS" />
+
+    <!-- Allow anonymous access to resources from runtime info ui -->
+    <sec:intercept-url pattern="/img/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/js/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/scripts/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/styles/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/docs.*" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/rest_docs.*" access="ROLE_ANONYMOUS" />
+
+    <!-- Allow anonymous access to the player redirect -->
+    <sec:intercept-url pattern="/play/*" access="ROLE_ANONYMOUS" />
+
+    <!-- Define roles for metrics endpoint -->
+    <sec:intercept-url pattern="/metrics" access="ROLE_ADMIN, ROLE_METRICS" />
+
+    <!-- Protect admin UI facade -->
+    <sec:intercept-url pattern="/workflow/definitions.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/acl/acls.json" method="GET" access="ROLE_ADMIN, ROLE_UI_ACLS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/acl/*" method="GET" access="ROLE_ADMIN, ROLE_UI_ACLS_VIEW" />
+    <sec:intercept-url pattern="/acl-manager/acl/*" method="GET" access="ROLE_ADMIN, ROLE_UI_ACLS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/capture-agents/agents.json" method="GET" access="ROLE_ADMIN, ROLE_UI_LOCATIONS_VIEW, ROLE_UI_EVENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/capture-agents/*" method="GET" access="ROLE_ADMIN, ROLE_UI_LOCATIONS_DETAILS_CAPABILITIES_VIEW, ROLE_UI_LOCATIONS_DETAILS_CONFIGURATION_VIEW, ROLE_UI_LOCATIONS_DETAILS_GENERAL_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/asset/assets.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/asset/attachment/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/asset/catalog/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/asset/media/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/asset/publication/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/catalogAdapters" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_METADATA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/events.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/workflowProperties" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_CREATE, ROLE_UI_TASKS_CREATE, ROLE_UI_EVENTS_EDITOR_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/new/metadata" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_METADATA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/new/processing" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_CREATE, ROLE_UI_TASKS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/attachments.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ATTACHMENTS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comments" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/publications.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_PUBLICATIONS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/hasActiveTransaction" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/hasSnapshots.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/media.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_MEDIA_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/media/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_MEDIA_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/metadata.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_METADATA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/scheduling.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_SCHEDULING_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/operations.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/operations/*" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/errors.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/errors/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_VIEW"/>
+    <sec:intercept-url pattern="/admin-ng/event/*/access.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ACL_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/groups/groups.json" method="GET" access="ROLE_ADMIN, ROLE_UI_GROUPS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/groups/*" method="GET" access="ROLE_ADMIN, ROLE_UI_GROUPS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/index/rebuild/states.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERVICES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/job/jobs.json" method="GET" access="ROLE_ADMIN, ROLE_UI_JOBS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/series.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/new/metadata" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_METADATA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/new/themes" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_THEMES_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/series/new/tobira/page" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_TOBIRA_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/series/*/access.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_ACL_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/*/metadata.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_METADATA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/*/theme.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_THEMES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/*/tobira/pages" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_TOBIRA_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/server/servers.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERVERS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/*/hasEvents.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/series/configuration.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/services/services.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERVICES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/tasks/processing.json" method="GET" access="ROLE_ADMIN, ROLE_UI_TASKS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/themes/themes.json" method="GET" access="ROLE_ADMIN, ROLE_UI_THEMES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/themes/*/usage.json" method="GET" access="ROLE_ADMIN, ROLE_UI_THEMES_DETAILS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/themes/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_THEMES_DETAILS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/tools/*/editor.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_EDITOR_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/tools/*/thumbnail.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_EDITOR_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/tools/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_EDITOR_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/users/users.json" method="GET" access="ROLE_ADMIN, ROLE_UI_USERS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/users/*.json" method="GET" access="ROLE_ADMIN, ROLE_UI_USERS_EDIT, ROLE_UI_EVENTS_DETAILS_ACL_USER_ROLES_VIEW, ROLE_UI_SERIES_DETAILS_ACL_USER_ROLES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/user-settings/signature" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/resources/events/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/jobs/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_JOBS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/series/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERIES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/servers/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERVERS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/services/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_SERVICES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/themes/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_THEMES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/recordings/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_LOCATIONS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/users/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_USERS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/groups/filters.json" method="GET" access="ROLE_ADMIN, ROLE_UI_GROUPS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/components.json" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/resources/providers.json" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/resources/THEMES.json" method="GET" access="ROLE_ADMIN, ROLE_UI_THEMES_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/resources/*.json" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/statistics/*.json" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/statistics/export.csv" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/assets/assets/**" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/adopter/**" method="GET" access="ROLE_ADMIN" />
+
+    <sec:intercept-url pattern="/admin-ng/acl/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_ACLS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/metadata" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_METADATA_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/scheduling" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_SCHEDULING_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/action/stop" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/action/retry" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*/action/none" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_EDIT"/>
+    <sec:intercept-url pattern="/admin-ng/event/bulk/update" method="PUT" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_SCHEDULING_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/groups/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_GROUPS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/series/*/metadata" method="PUT" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_METADATA_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/series/*/theme" method="PUT" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_THEMES_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/themes/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_THEMES_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/users/*" method="PUT" access="ROLE_ADMIN, ROLE_UI_USERS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/user-settings/signature/*" method="PUT" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/adopter/**" method="PUT" access="ROLE_ADMIN" />
+
+    <sec:intercept-url pattern="/services/maintenance" method="POST" access="ROLE_ADMIN, ROLE_UI_SERVERS_MAINTENANCE_EDIT" />
+    <sec:intercept-url pattern="/services/sanitize" method="POST" access="ROLE_ADMIN, ROLE_UI_SERVICES_STATUS_EDIT" />
+    <sec:intercept-url pattern="/staticfiles" method="POST" access="ROLE_ADMIN, ROLE_UI_THEMES_CREATE, ROLE_UI_THEMES_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/acl" method="POST" access="ROLE_ADMIN, ROLE_UI_ACLS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/bulk/conflicts" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_SCHEDULING_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/deleteEvents" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/events/metadata.json" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_METADATA_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/new" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/new/conflicts" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/scheduling.json" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_SCHEDULING_VIEW" />
+    <sec:intercept-url pattern="/admin-ng/event/*/access" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ACL_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/assets" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_ASSETS_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*/reply" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/groups" method="POST" access="ROLE_ADMIN, ROLE_UI_GROUPS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/series/deleteSeries" method="POST" access="ROLE_ADMIN, ROLE_UI_SERIES_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/series/new" method="POST" access="ROLE_ADMIN, ROLE_UI_SERIES_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/series/*/access" method="POST" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_ACL_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/statistics/data.json" method="POST" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/tasks/new" method="POST" access="ROLE_ADMIN, ROLE_UI_TASKS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/themes" method="POST" access="ROLE_ADMIN, ROLE_UI_THEMES_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/tools/*/editor.json" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_EDITOR_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/tools/*/thumbnail.json" method="POST" access="ROLE_ADMIN, ROLE_UI_EVENTS_EDITOR_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/users" method="POST" access="ROLE_ADMIN, ROLE_UI_USERS_CREATE" />
+    <sec:intercept-url pattern="/admin-ng/user-settings/signature" method="POST" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/adopter/**" method="POST" access="ROLE_ADMIN" />
+
+    <sec:intercept-url pattern="/admin-ng/acl/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_ACLS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/capture-agents/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_LOCATIONS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/comment/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_COMMENTS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/*/workflows/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_EVENTS_DETAILS_WORKFLOWS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/event/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_EVENTS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/groups/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_GROUPS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/series/*/theme" method="DELETE" access="ROLE_ADMIN, ROLE_UI_SERIES_DETAILS_THEMES_EDIT" />
+    <sec:intercept-url pattern="/admin-ng/series/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_SERIES_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/themes/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_THEMES_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/users/*" method="DELETE" access="ROLE_ADMIN, ROLE_UI_USERS_DELETE" />
+    <sec:intercept-url pattern="/admin-ng/adopter/**" method="DELETE" access="ROLE_ADMIN" />
+
+    <!-- Securing the URLs for the external API interface -->
+    <!-- External API GET Endpoints -->
+    <sec:intercept-url pattern="/api" method="GET" access="ROLE_ADMIN, ROLE_API"/>
+    <sec:intercept-url pattern="/api/agents" method="GET" access="ROLE_ADMIN, ROLE_API_CAPTURE_AGENTS_VIEW"/>
+    <sec:intercept-url pattern="/api/agents/*" method="GET" access="ROLE_ADMIN, ROLE_API_CAPTURE_AGENTS_VIEW"/>
+    <sec:intercept-url pattern="/api/events" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/acl" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_ACL_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/media" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_MEDIA_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/media/*" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_MEDIA_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/metadata" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/metadata/*" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/publications" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_PUBLICATIONS_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/publications/*" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_PUBLICATIONS_VIEW"/>
+    <sec:intercept-url pattern="/api/events/*/scheduling" method="GET" access="ROLE_ADMIN, ROLE_API_EVENTS_SCHEDULING_VIEW"/>
+    <sec:intercept-url pattern="/api/groups" method="GET" access="ROLE_ADMIN, ROLE_API_GROUPS_VIEW"/>
+    <sec:intercept-url pattern="/api/groups/*" method="GET" access="ROLE_ADMIN, ROLE_API_GROUPS_VIEW"/>
+    <sec:intercept-url pattern="/api/info/*" method="GET" access="ROLE_ADMIN, ROLE_API" />
+    <sec:intercept-url pattern="/api/info/me/*" method="GET" access="ROLE_ADMIN, ROLE_API" />
+    <sec:intercept-url pattern="/api/info/organization/properties/engageuiurl" method="GET" access="ROLE_ADMIN, ROLE_UI_EVENTS_EMBEDDING_CODE_VIEW" />
+    <sec:intercept-url pattern="/api/listproviders" method="GET" access="ROLE_ADMIN, ROLE_API_LISTPROVIDERS_VIEW"/>
+    <sec:intercept-url pattern="/api/listproviders/*" method="GET" access="ROLE_ADMIN, ROLE_API_LISTPROVIDERS_VIEW"/>
+    <sec:intercept-url pattern="/api/playlists" method="GET" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_VIEW"/>
+    <sec:intercept-url pattern="/api/playlists/*" method="GET" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_VIEW"/>
+    <sec:intercept-url pattern="/api/series" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_VIEW"/>
+    <sec:intercept-url pattern="/api/series/*" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_VIEW"/>
+    <sec:intercept-url pattern="/api/series/*/acl" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_ACL_VIEW"/>
+    <sec:intercept-url pattern="/api/series/*/metadata" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_VIEW"/>
+    <sec:intercept-url pattern="/api/series/*/metadata/*" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_VIEW"/>
+    <sec:intercept-url pattern="/api/series/*/properties" method="GET" access="ROLE_ADMIN, ROLE_API_SERIES_PROPERTIES_VIEW"/>
+    <sec:intercept-url pattern="/api/statistics/providers" method="GET" access="ROLE_ADMIN, ROLE_API_STATISTICS_VIEW"/>
+    <sec:intercept-url pattern="/api/statistics/providers/*" method="GET" access="ROLE_ADMIN, ROLE_API_STATISTICS_VIEW"/>
+    <sec:intercept-url pattern="/api/version" method="GET" access="ROLE_ADMIN, ROLE_API"/>
+    <sec:intercept-url pattern="/api/version/*" method="GET" access="ROLE_ADMIN, ROLE_API"/>
+    <sec:intercept-url pattern="/api/workflows" method="GET" access="ROLE_ADMIN, ROLE_API_WORKFLOW_INSTANCE_VIEW"/>
+    <sec:intercept-url pattern="/api/workflows/*" method="GET" access="ROLE_ADMIN, ROLE_API_WORKFLOW_INSTANCE_VIEW"/>
+    <sec:intercept-url pattern="/api/workflow-definitions" method="GET" access="ROLE_ADMIN, ROLE_API_WORKFLOW_DEFINITION_VIEW"/>
+    <sec:intercept-url pattern="/api/workflow-definitions/*" method="GET" access="ROLE_ADMIN, ROLE_API_WORKFLOW_DEFINITION_VIEW"/>
+    <!-- External API PUT Endpoints -->
+    <sec:intercept-url pattern="/api/events/*" method="PUT" access="ROLE_ADMIN, ROLE_API_EVENTS_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/acl" method="PUT" access="ROLE_ADMIN, ROLE_API_EVENTS_ACL_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/metadata" method="PUT" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/metadata/*" method="PUT" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/scheduling" method="PUT" access="ROLE_ADMIN, ROLE_API_EVENTS_SCHEDULING_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/track" method="POST" access="ROLE_ADMIN, ROLE_API_EVENTS_TRACK_EDIT"/>
+    <sec:intercept-url pattern="/api/groups/*" method="PUT" access="ROLE_ADMIN, ROLE_API_GROUPS_EDIT"/>
+    <sec:intercept-url pattern="/api/playlists/*" method="PUT" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_CREATE, ROLE_API_PLAYLISTS_EDIT"/>
+    <sec:intercept-url pattern="/api/series/*" method="PUT" access="ROLE_ADMIN, ROLE_API_SERIES_EDIT"/>
+    <sec:intercept-url pattern="/api/series/*/acl" method="PUT" access="ROLE_ADMIN, ROLE_API_SERIES_ACL_EDIT"/>
+    <sec:intercept-url pattern="/api/series/*/metadata" method="PUT" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_EDIT"/>
+    <sec:intercept-url pattern="/api/series/*/metadata/*" method="PUT" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_EDIT"/>
+    <sec:intercept-url pattern="/api/series/*/properties" method="PUT" access="ROLE_ADMIN, ROLE_API_SERIES_PROPERTIES_EDIT"/>
+    <sec:intercept-url pattern="/api/workflows/*" method="PUT" access="ROLE_ADMIN, ROLE_API_WORKFLOW_INSTANCE_EDIT"/>
+    <!-- External API POST Endpoints -->
+    <sec:intercept-url pattern="/api/events" method="POST" access="ROLE_ADMIN, ROLE_API_EVENTS_CREATE"/>
+    <sec:intercept-url pattern="/api/events/*" method="POST" access="ROLE_ADMIN, ROLE_API_EVENTS_EDIT"/>
+    <sec:intercept-url pattern="/api/events/*/acl/*" method="POST" access="ROLE_ADMIN, ROLE_API_EVENTS_ACL_EDIT"/>
+    <sec:intercept-url pattern="/api/groups" method="POST" access="ROLE_ADMIN, ROLE_API_GROUPS_CREATE"/>
+    <sec:intercept-url pattern="/api/groups/*/members" method="POST" access="ROLE_ADMIN, ROLE_API_GROUPS_EDIT"/>
+    <sec:intercept-url pattern="/api/clearIndex" method="POST" access="ROLE_ADMIN"/>
+    <sec:intercept-url pattern="/api/recreateIndex" method="POST" access="ROLE_ADMIN"/>
+    <sec:intercept-url pattern="/api/recreateIndex/*" method="POST" access="ROLE_ADMIN"/>
+    <sec:intercept-url pattern="/api/playlists/*" method="POST" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_CREATE, ROLE_API_PLAYLISTS_EDIT"/>
+    <sec:intercept-url pattern="/api/playlists/*/entries" method="POST" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_CREATE, ROLE_API_PLAYLISTS_EDIT"/>
+    <sec:intercept-url pattern="/api/series" method="POST" access="ROLE_ADMIN, ROLE_API_SERIES_CREATE"/>
+    <sec:intercept-url pattern="/api/security/sign" method="POST" access="ROLE_ADMIN, ROLE_API_SECURITY_EDIT"/>
+    <sec:intercept-url pattern="/api/statistics/data/query" method="POST" access="ROLE_ADMIN, ROLE_API_STATISTICS_VIEW"/>
+    <sec:intercept-url pattern="/api/statistics/data/export.csv" method="POST" access="ROLE_ADMIN, ROLE_API_STATISTICS_VIEW"/>
+    <sec:intercept-url pattern="/api/workflows" method="POST" access="ROLE_ADMIN, ROLE_API_WORKFLOW_INSTANCE_CREATE"/>
+    <!-- External API DELETE Endpoints -->
+    <sec:intercept-url pattern="/api/events/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_EVENTS_DELETE"/>
+    <sec:intercept-url pattern="/api/events/*/acl/*/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_EVENTS_ACL_DELETE"/>
+    <sec:intercept-url pattern="/api/events/*/metadata" method="DELETE" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_DELETE"/>
+    <sec:intercept-url pattern="/api/events/*/metadata/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_EVENTS_METADATA_DELETE"/>
+    <sec:intercept-url pattern="/api/groups/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_GROUPS_DELETE"/>
+    <sec:intercept-url pattern="/api/groups/*/members/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_GROUPS_EDIT"/>
+    <sec:intercept-url pattern="/api/playlists/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_PLAYLISTS_DELETE"/>
+    <sec:intercept-url pattern="/api/series/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_SERIES_DELETE"/>
+    <sec:intercept-url pattern="/api/series/*/metadata" method="DELETE" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_DELETE"/>
+    <sec:intercept-url pattern="/api/series/*/metadata/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_SERIES_METADATA_DELETE"/>
+    <sec:intercept-url pattern="/api/workflows/*" method="DELETE" access="ROLE_ADMIN, ROLE_API_WORKFLOW_INSTANCE_DELETE"/>
+
+    <!-- Enable anonymous access to the admin ui -->
+    <sec:intercept-url pattern="/admin-ng/fonts/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/img/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/lib/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/modules/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/public/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/scripts/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/shared/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ng/styles/**" access="ROLE_ANONYMOUS" />
+
+    <sec:intercept-url pattern="/admin-ui/static/media/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ui/static/js/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/admin-ui/static/css/**" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable anonymous access to the /info/** resource -->
+    <sec:intercept-url pattern="/info/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/i18n/languages.json" method="GET" access="ROLE_ANONYMOUS" />
+
+    <!-- anonymous access to user interface configuration -->
+    <sec:intercept-url pattern="/ui/config/**" access="ROLE_ANONYMOUS" />
+
+   <!-- Paella player 7 -->
+    <sec:intercept-url pattern="/paella7/ui/auth.html" access="ROLE_USER" />
+    <sec:intercept-url pattern="/paella7/ui/**" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable anonymous access to the engage player and the GET endpoints it requires -->
+    <sec:intercept-url pattern="/engage/ui/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/engage/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/engage-player/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/search/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/usertracking/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/usertracking/**" method="PUT" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/static/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/export/**" method="GET" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable access to Opencast Studio -->
+    <!-- Admins can access it, as can users with 'ROLE_STUDIO'. A few static -->
+    <!-- files are also accessible to everyone: 'manifest.json' and source maps -->
+    <!-- are requested without cookies by most browsers. To prevent random -->
+    <!-- errors, we make those files public. They do not contain any secrets. -->
+    <sec:intercept-url pattern="/studio/manifest.json" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/studio/static/js/**" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/studio/**" access="ROLE_ADMIN, ROLE_STUDIO" />
+    <sec:intercept-url pattern="/studio-api/**" access="ROLE_ADMIN, ROLE_STUDIO" />
+
+    <!-- Enable access to Opencast Editor -->
+    <sec:intercept-url pattern="/editor/**" access="ROLE_ADMIN, ROLE_USER" />
+    <sec:intercept-url pattern="/editor-ui/**" access="ROLE_ADMIN, ROLE_USER" />
+
+    <!-- Enable access to the Tobira module -->
+    <sec:intercept-url pattern="/tobira/**" access="ROLE_ADMIN" />
+    <sec:intercept-url pattern="/tobira/version" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable anonymous access to the annotation and the series endpoints -->
+    <sec:intercept-url pattern="/series/**" method="GET" access="ROLE_ANONYMOUS, ROLE_CAPTURE_AGENT" />
+    <sec:intercept-url pattern="/annotation/**" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/annotation/**" method="PUT" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/playlists/**" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable anonymous access to the rss and atom feeds -->
+    <sec:intercept-url pattern="/feeds/**" method="GET" access="ROLE_ANONYMOUS" />
+
+    <!-- Secure the system management URLs for admins only -->
+    <sec:intercept-url pattern="/services/available.*" method="GET" access="ROLE_ADMIN, ROLE_CAPTURE_AGENT" />
+    <sec:intercept-url pattern="/services/**" access="ROLE_ADMIN"/>
+    <sec:intercept-url pattern="/signing/**" access="ROLE_ADMIN" />
+    <sec:intercept-url pattern="/system/**" access="ROLE_ADMIN" />
+    <sec:intercept-url pattern="/config/**" access="ROLE_ADMIN" />
+
+    <!-- Enable capture agent updates and ingest -->
+    <sec:intercept-url pattern="/capture-admin/**" access="ROLE_ADMIN, ROLE_CAPTURE_AGENT" />
+    <sec:intercept-url pattern="/recordings/**" method="GET" access="ROLE_ADMIN, ROLE_CAPTURE_AGENT" />
+    <sec:intercept-url pattern="/ingest/**" access="ROLE_ADMIN, ROLE_CAPTURE_AGENT, ROLE_STUDIO" />
+    <sec:intercept-url pattern="/workflow/definitions.xml" method="GET" access="ROLE_ADMIN, ROLE_CAPTURE_AGENT" />
+
+    <!-- Secure the user management URLs for admins only -->
+    <sec:intercept-url pattern="/users/**" access="ROLE_ADMIN" />
+    <sec:intercept-url pattern="/admin/users.html" access="ROLE_ADMIN" />
+
+    <!-- Enable 2-legged OAuth access ("signed fetch") to the LTI launch servlet -->
+    <sec:intercept-url pattern="/lti/**" access="ROLE_OAUTH_USER" />
+
+    <!-- Enable access to the LTI tools -->
+    <sec:intercept-url pattern="/ltitools/**" access="ROLE_OAUTH_USER" />
+
+    <!-- Enable access to the LTI service -->
+    <sec:intercept-url pattern="/lti-service-gui/**" access="ROLE_OAUTH_USER" />
+    <sec:intercept-url pattern="/lti-service/**" access="ROLE_ADMIN, ROLE_OAUTH_USER" />
+
+    <sec:intercept-url pattern="/transcripts/watson/results*" method="GET" access="ROLE_ANONYMOUS" />
+    <sec:intercept-url pattern="/transcripts/watson/results*" method="POST" access="ROLE_ANONYMOUS" />
+
+    <!-- Enable access to the redirect routes -->
+    <sec:intercept-url pattern="/redirect/**" access="ROLE_ANONYMOUS" />
+
+    <!-- Everything else is for the admin users -->
+    <sec:intercept-url pattern="/admin-ui" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ui/" method="GET" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ui/index.html" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/admin-ng/index.html" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/index.html" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/index.js" access="ROLE_ADMIN, ROLE_ADMIN_UI" />
+    <sec:intercept-url pattern="/**" access="ROLE_ADMIN" />
+
+    <!-- ############################# -->
+    <!-- # LOGIN / LOGOUT MECHANISMS # -->
+    <!-- ############################# -->
+
+    <!-- Uncomment to enable x509 client certificates for identifying clients -->
+    <!-- sec:x509 subject-principal-regex="CN=(.*?)," user-service-ref="userDetailsService" / -->
+
+    <!-- Enable and configure the failure URL for form-based logins -->
+    <!-- CAS Auth: Comment this if using CAS authentication -->
+    <sec:form-login authentication-failure-url="/login.html?error" authentication-success-handler-ref="authSuccessHandler" />
+
+    <!-- (Pre-)Authentication filter chain(s) -->
+    <sec:custom-filter position="BASIC_AUTH_FILTER" ref="authenticationFilters" />
+    <sec:custom-filter position="PRE_AUTH_FILTER" ref="preAuthenticationFilters" />
+
+    <sec:custom-filter ref="asyncTimeoutRedirectFilter" after="EXCEPTION_TRANSLATION_FILTER"/>
+
+    <!-- Opencast is shipping its own implementation of the anonymous filter -->
+    <sec:custom-filter ref="anonymousFilter" position="ANONYMOUS_FILTER" />
+
+    <!-- CAS Auth:  Uncomment this if using CAS authentication
+    <sec:custom-filter position="FORM_LOGIN_FILTER" ref="casFilter" />
+    -->
+
+    <!-- Enables "remember me" functionality -->
+    <sec:remember-me services-ref="rememberMeServices" />
+
+    <!-- Set the request cache -->
+    <sec:request-cache ref="requestCache" />
+
+    <!-- If any URLs are to be exposed to anonymous users, the "sec:anonymous" filter must be present -->
+    <sec:anonymous enabled="false" />
+
+    <!-- CAS Auth:  Uncomment this if using CAS authentication  -->
+    <!-- Enables CAS Single Sign Out
+    <sec:logout logout-success-url="/cas-logout.jsp"/>
+    <sec:custom-filter ref="requestSingleLogoutFilter" before="LOGOUT_FILTER"/>
+    <sec:custom-filter ref="singleLogoutFilter" before="FORM_LOGIN_FILTER"/>
+    -->
+
+    <!-- Enables log out -->
+    <sec:logout success-handler-ref="logoutSuccessHandler" />
+
+    <!-- JWT single log out
+         Please specify the URL to return to after logging out. Comment out the logoutSuccessHandler above.
+    <sec:logout logout-success-url="https://auth.example.org/sign_out?rd=http://www.opencast.org" />
+    -->
+
+  </sec:http>
+
+  <bean id="rememberMeServices" class="org.opencastproject.kernel.security.SystemTokenBasedRememberMeService">
+    <property name="userDetailsService" ref="userDetailsService"/>
+    <!-- All following settings are optional -->
+    <property name="tokenValiditySeconds" value="1209600"/>
+    <property name="cookieName" value="oc-remember-me"/>
+    <!-- The following key will be augmented by system properties if left at the default value.
+         Thus, leaving this untouched is okay. This key must be equal to the key passed to
+         rememberMeAuthenticationProvider (s. below). To generate cookies which are valid for the whole cluster,
+         set this manually. The key won't be augmented/randomized if you use something different than 'opencast'. -->
+    <property name="key" value="opencast"/>
+  </bean>
+
+  <bean id="rememberMeAuthenticationProvider"
+        class="org.opencastproject.kernel.security.SystemTokenBasedRememberMeAuthenticationProvider">
+    <!-- This key must be equal to the key passed to rememberMeServices (s. above) -->
+    <property name="key" value="opencast"/>
+  </bean>
+
+  <!-- ############################# -->
+  <!-- # Authentication Filters    # -->
+  <!-- ############################# -->
+
+  <bean id="authenticationFilters" class="org.springframework.web.filter.CompositeFilter">
+    <property name="filters">
+      <list>
+        <!-- Digest auth is used by capture agents and is used to enable transparent clustering of services -->
+        <!-- ATTENTION! Do not deactivate the digest filter, otherwise the distributed setup would not work -->
+        <ref bean="digestFilter" />
+
+        <!-- Basic authentication  -->
+        <ref bean="basicAuthenticationFilter" />
+
+        <!-- 2-legged OAuth is used by trusted 3rd party applications, including LTI. -->
+        <!-- Uncomment the line below to support LTI or other OAuth clients.          -->
+        <ref bean="oauthProtectedResourceFilter" />
+      </list>
+    </property>
+  </bean>
+
+  <!-- ################################ -->
+  <!-- # Pre-Aauthentication Filters    # -->
+  <!-- ################################ -->
+
+  <bean id="preAuthenticationFilters" class="org.springframework.web.filter.CompositeFilter">
+    <property name="filters">
+      <list>
+        <!-- Uncomment the line below to support Shibboleth. -->
+        <!-- <ref bean="shibbolethHeaderFilter" /> -->
+
+        <!-- Uncomment the line below to support JWT.
+        <ref bean="jwtHeaderFilter" /> -->
+        <!-- Additionally/alternatively uncomment this to support passing a JWT in a URL parameter.
+        <ref bean="jwtRequestParameterFilter" /> -->
+      </list>
+    </property>
+  </bean>
+
+  <!-- ########################################### -->
+  <!-- # Custom ajax timeout Filter Definition   # -->
+  <!-- ########################################### -->
+
+  <bean id="asyncTimeoutRedirectFilter" class="org.opencastproject.kernel.security.AsyncTimeoutRedirectFilter" />
+
+  <!-- ######################################## -->
+  <!-- # Custom Anonymous Filter Definition   # -->
+  <!-- ######################################## -->
+
+  <bean id="anonymousFilter" class="org.opencastproject.kernel.security.TrustedAnonymousAuthenticationFilter">
+    <property name="userAttribute" ref="anonymousUserAttributes" />
+    <property name="key" value="anonymousKey" />
+  </bean>
+
+  <bean id="anonymousUserAttributes" class="org.springframework.security.core.userdetails.memory.UserAttribute">
+    <property name="authoritiesAsString" value="ROLE_ANONYMOUS"/>
+    <property name="password" value="empty"/>
+  </bean>
+
+  <!-- ######################################## -->
+  <!-- # Authentication Entry and Exit Points # -->
+  <!-- ######################################## -->
+
+  <!-- Differentiates between "normal" user requests and those requesting digest auth -->
+  <bean id="opencastEntryPoint" class="org.opencastproject.kernel.security.DelegatingAuthenticationEntryPoint">
+    <!-- CAS Auth:  Comment this if using CAS authentication -->
+    <property name="userEntryPoint" ref="userEntryPoint" />
+    <!-- CAS Auth:  Uncomment this if using CAS authentication -->
+    <!-- <property name="userEntryPoint" ref="casEntryPoint" /> -->
+    <property name="digestAuthenticationEntryPoint" ref="digestEntryPoint" />
+    <property name="basicAuthenticationEntryPoint" ref="basicEntryPoint" />
+  </bean>
+
+  <!-- Redirects unauthenticated requests to the login form -->
+  <bean id="userEntryPoint" class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
+    <property name="loginFormUrl" value="/login.html" />
+  </bean>
+
+  <!-- Redirect unauthenticated requests to custom login url with configurable redirect query parameter
+       Example: http://localhost/Shibboleth.sso/Login?target=<RELATIVE_REQUEST_URL> -->
+  <!--bean id="userEntryPoint" class="org.opencastproject.kernel.security.RedirectQueryParamAuthenticationEntryPoint">
+    <constructor-arg index="0" value="/Shibboleth.sso/Login" />
+    <constructor-arg index="1" value="target" />
+  </bean-->
+
+  <!-- Returns a 401 request for authentication via digest auth -->
+  <bean id="digestEntryPoint" class="org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint">
+    <property name="realmName" value="Opencast" />
+    <property name="key" value="opencast" />
+    <property name="nonceValiditySeconds" value="300" />
+  </bean>
+
+  <bean id="basicEntryPoint" class="org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint">
+    <property name="realmName" value="Opencast"/>
+  </bean>
+
+  <bean id="authSuccessHandler" class="org.opencastproject.kernel.security.AuthenticationSuccessHandler">
+    <property name="securityService" ref="securityService" />
+    <property name="welcomePages">
+      <map>
+        <entry key="ROLE_ADMIN" value="/index.html" />
+        <entry key="ROLE_ADMIN_UI" value="/index.html" />
+        <entry key="*" value="/engage/ui/index.html" /> <!-- Any role not listed explicitly will redirect here -->
+      </map>
+    </property>
+  </bean>
+
+  <bean id="logoutSuccessHandler" class="org.opencastproject.kernel.security.LogoutSuccessHandler">
+    <property name="userDirectoryService" ref="userDirectoryService" />
+    <!-- Shibboleth log out
+    <property name="defaultTargetUrl" value="/Shibboleth.sso/Logout?return=www.opencast.org"/>
+    -->
+  </bean>
+
+  <!-- ################# -->
+  <!-- # Digest Filter # -->
+  <!-- ################# -->
+
+  <!-- Handles the details of the digest authentication dance -->
+  <bean id="digestFilter" class="org.springframework.security.web.authentication.www.DigestAuthenticationFilter">
+    <!--  Use only the in-memory users, as these have passwords that are not hashed -->
+    <property name="userDetailsService" ref="userDetailsService" />
+    <property name="authenticationEntryPoint" ref="digestEntryPoint" />
+    <property name="createAuthenticatedToken" value="true" />
+    <property name="userCache">
+      <bean class="org.springframework.security.core.userdetails.cache.NullUserCache" />
+    </property>
+  </bean>
+
+  <!-- ############################### -->
+  <!-- # Basic Authentication Filter # -->
+  <!-- ############################### -->
+
+  <!-- Handles the details of the basic authentication dance -->
+  <bean id="basicAuthenticationFilter" class="org.springframework.security.web.authentication.www.BasicAuthenticationFilter">
+    <property name="authenticationManager" ref="authenticationManager"/>
+    <property name="authenticationEntryPoint" ref="basicEntryPoint"/>
+  </bean>
+
+  <!-- ####################### -->
+  <!-- # OAuth (LTI) Support # -->
+  <!-- ####################### -->
+
+  <bean name="oauthProtectedResourceFilter" class="org.opencastproject.kernel.security.LtiProcessingFilter">
+    <property name="consumerDetailsService" ref="oAuthConsumerDetailsService" />
+    <property name="tokenServices">
+      <bean class="org.springframework.security.oauth.provider.token.InMemoryProviderTokenServices" />
+    </property>
+    <property name="nonceServices">
+      <bean class="org.springframework.security.oauth.provider.nonce.InMemoryNonceServices" />
+    </property>
+    <property name="authHandler" ref="ltiLaunchAuthenticationHandler" />
+  </bean>
+
+  <!-- ############### -->
+  <!-- # CAS Support # -->
+  <!-- ############### -->
+  <!--
+  <bean id="casFilter"
+    class="org.springframework.security.cas.web.CasAuthenticationFilter">
+    <property name="authenticationManager" ref="authenticationManager"/>
+    <property name="authenticationSuccessHandler" ref="authSuccessHandler" />
+    <property name="serviceProperties" ref="serviceProperties" />
+    <property name="authenticationDetailsSource">
+      <bean class="org.springframework.security.cas.web.authentication.ServiceAuthenticationDetailsSource"/>
+    </property>
+  </bean>
+
+  <bean id="casEntryPoint"
+    class="org.springframework.security.cas.web.CasAuthenticationEntryPoint">
+    <property name="loginUrl" value="https://auth-test.berkeley.edu/cas/login"/>
+    <property name="serviceProperties" ref="serviceProperties"/>
+  </bean>
+
+  <bean id="serviceProperties"
+    class="org.springframework.security.cas.ServiceProperties">
+    <property name="service" value="https://localhost/j_spring_cas_security_check"/>
+    <property name="sendRenew" value="false"/>
+  </bean>
+
+  <bean id="casAuthenticationProvider"
+    class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
+    <property name="serviceProperties" ref="serviceProperties" />
+    <property name="authenticationUserDetailsService">
+      <bean class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
+          <constructor-arg ref="userDetailsService" />
+      </bean>
+    </property>
+    <property name="ticketValidator">
+      <bean class="org.jasig.cas.client.validation.Cas20ServiceTicketValidator">
+        <constructor-arg index="0" value="https://auth-test.berkeley.edu/cas" />
+      </bean>
+    </property>
+    <property name="key" value="cas"/>
+  </bean>
+
+  <bean id="singleLogoutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter"/>
+
+  <bean id="requestSingleLogoutFilter" class="org.springframework.security.web.authentication.logout.LogoutFilter">
+    <constructor-arg value="https://auth-test.berkeley.edu/cas/logout"/>
+    <constructor-arg>
+      <bean class= "org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler"/>
+    </constructor-arg>
+    <property name="filterProcessesUrl" value="https://localhost/j_spring_security_logout"/>
+  </bean>
+  -->
+
+  <!-- ###################### -->
+  <!-- # Shibboleth Support # -->
+  <!-- ###################### -->
+
+  <!-- General Shibboleth header extration filter
+  <bean id="shibbolethHeaderFilter"
+        class="org.opencastproject.security.shibboleth.ShibbolethRequestHeaderAuthenticationFilter">
+    <property name="principalRequestHeader" value="<this need to be configured>"/>
+    <property name="authenticationManager" ref="authenticationManager" />
+    <property name="userDetailsService" ref="userDetailsService" />
+    <property name="userDirectoryService" ref="userDirectoryService" />
+    <property name="shibbolethLoginHandler" ref="aaiLoginHandler" />
+    <property name="exceptionIfHeaderMissing" value="false" />
+  </bean>-->
+
+  <!-- AAI header extractor and user generator
+  <bean id="aaiLoginHandler" class="org.opencastproject.security.aai.ConfigurableLoginHandler">
+    <property name="securityService" ref="securityService" />
+    <property name="userReferenceProvider" ref="userReferenceProvider" />
+  </bean>-->
+
+  <!-- Dynamic AAI Loginhandler
+  <bean id="aaiLoginHandler" class="org.opencastproject.security.aai.DynamicLoginHandler">
+    <property name="securityService" ref="securityService" />
+    <property name="userReferenceProvider" ref="userReferenceProvider" />
+    <property name="attributeMapper" ref="attributeMapper" />
+  </bean>
+
+  <bean id="attributeMapper" class="org.opencastproject.security.aai.api.AttributeMapper">
+    <property name="useHeader" value="true" />
+    <property name="multiValueDelimiter" value=";" />
+    <property name="attributeMap" ref="attributeMap" />
+    <property name="aaiAttributes" ref="aaiAttributes" />
+  </bean>
+
+  <util:list id="aaiAttributes" value-type="java.lang.String">
+    <value>sn</value>
+    <value>givenName</value>
+    <value>mail</value>
+    <value>homeOrganization</value>
+    <value>eduPersonEntitlement</value>
+    <value>eduPersonPrincipalName</value>
+    <value>homeOrganization</value>
+  </util:list>
+
+  <util:map id="attributeMap" map-class="java.util.HashMap">
+    <entry key="roles" value-ref="roleMapping" />
+    <entry key="displayName" value-ref="displayNameMapping" />
+    <entry key="mail" value-ref="mailMapping" />
+  </util:map>
+
+  <util:list id="displayNameMapping" value-type="java.lang.String">
+    <value>['givenName'][0] + ' ' + ['sn'][0]</value>
+  </util:list>
+
+  <util:list id="mailMapping" value-type="java.lang.String">
+    <value>['mail'][0]</value>
+  </util:list>
+
+  <util:list id="roleMapping" value-type="java.lang.String">
+    <value>'ROLE_AAI_USER'</value>
+    <value>'ROLE_AAI_USER_' + ['eduPersonPrincipalName']</value>
+    <value>('ROLE_AAI_OWNER_' + ['eduPersonPrincipalName']).replaceAll("[^a-zA-Z0-9]","_").toUpperCase()</value>
+    <value>['homeOrganization'] != null ? 'ROLE_AAI_ORG_' + ['homeOrganization'] + '_MEMBER' : null</value>
+    <value>['eduPersonEntitlement'].contains('urn:mace:opencast.org:permission:shibboleth:opencast_admin') ? 'ROLE_ADMIN' : null</value>
+    <value>['eduPersonPrincipalName'].contains('john.doe@example.org') ? 'ROLE_ADMIN' : null</value>
+    <value>['eduPersonScopedAffiliation'].contains('faculty@example.org') ? 'ROLE_GROUP_AAI_TRAINER' : null</value>
+  </util:list>
+
+   -->
+
+  <bean id="preauthAuthProvider"
+        class="org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider">
+    <property name="preAuthenticatedUserDetailsService">
+      <bean id="userDetailsServiceWrapper" class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
+        <property name="userDetailsService" ref="userDetailsService"/>
+      </bean>
+    </property>
+  </bean>
+
+  <!-- ################ -->
+  <!-- # JWT Support # -->
+  <!-- ################ -->
+
+  <!-- General JWT header extraction filter
+  <bean id="jwtHeaderFilter" class="org.opencastproject.security.jwt.JWTRequestHeaderAuthenticationFilter">
+    <property name="principalRequestHeader" value="Authorization"/>
+    <property name="principalPrefix" value="Bearer "/>
+    <property name="authenticationManager" ref="authenticationManager" />
+    <property name="loginHandler" ref="jwtLoginHandler" />
+    <property name="exceptionIfHeaderMissing" value="false" />
+  </bean>-->
+
+  <!-- General JWT request parameter extraction filter
+  <bean id="jwtRequestParameterFilter" class="org.opencastproject.security.jwt.JWTRequestParameterAuthenticationFilter">
+    <property name="parameterName" value="jwt" />
+    <property name="authenticationManager" ref="authenticationManager" />
+    <property name="loginHandler" ref="jwtLoginHandler" />
+    <property name="exceptionIfParameterMissing" value="false" />
+  </bean>-->
+
+  <!-- JWT login handler
+  <bean id="jwtLoginHandler" class="org.opencastproject.security.jwt.DynamicLoginHandler">
+    <property name="userDetailsService" ref="userDetailsService" />
+    <property name="userDirectoryService" ref="userDirectoryService" />
+    <property name="securityService" ref="securityService" />
+    <property name="userReferenceProvider" ref="userReferenceProvider" />
+    <property name="jwksUrl" value="https://auth.example.org/.well-known/jwks.json" />
+    <property name="jwksCacheExpiresIn" value="1440" />
+    <property name="secret" value="***" />
+    <property name="expectedAlgorithms" ref="jwtExpectedAlgorithms" />
+    <property name="claimConstraints" ref="jwtClaimConstraints" />
+    <property name="usernameMapping" value="['username'].asString()" />
+    <property name="nameMapping" value="['name'].asString()" />
+    <property name="emailMapping" value="['email'].asString()" />
+    <property name="roleMappings" ref="jwtRoleMappings" />
+    <property name="jwtCacheSize" value="500" />
+    <property name="jwtCacheExpiresIn" value="60" />
+  </bean>-->
+
+  <!-- The signing algorithms expected for the JWT signature
+  <util:list id="jwtExpectedAlgorithms" value-type="java.lang.String">
+    <value>RS256</value>
+  </util:list>-->
+
+  <!-- The claim constraints that are expected to be fulfilled by the JWT
+       If you are using JWTs for OpenID Connect, see the specification for claims that must be validated:
+       https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
+  <util:list id="jwtClaimConstraints" value-type="java.lang.String">
+    <value>containsKey('iss')</value>
+    <value>containsKey('aud')</value>
+    <value>containsKey('username')</value>
+    <value>containsKey('name')</value>
+    <value>containsKey('email')</value>
+    <value>containsKey('domain')</value>
+    <value>containsKey('affiliation')</value>
+    <value>['iss'].asString() eq 'https://auth.example.org'</value>
+    <value>['aud'].asString() eq 'client-id'</value>
+    <value>['username'].asString() matches '.*@example\.org'</value>
+    <value>['domain'].asString() eq 'example.org'</value>
+    <value>['affiliation'].asList(T(String)).contains('faculty@example.org')</value>
+  </util:list>-->
+
+  <!-- The mapping from JWT claims to Opencast roles
+  <util:list id="jwtRoleMappings" value-type="java.lang.String">
+    <value>'ROLE_JWT_USER'</value>
+    <value>'ROLE_JWT_USER_' + ['username'].asString()</value>
+    <value>('ROLE_JWT_OWNER_' + ['username'].asString()).replaceAll("[^a-zA-Z0-9]","_").toUpperCase()</value>
+    <value>['domain'] != null ? 'ROLE_JWT_ORG_' + ['domain'].asString() + '_MEMBER' : null</value>
+    <value>['username'].asString() eq ('j_doe01@example.org') ? 'ROLE_ADMIN' : null</value>
+    <value>['affiliation'].asList(T(String)).contains('faculty@example.org') ? 'ROLE_GROUP_JWT_TRAINER' : null</value>
+  </util:list>-->
+
+  <!-- ################ -->
+  <!-- # LDAP Support # -->
+  <!-- ################ -->
+
+  <!--
+  <bean id="contextSource"
+    class="org.springframework.security.ldap.DefaultSpringSecurityContextSource">
+    < ! - - URL of the LDAP server - - >
+    <constructor-arg value="ldap://myldapserver:myport" />
+    < ! - - "Distinguished name" for the unprivileged user - - >
+    < ! - - This user is merely to perform searches in the LDAP to find the users to login - - >
+    <property name="userDn" value="uid=user-id,dc=example,dc=com" />
+    < ! - - Password of the user above - - >
+    <property name="password" value="mypassword" />
+  </bean>
+  -->
+
+  <!--
+  <bean id="userDetailsMapper" class="org.opencastproject.security.ldap.OpencastUserDetailsMapper">
+    <constructor-arg ref="userDetailsService" />
+  </bean>
+  -->
+
+  <!--
+  <bean id="ldapAuthProvider"
+    class="org.springframework.security.ldap.authentication.LdapAuthenticationProvider">
+    <constructor-arg>
+      <bean
+        class="org.springframework.security.ldap.authentication.BindAuthenticator">
+        <constructor-arg ref="contextSource" />
+        <property name="userDnPatterns">
+          <list>
+            < ! - - Dn patterns to search for valid users. Multiple "<value>" tags are allowed - - >
+            <value>uid={0},dc=example,dc=com</value>
+          </list>
+        </property>
+        < ! - - If your user IDs are not part of the user Dn's, you can use a search filter to find them - - >
+        < ! - - This property can be used together with the "userDnPatterns" above - - >
+        < ! - -
+        <property name="userSearch">
+          <bean name="filterUserSearch" class="org.springframework.security.ldap.search.FilterBasedLdapUserSearch">
+            < ! - - Base Dn from where the users will be searched for - - >
+            <constructor-arg index="0" value="ou=GroupName,dc=my-institution,dc=country" />
+            < ! - - Filter to located valid users. Use {0} as a placeholder for the login name - - >
+            <constructor-arg index="1" value="(uid={0})" />
+            <constructor-arg ref="contextSource" />
+          </bean>
+        </property>
+        - - >
+      </bean>
+    </constructor-arg>
+    < ! - - Retrieve user and attributes from opencasts user details service - - >
+    <property name="userDetailsContextMapper" ref="userDetailsMapper"/>
+    < ! - - Defines how the user attributes are converted to authorities (roles) - - >
+    < ! - - Output is ignored if used together with userDetailsMapper - - >
+    < ! - - constructor-arg ref="authoritiesPopulator" /- - >
+  </bean>
+  -->
+
+  <!-- #################### -->
+  <!-- # OSGI Integration # -->
+  <!-- #################### -->
+
+  <!-- Obtain services from the OSGI service registry -->
+  <osgi:reference id="userDetailsService" cardinality="1..1"
+                  interface="org.springframework.security.core.userdetails.UserDetailsService" />
+
+  <osgi:reference id="securityService" cardinality="1..1"
+                  interface="org.opencastproject.security.api.SecurityService" />
+
+  <!-- Uncomment to enable external users e.g. used together with shibboleth and JWT -->
+  <!-- <osgi:reference id="userReferenceProvider" cardinality="1..1"
+                  interface="org.opencastproject.userdirectory.api.UserReferenceProvider"  /> -->
+
+  <osgi:reference id="userDirectoryService" cardinality="1..1"
+                  interface="org.opencastproject.security.api.UserDirectoryService" />
+
+  <!-- Uncomment this as an alternative to the userDetailsMapper -->
+  <!-- Make sure you provide the same instanceId you used in org.opencastproject.userdirectory.ldap-….cfg -->
+  <!--
+  <osgi:reference id="authoritiesPopulator" cardinality="1..1"
+                  interface="org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator"
+                  filter="(instanceId=theId)"/>
+  -->
+
+  <osgi:reference id="oAuthConsumerDetailsService" cardinality="1..1"
+                  interface="org.springframework.security.oauth.provider.ConsumerDetailsService" />
+
+  <osgi:reference id="ltiLaunchAuthenticationHandler" cardinality="1..1"
+                  interface="org.springframework.security.oauth.provider.OAuthAuthenticationHandler" />
+
+
+  <!-- ############################# -->
+  <!-- # Spring Security Internals # -->
+  <!-- ############################# -->
+
+  <bean id="passwordEncoder" class="org.opencastproject.kernel.security.CustomPasswordEncoder" />
+
+  <sec:authentication-manager alias="authenticationManager">
+    <sec:authentication-provider ref="rememberMeAuthenticationProvider"/>
+    <!-- CAS Auth: Uncomment this if using CAS authentication -->
+    <!-- <sec:authentication-provider ref="casAuthenticationProvider" /> -->
+    <!-- Uncomment this if using Shibboleth or JWT authentication -->
+    <!-- <sec:authentication-provider ref="preauthAuthProvider" /> -->
+    <!-- Uncomment the following line if using LDAP -->
+    <!-- <sec:authentication-provider ref="ldapAuthProvider" /> -->
+    <sec:authentication-provider user-service-ref="userDetailsService">
+      <!-- The JPA user directory stores bcrypt hashed passwords, but still works with legacy md5 hashes -->
+      <sec:password-encoder ref="passwordEncoder">
+        <!-- This salt is used only for decoding legacy MD5 hased passwords -->
+        <sec:salt-source user-property="username" />
+      </sec:password-encoder>
+    </sec:authentication-provider>
+  </sec:authentication-manager>
+
+  <!-- Do not use a request cache -->
+  <bean id="requestCache" class="org.springframework.security.web.savedrequest.NullRequestCache" />
+
+</beans>
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6093ca043e3b076211663a040f7866a953c4e37e
--- /dev/null
+++ b/.github/workflows/run-tests.yml
@@ -0,0 +1,67 @@
+name: Run plugin tests
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  tests:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup PHP with Composer
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.2'
+          tools: composer:v2
+
+      - name: Install node
+        uses: actions/setup-node@v4
+        with:
+          node-version: 22.x
+
+      - name: Build Opencast plugin
+        run: npm run build-dev
+
+      - name: Start containers
+        working-directory: ./.github/docker
+        run: docker compose up --wait
+
+      # Needed as studip directory has root owner and chown on whole directory takes too long
+      - name: Allow read, write and execute for all users in data and plugin directory
+        working-directory: ./.github/docker
+        run: docker compose exec studip chmod -R 777 data public/plugins_packages
+
+      # Auto migrate in studip image seems to be broken
+      - name: Migrate studip
+        working-directory: ./.github/docker
+        run: docker compose exec studip php ./cli/studip migrate
+
+      - name: Register plugin
+        working-directory: ./.github/docker
+        run: docker compose exec studip php ./cli/studip plugin:register public/plugins_packages/elan-ev/OpencastV3
+
+      - name: Activate plugin
+        working-directory: ./.github/docker
+        run: docker compose exec studip php ./cli/studip plugin:activate OpencastV3
+
+      - name: Configure plugin
+        working-directory: ./.github/docker
+        run: docker compose exec -T studip_db mysql -u studip_user --password=studip_password studip_db < ./oc.sql
+
+      - name: Trigger playlists migration to Opencast
+        run: curl http://localhost/plugins.php/opencastv3/api/migrate_playlists -u root@studip:testing
+
+      - name: Run tests
+        run: npm run tests
+
+      - name: Stop containers
+        working-directory: ./.github/docker
+        if: always()
+        run: docker compose down
diff --git a/codeception.yml b/codeception.yml
index 3cec6442d169a4f79354f476c36190b4e32e98c3..b709ae2860f7c46b1f3fc5a298985e07839e84fe 100644
--- a/codeception.yml
+++ b/codeception.yml
@@ -6,8 +6,20 @@ suites:
         modules:
             enabled:
                 - REST:
-                    url: https://studip.me/testip/plugins.php/opencast/api
+                    url: '%STUDIP_REST_URL%'
                     depends: PhpBrowser
+                - \Helper\Api:
+                    opencast_rest_url: '%OPENCAST_REST_URL%'
+                    api_token: '%API_TOKEN%'
+                    opencast_admin_user: '%OPENCAST_ADMIN_NAME%'
+                    opencast_admin_password: '%OPENCAST_ADMIN_PASSWORD%'
+                    dozent_name: '%DOZENT_NAME%'
+                    dozent_password: '%DOZENT_PASSWORD%'
+                    course_student: '%COURSE_STUDENT%'
+                    author_name: '%AUTHOR_NAME%'
+                    author_password: '%AUTHOR_PASSWORD%'
+                    config_id: '%CONFIG_ID%'
+                    course_id: '%COURSE_ID%'
         step_decorators:
             - \Codeception\Step\AsJson
 
@@ -19,4 +31,8 @@ paths:
 
 settings:
     shuffle: false
-    lint: true
\ No newline at end of file
+    lint: true
+
+params:
+    - env
+    - tests/.env
diff --git a/composer.json b/composer.json
index 29139afc86939f35c322578b1cab6224a8f62fae..8d40b202dd4a875160dd206fdb11aacc1bae57ee 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
         "codeception/codeception": "^4.2",
         "codeception/module-phpbrowser": "^1.0.0",
         "codeception/module-asserts": "^1.0.0",
-        "codeception/module-rest": "^1.0.0"
+        "codeception/module-rest": "^1.0.0",
+        "vlucas/phpdotenv": "^5.6"
     }
 }
diff --git a/composer.lock b/composer.lock
index c4fec537b4ca24c2f4d12f04490582d847f4f85a..85f64fc6bfdf0b6fd802cd32cdf6a412d023325c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "fa92a4107e50c23dc29961cd10970822",
+    "content-hash": "24cbffd2270b78d8e4431b02761bc4f3",
     "packages": [
         {
             "name": "elan-ev/opencast-api",
@@ -1261,6 +1261,68 @@
             ],
             "time": "2022-12-30T00:15:36+00:00"
         },
+        {
+            "name": "graham-campbell/result-type",
+            "version": "v1.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/GrahamCampbell/Result-Type.git",
+                "reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
+                "reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GrahamCampbell\\ResultType\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "An Implementation Of The Result Type",
+            "keywords": [
+                "Graham Campbell",
+                "GrahamCampbell",
+                "Result Type",
+                "Result-Type",
+                "result"
+            ],
+            "support": {
+                "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+                "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-07-20T21:45:45+00:00"
+        },
         {
             "name": "justinrainbow/json-schema",
             "version": "v5.2.13",
@@ -1509,6 +1571,81 @@
             },
             "time": "2022-02-21T01:04:05+00:00"
         },
+        {
+            "name": "phpoption/phpoption",
+            "version": "1.9.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/schmittjoh/php-option.git",
+                "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54",
+                "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "1.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpOption\\": "src/PhpOption/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Johannes M. Schmitt",
+                    "email": "schmittjoh@gmail.com",
+                    "homepage": "https://github.com/schmittjoh"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "Option Type for PHP",
+            "keywords": [
+                "language",
+                "option",
+                "php",
+                "type"
+            ],
+            "support": {
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.9.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-07-20T21:41:07+00:00"
+        },
         {
             "name": "phpunit/php-code-coverage",
             "version": "7.0.17",
@@ -4105,6 +4242,90 @@
                 }
             ],
             "time": "2024-03-03T12:36:25+00:00"
+        },
+        {
+            "name": "vlucas/phpdotenv",
+            "version": "v5.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/vlucas/phpdotenv.git",
+                "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2",
+                "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2",
+                "shasum": ""
+            },
+            "require": {
+                "ext-pcre": "*",
+                "graham-campbell/result-type": "^1.1.3",
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.3",
+                "symfony/polyfill-ctype": "^1.24",
+                "symfony/polyfill-mbstring": "^1.24",
+                "symfony/polyfill-php80": "^1.24"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-filter": "*",
+                "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+            },
+            "suggest": {
+                "ext-filter": "Required to use the boolean validator."
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "5.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dotenv\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Vance Lucas",
+                    "email": "vance@vancelucas.com",
+                    "homepage": "https://github.com/vlucas"
+                }
+            ],
+            "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
+            "keywords": [
+                "dotenv",
+                "env",
+                "environment"
+            ],
+            "support": {
+                "issues": "https://github.com/vlucas/phpdotenv/issues",
+                "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-07-20T21:52:34+00:00"
         }
     ],
     "aliases": [],
@@ -4117,5 +4338,5 @@
     "platform-overrides": {
         "php": "7.2.5"
     },
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.3.0"
 }
diff --git a/courseware/devbuild.sh b/courseware/devbuild.sh
new file mode 100644
index 0000000000000000000000000000000000000000..9364c4968b52fd6c6b68ab0ca7dc62be81969b8a
--- /dev/null
+++ b/courseware/devbuild.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+
+rm -rf ./node_modules
+npm run build-dev
\ No newline at end of file
diff --git a/courseware/package.json b/courseware/package.json
index 9c75ad4e15eb42cd26b2b60028365d32e22a76ec..69e3208625e657460fc17a57c6a27d415b2b1609 100644
--- a/courseware/package.json
+++ b/courseware/package.json
@@ -23,6 +23,7 @@
     "scripts": {
         "prebuild": "npm install",
         "build": "webpack --mode production --config ./webpack-courseware.config.js",
+        "build-dev": "webpack --mode development --config ./webpack-courseware.config.js",
         "devcw": "webpack --mode development --watch --config ./webpack-courseware.config.js",
         "dev": "webpack --mode development --watch --config ./webpack-courseware.config.js"
     },
diff --git a/lib/Models/PlaylistSeminars.php b/lib/Models/PlaylistSeminars.php
index 28e2abcc87a74f1a71c8333c42cbdca9c19c5484..f567888773cb067924638b75dd19c8ae054bac10 100644
--- a/lib/Models/PlaylistSeminars.php
+++ b/lib/Models/PlaylistSeminars.php
@@ -219,7 +219,7 @@ class PlaylistSeminars extends \SimpleORMap
             $course = \Course::find($course_id);
 
             // Check if user has access to this seminar
-            if ($perm->have_studip_perm($course_id, 'user', $user_id)) {
+            if ($perm->have_studip_perm('user', $course_id, $user_id)) {
                 $lecturers = [];
                 $lecturers_obj = $course->getMembersWithStatus('dozent');
                 foreach ($lecturers_obj as $lecturer) {
diff --git a/lib/Models/Playlists.php b/lib/Models/Playlists.php
index e53501a7c6423b14332d6d9df3f760304b370897..2fa6fed74345c9c0d1515b56b995e6e92514e238 100644
--- a/lib/Models/Playlists.php
+++ b/lib/Models/Playlists.php
@@ -195,7 +195,7 @@ class Playlists extends UPMap
                     $course = \Course::find($filter['value']);
 
                     // check, if user has access to this seminar
-                    if (!empty($course) && $perm->have_studip_perm($course->id, 'user')) {
+                    if (!empty($course) && $perm->have_studip_perm('user', $course->id)) {
                         $courses[$course->id] = [
                             'id' => $course->id,
                             'compare' => $filter['compare']
diff --git a/lib/Models/Videos.php b/lib/Models/Videos.php
index 1422f7fee251f6c21a7defaa929eabad17871ba2..87b899aa218d113902f9b41cc48dfe62390075d3 100644
--- a/lib/Models/Videos.php
+++ b/lib/Models/Videos.php
@@ -281,7 +281,7 @@ class Videos extends UPMap
                     $course = \Course::find($filter['value']);
 
                     // check, if user has access to this seminar
-                    if (!empty($course) && $perm->have_studip_perm($course->id, 'user')) {
+                    if (!empty($course) && $perm->have_studip_perm('user', $course->id)) {
                         $course_ids[$course->id] = [
                             'id' => $course->id,
                             'compare' => $filter['compare']
diff --git a/lib/Routes/Course/CourseListForPlaylistVideos.php b/lib/Routes/Course/CourseListForPlaylistVideos.php
index 60d7dbd16dabf0d70e136cfc4ef90e7808648f7d..b68bde294577b9ddd55be9bba3f1cd6e3d50ce8a 100644
--- a/lib/Routes/Course/CourseListForPlaylistVideos.php
+++ b/lib/Routes/Course/CourseListForPlaylistVideos.php
@@ -33,7 +33,7 @@ class CourseListForPlaylistVideos extends OpencastController
         // check if playlist is connected to the passed course and user is part of that course as well
         $permission = false;
         if ($params['cid']) {
-            if ($perm->have_studip_perm($params['cid'], 'user')) {
+            if ($perm->have_studip_perm('user', $params['cid'])) {
                 $permission = true;
             }
         }
diff --git a/lib/Routes/Course/CourseListPlaylist.php b/lib/Routes/Course/CourseListPlaylist.php
index a605e3c8baa93abf36752e339e6d3d62181a9896..1708d0d439ba492caebd34f621a07af5601595e8 100644
--- a/lib/Routes/Course/CourseListPlaylist.php
+++ b/lib/Routes/Course/CourseListPlaylist.php
@@ -33,7 +33,7 @@ class CourseListPlaylist extends OpencastController
         }
 
         // check if user has access to this seminar
-        if (!$perm->have_studip_perm($course_id, 'user')) {
+        if (!$perm->have_studip_perm('user', $course_id)) {
             throw new \AccessDeniedException();
         }
 
diff --git a/lib/Routes/Playlist/PlaylistVideoList.php b/lib/Routes/Playlist/PlaylistVideoList.php
index cb578e25656d91e8ec5ce0568264d27d7ce613ea..64b7b1d38602670d627dc093d6b1ed6f4f31ee33 100644
--- a/lib/Routes/Playlist/PlaylistVideoList.php
+++ b/lib/Routes/Playlist/PlaylistVideoList.php
@@ -33,7 +33,7 @@ class PlaylistVideoList extends OpencastController
         // check if playlist is connected to the passed course and user is part of that course as well
         $permission = false;
         if ($params['cid']) {
-            if ($perm->have_studip_perm($params['cid'], 'user')) {
+            if ($perm->have_studip_perm('user', $params['cid'])) {
                 $permission = true;
             }
         }
diff --git a/lib/Routes/Tags/TagListForPlaylistVideos.php b/lib/Routes/Tags/TagListForPlaylistVideos.php
index b21790f2a74708f7326a70c5988016c299b6296f..763fb082df20911008d7083299e0083949c77d6f 100644
--- a/lib/Routes/Tags/TagListForPlaylistVideos.php
+++ b/lib/Routes/Tags/TagListForPlaylistVideos.php
@@ -30,7 +30,7 @@ class TagListForPlaylistVideos extends OpencastController
         // check if playlist is connected to the passed course and user is part of that course as well
         $permission = false;
         if ($params['cid']) {
-            if ($perm->have_studip_perm($params['cid'], 'user')) {
+            if ($perm->have_studip_perm('user', $params['cid'])) {
                 $permission = true;
             }
         }
diff --git a/lib/Routes/Video/VideoAdd.php b/lib/Routes/Video/VideoAdd.php
index 9133e309fd1c6cd678de771012eb809d4ee2140c..d3fb4a707f6bcdbbc8e1ef8fd5d62e75e3ce1c80 100644
--- a/lib/Routes/Video/VideoAdd.php
+++ b/lib/Routes/Video/VideoAdd.php
@@ -68,7 +68,7 @@ class VideoAdd extends OpencastController
             }
 
             // Assign permissions to teachers of the course, when it is a student upload in a course.
-            if (!empty($course_id) && $perm->have_studip_perm($course_id, 'user', $user->id)) {
+            if (!empty($course_id) && $perm->have_studip_perm('user', $course_id, $user->id)) {
                 VideosUserPerms::assignCourseLecturerPermissions($course_id, $video->id);
             }
 
diff --git a/package.json b/package.json
index b26c6d104d932b1eb93c86ae5c2fb2cf5f66a2c3..63e33a7abc4ce2f888a5600c3b0dfcf97b4df8b1 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
   "author": "Till Glöggler <tgloeggl@uos.de>",
   "scripts": {
     "build": "composer install --no-dev && npm install && webpack --mode production -d source-map && cd courseware; sh prebuild.sh",
+    "build-dev": "composer install && npm install && webpack --mode development -d source-map && cd courseware; sh devbuild.sh",
     "devcw": "cd courseware; sh watch.sh",
     "predev": "npm install",
     "dev": "webpack --mode development --watch",
diff --git a/tests/.env b/tests/.env
new file mode 100644
index 0000000000000000000000000000000000000000..16d49e7604b14b139f948dfbd5b850059bb71a10
--- /dev/null
+++ b/tests/.env
@@ -0,0 +1,27 @@
+STUDIP_REST_URL=http://localhost/plugins.php/opencastv3/api
+OPENCAST_REST_URL=http://127.0.0.1:8081/api
+
+# Opencast server ID
+CONFIG_ID=1
+
+# Opencast API token for user provider
+API_TOKEN=mytoken1234abcdef
+
+# Opencast admin user
+OPENCAST_ADMIN_NAME=admin
+OPENCAST_ADMIN_PASSWORD=opencast
+
+
+# Stud.IP dozent in test course and
+DOZENT_NAME=test_dozent
+DOZENT_PASSWORD=testing
+
+# Student in the test course
+COURSE_STUDENT=test_autor
+
+# Simple author in Stud.IP whithout course subscriptions
+AUTHOR_NAME=simple_autor
+AUTHOR_PASSWORD=testing
+
+# ID of test course
+COURSE_ID=a07535cf2f8a72df33c12ddfa4b53dde
diff --git a/tests/AclCest.php b/tests/AclCest.php
new file mode 100644
index 0000000000000000000000000000000000000000..faa0ace3a34dbb0957c0c07e21f1461a41aa101f
--- /dev/null
+++ b/tests/AclCest.php
@@ -0,0 +1,159 @@
+<?php
+
+class AclCest
+{
+    private $opencast_rest_url;
+    private $config_id;
+    private $api_token;
+    private $opencast_admin_user;
+    private $opencast_admin_password;
+    private $dozent_name;
+    private $course_student;
+    private $course_id;
+
+    public function _before(ApiTester $I)
+    {
+        $config = $I->getConfig();
+
+        $this->opencast_rest_url = $config['opencast_rest_url'];
+        $this->config_id = $config['config_id'];
+        $this->api_token = $config['api_token'];
+        $this->opencast_admin_user = $config['opencast_admin_user'];
+        $this->opencast_admin_password = $config['opencast_admin_password'];
+        $this->dozent_name = $config['dozent_name'];
+        $this->course_student = $config['course_student'];
+        $this->course_id = $config['course_id'];
+
+        $I->amHttpAuthenticated($config['dozent_name'], $config['dozent_password']);
+    }
+
+    // tests
+    public function testPlaylistAcl(ApiTester $I)
+    {
+        $playlist = [
+            'title'       => 'Meine Videos' ,
+            'description' => 'Videoliste',
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
+        ];
+
+        $response = $I->sendPostAsJson('/playlists', $playlist);
+
+        $I->seeResponseCodeIs(201);
+        $I->seeResponseIsJson();
+
+        $I->seeResponseContainsJson($playlist);
+        $I->seeResponseContainsJson(['users' => [['perm' => 'owner']]]);
+
+        list($service_playlist_id) = $I->grabDataFromResponseByJsonPath('$.service_playlist_id');
+
+        // Check if user has correct playlist role
+        $response = $I->sendGetAsJson('/opencast/user/' . $this->dozent_name, ['token' => $this->api_token]);
+
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseIsJson();
+
+        $I->seeResponseContainsJson([
+            'username' => $this->dozent_name,
+            'roles' => [
+                'PLAYLIST_' . $service_playlist_id . '_write',
+            ]
+        ]);
+
+        // Check ACLs in Opencast
+
+        // Login as opencast admin
+        $I->amHttpAuthenticated($this->opencast_admin_user, $this->opencast_admin_password);
+
+        $response = $I->sendGetAsJson($this->opencast_rest_url . '/playlists/' . $service_playlist_id);
+        $I->seeResponseContainsJson(['accessControlEntries' => [
+            ['allow' => true, 'role' => 'PLAYLIST_' . $service_playlist_id . '_read', 'action' => 'read'],
+            ['allow' => true, 'role' => 'PLAYLIST_' . $service_playlist_id . '_write', 'action' => 'read'],
+            ['allow' => true, 'role' => 'PLAYLIST_' . $service_playlist_id . '_write', 'action' => 'write'],
+        ]]);
+    }
+
+    public function testCoursePlaylistAcl(ApiTester $I)
+    {
+        // Create a playlist
+        $playlist = [
+            'title'       => 'Meine Videos' ,
+            'description' => 'Videoliste',
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
+        ];
+
+        $response = $I->sendPostAsJson('/playlists', $playlist);
+        $I->seeResponseCodeIs(201);
+        $I->seeResponseIsJson();
+
+        $I->seeResponseContainsJson($playlist);
+        $I->seeResponseContainsJson(['users' => [['perm' => 'owner']]]);
+
+        list($token) = $I->grabDataFromResponseByJsonPath('$.token');
+        list($service_playlist_id) = $I->grabDataFromResponseByJsonPath('$.service_playlist_id');
+
+        // Add playlist to course
+        $response = $I->sendPost('/courses/' . $this->course_id . '/playlist/' . $token);
+        $I->seeResponseCodeIs(204);
+
+        // Check if student of course has read access only
+        $response = $I->sendGetAsJson('/opencast/user/' . $this->course_student, ['token' => $this->api_token]);
+
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseIsJson();
+
+        $I->seeResponseContainsJson([
+            'username' => $this->course_student,
+            'roles' => [
+                'PLAYLIST_' . $service_playlist_id . '_read',
+            ]
+        ]);
+        $I->dontSeeResponseContainsJson(['roles' => [
+            'PLAYLIST_' . $service_playlist_id . '_write',
+        ]]);
+    }
+
+    public function testRemoveCoursePlaylistAcl(ApiTester $I)
+    {
+        // Create a playlist
+        $playlist = [
+            'title'       => 'Meine Videos' ,
+            'description' => 'Videoliste',
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
+        ];
+
+        $response = $I->sendPostAsJson('/playlists', $playlist);
+        $I->seeResponseCodeIs(201);
+        $I->seeResponseIsJson();
+
+        $I->seeResponseContainsJson($playlist);
+        $I->seeResponseContainsJson(['users' => [['perm' => 'owner']]]);
+
+        list($token) = $I->grabDataFromResponseByJsonPath('$.token');
+        list($service_playlist_id) = $I->grabDataFromResponseByJsonPath('$.service_playlist_id');
+
+        // Add playlist to course
+        $response = $I->sendPost('/courses/' . $this->course_id . '/playlist/' . $token);
+        $I->seeResponseCodeIs(204);
+
+        // Remove playlist from course
+        $response = $I->sendDelete('/courses/' . $this->course_id . '/playlist/' . $token);
+        $I->seeResponseCodeIs(204);
+
+        // Check if student of course has no access
+        $response = $I->sendGetAsJson('/opencast/user/' . $this->course_student, ['token' => $this->api_token]);
+
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseIsJson();
+
+        $I->dontseeResponseContainsJson([
+            'username' => $this->course_student,
+            'roles' => [
+                'PLAYLIST_' . $service_playlist_id . '_read',
+                'PLAYLIST_' . $service_playlist_id . '_write',
+            ]
+        ]);
+    }
+}
diff --git a/tests/CoursesCest.php b/tests/CoursesCest.php
index 9376dc5d334d328a74925b735d65e5a849403f59..2e257081eb07df0ffffb54d19316b1b88ea66b31 100644
--- a/tests/CoursesCest.php
+++ b/tests/CoursesCest.php
@@ -2,11 +2,17 @@
 
 class CoursesCest
 {
-    private $course_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
+    private $config_id;
+    private $course_id;
 
     public function _before(ApiTester $I)
     {
-        $I->amHttpAuthenticated('apitester', 'apitester');
+        $config = $I->getConfig();
+
+        $this->config_id = $config['config_id'];
+        $this->course_id = $config['course_id'];
+
+        $I->amHttpAuthenticated($config['dozent_name'], $config['dozent_password']);
     }
 
     // tests
@@ -16,7 +22,8 @@ class CoursesCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -29,13 +36,18 @@ class CoursesCest
         list($token) = $I->grabDataFromResponseByJsonPath('$.token');
 
         // Add playlist to course
-        $response = $I->sendPut('/courses/' . $this->course_id . '/playlist/' . $token);
+        $response = $I->sendPost('/courses/' . $this->course_id . '/playlist/' . $token);
         $I->seeResponseCodeIs(204);
 
-        $response = $I->sendGet('/courses/' . $this->course_id . '/playlist');
+        $response = $I->sendGet('/courses/' . $this->course_id . '/playlists');
         $I->seeResponseCodeIs(200);
         $I->seeResponseIsJson();
-        $I->seeResponseContainsJson($playlist);
+        $I->seeResponseContainsJson([
+            'title' => $playlist['title'],
+            'description' => $playlist['description'],
+            'visibility' => 'visible',
+            'config_id' => $playlist['config_id'],
+        ]);
     }
 
     public function testAddPlaylist(ApiTester $I)
@@ -44,7 +56,8 @@ class CoursesCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -57,7 +70,7 @@ class CoursesCest
         list($token) = $I->grabDataFromResponseByJsonPath('$.token');
 
         // Add playlist to course
-        $response = $I->sendPut('/courses/' . $this->course_id . '/playlist/' . $token);
+        $response = $I->sendPost('/courses/' . $this->course_id . '/playlist/' . $token);
         $I->seeResponseCodeIs(204);
     }
 
@@ -67,7 +80,8 @@ class CoursesCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -80,7 +94,7 @@ class CoursesCest
         list($token) = $I->grabDataFromResponseByJsonPath('$.token');
 
         // Add playlist to course
-        $response = $I->sendPut('/courses/' . $this->course_id . '/playlist/' . $token);
+        $response = $I->sendPost('/courses/' . $this->course_id . '/playlist/' . $token);
         $I->seeResponseCodeIs(204);
 
         // Remove playlist from course
diff --git a/tests/PlaylistsCest.php b/tests/PlaylistsCest.php
index 7a933ff1d7c79bb2284d1adc9a666e449c875014..9037e5edfe4adf684774c51cb2ff4cdf7b548503 100644
--- a/tests/PlaylistsCest.php
+++ b/tests/PlaylistsCest.php
@@ -2,9 +2,20 @@
 
 class PlaylistsCest
 {
+    private $config_id;
+
+    private $author_name;
+    private $author_password;
+
     public function _before(ApiTester $I)
     {
-        $I->amHttpAuthenticated('apitester', 'apitester');
+        $config = $I->getConfig();
+
+        $this->config_id = $config['config_id'];
+        $this->author_name = $config['author_name'];
+        $this->author_password = $config['author_password'];
+
+        $I->amHttpAuthenticated($config['dozent_name'], $config['dozent_password']);
     }
 
     // tests
@@ -13,7 +24,8 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -29,7 +41,8 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -51,13 +64,15 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $playlist2 = [
             'title'       => 'Meine Videos 2',
             'description' => 'Videoliste 2',
-            'visibility'  => 'free'
+            'visibility'  => 'free',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -83,7 +98,8 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -171,13 +187,15 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $playlist2 = [
             'title'       => 'Meine Videos 2' ,
             'description' => 'Videoliste 2',
-            'visibility'  => 'free'
+            'visibility'  => 'free',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -192,13 +210,13 @@ class PlaylistsCest
 
         // give write perms to other user
         $response = $I->sendPutAsJson('/playlists/' . $token .'/user', [
-            'username' => "apitester_autor1",
+            'username' => $this->author_name,
             'perm'     => 'write'
         ]);
         $I->seeResponseCodeIs(200);
 
         // then, try to edit it as a different user
-        $I->amHttpAuthenticated('apitester_autor1', 'apitester_autor1');
+        $I->amHttpAuthenticated($this->author_name, $this->author_password);
 
         $response = $I->sendPutAsJson('/playlists/' . $token, $playlist2);
         $I->seeResponseCodeIs(200);
@@ -212,13 +230,15 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $playlist2 = [
             'title'       => 'Meine Videos 2' ,
             'description' => 'Videoliste 2',
-            'visibility'  => 'free'
+            'visibility'  => 'free',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -233,13 +253,13 @@ class PlaylistsCest
 
         // give write perms to other user
         $response = $I->sendPutAsJson('/playlists/' . $token .'/user', [
-            'username' => "apitester_autor1",
+            'username' => $this->author_name,
             'perm'     => 'read'
         ]);
         $I->seeResponseCodeIs(200);
 
         // then, try to edit it as a different user
-        $I->amHttpAuthenticated('apitester_autor1', 'apitester_autor1');
+        $I->amHttpAuthenticated($this->author_name, $this->author_password);
 
         $response = $I->sendPutAsJson('/playlists/' . $token, $playlist2);
         $I->seeResponseCodeIs(500);
@@ -251,13 +271,15 @@ class PlaylistsCest
         $playlist = [
             'title'       => 'Meine Videos' ,
             'description' => 'Videoliste',
-            'visibility'  => 'internal'
+            'visibility'  => 'internal',
+            'config_id'   => $this->config_id,
         ];
 
         $playlist2 = [
             'title'       => 'Meine Videos 2' ,
             'description' => 'Videoliste 2',
-            'visibility'  => 'free'
+            'visibility'  => 'free',
+            'config_id'   => $this->config_id,
         ];
 
         $response = $I->sendPostAsJson('/playlists', $playlist);
@@ -272,17 +294,17 @@ class PlaylistsCest
 
         // give write perms to other user
         $response = $I->sendPutAsJson('/playlists/' . $token .'/user', [
-            'username' => "apitester_autor1",
+            'username' => $this->author_name,
             'perm'     => 'write'
         ]);
         $I->seeResponseCodeIs(200);
 
         // remove write perms for user
-        $response = $I->sendDelete('/playlists/' . $token .'/user/apitester_autor1');
+        $response = $I->sendDelete('/playlists/' . $token .'/user/' . $this->author_name);
         $I->seeResponseCodeIs(204);
 
         // then, try to edit it as a different user
-        $I->amHttpAuthenticated('apitester_autor1', 'apitester_autor1');
+        $I->amHttpAuthenticated($this->author_name, $this->author_password);
 
         $response = $I->sendPutAsJson('/playlists/' . $token, $playlist2);
         $I->seeResponseCodeIs(500);
diff --git a/tests/UsersCest.php b/tests/UsersCest.php
index 01c17edad325955b1698a4874583cd4052888eac..a9f95563cf91781c6889abc2aad2115ec14945cd 100644
--- a/tests/UsersCest.php
+++ b/tests/UsersCest.php
@@ -2,9 +2,15 @@
 
 class UsersCest
 {
+    private $dozent_name;
+
     public function _before(ApiTester $I)
     {
-        $I->amHttpAuthenticated('apitester', 'apitester');
+        $config = $I->getConfig();
+
+        $this->dozent_name = $config['dozent_name'];
+
+        $I->amHttpAuthenticated($config['dozent_name'], $config['dozent_password']);
     }
 
     // tests
@@ -17,7 +23,7 @@ class UsersCest
         $I->seeResponseContainsJson([
             'type' => 'user',
             'data' => [
-                'username' => 'apitester'
+                'username' => $this->dozent_name,
             ]
         ]);
     }
diff --git a/tests/_support/Helper/Api.php b/tests/_support/Helper/Api.php
index 7a4621e8542c3739f981cddb4db6bf7e1f7fbed9..f38fa1daa25b6e096b745690b42a8bce3ee12e49 100644
--- a/tests/_support/Helper/Api.php
+++ b/tests/_support/Helper/Api.php
@@ -6,5 +6,21 @@ namespace Helper;
 
 class Api extends \Codeception\Module
 {
+    protected $requiredFields = [
+        'opencast_rest_url',
+        'config_id',
+        'api_token',
+        'opencast_admin_user',
+        'opencast_admin_password',
+        'dozent_name',
+        'dozent_password',
+        'course_student',
+        'author_name',
+        'author_password',
+        'course_id',
+    ];
 
+    public function getConfig(): array {
+        return $this->config;
+    }
 }