From 349cc237c5ac83b878b8b5216d4cf631fd45e34e Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Wed, 30 Aug 2023 22:37:21 +0000
Subject: [PATCH] Rewrote permissions system.

---
 README.md                                     |   2 +-
 ...23_08_30_213930_new_permissions_system.php | 125 +++
 misuzu.php                                    |   1 -
 public-legacy/auth/login.php                  |   2 +-
 public-legacy/comments.php                    |  27 +-
 public-legacy/forum/forum.php                 |  37 +-
 public-legacy/forum/index.php                 |  28 +-
 public-legacy/forum/leaderboard.php           |   2 +-
 public-legacy/forum/post.php                  |   8 +-
 public-legacy/forum/posting.php               |  17 +-
 public-legacy/forum/topic.php                 |  36 +-
 public-legacy/manage/changelog/change.php     |   2 +-
 public-legacy/manage/changelog/index.php      |   2 +-
 public-legacy/manage/changelog/tag.php        |   2 +-
 public-legacy/manage/changelog/tags.php       |   2 +-
 public-legacy/manage/forum/index.php          |  24 +-
 public-legacy/manage/forum/redirs.php         |   2 +-
 public-legacy/manage/general/emoticon.php     |   2 +-
 public-legacy/manage/general/emoticons.php    |   2 +-
 public-legacy/manage/general/logs.php         |   2 +-
 .../manage/general/setting-delete.php         |   2 +-
 public-legacy/manage/general/setting.php      |   2 +-
 public-legacy/manage/general/settings.php     |   2 +-
 public-legacy/manage/news/categories.php      |   2 +-
 public-legacy/manage/news/category.php        |   2 +-
 public-legacy/manage/news/post.php            |   2 +-
 public-legacy/manage/news/posts.php           |   2 +-
 public-legacy/manage/users/ban.php            |   2 +-
 public-legacy/manage/users/bans.php           |   2 +-
 public-legacy/manage/users/index.php          |   2 +-
 public-legacy/manage/users/note.php           |   2 +-
 public-legacy/manage/users/notes.php          |   2 +-
 public-legacy/manage/users/role.php           |  43 +-
 public-legacy/manage/users/roles.php          |   2 +-
 public-legacy/manage/users/user.php           |  48 +-
 public-legacy/manage/users/warning.php        |   2 +-
 public-legacy/manage/users/warnings.php       |   2 +-
 public-legacy/members.php                     |   3 +-
 public-legacy/profile.php                     |  34 +-
 public-legacy/search.php                      |   2 +-
 public-legacy/settings/account.php            |   5 +-
 public-legacy/settings/data.php               |   4 +-
 public/index.php                              |  32 +-
 src/Auth/AuthInfo.php                         |  40 +-
 src/Comments/CommentsEx.php                   |  10 +-
 src/MisuzuContext.php                         |  15 +-
 src/Perm.php                                  | 355 +++++++++
 src/Perms/IPermissionResult.php               |   9 +
 src/Perms/PermissionInfo.php                  |  83 ++
 src/Perms/PermissionResult.php                |  16 +
 src/Perms/PermissionResultShared.php          |  24 +
 src/Perms/Permissions.php                     | 363 +++++++++
 src/SharpChat/SharpChatPerms.php              |  30 +-
 src/SharpChat/SharpChatRoutes.php             |   6 +-
 src/Users/Assets/AssetsRoutes.php             |   5 +-
 src/perms.php                                 | 729 ------------------
 templates/_layout/comments.twig               |  12 +-
 templates/manage/forum/listing.twig           |  12 +-
 templates/manage/macros.twig                  |  14 +-
 templates/manage/users/role.twig              |   2 +-
 templates/manage/users/user.twig              |   4 +-
 tools/cron                                    |  12 +-
 tools/recalc-perms                            |   8 +
 63 files changed, 1286 insertions(+), 989 deletions(-)
 create mode 100644 database/2023_08_30_213930_new_permissions_system.php
 create mode 100644 src/Perm.php
 create mode 100644 src/Perms/IPermissionResult.php
 create mode 100644 src/Perms/PermissionInfo.php
 create mode 100644 src/Perms/PermissionResult.php
 create mode 100644 src/Perms/PermissionResultShared.php
 create mode 100644 src/Perms/Permissions.php
 delete mode 100644 src/perms.php
 create mode 100644 tools/recalc-perms

diff --git a/README.md b/README.md
index 845cdba5..5697f95d 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,6 @@
 > Misuzu can and will steal your lunch money.
 
 ## Requirements
- - PHP 8.2
+ - PHP 8.2 (64-bit)
  - MariaDB 10.6
  - [Composer](https://getcomposer.org/)
diff --git a/database/2023_08_30_213930_new_permissions_system.php b/database/2023_08_30_213930_new_permissions_system.php
new file mode 100644
index 00000000..66976322
--- /dev/null
+++ b/database/2023_08_30_213930_new_permissions_system.php
@@ -0,0 +1,125 @@
+<?php
+use Index\Data\IDbConnection;
+use Index\Data\Migration\IDbMigration;
+
+final class NewPermissionsSystem_20230830_213930 implements IDbMigration {
+    public function migrate(IDbConnection $conn): void {
+        // make sure cron doesn't fuck us over
+        $conn->execute('DELETE FROM msz_config WHERE config_name = "perms.needsRecalc"');
+
+        $conn->execute('
+            CREATE TABLE msz_perms (
+                user_id        INT(10) UNSIGNED        NULL DEFAULT NULL,
+                role_id        INT(10) UNSIGNED        NULL DEFAULT NULL,
+                forum_id       INT(10) UNSIGNED        NULL DEFAULT NULL,
+                perms_category VARBINARY(64)       NOT NULL,
+                perms_allow    BIGINT(20) UNSIGNED NOT NULL,
+                perms_deny     BIGINT(20) UNSIGNED NOT NULL,
+                UNIQUE KEY perms_unique (user_id, role_id, forum_id, perms_category),
+                KEY perms_user_foreign (user_id),
+                KEY perms_role_foreign (role_id),
+                KEY perms_forum_foreign (forum_id),
+                KEY perms_category_index (perms_category),
+                CONSTRAINT perms_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT perms_role_foreign
+                    FOREIGN KEY (role_id)
+                    REFERENCES msz_roles (role_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT perms_forum_foreign
+                    FOREIGN KEY (forum_id)
+                    REFERENCES msz_forum_categories (forum_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) ENGINE=InnoDB COLLATE=utf8mb4_bin
+        ');
+
+        $conn->execute('
+            ALTER TABLE msz_perms
+            ADD CONSTRAINT perms_53bit
+                CHECK (perms_allow >= 0 AND perms_deny >= 0 AND perms_allow <= 9007199254740991 AND perms_deny <= 9007199254740991),
+            ADD CONSTRAINT perms_only_user_or_role
+                CHECK ((user_id IS NULL AND role_id IS NULL) OR (user_id IS NULL AND role_id IS NOT NULL) OR (user_id IS NOT NULL AND role_id IS NULL))
+        ');
+
+        $conn->execute('
+            CREATE TABLE msz_perms_calculated (
+                user_id          INT(10) UNSIGNED        NULL DEFAULT NULL,
+                forum_id         INT(10) UNSIGNED        NULL DEFAULT NULL,
+                perms_category   VARBINARY(64)       NOT NULL,
+                perms_calculated BIGINT(20) UNSIGNED NOT NULL,
+                UNIQUE KEY perms_calculated_unique (user_id, forum_id, perms_category),
+                KEY perms_calculated_user_foreign (user_id),
+                KEY perms_calculated_forum_foreign (forum_id),
+                KEY perms_calculated_category_index (perms_category),
+                CONSTRAINT perms_calculated_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT perms_calculated_forum_foreign
+                    FOREIGN KEY (forum_id)
+                    REFERENCES msz_forum_categories (forum_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) ENGINE=InnoDB COLLATE=utf8mb4_bin
+        ');
+
+        $conn->execute('
+            ALTER TABLE msz_perms_calculated
+            ADD CONSTRAINT perms_calculated_53bit
+                CHECK (perms_calculated >= 0 AND perms_calculated <= 9007199254740991)
+        ');
+
+        $insert = $conn->prepare('INSERT INTO msz_perms (user_id, role_id, forum_id, perms_category, perms_allow, perms_deny) VALUES (?, ?, ?, ?, ?, ?)');
+
+        $result = $conn->query('SELECT user_id, role_id, general_perms_allow, general_perms_deny, user_perms_allow, user_perms_deny, changelog_perms_allow, changelog_perms_deny, news_perms_allow, news_perms_deny, forum_perms_allow, forum_perms_deny, comments_perms_allow, comments_perms_deny FROM msz_permissions');
+        while($result->next()) {
+            $insert->addParameter(1, $result->isNull(0) ? null : $result->getString(0));
+            $insert->addParameter(2, $result->isNull(1) ? null : $result->getString(1));
+            $insert->addParameter(3, null);
+            $insert->addParameter(4, 'user');
+            $insert->addParameter(5, $result->getInteger(4));
+            $insert->addParameter(6, $result->getInteger(5));
+            $insert->execute();
+
+            $allow = $result->getInteger(2);
+            $allow |= $result->getInteger(6) << 8;
+            $allow |= $result->getInteger(8) << 16;
+            $allow |= $result->getInteger(10) << 24;
+            $allow |= $result->getInteger(12) << 32;
+
+            $deny = $result->getInteger(3);
+            $deny |= $result->getInteger(7) << 8;
+            $deny |= $result->getInteger(9) << 16;
+            $deny |= $result->getInteger(11) << 24;
+            $deny |= $result->getInteger(13) << 32;
+
+            $insert->addParameter(4, 'global');
+            $insert->addParameter(5, $allow);
+            $insert->addParameter(6, $deny);
+            $insert->execute();
+        }
+
+        $result = $conn->query('SELECT user_id, role_id, forum_id, forum_perms_allow, forum_perms_deny FROM msz_forum_permissions');
+        while($result->next()) {
+            $insert->addParameter(1, $result->isNull(0) ? null : $result->getString(0));
+            $insert->addParameter(2, $result->isNull(1) ? null : $result->getString(1));
+            $insert->addParameter(3, $result->getString(2));
+            $insert->addParameter(4, 'forum');
+            $insert->addParameter(5, $result->getInteger(3));
+            $insert->addParameter(6, $result->getInteger(4));
+            $insert->execute();
+        }
+
+        $conn->execute('DROP TABLE msz_forum_permissions');
+        $conn->execute('DROP TABLE msz_permissions');
+
+        // schedule recalc
+        $conn->execute('INSERT INTO msz_config (config_name, config_value) VALUES ("perms.needsRecalc", "b:1;")');
+    }
+}
diff --git a/misuzu.php b/misuzu.php
index 938ebe50..b7262682 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -23,7 +23,6 @@ mb_internal_encoding('utf-8');
 date_default_timezone_set('utc');
 
 require_once MSZ_ROOT . '/utility.php';
-require_once MSZ_SOURCE . '/perms.php';
 require_once MSZ_SOURCE . '/url.php';
 
 $dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED);
diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php
index c840ad82..6ebc58ac 100644
--- a/public-legacy/auth/login.php
+++ b/public-legacy/auth/login.php
@@ -113,7 +113,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
     if($userInfo->passwordNeedsRehash())
         $users->updateUser($userInfo, password: $_POST['login']['password']);
 
-    if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userInfo->getId(), $loginPermVal)) {
+    if(!empty($loginPermCat) && $loginPermVal > 0 && !$msz->getPerms()->checkPermissions($loginPermCat, $loginPermVal, $userInfo)) {
         $notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
         $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
         break;
diff --git a/public-legacy/comments.php b/public-legacy/comments.php
index 83421e96..e31ba1e2 100644
--- a/public-legacy/comments.php
+++ b/public-legacy/comments.php
@@ -30,7 +30,7 @@ if($msz->hasActiveBan()) {
 $currentUserInfo = $msz->getActiveUser();
 
 $comments = $msz->getComments();
-$commentPerms = perms_for_comments($currentUserInfo->getId());
+$perms = $msz->getAuthInfo()->getPerms('global');
 
 $commentId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
 $commentMode = (string)filter_input(INPUT_GET, 'm');
@@ -55,7 +55,7 @@ if($commentMode !== 'create' && empty($commentInfo)) {
 switch($commentMode) {
     case 'pin':
     case 'unpin':
-        if(!$commentPerms['can_pin'] && !$categoryInfo->isOwner($currentUserInfo)) {
+        if(!$perms->check(Perm::G_COMMENTS_PIN) && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to pin comments.", 403);
             break;
         }
@@ -92,7 +92,7 @@ switch($commentMode) {
         break;
 
     case 'vote':
-        if(!$commentPerms['can_vote'] && !$categoryInfo->isOwner($currentUserInfo)) {
+        if(!$perms->check(Perm::G_COMMENTS_VOTE) && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to vote on comments.", 403);
             break;
         }
@@ -113,21 +113,23 @@ switch($commentMode) {
         break;
 
     case 'delete':
-        if(!$commentPerms['can_delete'] && !$categoryInfo->isOwner($currentUserInfo)) {
+        $canDelete = $perms->check(Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY);
+        if(!$canDelete && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to delete comments.", 403);
             break;
         }
 
+        $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
         if($commentInfo->isDeleted()) {
             echo render_info(
-                $commentPerms['can_delete_any'] ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
+                $canDeleteAny ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
                 400
             );
             break;
         }
 
         $isOwnComment = $commentInfo->getUserId() === $currentUserInfo->getId();
-        $isModAction  = $commentPerms['can_delete_any'] && !$isOwnComment;
+        $isModAction  = $canDeleteAny && !$isOwnComment;
 
         if(!$isModAction && !$isOwnComment) {
             echo render_info("You're not allowed to delete comments made by others.", 403);
@@ -150,7 +152,7 @@ switch($commentMode) {
         break;
 
     case 'restore':
-        if(!$commentPerms['can_delete_any']) {
+        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY)) {
             echo render_info("You're not allowed to restore deleted comments.", 403);
             break;
         }
@@ -172,7 +174,7 @@ switch($commentMode) {
         break;
 
     case 'create':
-        if(!$commentPerms['can_comment'] && !$categoryInfo->isOwner($currentUserInfo)) {
+        if(!$perms->check(Perm::G_COMMENTS_CREATE) && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to post comments.", 403);
             break;
         }
@@ -192,15 +194,16 @@ switch($commentMode) {
             break;
         }
 
-        if($categoryInfo->isLocked() && !$commentPerms['can_lock']) {
+        $canLock = $perms->check(Perm::G_COMMENTS_LOCK);
+        if($categoryInfo->isLocked() && !$canLock) {
             echo render_info('This comment category has been locked.', 403);
             break;
         }
 
         $commentText = !empty($_POST['comment']['text'])  && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : '';
         $commentReply = (string)(!empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0);
-        $commentLock = !empty($_POST['comment']['lock'])  && $commentPerms['can_lock'];
-        $commentPin = !empty($_POST['comment']['pin'])   && $commentPerms['can_pin'];
+        $commentLock = !empty($_POST['comment']['lock']) && $canLock;
+        $commentPin = !empty($_POST['comment']['pin']) && $perms->check(Perm::G_COMMENTS_PIN);
 
         if($commentLock) {
             if($categoryInfo->isLocked())
@@ -212,7 +215,7 @@ switch($commentMode) {
         if(strlen($commentText) > 0) {
             $commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText);
         } else {
-            if($commentPerms['can_lock']) {
+            if($canLock) {
                 echo render_info('The action has been processed.', 400);
             } else {
                 echo render_info('Your comment is too short.', 400);
diff --git a/public-legacy/forum/forum.php b/public-legacy/forum/forum.php
index 9c397e01..87f30dfb 100644
--- a/public-legacy/forum/forum.php
+++ b/public-legacy/forum/forum.php
@@ -3,6 +3,7 @@ namespace Misuzu;
 
 use stdClass;
 use RuntimeException;
+use Index\XArray;
 
 $forum = $msz->getForum();
 $users = $msz->getUsers();
@@ -16,18 +17,18 @@ try {
     return;
 }
 
+$perms = $msz->getAuthInfo()->getPerms('forum', $categoryInfo);
+
 $currentUser = $msz->getActiveUser();
 $currentUserId = $currentUser === null ? '0' : $currentUser->getId();
 
-$perms = forum_perms_get_user($categoryInfo->getId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-
-if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
+if(!$perms->check(Perm::F_CATEGORY_VIEW)) {
     echo render_error(403);
     return;
 }
 
-if(isset($currentUser) && $msz->hasActiveBan($currentUser))
-    $perms &= MSZ_FORUM_PERM_LIST_FORUM | MSZ_FORUM_PERM_VIEW_FORUM;
+if($msz->hasActiveBan())
+    $perms = $perms->apply(fn($calc) => $calc & (Perm::F_CATEGORY_LIST | Perm::F_CATEGORY_VIEW));
 
 if($categoryInfo->isLink()) {
     if($categoryInfo->hasLinkTarget()) {
@@ -40,7 +41,7 @@ if($categoryInfo->isLink()) {
 $forumPagination = new Pagination($forum->countTopics(
     categoryInfo: $categoryInfo,
     global: true,
-    deleted: perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST) ? null : false
+    deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false
 ), 20);
 
 if(!$forumPagination->hasValidOffset()) {
@@ -56,9 +57,9 @@ $topics = [];
 if($categoryInfo->mayHaveChildren()) {
     $children = $forum->getCategoryChildren($categoryInfo, hidden: false, asTree: true);
 
-    foreach($children as $child) {
-        $childPerms = forum_perms_get_user($child->info->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-        if(!perms_check($childPerms, MSZ_FORUM_PERM_LIST_FORUM)) {
+    foreach($children as $childId => $child) {
+        $childPerms = $msz->getAuthInfo()->getPerms('forum', $child->info);
+        if(!$childPerms->check(Perm::F_CATEGORY_LIST)) {
             unset($category->children[$childId]);
             continue;
         }
@@ -67,8 +68,8 @@ if($categoryInfo->mayHaveChildren()) {
 
         if($child->info->mayHaveChildren()) {
             foreach($child->children as $grandChildId => $grandChild) {
-                $grandChildPerms = forum_perms_get_user($grandChild->info->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                if(!perms_check($grandChildPerms, MSZ_FORUM_PERM_LIST_FORUM)) {
+                $grandChildPerms = $msz->getAuthInfo()->getPerms('forum', $grandChild->info);
+                if(!$grandChildPerms->check(Perm::F_CATEGORY_LIST)) {
                     unset($child->children[$grandChildId]);
                     continue;
                 }
@@ -78,8 +79,8 @@ if($categoryInfo->mayHaveChildren()) {
                 if($grandChild->info->mayHaveTopics()) {
                     $catIds = [$grandChild->info->getId()];
                     foreach($grandChild->childIds as $greatGrandChildId) {
-                        $greatGrandChildPerms = forum_perms_get_user($greatGrandChildId, (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                        if(perms_check($greatGrandChildPerms, MSZ_FORUM_PERM_LIST_FORUM))
+                        $greatGrandChildPerms = $msz->getAuthInfo()->getPerms('forum', $greatGrandChildId);
+                        if(!$greatGrandChildPerms->check(Perm::F_CATEGORY_LIST))
                             $catIds[] = $greatGrandChildId;
                     }
 
@@ -96,8 +97,8 @@ if($categoryInfo->mayHaveChildren()) {
         if($child->info->mayHaveChildren() || $child->info->mayHaveTopics()) {
             $catIds = [$child->info->getId()];
             foreach($child->childIds as $grandChildId) {
-                $grandChildPerms = forum_perms_get_user($grandChildId, (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                if(perms_check($grandChildPerms, MSZ_FORUM_PERM_LIST_FORUM))
+                $grandChildPerms = $msz->getAuthInfo()->getPerms('forum', $grandChildId);
+                if($grandChildPerms->check(Perm::F_CATEGORY_LIST))
                     $catIds[] = $grandChildId;
             }
 
@@ -138,7 +139,7 @@ if($categoryInfo->mayHaveTopics()) {
     $topicInfos = $forum->getTopics(
         categoryInfo: $categoryInfo,
         global: true,
-        deleted: perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST) ? null : false,
+        deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false,
         pagination: $forumPagination,
     );
 
@@ -183,8 +184,8 @@ if($categoryInfo->mayHaveTopics()) {
     }
 }
 
-$perms = perms_check_bulk($perms, [
-    'can_create_topic' => MSZ_FORUM_PERM_CREATE_TOPIC,
+$perms = $perms->checkMany([
+    'can_create_topic' => Perm::F_TOPIC_CREATE,
 ]);
 
 Template::render('forum.forum', [
diff --git a/public-legacy/forum/index.php b/public-legacy/forum/index.php
index 2fcc64a8..61819f5d 100644
--- a/public-legacy/forum/index.php
+++ b/public-legacy/forum/index.php
@@ -25,8 +25,8 @@ if($mode === 'mark') {
             : $forum->getCategoryChildren(parentInfo: $categoryId, includeSelf: true);
 
         foreach($categoryInfos as $categoryInfo) {
-            $perms = forum_perms_get_user($categoryInfo->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-            if(perms_check($perms, MSZ_FORUM_PERM_LIST_FORUM))
+            $perms = $msz->getAuthInfo()->getPerms('forum', $categoryInfo);
+            if($perms->check(Perm::F_CATEGORY_LIST))
                 $forum->updateUserReadCategory($userInfo, $categoryInfo);
         }
 
@@ -55,8 +55,8 @@ $userColours = [];
 $categories = $forum->getCategories(hidden: false, asTree: true);
 
 foreach($categories as $categoryId => $category) {
-    $perms = forum_perms_get_user($category->info->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-    if(!perms_check($perms, MSZ_FORUM_PERM_LIST_FORUM)) {
+    $perms = $msz->getAuthInfo()->getPerms('forum', $category->info);
+    if(!$perms->check(Perm::F_CATEGORY_LIST)) {
         unset($categories[$categoryId]);
         continue;
     }
@@ -65,8 +65,8 @@ foreach($categories as $categoryId => $category) {
 
     if($category->info->mayHaveChildren())
         foreach($category->children as $childId => $child) {
-            $childPerms = forum_perms_get_user($child->info->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-            if(!perms_check($childPerms, MSZ_FORUM_PERM_LIST_FORUM)) {
+            $childPerms = $msz->getAuthInfo()->getPerms('forum', $child->info);
+            if(!$childPerms->check(Perm::F_CATEGORY_LIST)) {
                 unset($category->children[$childId]);
                 continue;
             }
@@ -76,8 +76,8 @@ foreach($categories as $categoryId => $category) {
             if($category->info->isListing()) {
                 if($child->info->mayHaveChildren()) {
                     foreach($child->children as $grandChildId => $grandChild) {
-                        $grandChildPerms = forum_perms_get_user($grandChild->info->getId(), (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                        if(!perms_check($grandChildPerms, MSZ_FORUM_PERM_LIST_FORUM)) {
+                        $grandChildPerms = $msz->getAuthInfo()->getPerms('forum', $grandChild->info);
+                        if(!$grandChildPerms->check(Perm::F_CATEGORY_LIST)) {
                             unset($child->children[$grandChildId]);
                             continue;
                         }
@@ -87,8 +87,8 @@ foreach($categories as $categoryId => $category) {
                         if($grandChild->info->mayHaveTopics()) {
                             $catIds = [$grandChild->info->getId()];
                             foreach($grandChild->childIds as $greatGrandChildId) {
-                                $greatGrandChildPerms = forum_perms_get_user($greatGrandChildId, (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                                if(perms_check($greatGrandChildPerms, MSZ_FORUM_PERM_LIST_FORUM))
+                                $greatGrandChildPerms = $msz->getAuthInfo()->getPerms('forum', $greatGrandChildId);
+                                if($greatGrandChildPerms->check(Perm::F_CATEGORY_LIST))
                                     $catIds[] = $greatGrandChildId;
                             }
 
@@ -105,8 +105,8 @@ foreach($categories as $categoryId => $category) {
                 if($child->info->mayHaveChildren() || $child->info->mayHaveTopics()) {
                     $catIds = [$child->info->getId()];
                     foreach($child->childIds as $grandChildId) {
-                        $grandChildPerms = forum_perms_get_user($grandChildId, (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                        if(perms_check($grandChildPerms, MSZ_FORUM_PERM_LIST_FORUM))
+                        $grandChildPerms = $msz->getAuthInfo()->getPerms('forum', $grandChildId);
+                        if($grandChildPerms->check(Perm::F_CATEGORY_LIST))
                             $catIds[] = $grandChildId;
                     }
 
@@ -165,8 +165,8 @@ foreach($categories as $categoryId => $category) {
         if($category->info->mayHaveChildren() || $category->info->mayHaveTopics()) {
             $catIds = [$category->info->getId()];
             foreach($category->childIds as $childId) {
-                $childPerms = forum_perms_get_user($childId, (int)$currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-                if(perms_check($childPerms, MSZ_FORUM_PERM_LIST_FORUM))
+                $childPerms = $msz->getAuthInfo()->getPerms('forum', $childId);
+                if($childPerms->check(Perm::F_CATEGORY_LIST))
                     $catIds[] = $childId;
             }
 
diff --git a/public-legacy/forum/leaderboard.php b/public-legacy/forum/leaderboard.php
index fdd0e049..f780b672 100644
--- a/public-legacy/forum/leaderboard.php
+++ b/public-legacy/forum/leaderboard.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_FORUM, $msz->getActiveUser()->getId(), MSZ_PERM_FORUM_VIEW_LEADERBOARD)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_FORUM_LEADERBOARD_VIEW)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/forum/post.php b/public-legacy/forum/post.php
index d22c6e64..a893bc52 100644
--- a/public-legacy/forum/post.php
+++ b/public-legacy/forum/post.php
@@ -31,14 +31,14 @@ try {
     return;
 }
 
-$perms = forum_perms_get_user($postInfo->getCategoryId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+$perms = $msz->getAuthInfo()->getPerms('forum', $postInfo->getCategoryId());
 
-if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
+if(!$perms->check(Perm::F_CATEGORY_VIEW)) {
     echo render_error(403);
     return;
 }
 
-$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
+$canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
 
 switch($postMode) {
     case 'delete':
@@ -53,7 +53,7 @@ switch($postMode) {
                 return;
             }
 
-            if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_POST)) {
+            if(!$perms->check(Perm::F_POST_DELETE_OWN)) {
                 echo render_info('You are not allowed to delete posts.', 403);
                 return;
             }
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 668952dd..ca7dc409 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -121,12 +121,13 @@ if(empty($forumId)) {
     $hasCategoryInfo = true;
 }
 
-$perms = forum_perms_get_user($categoryInfo->getId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+$perms = $msz->getAuthInfo()->getPerms('forum', $categoryInfo);
 
 if($categoryInfo->isArchived()
-    || (isset($topicInfo) && $topicInfo->isLocked() && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC))
-    || !perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM | MSZ_FORUM_PERM_CREATE_POST)
-    || (!isset($topicInfo) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) {
+    || (isset($topicInfo) && $topicInfo->isLocked() && !$perms->check(Perm::F_TOPIC_LOCK))
+    || !$perms->check(Perm::F_CATEGORY_VIEW)
+    || !$perms->check(Perm::F_POST_CREATE)
+    || (!isset($topicInfo) && !$perms->check(Perm::F_TOPIC_CREATE))) {
     echo render_error(403);
     return;
 }
@@ -141,16 +142,16 @@ $topicTypes = [];
 if($mode === 'create' || $mode === 'edit') {
     $topicTypes['discussion'] = 'Normal discussion';
 
-    if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC))
+    if($perms->check(Perm::F_TOPIC_STICKY))
         $topicTypes['sticky'] = 'Sticky topic';
-    if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC))
+    if($perms->check(Perm::F_TOPIC_ANNOUNCE_LOCAL))
         $topicTypes['announce'] = 'Announcement';
-    if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC))
+    if($perms->check(Perm::F_TOPIC_ANNOUNCE_GLOBAL))
         $topicTypes['global'] = 'Global Announcement';
 }
 
 // edit mode stuff
-if($mode === 'edit' && !perms_check($perms, $postInfo->getUserId() === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
+if($mode === 'edit' && !$perms->check($postInfo->getUserId() === $currentUserId ? Perm::F_POST_EDIT_OWN : Perm::F_POST_EDIT_ANY)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/forum/topic.php b/public-legacy/forum/topic.php
index 7a0a36f1..8a8ae759 100644
--- a/public-legacy/forum/topic.php
+++ b/public-legacy/forum/topic.php
@@ -25,8 +25,8 @@ if($topicId < 1 && $postId > 0) {
     }
 
     $categoryId = $postInfo->getCategoryId();
-    $perms = forum_perms_get_user($categoryId, $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
-    $canDeleteAny = !perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
+    $perms = $msz->getAuthInfo()->getPerms('forum', $postInfo->getCategoryId());
+    $canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
 
     if($postInfo->isDeleted() && !$canDeleteAny) {
         echo render_error(404);
@@ -53,13 +53,13 @@ if(!$topicIsNuked) {
 
     if($categoryId !== (int)$topicInfo->getCategoryId()) {
         $categoryId = (int)$topicInfo->getCategoryId();
-        $perms = forum_perms_get_user($categoryId, $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+        $perms = $msz->getAuthInfo()->getPerms('forum', $topicInfo->getCategoryId());
     }
 
-    if(isset($currentUser) && $msz->hasActiveBan($currentUser))
-        $perms &= MSZ_FORUM_PERM_LIST_FORUM | MSZ_FORUM_PERM_VIEW_FORUM;
+    if($msz->hasActiveBan())
+        $perms = $perms->apply(fn($calc) => $calc & (Perm::F_CATEGORY_LIST | Perm::F_CATEGORY_VIEW));
 
-    $canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
+    $canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
 }
 
 if(($topicIsNuked || $topicIsDeleted) && $forum->hasTopicRedirect($topicId)) {
@@ -75,7 +75,7 @@ if(($topicIsNuked || $topicIsDeleted) && $forum->hasTopicRedirect($topicId)) {
     }
 }
 
-if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
+if(!$perms->check(Perm::F_CATEGORY_VIEW)) {
     echo render_error(403);
     return;
 }
@@ -89,9 +89,9 @@ $topicIsLocked = $topicInfo->isLocked();
 $topicIsArchived = $categoryInfo->isArchived();
 $topicPostsTotal = $topicInfo->getTotalPostsCount();
 $topicIsFrozen = $topicIsArchived || $topicIsDeleted;
-$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST);
-$canBumpTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_BUMP_TOPIC);
-$canLockTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC);
+$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && $perms->check(Perm::F_POST_DELETE_OWN);
+$canBumpTopic = !$topicIsFrozen && $perms->check(Perm::F_TOPIC_BUMP);
+$canLockTopic = !$topicIsFrozen && $perms->check(Perm::F_TOPIC_LOCK);
 $canNukeOrRestore = $canDeleteAny && $topicIsDeleted;
 $canDelete = !$topicIsDeleted && (
     $canDeleteAny || (
@@ -304,7 +304,7 @@ if(!$topicPagination->hasValidOffset()) {
 
 $postInfos = $forum->getPosts(
     topicInfo: $topicInfo,
-    deleted: perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST) ? null : false,
+    deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false,
     pagination: $topicPagination,
 );
 
@@ -343,19 +343,19 @@ foreach($postInfos as $postInfo) {
         && $originalPostInfo->getUserId() === $postInfo->getUserId();
 }
 
-$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST);
+$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && $perms->check(Perm::F_POST_CREATE);
 
 if(!$forum->checkUserHasReadTopic($userInfo, $topicInfo))
     $forum->incrementTopicView($topicInfo);
 
 $forum->updateUserReadTopic($currentUser, $topicInfo);
 
-$perms = perms_check_bulk($perms, [
-    'can_create_post' => MSZ_FORUM_PERM_CREATE_POST,
-    'can_edit_post' => MSZ_FORUM_PERM_EDIT_POST,
-    'can_edit_any_post' => MSZ_FORUM_PERM_EDIT_ANY_POST,
-    'can_delete_post' => MSZ_FORUM_PERM_DELETE_POST,
-    'can_delete_any_post' => MSZ_FORUM_PERM_DELETE_ANY_POST,
+$perms = $perms->checkMany([
+    'can_create_post' => Perm::F_POST_CREATE,
+    'can_edit_post' => Perm::F_POST_EDIT_OWN,
+    'can_edit_any_post' => Perm::F_POST_EDIT_ANY,
+    'can_delete_post' => Perm::F_POST_DELETE_OWN,
+    'can_delete_any_post' => Perm::F_POST_DELETE_ANY,
 ]);
 
 Template::render('forum.topic', [
diff --git a/public-legacy/manage/changelog/change.php b/public-legacy/manage/changelog/change.php
index a7768da5..948081f6 100644
--- a/public-legacy/manage/changelog/change.php
+++ b/public-legacy/manage/changelog/change.php
@@ -7,7 +7,7 @@ use Index\DateTime;
 use Index\XArray;
 use Misuzu\Changelog\Changelog;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_CHANGELOG, $msz->getActiveUser()->getId(), MSZ_PERM_CHANGELOG_MANAGE_CHANGES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_CHANGES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/changelog/index.php b/public-legacy/manage/changelog/index.php
index ca9c82fb..69d64c3c 100644
--- a/public-legacy/manage/changelog/index.php
+++ b/public-legacy/manage/changelog/index.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_CHANGELOG, $msz->getActiveUser()->getId(), MSZ_PERM_CHANGELOG_MANAGE_CHANGES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_CHANGES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/changelog/tag.php b/public-legacy/manage/changelog/tag.php
index abb957eb..1199b440 100644
--- a/public-legacy/manage/changelog/tag.php
+++ b/public-legacy/manage/changelog/tag.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_CHANGELOG, $msz->getActiveUser()->getId(), MSZ_PERM_CHANGELOG_MANAGE_TAGS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_TAGS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/changelog/tags.php b/public-legacy/manage/changelog/tags.php
index 05e016de..0742adfc 100644
--- a/public-legacy/manage/changelog/tags.php
+++ b/public-legacy/manage/changelog/tags.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_CHANGELOG, $msz->getActiveUser()->getId(), MSZ_PERM_CHANGELOG_MANAGE_TAGS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_TAGS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/forum/index.php b/public-legacy/manage/forum/index.php
index 9c713924..19136ee0 100644
--- a/public-legacy/manage/forum/index.php
+++ b/public-legacy/manage/forum/index.php
@@ -1,18 +1,24 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_FORUM_MANAGE_FORUMS)) {
+use Misuzu\Perm;
+
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_FORUM_CATEGORIES_MANAGE)) {
     echo render_error(403);
     return;
 }
 
-$rawPerms = perms_create(MSZ_FORUM_PERM_MODES);
-$perms = manage_forum_perms_list($rawPerms);
+$perms = $msz->getPerms();
+$permsInfos = $perms->getPermissionInfo(categoryNames: Perm::INFO_FOR_FORUM_CATEGORY);
+$permsLists = Perm::createList(Perm::LISTS_FOR_FORUM_CATEGORY);
 
-if(!empty($_POST['perms']) && is_array($_POST['perms'])) {
-    $finalPerms = manage_perms_apply($perms, $_POST['perms'], $rawPerms);
-    $perms = manage_forum_perms_list($finalPerms);
-    Template::set('calculated_perms', $finalPerms);
-}
+if(filter_has_var(INPUT_POST, 'perms'))
+    Template::set('calculated_perms', Perm::convertSubmission(
+        filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
+        Perm::INFO_FOR_FORUM_CATEGORY
+    ));
 
-Template::render('manage.forum.listing', compact('perms'));
+Template::render('manage.forum.listing', [
+    'perms_lists' => $permsLists,
+    'perms_infos' => $permsInfos,
+]);
diff --git a/public-legacy/manage/forum/redirs.php b/public-legacy/manage/forum/redirs.php
index a892a0f0..06ddfec8 100644
--- a/public-legacy/manage/forum/redirs.php
+++ b/public-legacy/manage/forum/redirs.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_FORUM_TOPIC_REDIRS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php
index 57ca1e5e..b2d1bcd8 100644
--- a/public-legacy/manage/general/emoticon.php
+++ b/public-legacy/manage/general/emoticon.php
@@ -4,7 +4,7 @@ namespace Misuzu;
 use RuntimeException;
 use Index\XArray;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_MANAGE_EMOTES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_EMOTES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php
index c8ed5c19..2a3a002f 100644
--- a/public-legacy/manage/general/emoticons.php
+++ b/public-legacy/manage/general/emoticons.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_MANAGE_EMOTES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_EMOTES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/logs.php b/public-legacy/manage/general/logs.php
index f1131248..66e29880 100644
--- a/public-legacy/manage/general/logs.php
+++ b/public-legacy/manage/general/logs.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Pagination;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_VIEW_LOGS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_LOGS_VIEW)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/setting-delete.php b/public-legacy/manage/general/setting-delete.php
index 0d3224a2..bd92db88 100644
--- a/public-legacy/manage/general/setting-delete.php
+++ b/public-legacy/manage/general/setting-delete.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Config\CfgTools;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_MANAGE_CONFIG)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CONFIG_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/setting.php b/public-legacy/manage/general/setting.php
index 496b3f35..83546305 100644
--- a/public-legacy/manage/general/setting.php
+++ b/public-legacy/manage/general/setting.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Config\DbConfig;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_MANAGE_CONFIG)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CONFIG_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/general/settings.php b/public-legacy/manage/general/settings.php
index e63eafe7..4acaa7ca 100644
--- a/public-legacy/manage/general/settings.php
+++ b/public-legacy/manage/general/settings.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_GENERAL, $msz->getActiveUser()->getId(), MSZ_PERM_GENERAL_MANAGE_CONFIG)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CONFIG_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/news/categories.php b/public-legacy/manage/news/categories.php
index c348f5e1..df1584a3 100644
--- a/public-legacy/manage/news/categories.php
+++ b/public-legacy/manage/news/categories.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_NEWS, $msz->getActiveUser()->getId(), MSZ_PERM_NEWS_MANAGE_CATEGORIES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_NEWS_CATEGORIES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/news/category.php b/public-legacy/manage/news/category.php
index 502993a7..98af6e15 100644
--- a/public-legacy/manage/news/category.php
+++ b/public-legacy/manage/news/category.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_NEWS, $msz->getActiveUser()->getId(), MSZ_PERM_NEWS_MANAGE_CATEGORIES)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_NEWS_CATEGORIES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php
index bbb5b22c..6f2a6f42 100644
--- a/public-legacy/manage/news/post.php
+++ b/public-legacy/manage/news/post.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_NEWS, $msz->getActiveUser()->getId(), MSZ_PERM_NEWS_MANAGE_POSTS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_NEWS_POSTS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/news/posts.php b/public-legacy/manage/news/posts.php
index cd65f07c..31832594 100644
--- a/public-legacy/manage/news/posts.php
+++ b/public-legacy/manage/news/posts.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_NEWS, $msz->getActiveUser()->getId(), MSZ_PERM_NEWS_MANAGE_POSTS)) {
+if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_NEWS_POSTS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/ban.php b/public-legacy/manage/users/ban.php
index a3bd2088..6e724a10 100644
--- a/public-legacy/manage/users/ban.php
+++ b/public-legacy/manage/users/ban.php
@@ -5,7 +5,7 @@ use DateTimeInterface;
 use RuntimeException;
 use Index\DateTime;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_BANS)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_BANS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/bans.php b/public-legacy/manage/users/bans.php
index a1b6a0b1..3eb35c15 100644
--- a/public-legacy/manage/users/bans.php
+++ b/public-legacy/manage/users/bans.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_BANS)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_BANS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/index.php b/public-legacy/manage/users/index.php
index 89601d59..c7fb1894 100644
--- a/public-legacy/manage/users/index.php
+++ b/public-legacy/manage/users/index.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_USERS)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_USERS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php
index 21f8d219..3bdb41f7 100644
--- a/public-legacy/manage/users/note.php
+++ b/public-legacy/manage/users/note.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_NOTES)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_NOTES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/notes.php b/public-legacy/manage/users/notes.php
index 9199f618..14888697 100644
--- a/public-legacy/manage/users/notes.php
+++ b/public-legacy/manage/users/notes.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_NOTES)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_NOTES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/role.php b/public-legacy/manage/users/role.php
index 93045157..68fc203c 100644
--- a/public-legacy/manage/users/role.php
+++ b/public-legacy/manage/users/role.php
@@ -4,14 +4,17 @@ namespace Misuzu;
 use RuntimeException;
 use Index\Colour\Colour;
 use Index\Colour\ColourRGB;
+use Misuzu\Perm;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_ROLES)) {
+$viewerPerms = $msz->getAuthInfo()->getPerms('user');
+if(!$viewerPerms->check(Perm::U_ROLES_MANAGE)) {
     echo render_error(403);
     return;
 }
 
 $users = $msz->getUsers();
 $roles = $msz->getRoles();
+$perms = $msz->getPerms();
 
 if(filter_has_var(INPUT_GET, 'r')) {
     $roleId = (string)filter_input(INPUT_GET, 'r', FILTER_SANITIZE_NUMBER_INT);
@@ -26,10 +29,10 @@ if(filter_has_var(INPUT_GET, 'r')) {
 } else $isNew = true;
 
 $currentUser = $msz->getActiveUser();
-$canEditPerms = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_PERMS);
+$canEditPerms = $viewerPerms->check(Perm::U_PERMS_MANAGE);
 
-if($canEditPerms)
-    $permissions = manage_perms_list(perms_get_role_raw($roleId ?? 0));
+$permsInfos = $perms->getPermissionInfo(roleInfo: $roleInfo, categoryNames: Perm::INFO_FOR_ROLE);
+$permsLists = Perm::createList(Perm::LISTS_FOR_ROLE);
 
 while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
     $userRank = $users->getUserRank($currentUser);
@@ -120,27 +123,16 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
         [$roleInfo->getId()]
     );
 
-    if(!empty($permissions) && !empty($_POST['perms']) && is_array($_POST['perms'])) {
-        $perms = manage_perms_apply($permissions, $_POST['perms']);
+    if($canEditPerms && filter_has_var(INPUT_POST, 'perms')) {
+        $permsApply = Perm::convertSubmission(
+            filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
+            Perm::INFO_FOR_ROLE
+        );
 
-        if($perms !== null) {
-            $permKeys = array_keys($perms);
-            $setPermissions = DB::prepare('
-                REPLACE INTO `msz_permissions` (`role_id`, `user_id`, `' . implode('`, `', $permKeys) . '`)
-                VALUES (:role_id, NULL, :' . implode(', :', $permKeys) . ')
-            ');
-            $setPermissions->bind('role_id', $roleInfo->getId());
+        foreach($permsApply as $categoryName => $values)
+            $perms->setPermissions($categoryName, $values['allow'], $values['deny'], roleInfo: $roleInfo);
 
-            foreach($perms as $key => $value) {
-                $setPermissions->bind($key, $value);
-            }
-
-            $setPermissions->execute();
-        } else {
-            $deletePermissions = DB::prepare('DELETE FROM `msz_permissions` WHERE `role_id` = :role_id AND `user_id` IS NULL');
-            $deletePermissions->bind('role_id', $roleInfo->getId());
-            $deletePermissions->execute();
-        }
+        $msz->getConfig()->setBoolean('perms.needsRecalc', true);
     }
 
     url_redirect('manage-role', ['role' => $roleInfo->getId()]);
@@ -150,6 +142,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
 Template::render('manage.users.role', [
     'role_new' => $isNew,
     'role_info' => $roleInfo ?? null,
-    'can_manage_perms' => $canEditPerms,
-    'permissions' => $permissions ?? [],
+    'can_edit_perms' => $canEditPerms,
+    'perms_lists' => $permsLists,
+    'perms_infos' => $permsInfos,
 ]);
diff --git a/public-legacy/manage/users/roles.php b/public-legacy/manage/users/roles.php
index c11652f6..31574bcf 100644
--- a/public-legacy/manage/users/roles.php
+++ b/public-legacy/manage/users/roles.php
@@ -1,7 +1,7 @@
 <?php
 namespace Misuzu;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_ROLES)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_ROLES_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php
index 1fcea86b..eff398d3 100644
--- a/public-legacy/manage/users/user.php
+++ b/public-legacy/manage/users/user.php
@@ -3,9 +3,11 @@ namespace Misuzu;
 
 use RuntimeException;
 use Index\Colour\Colour;
+use Misuzu\Perm;
 use Misuzu\Auth\AuthTokenCookie;
 use Misuzu\Users\User;
 
+$viewerPerms = $msz->getAuthInfo()->getPerms('user');
 if(!$msz->isLoggedIn()) {
     echo render_error(403);
     return;
@@ -13,15 +15,16 @@ if(!$msz->isLoggedIn()) {
 
 $users = $msz->getUsers();
 $roles = $msz->getRoles();
+$perms = $msz->getPerms();
 
 $currentUser = $msz->getActiveUser();
 
-$canManageUsers = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_USERS);
-$canManagePerms = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_PERMS);
-$canManageNotes = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_NOTES);
-$canManageWarnings = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_WARNINGS);
-$canManageBans = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_MANAGE_BANS);
-$canImpersonate = perms_check_user(MSZ_PERMS_USER, $currentUser->getId(), MSZ_PERM_USER_IMPERSONATE);
+$canManageUsers = $viewerPerms->check(Perm::U_USERS_MANAGE);
+$canManagePerms = $viewerPerms->check(Perm::U_PERMS_MANAGE);
+$canManageNotes = $viewerPerms->check(Perm::U_NOTES_MANAGE);
+$canManageWarnings = $viewerPerms->check(Perm::U_WARNINGS_MANAGE);
+$canManageBans = $viewerPerms->check(Perm::U_BANS_MANAGE);
+$canImpersonate = $viewerPerms->check(Perm::U_CAN_IMPERSONATE);
 $canSendTestMail = $currentUser->isSuperUser();
 $hasAccess = $canManageUsers || $canManageNotes || $canManageWarnings || $canManageBans;
 
@@ -45,7 +48,9 @@ $userRank = $users->getUserRank($userInfo);
 
 $canEdit = $canManageUsers && ($currentUser->isSuperUser() || (string)$currentUser->getId() === $userInfo->getId() || $currentUserRank > $userRank);
 $canEditPerms = $canEdit && $canManagePerms;
-$permissions = $canEditPerms ? manage_perms_list(perms_get_user_raw($userId)) : [];
+
+$permsInfos = $perms->getPermissionInfo(userInfo: $userInfo, categoryNames: Perm::INFO_FOR_USER);
+$permsLists = Perm::createList(Perm::LISTS_FOR_USER);
 
 if(CSRF::validateRequest() && $canEdit) {
     if(!empty($_POST['impersonate_user'])) {
@@ -136,11 +141,14 @@ if(CSRF::validateRequest() && $canEdit) {
 
         if(!empty($addRoles))
             $users->addRoles($userInfo, $addRoles);
+
+        if(!empty($addRoles) || !empty($removeRoles))
+            $msz->getConfig()->setBoolean('perms.needsRecalc', true);
     }
 
     if(!empty($_POST['user']) && is_array($_POST['user'])) {
-        $setCountry      = (string)($_POST['user']['country'] ?? '');
-        $setTitle        = (string)($_POST['user']['title'] ?? '');
+        $setCountry = (string)($_POST['user']['country'] ?? '');
+        $setTitle = (string)($_POST['user']['title'] ?? '');
 
         $displayRole = (string)($_POST['user']['display_role'] ?? 0);
         if(!$users->hasRole($userInfo, $displayRole))
@@ -193,19 +201,16 @@ if(CSRF::validateRequest() && $canEdit) {
         }
     }
 
-    if($canEditPerms && !empty($_POST['perms']) && is_array($_POST['perms'])) {
-        $perms = manage_perms_apply($permissions, $_POST['perms']);
+    if($canEditPerms && filter_has_var(INPUT_POST, 'perms')) {
+        $permsApply = Perm::convertSubmission(
+            filter_input(INPUT_POST, 'perms', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY),
+            Perm::INFO_FOR_USER
+        );
 
-        if($perms !== null) {
-            if(!perms_set_user_raw($userId, $perms))
-                $notices[] = 'Failed to update permissions.';
-        } else {
-            if(!perms_delete_user($userId))
-                $notices[] = 'Failed to remove permissions.';
-        }
+        foreach($permsApply as $categoryName => $values)
+            $perms->setPermissions($categoryName, $values['allow'], $values['deny'], userInfo: $userInfo);
 
-        // this smells, make it refresh/apply in a non-retarded way
-        $permissions = manage_perms_list(perms_get_user_raw($userId));
+        $msz->getConfig()->setBoolean('perms.needsRecalc', true);
     }
 
     url_redirect('manage-user', ['user' => $userInfo->getId()]);
@@ -227,5 +232,6 @@ Template::render('manage.users.user', [
     'can_manage_bans' => $canManageBans,
     'can_impersonate' => $canImpersonate,
     'can_send_test_mail' => $canSendTestMail,
-    'permissions' => $permissions ?? [],
+    'perms_lists' => $permsLists,
+    'perms_infos' => $permsInfos,
 ]);
diff --git a/public-legacy/manage/users/warning.php b/public-legacy/manage/users/warning.php
index 623953b3..9bfc6dc1 100644
--- a/public-legacy/manage/users/warning.php
+++ b/public-legacy/manage/users/warning.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_WARNINGS)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_WARNINGS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/manage/users/warnings.php b/public-legacy/manage/users/warnings.php
index d022e9f0..8694144c 100644
--- a/public-legacy/manage/users/warnings.php
+++ b/public-legacy/manage/users/warnings.php
@@ -3,7 +3,7 @@ namespace Misuzu;
 
 use RuntimeException;
 
-if(!$msz->isLoggedIn() || !perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_WARNINGS)) {
+if(!$msz->getAuthInfo()->getPerms('user')->check(Perm::U_WARNINGS_MANAGE)) {
     echo render_error(403);
     return;
 }
diff --git a/public-legacy/members.php b/public-legacy/members.php
index 293f025e..9b74c45b 100644
--- a/public-legacy/members.php
+++ b/public-legacy/members.php
@@ -67,8 +67,6 @@ if(empty($orderDir)) {
     return;
 }
 
-$canManageUsers = perms_check_user(MSZ_PERMS_USER, $msz->getActiveUser()->getId(), MSZ_PERM_USER_MANAGE_USERS);
-
 if($roleId === null) {
     $roleInfo = $roles->getDefaultRole();
 } else {
@@ -80,6 +78,7 @@ if($roleId === null) {
     }
 }
 
+$canManageUsers = $msz->getAuthInfo()->getPerms('user')->check(Perm::U_USERS_MANAGE);
 $deleted = $canManageUsers ? null : false;
 
 $rolesAll = $roles->getRoles(hidden: false);
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 49a807b2..fd997a7f 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -65,15 +65,15 @@ $notices = [];
 $userRank = $users->getUserRank($userInfo);
 $viewerRank = $viewingAsGuest ? 0 : $users->getUserRank($viewerInfo);
 
+$viewerPerms = $msz->getAuthInfo()->getPerms('user');
+
 $activeBanInfo = $msz->tryGetActiveBan($userInfo);
 $isBanned = $activeBanInfo !== null;
 $profileFields = $msz->getProfileFields();
 $viewingOwnProfile = (string)$viewerId === $userInfo->getId();
-$userPerms = perms_get_user($viewerId)[MSZ_PERMS_USER];
-$canManageWarnings = perms_check($userPerms, MSZ_PERM_USER_MANAGE_WARNINGS);
+$canManageWarnings = $viewerPerms->check(Perm::U_WARNINGS_MANAGE);
 $canEdit = !$viewingAsGuest && ((!$isBanned && $viewingOwnProfile) || $viewerInfo->isSuperUser() || (
-    perms_check($userPerms, MSZ_PERM_USER_MANAGE_USERS)
-    && ($viewingOwnProfile || $viewerRank > $userRank)
+    $viewerPerms->check(Perm::U_USERS_MANAGE) && ($viewingOwnProfile || $viewerRank > $userRank)
 ));
 $avatarInfo = new UserAvatarAsset($userInfo);
 $backgroundInfo = new UserBackgroundAsset($userInfo);
@@ -84,13 +84,13 @@ if($isEditing) {
         return;
     }
 
-    $perms = perms_check_bulk($userPerms, [
-        'edit_profile' => MSZ_PERM_USER_EDIT_PROFILE,
-        'edit_avatar' => MSZ_PERM_USER_CHANGE_AVATAR,
-        'edit_background' => MSZ_PERM_USER_CHANGE_BACKGROUND,
-        'edit_about' => MSZ_PERM_USER_EDIT_ABOUT,
-        'edit_birthdate' => MSZ_PERM_USER_EDIT_BIRTHDATE,
-        'edit_signature' => MSZ_PERM_USER_EDIT_SIGNATURE,
+    $perms = $viewerPerms->checkMany([
+        'edit_profile' => Perm::U_PROFILE_EDIT,
+        'edit_avatar' => Perm::U_AVATAR_CHANGE,
+        'edit_background' => PERM::U_PROFILE_BACKGROUND_CHANGE,
+        'edit_about' => Perm::U_PROFILE_ABOUT_EDIT,
+        'edit_birthdate' => Perm::U_PROFILE_BIRTHDATE_EDIT,
+        'edit_signature' => Perm::U_FORUM_SIGNATURE_EDIT,
     ]);
 
     Template::set([
@@ -105,7 +105,7 @@ if($isEditing) {
             $profileFieldsSubmit = filter_input(INPUT_POST, 'profile', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
 
             if(!empty($profileFieldsSubmit)) {
-                if(!$perms['edit_profile']) {
+                if(!$perms->edit_profile) {
                     $notices[] = 'You\'re not allowed to edit your profile';
                 } else {
                     $profileFieldInfos = $profileFields->getFields();
@@ -139,7 +139,7 @@ if($isEditing) {
             }
 
             if(!empty($_POST['about']) && is_array($_POST['about'])) {
-                if(!$perms['edit_about']) {
+                if(!$perms->edit_about) {
                     $notices[] = 'You\'re not allowed to edit your about page.';
                 } else {
                     $aboutText  = (string)($_POST['about']['text'] ?? '');
@@ -163,7 +163,7 @@ if($isEditing) {
             }
 
             if(!empty($_POST['signature']) && is_array($_POST['signature'])) {
-                if(!$perms['edit_signature']) {
+                if(!$perms->edit_signature) {
                     $notices[] = 'You\'re not allowed to edit your forum signature.';
                 } else {
                     $sigText  = (string)($_POST['signature']['text'] ?? '');
@@ -187,7 +187,7 @@ if($isEditing) {
             }
 
             if(!empty($_POST['birthdate']) && is_array($_POST['birthdate'])) {
-                if(!$perms['edit_birthdate']) {
+                if(!$perms->edit_birthdate) {
                     $notices[] = "You aren't allow to change your birthdate.";
                 } else {
                     $birthYear  = (int)($_POST['birthdate']['year'] ?? 0);
@@ -215,7 +215,7 @@ if($isEditing) {
                 if(!empty($_POST['avatar']['delete'])) {
                     $avatarInfo->delete();
                 } else {
-                    if(!$perms['edit_avatar']) {
+                    if(!$perms->edit_avatar) {
                         $notices[] = 'You aren\'t allow to change your avatar.';
                     } elseif(!empty($_FILES['avatar'])
                         && is_array($_FILES['avatar'])
@@ -260,7 +260,7 @@ if($isEditing) {
                 if((int)($_POST['background']['attach'] ?? -1) === 0) {
                     $backgroundInfo->delete();
                 } else {
-                    if(!$perms['edit_background']) {
+                    if(!$perms->edit_background) {
                         $notices[] = 'You aren\'t allow to change your background.';
                     } elseif(!empty($_FILES['background']) && is_array($_FILES['background'])) {
                         if(!empty($_FILES['background']['name']['file'])) {
diff --git a/public-legacy/search.php b/public-legacy/search.php
index 715a27e8..851a9a0b 100644
--- a/public-legacy/search.php
+++ b/public-legacy/search.php
@@ -83,7 +83,7 @@ if(!empty($searchQuery)) {
 
         $forumCategoryIds = XArray::where(
             $forum->getCategories(hidden: false),
-            fn($categoryInfo) => $categoryInfo->mayHaveTopics() && forum_perms_check_user(MSZ_FORUM_PERMS_GENERAL, $categoryInfo->getId(), $currentUserId, MSZ_FORUM_PERM_VIEW_FORUM)
+            fn($categoryInfo) => $categoryInfo->mayHaveTopics() && $msz->getAuthInfo()->getPerms('forum', $categoryInfo)->check(Perm::F_CATEGORY_VIEW)
         );
 
         $forumTopicInfos = $forum->getTopics(categoryInfo: $forumCategoryIds, deleted: false, searchQuery: $searchQueryEvaluated);
diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php
index 277bca24..1ab62046 100644
--- a/public-legacy/settings/account.php
+++ b/public-legacy/settings/account.php
@@ -35,9 +35,10 @@ if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) {
                 break;
 
             case 'leave':
-                if($roleInfo->isLeavable())
+                if($roleInfo->isLeavable()) {
                     $users->removeRoles($userInfo, $roleInfo);
-                else
+                    $msz->getConfig()->setBoolean('perms.needsRecalc', true);
+                } else
                     $errors[] = "You're not allow to leave this role, an administrator has to remove it for you.";
                 break;
         }
diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php
index 21edd723..b867c7a4 100644
--- a/public-legacy/settings/data.php
+++ b/public-legacy/settings/data.php
@@ -125,14 +125,14 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_categories',    ['category_id:s', 'category_name:s', 'owner_id:s:n', 'category_created:t', 'category_locked:t:n'], 'owner_id');
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_posts',         ['comment_id:s', 'category_id:s', 'user_id:s:n', 'comment_reply_to:s:n', 'comment_text:s', 'comment_created:t', 'comment_pinned:t:n', 'comment_edited:t:n', 'comment_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'comments_votes',         ['comment_id:s', 'user_id:s', 'comment_vote:i']);
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_permissions',      ['user_id:s:n', 'role_id:s:n', 'forum_id:s', 'forum_perms_allow:i', 'forum_perms_deny:i']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_posts',            ['post_id:s', 'topic_id:s', 'forum_id:s', 'user_id:s:n', 'post_ip:a', 'post_text:s', 'post_parse:i', 'post_display_signature:b', 'post_created:t', 'post_edited:t:n', 'post_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics',           ['topic_id:s', 'forum_id:s', 'user_id:s:n', 'topic_type:i', 'topic_title:s', 'topic_count_views:i', 'topic_created:t', 'topic_bumped:t', 'topic_deleted:t:n', 'topic_locked:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_redirects', ['topic_id:s', 'user_id:s:n', 'topic_redir_url:s', 'topic_redir_created:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_track',     ['user_id:s', 'topic_id:s', 'forum_id:s', 'track_last_read:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'login_attempts',         ['user_id:s:n', 'attempt_success:b', 'attempt_ip:a', 'attempt_country:s', 'attempt_created:t', 'attempt_user_agent:s']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts',             ['post_id:s', 'category_id:s', 'user_id:s:n', 'comment_section_id:s:n', 'post_is_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']);
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'permissions',            ['user_id:s:n', 'role_id:s:n', 'general_perms_allow:i', 'general_perms_deny:i', 'user_perms_allow:i', 'user_perms_deny:i', 'changelog_perms_allow:i', 'changelog_perms_deny:i', 'news_perms_allow:i', 'news_perms_deny:i', 'forum_perms_allow:i', 'forum_perms_deny:i', 'comments_perms_allow:i', 'comments_perms_deny:i']);
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'perms',                  ['user_id:s:n', 'role_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_allow:i', 'perms_deny:i']);
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'perms_calculated',       ['user_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_calculated:i']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'profile_fields_values',  ['field_id:s', 'user_id:s', 'format_id:s', 'field_value:s']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'sessions',               ['session_id:s', 'user_id:s', 'session_key:n', 'session_ip:a', 'session_ip_last:a:n', 'session_user_agent:s', 'session_country:s', 'session_expires:t', 'session_expires_bump:b', 'session_created:t', 'session_active:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'users',                  ['user_id:s', 'username:s', 'password:n', 'email:s', 'register_ip:a', 'last_ip:a', 'user_super:b', 'user_country:s', 'user_colour:i:n', 'user_created:t', 'user_active:t:n', 'user_deleted:t:n', 'display_role:s:n', 'user_totp_key:n', 'user_about_content:s:n', 'user_about_parser:i', 'user_signature_content:s:n', 'user_signature_parser:i', 'user_birthdate:s:n', 'user_background_settings:i:n', 'user_title:s:n']);
diff --git a/public/index.php b/public/index.php
index 4ea1c261..456548ac 100644
--- a/public/index.php
+++ b/public/index.php
@@ -188,8 +188,9 @@ if($inManageMode) {
     if($msz->isLoggedIn() && !$msz->hasActiveBan()) {
         $manageUser = $msz->getActiveUser();
         $manageUserId = $manageUser->getId();
+        $manageGlobalPerms = $msz->getAuthInfo()->getPerms('global');
 
-        if(perms_check_user(MSZ_PERMS_GENERAL, $manageUserId, MSZ_PERM_GENERAL_CAN_MANAGE)) {
+        if($manageGlobalPerms->check(Perm::G_IS_JANITOR)) {
             $hasManageAccess = true;
             $manageMenu = [
                 'General' => [
@@ -197,37 +198,38 @@ if($inManageMode) {
                 ],
             ];
 
-            if(perms_check_user(MSZ_PERMS_GENERAL, $manageUserId, MSZ_PERM_GENERAL_VIEW_LOGS))
+            if($manageGlobalPerms->check(Perm::G_LOGS_VIEW))
                 $manageMenu['General']['Logs'] = url('manage-general-logs');
-            if(perms_check_user(MSZ_PERMS_GENERAL, $manageUserId, MSZ_PERM_GENERAL_MANAGE_EMOTES))
+            if($manageGlobalPerms->check(Perm::G_EMOTES_MANAGE))
                 $manageMenu['General']['Emoticons'] = url('manage-general-emoticons');
-            if(perms_check_user(MSZ_PERMS_GENERAL, $manageUserId, MSZ_PERM_GENERAL_MANAGE_CONFIG))
+            if($manageGlobalPerms->check(Perm::G_CONFIG_MANAGE))
                 $manageMenu['General']['Settings'] = url('manage-general-settings');
 
-            if(perms_check_user(MSZ_PERMS_USER, $manageUserId, MSZ_PERM_USER_MANAGE_USERS))
+            $manageUserPerms = $msz->getAuthInfo()->getPerms('user');
+            if($manageUserPerms->check(Perm::U_USERS_MANAGE))
                 $manageMenu['Users & Roles']['Users'] = url('manage-users');
-            if(perms_check_user(MSZ_PERMS_USER, $manageUserId, MSZ_PERM_USER_MANAGE_ROLES))
+            if($manageUserPerms->check(Perm::U_ROLES_MANAGE))
                 $manageMenu['Users & Roles']['Roles'] = url('manage-roles');
-            if(perms_check_user(MSZ_PERMS_USER, $manageUserId, MSZ_PERM_USER_MANAGE_NOTES))
+            if($manageUserPerms->check(Perm::U_NOTES_MANAGE))
                 $manageMenu['Users & Roles']['Notes'] = url('manage-users-notes');
-            if(perms_check_user(MSZ_PERMS_USER, $manageUserId, MSZ_PERM_USER_MANAGE_WARNINGS))
+            if($manageUserPerms->check(Perm::U_WARNINGS_MANAGE))
                 $manageMenu['Users & Roles']['Warnings'] = url('manage-users-warnings');
-            if(perms_check_user(MSZ_PERMS_USER, $manageUserId, MSZ_PERM_USER_MANAGE_BANS))
+            if($manageUserPerms->check(Perm::U_BANS_MANAGE))
                 $manageMenu['Users & Roles']['Bans'] = url('manage-users-bans');
 
-            if(perms_check_user(MSZ_PERMS_NEWS, $manageUserId, MSZ_PERM_NEWS_MANAGE_POSTS))
+            if($manageGlobalPerms->check(Perm::G_NEWS_POSTS_MANAGE))
                 $manageMenu['News']['Posts'] = url('manage-news-posts');
-            if(perms_check_user(MSZ_PERMS_NEWS, $manageUserId, MSZ_PERM_NEWS_MANAGE_CATEGORIES))
+            if($manageGlobalPerms->check(Perm::G_NEWS_CATEGORIES_MANAGE))
                 $manageMenu['News']['Categories'] = url('manage-news-categories');
 
-            if(perms_check_user(MSZ_PERMS_FORUM, $manageUserId, MSZ_PERM_FORUM_MANAGE_FORUMS))
+            if($manageGlobalPerms->check(Perm::G_FORUM_CATEGORIES_MANAGE))
                 $manageMenu['Forum']['Permission Calculator'] = url('manage-forum-categories');
-            if(perms_check_user(MSZ_PERMS_FORUM, $manageUserId, MSZ_PERM_FORUM_TOPIC_REDIRS))
+            if($manageGlobalPerms->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE))
                 $manageMenu['Forum']['Topic Redirects'] = url('manage-forum-topic-redirs');
 
-            if(perms_check_user(MSZ_PERMS_CHANGELOG, $manageUserId, MSZ_PERM_CHANGELOG_MANAGE_CHANGES))
+            if($manageGlobalPerms->check(Perm::G_CL_CHANGES_MANAGE))
                 $manageMenu['Changelog']['Changes'] = url('manage-changelog-changes');
-            if(perms_check_user(MSZ_PERMS_CHANGELOG, $manageUserId, MSZ_PERM_CHANGELOG_MANAGE_TAGS))
+            if($manageGlobalPerms->check(Perm::G_CL_TAGS_MANAGE))
                 $manageMenu['Changelog']['Tags'] = url('manage-changelog-tags');
 
             Template::set('manage_menu', $manageMenu);
diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php
index b672f4f8..07699974 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -1,27 +1,24 @@
 <?php
 namespace Misuzu\Auth;
 
+use Index\XArray;
 use Misuzu\Auth\SessionInfo;
+use Misuzu\Forum\ForumCategoryInfo;
+use Misuzu\Perms\IPermissionResult;
+use Misuzu\Perms\Permissions;
 use Misuzu\Users\UserInfo;
 
 class AuthInfo {
+    private Permissions $permissions;
     private AuthTokenInfo $tokenInfo;
     private ?UserInfo $userInfo;
     private ?SessionInfo $sessionInfo;
     private ?UserInfo $realUserInfo;
+    private array $perms;
 
-    public function __construct(
-        ?AuthTokenInfo $tokenInfo = null,
-        ?UserInfo $userInfo = null,
-        ?SessionInfo $sessionInfo = null,
-        ?UserInfo $realUserInfo = null
-    ) {
-        $this->setInfo(
-            $tokenInfo ?? AuthTokenInfo::empty(),
-            $userInfo,
-            $sessionInfo,
-            $realUserInfo
-        );
+    public function __construct(Permissions $permissions) {
+        $this->permissions = $permissions;
+        $this->setInfo(AuthTokenInfo::empty());
     }
 
     public function setInfo(
@@ -34,6 +31,7 @@ class AuthInfo {
         $this->userInfo = $userInfo;
         $this->sessionInfo = $sessionInfo;
         $this->realUserInfo = $realUserInfo;
+        $this->perms = [];
     }
 
     public function removeInfo(): void {
@@ -76,15 +74,17 @@ class AuthInfo {
         return $this->realUserInfo;
     }
 
-    private static AuthInfo $empty;
+    public function getPerms(
+        string $category,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null
+    ): IPermissionResult {
+        $cacheKey = $category;
+        if($forumCategoryInfo !== null)
+            $cacheKey .= '|' . ($forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
 
-    public static function init(): void {
-        self::$empty = new AuthInfo(AuthTokenInfo::empty());
-    }
+        if(array_key_exists($cacheKey, $this->perms))
+            return $this->perms[$cacheKey];
 
-    public static function empty(): self {
-        return self::$empty;
+        return $this->perms[$cacheKey] = $this->permissions->getPermissions($category, $this->userInfo, $forumCategoryInfo);
     }
 }
-
-AuthInfo::init();
diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php
index 0df1b4b9..e5891bdf 100644
--- a/src/Comments/CommentsEx.php
+++ b/src/Comments/CommentsEx.php
@@ -4,6 +4,7 @@ namespace Misuzu\Comments;
 use stdClass;
 use RuntimeException;
 use Misuzu\MisuzuContext;
+use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Users\Users;
 
@@ -24,7 +25,14 @@ class CommentsEx {
         $hasUser = $this->authInfo->isLoggedIn();
         $info->user = $hasUser ? $this->authInfo->getUserInfo() : null;
         $info->colour = $hasUser ? $this->users->getUserColour($info->user) : null;
-        $info->perms = $hasUser ? perms_for_comments($info->user->getId()) : [];
+        $info->perms = $this->authInfo->getPerms('global')->checkMany([
+            'can_post' => Perm::G_COMMENTS_CREATE,
+            'can_delete' => Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY,
+            'can_delete_any' => Perm::G_COMMENTS_DELETE_ANY,
+            'can_pin' => Perm::G_COMMENTS_PIN,
+            'can_lock' => Perm::G_COMMENTS_LOCK,
+            'can_vote' => Perm::G_COMMENTS_VOTE,
+        ]);
         $info->category = $category;
         $info->posts = [];
 
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index 9e254842..0b0a9835 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -27,6 +27,7 @@ use Misuzu\Home\HomeRoutes;
 use Misuzu\Info\InfoRoutes;
 use Misuzu\News\News;
 use Misuzu\News\NewsRoutes;
+use Misuzu\Perms\Permissions;
 use Misuzu\Profile\ProfileFields;
 use Misuzu\Satori\SatoriRoutes;
 use Misuzu\SharpChat\SharpChatRoutes;
@@ -66,13 +67,15 @@ class MisuzuContext {
     private Counters $counters;
     private ProfileFields $profileFields;
     private Forum $forum;
+    private Permissions $perms;
     private AuthInfo $authInfo;
 
     public function __construct(IDbConnection $dbConn, IConfig $config) {
         $this->dbConn = $dbConn;
         $this->config = $config;
+        $this->perms = new Permissions($this->dbConn);
+        $this->authInfo = new AuthInfo($this->perms);
         $this->auditLog = new AuditLog($this->dbConn);
-        $this->authInfo = new AuthInfo;
         $this->bans = new Bans($this->dbConn);
         $this->changelog = new Changelog($this->dbConn);
         $this->comments = new Comments($this->dbConn);
@@ -184,6 +187,10 @@ class MisuzuContext {
         return $this->forum;
     }
 
+    public function getPerms(): Permissions {
+        return $this->perms;
+    }
+
     public function createAuthTokenPacker(): AuthTokenPacker {
         return new AuthTokenPacker($this->config->getString('auth.secret', 'meow'));
     }
@@ -276,7 +283,7 @@ class MisuzuContext {
             'menu' => [],
         ];
 
-        if($hasUserInfo && perms_check_user(MSZ_PERMS_GENERAL, $userInfo->getId(), MSZ_PERM_FORUM_VIEW_LEADERBOARD))
+        if($this->authInfo->getPerms('global')->check(Perm::G_FORUM_LEADERBOARD_VIEW))
             $forum['menu'][] = [
                 'title' => 'Leaderboard',
                 'url' => url('forum-leaderboard'),
@@ -325,7 +332,7 @@ class MisuzuContext {
                 'icon' => 'fas fa-search fa-fw',
             ];
 
-            if(!$this->hasActiveBan($userInfo) && perms_check_user(MSZ_PERMS_GENERAL, $userInfo->getId(), MSZ_PERM_GENERAL_CAN_MANAGE)) {
+            if(!$this->hasActiveBan($userInfo) && $this->authInfo->getPerms('global')->check(Perm::G_IS_JANITOR)) {
                 // restore behaviour where clicking this button switches between
                 // site version and broom version
                 if($inBroomCloset)
@@ -408,7 +415,7 @@ class MisuzuContext {
         new SharpChatRoutes(
             $this->router, $this->config->scopeTo('sockChat'),
             $this->bans, $this->emotes, $this->users,
-            $this->sessions, $this->authInfo,
+            $this->sessions, $this->perms, $this->authInfo,
             $this->createAuthTokenPacker(...)
         );
 
diff --git a/src/Perm.php b/src/Perm.php
new file mode 100644
index 00000000..f9eda95d
--- /dev/null
+++ b/src/Perm.php
@@ -0,0 +1,355 @@
+<?php
+namespace Misuzu;
+
+use stdClass;
+
+// All permissions should be defined in this class
+// To avoid future conflicts, unused/deprecated permissions should remain defined for any given category
+final class Perm {
+    // GLOBAL ALLOCATION:
+    // 0bXXXXX_XXXXXXXX_CCCCCCCC_FFFFFFFF_NNNNNNNN_LLLLLLLL_GGGGGGGG
+    //       G -> General global permissions
+    //      L -> Changelog permissions
+    //     N -> News permissions
+    //    F -> Global Forum perms
+    //   C -> Global Comments perms
+    //  X -> unallocated
+    public const G_IS_JANITOR                = 0b00000_00000000_00000000_00000000_00000000_00000000_00000001;
+    public const G_LOGS_VIEW                 = 0b00000_00000000_00000000_00000000_00000000_00000000_00000010;
+    public const G_EMOTES_MANAGE             = 0b00000_00000000_00000000_00000000_00000000_00000000_00000100;
+    public const G_CONFIG_MANAGE             = 0b00000_00000000_00000000_00000000_00000000_00000000_00001000;
+    //public const G_IS_TESTER               = 0b00000_00000000_00000000_00000000_00000000_00000000_00010000; // deprecated: tester status went unused
+    public const G_BLACKLIST_MANAGE          = 0b00000_00000000_00000000_00000000_00000000_00000000_00100000; // unused: blacklist is currently removed to reduce overhead and it seemed like it was broken
+    //public const G_TWITTER_MANAGE          = 0b00000_00000000_00000000_00000000_00000000_00000000_01000000; // deprecated: twitter integration has been removed
+
+    public const G_CL_CHANGES_MANAGE         = 0b00000_00000000_00000000_00000000_00000000_00000001_00000000;
+    public const G_CL_TAGS_MANAGE            = 0b00000_00000000_00000000_00000000_00000000_00000010_00000000;
+    //public const G_CL_ACTIONS_MANAGE       = 0b00000_00000000_00000000_00000000_00000000_00000100_00000000; // deprecated: actions are hardcoded now
+
+    public const G_NEWS_POSTS_MANAGE         = 0b00000_00000000_00000000_00000000_00000001_00000000_00000000;
+    public const G_NEWS_CATEGORIES_MANAGE    = 0b00000_00000000_00000000_00000000_00000010_00000000_00000000;
+
+    public const G_FORUM_CATEGORIES_MANAGE   = 0b00000_00000000_00000000_00000001_00000000_00000000_00000000;
+    public const G_FORUM_LEADERBOARD_VIEW    = 0b00000_00000000_00000000_00000010_00000000_00000000_00000000;
+    public const G_FORUM_TOPIC_REDIRS_MANAGE = 0b00000_00000000_00000000_00000100_00000000_00000000_00000000;
+
+    public const G_COMMENTS_CREATE           = 0b00000_00000000_00000001_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_EDIT_OWN         = 0b00000_00000000_00000010_00000000_00000000_00000000_00000000; // unused: editing not implemented
+    public const G_COMMENTS_EDIT_ANY         = 0b00000_00000000_00000100_00000000_00000000_00000000_00000000; // unused: editing not implemented
+    public const G_COMMENTS_DELETE_OWN       = 0b00000_00000000_00001000_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_DELETE_ANY       = 0b00000_00000000_00010000_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_PIN              = 0b00000_00000000_00100000_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_LOCK             = 0b00000_00000000_01000000_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_VOTE             = 0b00000_00000000_10000000_00000000_00000000_00000000_00000000;
+
+    // USER ALLOCATION:
+    // There's no rules here, manage perms started abouts halfway through the 31-bit integer
+    // Maybe formally define the octets regardless later?
+    public const U_PROFILE_EDIT              = 0b00000_00000000_00000000_00000000_00000000_00000000_00000001;
+    public const U_AVATAR_CHANGE             = 0b00000_00000000_00000000_00000000_00000000_00000000_00000010;
+    public const U_PROFILE_BACKGROUND_CHANGE = 0b00000_00000000_00000000_00000000_00000000_00000000_00000100;
+    public const U_PROFILE_ABOUT_EDIT        = 0b00000_00000000_00000000_00000000_00000000_00000000_00001000;
+    public const U_PROFILE_BIRTHDATE_EDIT    = 0b00000_00000000_00000000_00000000_00000000_00000000_00010000;
+    public const U_FORUM_SIGNATURE_EDIT      = 0b00000_00000000_00000000_00000000_00000000_00000000_00100000;
+    public const U_USERS_MANAGE              = 0b00000_00000000_00000000_00000000_00010000_00000000_00000000;
+    public const U_ROLES_MANAGE              = 0b00000_00000000_00000000_00000000_00100000_00000000_00000000;
+    public const U_PERMS_MANAGE              = 0b00000_00000000_00000000_00000000_01000000_00000000_00000000;
+    public const U_REPORTS_MANAGE            = 0b00000_00000000_00000000_00000000_10000000_00000000_00000000; // unused: reports are not implemented
+    public const U_WARNINGS_MANAGE           = 0b00000_00000000_00000000_00000001_00000000_00000000_00000000;
+    //public const U_BLACKLISTS_MANAGE       = 0b00000_00000000_00000000_00000010_00000000_00000000_00000000; // deprecated: replaced with GLOBAL_BLACKLIST_MANAGE
+    public const U_NOTES_MANAGE              = 0b00000_00000000_00000000_00000100_00000000_00000000_00000000;
+    public const U_BANS_MANAGE               = 0b00000_00000000_00000000_00001000_00000000_00000000_00000000;
+    public const U_CAN_IMPERSONATE           = 0b00000_00000000_00000000_00010000_00000000_00000000_00000000;
+
+    // FORUM ALLOCATION:
+    // 0bXXXXX_XXXXXXXX_XXXXXXXX_PPPPPPPP_PPPPTTTT_TTTTTTTT_CCCCCCCC
+    //     C -> Category related
+    //    T -> Topic related
+    //   N -> Post related
+    //  X -> unallocated
+    public const F_CATEGORY_LIST         = 0b00000_00000000_00000000_00000000_00000000_00000000_00000001;
+    public const F_CATEGORY_VIEW         = 0b00000_00000000_00000000_00000000_00000000_00000000_00000010;
+    public const F_TOPIC_CREATE          = 0b00000_00000000_00000000_00000000_00000000_00000100_00000000;
+    //public const F_TOPIC_DELETE        = 0b00000_00000000_00000000_00000000_00000000_00001000_00000000; // deprecated: use F_POST_DELETE_ANY instead
+    public const F_TOPIC_MOVE            = 0b00000_00000000_00000000_00000000_00000000_00010000_00000000; // unused: topic moving not implemented
+    public const F_TOPIC_LOCK            = 0b00000_00000000_00000000_00000000_00000000_00100000_00000000;
+    public const F_TOPIC_STICKY          = 0b00000_00000000_00000000_00000000_00000000_01000000_00000000;
+    public const F_TOPIC_ANNOUNCE_LOCAL  = 0b00000_00000000_00000000_00000000_00000000_10000000_00000000;
+    public const F_TOPIC_ANNOUNCE_GLOBAL = 0b00000_00000000_00000000_00000000_00000001_00000000_00000000;
+    public const F_TOPIC_BUMP            = 0b00000_00000000_00000000_00000000_00000010_00000000_00000000;
+    public const F_TOPIC_PRIORITY_VOTE   = 0b00000_00000000_00000000_00000000_00000100_00000000_00000000; // unused: feature postponed, reuse if it makes sense otherwise deprecate
+    public const F_POST_CREATE           = 0b00000_00000000_00000000_00000000_00010000_00000000_00000000;
+    public const F_POST_EDIT_OWN         = 0b00000_00000000_00000000_00000000_00100000_00000000_00000000;
+    public const F_POST_EDIT_ANY         = 0b00000_00000000_00000000_00000000_01000000_00000000_00000000;
+    public const F_POST_DELETE_OWN       = 0b00000_00000000_00000000_00000000_10000000_00000000_00000000;
+    public const F_POST_DELETE_ANY       = 0b00000_00000000_00000000_00000001_00000000_00000000_00000000;
+
+    public const INFO_FOR_USER = ['global', 'user'];
+    public const INFO_FOR_ROLE = self::INFO_FOR_USER; // just alias for now, no clue if this will ever desync
+    public const INFO_FOR_FORUM_CATEGORY = ['forum'];
+
+    public const LISTS_FOR_USER = ['global:general', 'global:changelog', 'global:news', 'global:forum', 'global:comments', 'user:personal', 'user:manage'];
+    public const LISTS_FOR_ROLE = self::LISTS_FOR_USER; // idem
+    public const LISTS_FOR_FORUM_CATEGORY = ['forum:category', 'forum:topic', 'forum:post'];
+
+    public const LISTS = [
+        'global:general' => [
+            'title' => 'Global Permissions',
+            'perms' => [
+                'global',
+                self::G_IS_JANITOR,
+                self::G_LOGS_VIEW,
+                self::G_EMOTES_MANAGE,
+                self::G_CONFIG_MANAGE,
+                self::G_BLACKLIST_MANAGE,
+            ],
+        ],
+
+        'global:changelog' => [
+            'title' => 'Changelog Permissions',
+            'perms' => [
+                'global',
+                self::G_CL_CHANGES_MANAGE,
+                self::G_CL_TAGS_MANAGE,
+            ],
+        ],
+
+        'global:news' => [
+            'title' => 'News Permissions',
+            'perms' => [
+                'global',
+                self::G_NEWS_POSTS_MANAGE,
+                self::G_NEWS_CATEGORIES_MANAGE,
+            ],
+        ],
+
+        'global:forum' => [
+            'title' => 'Global Forum Permissions',
+            'perms' => [
+                'global',
+                self::G_FORUM_CATEGORIES_MANAGE,
+                self::G_FORUM_LEADERBOARD_VIEW,
+                self::G_FORUM_TOPIC_REDIRS_MANAGE,
+            ],
+        ],
+
+        'global:comments' => [
+            'title' => 'Comments Permissions',
+            'perms' => [
+                'global',
+                self::G_COMMENTS_CREATE,
+                self::G_COMMENTS_EDIT_OWN,
+                self::G_COMMENTS_EDIT_ANY,
+                self::G_COMMENTS_DELETE_OWN,
+                self::G_COMMENTS_DELETE_ANY,
+                self::G_COMMENTS_PIN,
+                self::G_COMMENTS_LOCK,
+                self::G_COMMENTS_VOTE,
+            ],
+        ],
+
+        'user:personal' => [
+            'title' => 'User Permissions',
+            'perms' => [
+                'user',
+                self::U_PROFILE_EDIT,
+                self::U_AVATAR_CHANGE,
+                self::U_PROFILE_BACKGROUND_CHANGE,
+                self::U_PROFILE_ABOUT_EDIT,
+                self::U_FORUM_SIGNATURE_EDIT,
+                self::U_PROFILE_BIRTHDATE_EDIT,
+            ],
+        ],
+
+        'user:manage' => [
+            'title' => 'User Management Permissions',
+            'perms' => [
+                'user',
+                self::U_REPORTS_MANAGE,
+                self::U_NOTES_MANAGE,
+                self::U_WARNINGS_MANAGE,
+                self::U_BANS_MANAGE,
+                self::U_USERS_MANAGE,
+                self::U_ROLES_MANAGE,
+                self::U_PERMS_MANAGE,
+                self::U_CAN_IMPERSONATE,
+            ],
+        ],
+
+        'forum:category' => [
+            'title' => 'Forum Category Permissions',
+            'perms' => [
+                'forum',
+                self::F_CATEGORY_LIST,
+                self::F_CATEGORY_VIEW,
+            ],
+        ],
+
+        'forum:topic' => [
+            'title' => 'Forum Topic Permissions',
+            'perms' => [
+                'forum',
+                self::F_TOPIC_CREATE,
+                self::F_TOPIC_MOVE,
+                self::F_TOPIC_LOCK,
+                self::F_TOPIC_STICKY,
+                self::F_TOPIC_ANNOUNCE_LOCAL,
+                self::F_TOPIC_ANNOUNCE_GLOBAL,
+                self::F_TOPIC_BUMP,
+                self::F_TOPIC_PRIORITY_VOTE,
+            ],
+        ],
+
+        'forum:post' => [
+            'title' => 'Forum Topic Permissions',
+            'perms' => [
+                'forum',
+                self::F_POST_CREATE,
+                self::F_POST_EDIT_OWN,
+                self::F_POST_EDIT_ANY,
+                self::F_POST_DELETE_OWN,
+                self::F_POST_DELETE_ANY,
+            ],
+        ],
+    ];
+
+    public const LABELS = [
+        'global' => [
+            self::G_IS_JANITOR => 'Can access the Broom closet.',
+            self::G_LOGS_VIEW => 'Can view global audit logs.',
+            self::G_EMOTES_MANAGE => 'Can manage emoticons.',
+            self::G_CONFIG_MANAGE => 'Can manage global configuration.',
+            //self::G_IS_TESTER => 'Can test experimental features.',
+            self::G_BLACKLIST_MANAGE => 'Can manage registration IP address blacklist.',
+            //self::G_TWITTER_MANAGE => 'Can manage Twitter integration settings.',
+
+            self::G_CL_CHANGES_MANAGE => 'Can manage changelog entries.',
+            self::G_CL_TAGS_MANAGE => 'Can manage changelog tags.',
+            //self::G_CL_ACTIONS_MANAGE => 'Can manage changelog action types.',
+
+            self::G_NEWS_POSTS_MANAGE => 'Can manage news posts.',
+            self::G_NEWS_CATEGORIES_MANAGE => 'Can manage news categories.',
+
+            self::G_FORUM_CATEGORIES_MANAGE => 'Can manage forum categories.',
+            self::G_FORUM_LEADERBOARD_VIEW => 'Can view forum leaderboard.',
+            self::G_FORUM_TOPIC_REDIRS_MANAGE => 'Can create redirects for deleted forum topics.',
+
+            self::G_COMMENTS_CREATE => 'Can post comments.',
+            self::G_COMMENTS_EDIT_OWN => 'Can edit own comments.',
+            self::G_COMMENTS_EDIT_ANY => 'Can edit ANY comment.',
+            self::G_COMMENTS_DELETE_OWN => 'Can delete own comments.',
+            self::G_COMMENTS_DELETE_ANY => 'Can delete ANY comment.',
+            self::G_COMMENTS_PIN => 'Can pin commments.',
+            self::G_COMMENTS_LOCK => 'Can lock comment categories.',
+            self::G_COMMENTS_VOTE => 'Can vote (like or dislike) on comments.',
+        ],
+
+        'user' => [
+            self::U_PROFILE_EDIT => 'Can edit own profile.',
+            self::U_AVATAR_CHANGE => 'Can change own avatar.',
+            self::U_PROFILE_BACKGROUND_CHANGE => 'Can change own profile background.',
+            self::U_PROFILE_ABOUT_EDIT => 'Can edit own profile about section.',
+            self::U_PROFILE_BIRTHDATE_EDIT => 'Can edit own profile birthdate.',
+            self::U_FORUM_SIGNATURE_EDIT => 'Can edit own forum signature.',
+
+            self::U_USERS_MANAGE => 'Can manage other users.',
+            self::U_ROLES_MANAGE => 'Can manage user roles.',
+            self::U_PERMS_MANAGE => 'Can manage permissions.',
+            self::U_REPORTS_MANAGE => 'Can handle reports.',
+            self::U_WARNINGS_MANAGE => 'Can manage user warnings.',
+            //self::U_BLACKLISTS_MANAGE => 'Can manage registration IP address blacklist.',
+            self::U_NOTES_MANAGE => 'Can manage user notes.',
+            self::U_BANS_MANAGE => 'Can manage user bans.',
+            self::U_CAN_IMPERSONATE => 'Can impersonate select other users. Requires whitelisting in the configuration or super user status.',
+        ],
+
+        'forum' => [
+            self::F_CATEGORY_LIST => 'Can see the forum category listed but cannot access it.',
+            self::F_CATEGORY_VIEW => 'Can access the forum category.',
+
+            self::F_TOPIC_CREATE => 'Can create forum topics.',
+            //self::F_TOPIC_DELETE => 'Can delete forum topics.',
+            self::F_TOPIC_MOVE => 'Can move forum topics to other categories.',
+            self::F_TOPIC_LOCK => 'Can lock forum topics.',
+            self::F_TOPIC_STICKY => 'Can make sticky topics.',
+            self::F_TOPIC_ANNOUNCE_LOCAL => 'Can make local announcement topics.',
+            self::F_TOPIC_ANNOUNCE_GLOBAL => 'Can make global announcement topics.',
+            self::F_TOPIC_BUMP => 'Can bump topics without posting a reply.',
+            self::F_TOPIC_PRIORITY_VOTE => 'Can use the priority voting system.',
+
+            self::F_POST_CREATE => 'Can create forum posts (replies only, if not allowed to create topics).',
+            self::F_POST_EDIT_OWN => 'Can edit own forum posts.',
+            self::F_POST_EDIT_ANY => 'Can edit ANY forum post.',
+            self::F_POST_DELETE_OWN => 'Can delete own forum posts.',
+            self::F_POST_DELETE_ANY => 'Can delete ANY forum post.',
+        ],
+    ];
+
+    public static function label(string $category, int $permission): string {
+        return array_key_exists($category, self::LABELS)
+            && array_key_exists($permission, self::LABELS[$category])
+            ? self::LABELS[$category][$permission] : '';
+    }
+
+    public static function createList(array $sections): array {
+        $list = [];
+
+        foreach($sections as $sectionName) {
+            if(!array_key_exists($sectionName, self::LISTS))
+                continue;
+
+            $currentCategoryName = '';
+            $sectionInfo = self::LISTS[$sectionName];
+
+            $list[] = $item = new stdClass;
+            $item->name = $sectionName;
+            $item->title = $sectionInfo['title'];
+            $item->perms = [];
+
+            foreach($sectionInfo['perms'] as $permInfo) {
+                if(is_string($permInfo)) {
+                    $currentCategoryName = $permInfo;
+                    continue;
+                }
+
+                $categoryName = $currentCategoryName;
+                $perm = 0;
+                if(is_array($permInfo))
+                    [$categoryName, $perm] = $permInfo;
+                elseif(is_int($permInfo))
+                    $perm = $permInfo;
+
+                $item->perms[] = $permItem = new stdClass;
+                $permItem->category = $categoryName;
+                $permItem->name = sprintf('perms[%s:%d]', $categoryName, $perm);
+                $permItem->title = self::label($categoryName, $perm);
+                $permItem->value = $perm;
+            }
+        }
+
+        return $list;
+    }
+
+    public static function convertSubmission(array $raw, array $categories): array {
+        $apply = [];
+
+        foreach($raw as $name => $mode) {
+            $nameParts = explode(':', $name, 2);
+            if(count($nameParts) !== 2 || !ctype_alpha($nameParts[0]) || !ctype_digit($nameParts[1]))
+                continue;
+
+            [$category, $value] = $nameParts;
+            if(!in_array($category, $categories))
+                continue;
+
+            if($mode === 'yes' || $mode === 'never') {
+                if(!array_key_exists($category, $apply))
+                    $apply[$category] = ['allow' => 0, 'deny' => 0];
+
+                $apply[$category][$mode === 'yes' ? 'allow' : 'deny'] |= (int)$value;
+            }
+        }
+
+        return $apply;
+    }
+}
diff --git a/src/Perms/IPermissionResult.php b/src/Perms/IPermissionResult.php
new file mode 100644
index 00000000..e6701b55
--- /dev/null
+++ b/src/Perms/IPermissionResult.php
@@ -0,0 +1,9 @@
+<?php
+namespace Misuzu\Perms;
+
+interface IPermissionResult {
+    public function getCalculated(): int;
+    public function check(int $perm): bool;
+    public function checkMany(array $perms): object;
+    public function apply(callable $callable): IPermissionResult;
+}
diff --git a/src/Perms/PermissionInfo.php b/src/Perms/PermissionInfo.php
new file mode 100644
index 00000000..1a0cc404
--- /dev/null
+++ b/src/Perms/PermissionInfo.php
@@ -0,0 +1,83 @@
+<?php
+namespace Misuzu\Perms;
+
+use Index\Data\IDbResult;
+
+class PermissionInfo implements IPermissionResult {
+    use PermissionResultShared;
+
+    private ?string $userId;
+    private ?string $roleId;
+    private ?string $forumCategoryId;
+    private string $category;
+    private int $allow;
+    private int $deny;
+    private int $calculated;
+
+    public function __construct(IDbResult $result) {
+        $this->userId = $result->isNull(0) ? null : $result->getInteger(0);
+        $this->roleId = $result->isNull(1) ? null : $result->getInteger(1);
+        $this->forumCategoryId = $result->isNull(2) ? null : $result->getInteger(2);
+        $this->category = $result->getString(3);
+        $this->allow = $result->getInteger(4);
+        $this->deny = $result->getInteger(5);
+        $this->calculated = $this->allow & ~$this->deny;
+    }
+
+    public function hasUserId(): bool {
+        return $this->userId !== null;
+    }
+
+    public function getUserId(): ?string {
+        return $this->userId;
+    }
+
+    public function hasRoleId(): bool {
+        return $this->roleId !== null;
+    }
+
+    public function getRoleId(): ?string {
+        return $this->roleId;
+    }
+
+    public function hasForumCategoryId(): bool {
+        return $this->forumCategoryId !== null;
+    }
+
+    public function getForumCategoryId(): ?string {
+        return $this->forumCategoryId;
+    }
+
+    public function getCategory(): string {
+        return $this->category;
+    }
+
+    public function getAllow(): int {
+        return $this->allow;
+    }
+
+    public function getDeny(): int {
+        return $this->deny;
+    }
+
+    public function getCalculated(): int {
+        return $this->calculated;
+    }
+
+    public function check(int $perm): bool {
+        return ($this->calculated & $perm) > 0;
+    }
+
+    public function checkAllow(int $perm): bool {
+        return ($this->allow & $perm) > 0;
+    }
+
+    public function checkDeny(int $perm): bool {
+        return ($this->deny & $perm) > 0;
+    }
+
+    public function checkNeutral(int $perm): bool {
+        return ($this->allow & $perm) === 0
+            && ($this->deny & $perm) === 0;
+    }
+}
diff --git a/src/Perms/PermissionResult.php b/src/Perms/PermissionResult.php
new file mode 100644
index 00000000..d7f79f05
--- /dev/null
+++ b/src/Perms/PermissionResult.php
@@ -0,0 +1,16 @@
+<?php
+namespace Misuzu\Perms;
+
+class PermissionResult implements IPermissionResult {
+    use PermissionResultShared;
+
+    public function __construct(private int $calculated) {}
+
+    public function getCalculated(): int {
+        return $this->calculated;
+    }
+
+    public function check(int $perm): bool {
+        return ($this->calculated & $perm) > 0;
+    }
+}
diff --git a/src/Perms/PermissionResultShared.php b/src/Perms/PermissionResultShared.php
new file mode 100644
index 00000000..bf10679f
--- /dev/null
+++ b/src/Perms/PermissionResultShared.php
@@ -0,0 +1,24 @@
+<?php
+namespace Misuzu\Perms;
+
+use stdClass;
+use InvalidArgumentException;
+
+trait PermissionResultShared {
+    public function checkMany(array $perms): object {
+        if(empty($perms))
+            throw new InvalidArgumentException('$perms must not be empty.');
+
+        $result = new stdClass;
+        $calculated = $this->getCalculated();
+
+        foreach($perms as $name => $perm)
+            $result->{$name} = ($calculated & $perm) > 0;
+
+        return $result;
+    }
+
+    public function apply(callable $callable): IPermissionResult {
+        return new PermissionResult($callable($this->getCalculated()));
+    }
+}
diff --git a/src/Perms/Permissions.php b/src/Perms/Permissions.php
new file mode 100644
index 00000000..f5887f7a
--- /dev/null
+++ b/src/Perms/Permissions.php
@@ -0,0 +1,363 @@
+<?php
+namespace Misuzu\Perms;
+
+use stdClass;
+use InvalidArgumentException;
+use RuntimeException;
+use Index\DateTime;
+use Index\Data\DbStatementCache;
+use Index\Data\DbTools;
+use Index\Data\IDbConnection;
+use Index\Data\IDbStatement;
+use Misuzu\Forum\Forum;
+use Misuzu\Forum\ForumCategoryInfo;
+use Misuzu\Users\RoleInfo;
+use Misuzu\Users\UserInfo;
+
+class Permissions {
+    // limiting this to 53-bit in case it ever has to be sent to javascript or any other implicit float language
+    // For More Information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
+    // it's still a ways up from the 31-bit of the old permission system which only existed because i developed on a 32-bit laptop for a bit
+    public const PERMS_MIN = 0;
+    public const PERMS_MAX = 9007199254740991;
+
+    private IDbConnection $dbConn;
+    private DbStatementCache $cache;
+
+    public function __construct(IDbConnection $dbConn) {
+        $this->dbConn = $dbConn;
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    // this method is purely intended for getting the permission data for a single entity
+    // it should not be used to do actual permission checks
+    public function getPermissionInfo(
+        UserInfo|string|null $userInfo = null,
+        RoleInfo|string|null $roleInfo = null,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null,
+        array|string|null $categoryNames = null,
+    ): PermissionInfo|array|null {
+        $hasUserInfo = $userInfo !== null;
+        $hasRoleInfo = $roleInfo !== null;
+        if($hasUserInfo && $hasRoleInfo)
+            throw new InvalidArgumentException('$userInfo and $roleInfo may not be set at the same time.');
+
+        $hasForumCategoryInfo = $forumCategoryInfo !== null;
+        $hasCategoryName = $categoryNames !== null;
+        $categoryNamesIsArray = $hasCategoryName && is_array($categoryNames);
+        if($categoryNamesIsArray && empty($categoryNames))
+            throw new InvalidArgumentException('$categoryNames may not be empty if it is an array.');
+
+        $query = 'SELECT user_id, role_id, forum_id, perms_category, perms_allow, perms_deny FROM msz_perms';
+        $query .= sprintf(' WHERE user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND role_id %s', $hasRoleInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
+        if($hasCategoryName)
+            $query .= ' AND perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        if($hasUserInfo)
+            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
+        if($hasRoleInfo)
+            $stmt->addParameter(++$args, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
+        if($hasForumCategoryInfo)
+            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
+        if($hasCategoryName) {
+            if($categoryNamesIsArray) {
+                foreach($categoryNames as $name)
+                    $stmt->addParameter(++$args, $name);
+            } else
+                $stmt->addParameter(++$args, $categoryNames);
+        }
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+
+        if(is_string($categoryNames))
+            return $result->next() ? new PermissionInfo($result) : null;
+
+        $perms = [];
+        while($result->next())
+            $perms[$result->getString(3)] = new PermissionInfo($result);
+
+        return $perms;
+    }
+
+    public function setPermissions(
+        string $categoryName,
+        int $allow,
+        int $deny,
+        UserInfo|string|null $userInfo = null,
+        RoleInfo|string|null $roleInfo = null,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null
+    ): void {
+        if($allow < self::PERMS_MIN || $allow > self::PERMS_MAX)
+            throw new InvalidArgumentException('$allow must be an positive 53-bit integer.');
+        if($deny < self::PERMS_MIN || $deny > self::PERMS_MAX)
+            throw new InvalidArgumentException('$allow must be an positive 53-bit integer.');
+        if($userInfo !== null && $roleInfo !== null)
+            throw new InvalidArgumentException('$userInfo and $roleInfo may not be set at the same time.');
+
+        // because of funny technical reasons we have to delete separately
+        $this->removePermissions($categoryName, $userInfo, $roleInfo, $forumCategoryInfo);
+
+        // don't insert zeroes
+        if($allow === 0 && $deny === 0)
+            return;
+
+        $stmt = $this->cache->get('INSERT INTO msz_perms (user_id, role_id, forum_id, perms_category, perms_allow, perms_deny) VALUES (?, ?, ?, ?, ?, ?)');
+        $stmt->addParameter(1, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
+        $stmt->addParameter(2, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
+        $stmt->addParameter(3, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
+        $stmt->addParameter(4, $categoryName);
+        $stmt->addParameter(5, $allow);
+        $stmt->addParameter(6, $deny);
+        $stmt->execute();
+    }
+
+    public function removePermissions(
+        array|string|null $categoryNames,
+        UserInfo|string|null $userInfo = null,
+        RoleInfo|string|null $roleInfo = null,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null
+    ): void {
+        $hasUserInfo = $userInfo !== null;
+        $hasRoleInfo = $roleInfo !== null;
+        $hasForumCategoryInfo = $forumCategoryInfo !== null;
+        $hasCategoryNames = $categoryNames !== null;
+        $categoryNamesIsArray = $hasCategoryNames && is_array($categoryNames);
+
+        $query = 'DELETE FROM msz_perms';
+        $query .= sprintf(' WHERE user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND role_id %s', $hasRoleInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
+        if($hasCategoryNames)
+            $query .= ' AND perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        if($hasUserInfo)
+            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
+        if($hasRoleInfo)
+            $stmt->addParameter(++$args, $roleInfo instanceof RoleInfo ? $roleInfo->getId() : $roleInfo);
+        if($hasForumCategoryInfo)
+            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
+        if($categoryNamesIsArray) {
+            foreach($categoryNames as $name)
+                $stmt->addParameter(++$args, $name);
+        } else
+            $stmt->addParameter(++$args, $categoryNames);
+        $stmt->execute();
+    }
+
+    public function checkPermissions(
+        string $categoryName,
+        int $perms,
+        UserInfo|string|null $userInfo = null,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null
+    ): int {
+        $hasUserInfo = $userInfo !== null;
+        $hasForumCategoryInfo = $forumCategoryInfo !== null;
+
+        $query = 'SELECT perms_calculated & ? FROM msz_perms_calculated WHERE perms_category = ?';
+        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        $stmt->addParameter(++$args, $perms);
+        $stmt->addParameter(++$args, $categoryName);
+        if($hasForumCategoryInfo)
+            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
+        if($hasUserInfo)
+            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        return $result->next() ? $result->getInteger(0) : 0;
+    }
+
+    public function getPermissions(
+        string|array $categoryNames,
+        UserInfo|string|null $userInfo = null,
+        ForumCategoryInfo|string|null $forumCategoryInfo = null
+    ): PermissionResult|stdClass {
+        $categoryNamesIsArray = is_array($categoryNames);
+        if($categoryNamesIsArray && empty($categoryNames))
+            throw new InvalidArgumentException('$categoryNames may not be an empty array.');
+
+        $hasUserInfo = $userInfo !== null;
+        $hasForumCategoryInfo = $forumCategoryInfo !== null;
+
+        $query = 'SELECT perms_category, perms_calculated FROM msz_perms_calculated';
+        $query .= ' WHERE perms_category ' . ($categoryNamesIsArray ? sprintf('IN (%s)', DbTools::prepareListString($categoryNames)) : '= ?');
+        $query .= sprintf(' AND forum_id %s', $hasForumCategoryInfo ? '= ?' : 'IS NULL');
+        $query .= sprintf(' AND user_id %s', $hasUserInfo ? '= ?' : 'IS NULL');
+        $query .= ' GROUP BY perms_category';
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+
+        if($categoryNamesIsArray) {
+            foreach($categoryNames as $name)
+                $stmt->addParameter(++$args, $name);
+        } else
+            $stmt->addParameter(++$args, $categoryNames);
+        if($hasForumCategoryInfo)
+            $stmt->addParameter(++$args, $forumCategoryInfo instanceof ForumCategoryInfo ? $forumCategoryInfo->getId() : $forumCategoryInfo);
+        if($hasUserInfo)
+            $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$categoryNamesIsArray)
+            return new PermissionResult($result->next() ? $result->getInteger(1) : 0);
+
+        $results = [];
+        while($result->next())
+            $results[$result->getString(0)] = $result->getInteger(1);
+
+        $sets = new stdClass;
+        foreach($categoryNames as $categoryName)
+            $sets->{$categoryName} = new PermissionResult($results[$categoryName] ?? 0);
+
+        return $sets;
+    }
+
+    // precalculates all permissions for fast lookups, don't run this from the browser lol
+    // TODO: only recalc a subset of users (e.g. personal permission changes/role add/remove)
+    public function precalculatePermissions(Forum $forum): void {
+        self::precalculatePermissionsLog('Loading list of user IDs...');
+        $userIds = [];
+        $result = $this->dbConn->query('SELECT user_id FROM msz_users');
+        while($result->next())
+            $userIds[] = $result->getString(0);
+
+        self::precalculatePermissionsLog('Clearing existing precalculations...');
+        $this->dbConn->execute('TRUNCATE msz_perms_calculated');
+
+        self::precalculatePermissionsLog('Creating inserter statement...');
+        $insert = $this->cache->get('INSERT INTO msz_perms_calculated (user_id, forum_id, perms_category, perms_calculated) VALUES (?, ?, ?, ?)');
+
+        self::precalculatePermissionsLog('Calculating guest permissions...');
+        $result = $this->dbConn->query('SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IS NULL AND user_id IS NULL AND role_id IS NULL GROUP BY perms_category');
+        $insert->addParameter(1, null);
+        $insert->addParameter(2, null);
+        while($result->next()) {
+            $category = $result->getString(0);
+            $perms = $result->getInteger(1);
+            if($perms === 0)
+                continue;
+
+            self::precalculatePermissionsLog('Inserting guest permissions for category %s with value %x...', $category, $perms);
+            $insert->addParameter(3, $category);
+            $insert->addParameter(4, $perms);
+            $insert->execute();
+        }
+
+        self::precalculatePermissionsLog('Calculating user permissions...');
+        $stmt = $this->cache->get('SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IS NULL AND (user_id = ? OR role_id IN (SELECT role_id FROM msz_users_roles WHERE user_id = ?)) GROUP BY perms_category');
+        foreach($userIds as $userId) {
+            $insert->reset();
+            $insert->addParameter(1, $userId);
+            $insert->addParameter(2, null);
+
+            $stmt->reset();
+            $stmt->addParameter(1, $userId);
+            $stmt->addParameter(2, $userId);
+            $stmt->execute();
+
+            $result = $stmt->getResult();
+            while($result->next()) {
+                $category = $result->getString(0);
+                $perms = $result->getInteger(1);
+                if($perms === 0)
+                    continue;
+
+                self::precalculatePermissionsLog('Inserting user #%s permissions for category %s with value %x...', $userId, $category, $perms);
+                $insert->addParameter(3, $category);
+                $insert->addParameter(4, $perms);
+                $insert->execute();
+            }
+        }
+
+        self::precalculatePermissionsLog('Loading list of forum categories...');
+        $forumCats = $forum->getCategories(asTree: true);
+        foreach($forumCats as $forumCat)
+            $this->precalculatePermissionsForForumCategory($insert, $userIds, $forumCat);
+
+        self::precalculatePermissionsLog('Finished permission precalculations!');
+    }
+
+    private function precalculatePermissionsForForumCategory(IDbStatement $insert, array $userIds, object $forumCat, array $catIds = []): void {
+        $catIds[] = $currentCatId = $forumCat->info->getId();
+        self::precalculatePermissionsLog('Precalcuting permissions for forum category #%s (%s)...', $currentCatId, implode(' <- ', $catIds));
+
+        self::precalculatePermissionsLog('Calculating guest permission for forum category #%s...', $currentCatId);
+        $args = 0;
+        $stmt = $this->cache->get(sprintf(
+            'SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IN (%s) AND user_id IS NULL AND role_id IS NULL GROUP BY perms_category',
+            DbTools::prepareListString($catIds)
+        ));
+        foreach($catIds as $catId)
+            $stmt->addParameter(++$args, $catId);
+        $stmt->execute();
+
+        $insert->reset();
+        $insert->addParameter(1, null);
+        $insert->addParameter(2, $currentCatId);
+        $result = $stmt->getResult();
+        while($result->next()) {
+            $category = $result->getString(0);
+            $perms = $result->getInteger(1);
+            if($perms === 0)
+                continue;
+
+            self::precalculatePermissionsLog('Inserting guest permissions for category %s with value %x for forum category #%s...', $category, $perms, $currentCatId);
+            $insert->addParameter(3, $category);
+            $insert->addParameter(4, $perms);
+            $insert->execute();
+        }
+
+        $args = 0;
+        $stmt = $this->cache->get(sprintf(
+            'SELECT perms_category, BIT_OR(perms_allow) & ~BIT_OR(perms_deny) FROM msz_perms WHERE forum_id IN (%s) AND (user_id = ? OR role_id IN (SELECT role_id FROM msz_users_roles WHERE user_id = ?)) GROUP BY perms_category',
+            DbTools::prepareListString($catIds)
+        ));
+        foreach($catIds as $catId)
+            $stmt->addParameter(++$args, $catId);
+        $startArgs = $args;
+        foreach($userIds as $userId) {
+            $args = $startArgs;
+            $stmt->addParameter(++$args, $userId);
+            $stmt->addParameter(++$args, $userId);
+            $stmt->execute();
+
+            $insert->reset();
+            $insert->addParameter(1, $userId);
+            $insert->addParameter(2, $currentCatId);
+            $result = $stmt->getResult();
+            while($result->next()) {
+                $category = $result->getString(0);
+                $perms = $result->getInteger(1);
+                if($perms === 0)
+                    continue;
+
+                self::precalculatePermissionsLog('Inserting user #%s permissions for category %s with value %x for forum category #%s...', $userId, $category, $perms, $currentCatId);
+                $insert->addParameter(3, $category);
+                $insert->addParameter(4, $perms);
+                $insert->execute();
+            }
+        }
+
+        foreach($forumCat->children as $forumChild)
+            $this->precalculatePermissionsForForumCategory($insert, $userIds, $forumChild, $catIds);
+    }
+
+    private static function precalculatePermissionsLog(string $fmt, ...$args): void {
+        echo DateTime::now()->format('[H:i:s.u] ');
+        vprintf($fmt, $args);
+        echo PHP_EOL;
+    }
+}
diff --git a/src/SharpChat/SharpChatPerms.php b/src/SharpChat/SharpChatPerms.php
index 3ce6fd2a..d83699cd 100644
--- a/src/SharpChat/SharpChatPerms.php
+++ b/src/SharpChat/SharpChatPerms.php
@@ -1,6 +1,8 @@
 <?php
 namespace Misuzu\SharpChat;
 
+use Misuzu\Perm;
+use Misuzu\Perms\Permissions;
 use Misuzu\Users\UserInfo;
 
 final class SharpChatPerms {
@@ -30,25 +32,25 @@ final class SharpChatPerms {
     private const PERMS_MANAGE_FORUM = self::P_CREATE_CHANNEL | self::P_SET_CHAN_PERMA | self::P_SET_CHAN_PASS
                                         | self::P_SET_CHAN_HIER | self::P_DELETE_CHANNEL | self::P_JOIN_ANY_CHAN;
 
-    public static function convert(UserInfo $userInfo): int {
-        $userInfo = (int)$userInfo->getId();
-        $perms = self::PERMS_DEFAULT;
+    public static function convert(Permissions $perms, UserInfo $userInfo): int {
+        $perms = $perms->getPermissions(['global', 'user'], $userInfo);
+        $convert = self::PERMS_DEFAULT;
 
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo, MSZ_PERM_USER_MANAGE_USERS))
-            $perms |= self::PERMS_MANAGE_USERS;
+        if($perms->user->check(Perm::U_USERS_MANAGE))
+            $convert |= self::PERMS_MANAGE_USERS;
 
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo, MSZ_PERM_USER_MANAGE_WARNINGS))
-            $perms |= self::P_KICK_USER;
+        if($perms->user->check(Perm::U_WARNINGS_MANAGE))
+            $convert |= self::P_KICK_USER;
 
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo, MSZ_PERM_USER_MANAGE_BANS))
-            $perms |= self::P_BAN_USER;
+        if($perms->user->check(Perm::U_BANS_MANAGE))
+            $convert |= self::P_BAN_USER;
 
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo, MSZ_PERM_USER_CHANGE_BACKGROUND))
-            $perms |= self::PERMS_CHANGE_BACKG;
+        if($perms->user->check(Perm::U_PROFILE_BACKGROUND_CHANGE))
+            $convert |= self::PERMS_CHANGE_BACKG;
 
-        if(perms_check_user(MSZ_PERMS_FORUM, $userInfo, MSZ_PERM_FORUM_MANAGE_FORUMS))
-            $perms |= self::PERMS_MANAGE_FORUM;
+        if($perms->global->check(Perm::G_FORUM_CATEGORIES_MANAGE))
+            $convert |= self::PERMS_MANAGE_FORUM;
 
-        return $perms;
+        return $convert;
     }
 }
diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php
index c683ffbd..08595d3c 100644
--- a/src/SharpChat/SharpChatRoutes.php
+++ b/src/SharpChat/SharpChatRoutes.php
@@ -10,6 +10,7 @@ use Misuzu\Auth\AuthInfo;
 use Misuzu\Auth\Sessions;
 use Misuzu\Config\IConfig;
 use Misuzu\Emoticons\Emotes;
+use Misuzu\Perms\Permissions;
 use Misuzu\Users\Bans;
 use Misuzu\Users\Users;
 
@@ -19,6 +20,7 @@ final class SharpChatRoutes {
     private Emotes $emotes;
     private Users $users;
     private Sessions $sessions;
+    private Permissions $perms;
     private AuthInfo $authInfo;
     private Closure $createAuthTokenPacker;
     private string $hashKey;
@@ -30,6 +32,7 @@ final class SharpChatRoutes {
         Emotes $emotes,
         Users $users,
         Sessions $sessions,
+        Permissions $perms,
         AuthInfo $authInfo,
         Closure $createAuthTokenPacker // this sucks lol
     ) {
@@ -38,6 +41,7 @@ final class SharpChatRoutes {
         $this->emotes = $emotes;
         $this->users = $users;
         $this->sessions = $sessions;
+        $this->perms = $perms;
         $this->authInfo = $authInfo;
         $this->createAuthTokenPacker = $createAuthTokenPacker;
         $this->hashKey = $this->config->getString('hashKey', 'woomy');
@@ -268,7 +272,7 @@ final class SharpChatRoutes {
             'colour_raw' => Colour::toMisuzu($userColour),
             'rank' => $userRank,
             'hierarchy' => $userRank,
-            'perms' => SharpChatPerms::convert($userInfo),
+            'perms' => SharpChatPerms::convert($this->perms, $userInfo),
         ];
     }
 
diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php
index 1d88490d..53975412 100644
--- a/src/Users/Assets/AssetsRoutes.php
+++ b/src/Users/Assets/AssetsRoutes.php
@@ -4,6 +4,7 @@ namespace Misuzu\Users\Assets;
 use InvalidArgumentException;
 use RuntimeException;
 use Index\Routing\IRouter;
+use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Users\Bans;
 use Misuzu\Users\Users;
@@ -28,8 +29,8 @@ class AssetsRoutes {
 
     private function canViewAsset($request, UserInfo $assetUser): bool {
         if($this->bans->countActiveBans($assetUser))
-            return $this->authInfo->isLoggedIn() // allow staff viewing profile to still see banned user assets
-                && perms_check_user(MSZ_PERMS_USER, (int)$this->authInfo->getUserId(), MSZ_PERM_USER_MANAGE_USERS)
+            // allow staff viewing profile to still see banned user assets
+            return $this->authInfo->getPerms('user')->check(Perm::U_USERS_MANAGE)
                 && parse_url($request->getHeaderFirstLine('Referer'), PHP_URL_PATH) === url('user-profile');
 
         return true;
diff --git a/src/perms.php b/src/perms.php
deleted file mode 100644
index 13eed9a4..00000000
--- a/src/perms.php
+++ /dev/null
@@ -1,729 +0,0 @@
-<?php
-define('MSZ_PERMS_ALLOW', 'allow');
-define('MSZ_PERMS_DENY', 'deny');
-
-define('MSZ_PERMS_GENERAL', 'general');
-define('MSZ_PERM_GENERAL_CAN_MANAGE',         0x00000001);
-define('MSZ_PERM_GENERAL_VIEW_LOGS',          0x00000002);
-define('MSZ_PERM_GENERAL_MANAGE_EMOTES',      0x00000004);
-define('MSZ_PERM_GENERAL_MANAGE_CONFIG',      0x00000008);
-//define('MSZ_PERM_GENERAL_IS_TESTER',        0x00000010); Has been unused for a while
-//define('MSZ_PERM_GENERAL_MANAGE_BLACKLIST', 0x00000020); Blacklist has been removed for now to reduce overhead and because it was broken(?)
-//define('MSZ_PERM_GENERAL_MANAGE_TWITTER',   0x00000040); Twitter integration has been removed
-
-define('MSZ_PERMS_USER', 'user');
-define('MSZ_PERM_USER_EDIT_PROFILE',        0x00000001);
-define('MSZ_PERM_USER_CHANGE_AVATAR',       0x00000002);
-define('MSZ_PERM_USER_CHANGE_BACKGROUND',   0x00000004);
-define('MSZ_PERM_USER_EDIT_ABOUT',          0x00000008);
-define('MSZ_PERM_USER_EDIT_BIRTHDATE',      0x00000010);
-define('MSZ_PERM_USER_EDIT_SIGNATURE',      0x00000020);
-define('MSZ_PERM_USER_MANAGE_USERS',        0x00100000);
-define('MSZ_PERM_USER_MANAGE_ROLES',        0x00200000);
-define('MSZ_PERM_USER_MANAGE_PERMS',        0x00400000);
-define('MSZ_PERM_USER_MANAGE_REPORTS',      0x00800000);
-define('MSZ_PERM_USER_MANAGE_WARNINGS',     0x01000000);
-//define('MSZ_PERM_USER_MANAGE_BLACKLISTS', 0x02000000); // Replaced with MSZ_PERM_GENERAL_MANAGE_BLACKLIST
-define('MSZ_PERM_USER_MANAGE_NOTES',        0x04000000);
-define('MSZ_PERM_USER_MANAGE_BANS',         0x08000000);
-define('MSZ_PERM_USER_IMPERSONATE',         0x10000000);
-
-define('MSZ_PERMS_CHANGELOG', 'changelog');
-define('MSZ_PERM_CHANGELOG_MANAGE_CHANGES',   0x00000001);
-define('MSZ_PERM_CHANGELOG_MANAGE_TAGS',      0x00000002);
-//define('MSZ_PERM_CHANGELOG_MANAGE_ACTIONS', 0x00000004); Deprecated, actions are hardcoded now
-
-define('MSZ_PERMS_NEWS', 'news');
-define('MSZ_PERM_NEWS_MANAGE_POSTS',      0x00000001);
-define('MSZ_PERM_NEWS_MANAGE_CATEGORIES', 0x00000002);
-
-define('MSZ_PERMS_FORUM', 'forum');
-define('MSZ_PERM_FORUM_MANAGE_FORUMS',    0x00000001);
-define('MSZ_PERM_FORUM_VIEW_LEADERBOARD', 0x00000002);
-define('MSZ_PERM_FORUM_TOPIC_REDIRS',     0x00000004);
-
-define('MSZ_PERMS_COMMENTS', 'comments');
-define('MSZ_PERM_COMMENTS_CREATE',     0x00000001);
-//define('MSZ_PERM_COMMENTS_EDIT_OWN', 0x00000002);
-//define('MSZ_PERM_COMMENTS_EDIT_ANY', 0x00000004);
-define('MSZ_PERM_COMMENTS_DELETE_OWN', 0x00000008);
-define('MSZ_PERM_COMMENTS_DELETE_ANY', 0x00000010);
-define('MSZ_PERM_COMMENTS_PIN',        0x00000020);
-define('MSZ_PERM_COMMENTS_LOCK',       0x00000040);
-define('MSZ_PERM_COMMENTS_VOTE',       0x00000080);
-
-define('MSZ_PERM_MODES', [
-    MSZ_PERMS_GENERAL, MSZ_PERMS_USER, MSZ_PERMS_CHANGELOG,
-    MSZ_PERMS_NEWS, MSZ_PERMS_FORUM, MSZ_PERMS_COMMENTS,
-]);
-
-define('MSZ_FORUM_PERMS_GENERAL', 'forum');
-
-define('MSZ_FORUM_PERM_LIST_FORUM', 0x00000001); // can see stats, but will get error when trying to view
-define('MSZ_FORUM_PERM_VIEW_FORUM', 0x00000002);
-
-define('MSZ_FORUM_PERM_CREATE_TOPIC',           0x00000400);
-//define('MSZ_FORUM_PERM_DELETE_TOPIC',         0x00000800); // use MSZ_FORUM_PERM_DELETE_ANY_POST instead
-define('MSZ_FORUM_PERM_MOVE_TOPIC',             0x00001000);
-define('MSZ_FORUM_PERM_LOCK_TOPIC',             0x00002000);
-define('MSZ_FORUM_PERM_STICKY_TOPIC',           0x00004000);
-define('MSZ_FORUM_PERM_ANNOUNCE_TOPIC',         0x00008000);
-define('MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC',  0x00010000);
-define('MSZ_FORUM_PERM_BUMP_TOPIC',             0x00020000);
-//define('MSZ_FORUM_PERM_PRIORITY_VOTE',        0x00040000); // feature postponed, perhaps reuse if it makes sense to
-
-define('MSZ_FORUM_PERM_CREATE_POST',     0x00100000);
-define('MSZ_FORUM_PERM_EDIT_POST',       0x00200000);
-define('MSZ_FORUM_PERM_EDIT_ANY_POST',   0x00400000);
-define('MSZ_FORUM_PERM_DELETE_POST',     0x00800000);
-define('MSZ_FORUM_PERM_DELETE_ANY_POST', 0x01000000);
-
-define('MSZ_FORUM_PERM_MODES', [
-    MSZ_FORUM_PERMS_GENERAL,
-]);
-
-function perms_get_keys(array $modes = MSZ_PERM_MODES): array {
-    $perms = [];
-
-    foreach($modes as $mode) {
-        $perms[] = perms_get_key($mode, MSZ_PERMS_ALLOW);
-        $perms[] = perms_get_key($mode, MSZ_PERMS_DENY);
-    }
-
-    return $perms;
-}
-
-function perms_create(array $modes = MSZ_PERM_MODES): array {
-    return array_fill_keys(perms_get_keys($modes), 0);
-}
-
-function perms_get_key(string $prefix, string $suffix): string {
-    return $prefix . '_perms_' . $suffix;
-}
-
-function perms_get_select(array $modes = MSZ_PERM_MODES, string $allow = MSZ_PERMS_ALLOW, string $deny = MSZ_PERMS_DENY): string {
-    $select = '';
-
-    foreach($modes as $mode) {
-        $select .= sprintf(
-            '(BIT_OR(`%1$s_perms_%2$s`) &~ BIT_OR(`%1$s_perms_%3$s`)) AS `%1$s`,',
-            $mode, $allow, $deny
-        );
-    }
-
-    $select = substr($select, 0, -1);
-
-    return $select;
-}
-
-function perms_get_blank(array $modes = MSZ_PERM_MODES): array {
-    return array_fill_keys($modes, 0);
-}
-
-function perms_get_user(int|string $user): array {
-    if(is_string($user))
-        $user = (int)$user;
-    if($user < 1)
-        return perms_get_blank();
-
-    static $memo = [];
-
-    if(array_key_exists($user, $memo)) {
-        return $memo[$user];
-    }
-
-    $getPerms = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT %s
-            FROM `msz_permissions`
-            WHERE (`user_id` = :user_id_1 AND `role_id` IS NULL)
-            OR (
-                `user_id` IS NULL
-                AND `role_id` IN (
-                    SELECT `role_id`
-                    FROM `msz_users_roles`
-                    WHERE `user_id` = :user_id_2
-                )
-            )
-        ',
-        perms_get_select()
-    ));
-    $getPerms->bind('user_id_1', $user);
-    $getPerms->bind('user_id_2', $user);
-
-    return $memo[$user] = $getPerms->fetch();
-}
-
-function perms_delete_user(int|string $user): bool {
-    if(is_string($user))
-        $user = (int)$user;
-    if($user < 1)
-        return false;
-
-    $deletePermissions = \Misuzu\DB::prepare('
-        DELETE FROM `msz_permissions`
-        WHERE `role_id` IS NULL
-        AND `user_id` = :user_id
-    ');
-    $deletePermissions->bind('user_id', $user);
-    return $deletePermissions->execute();
-}
-
-function perms_get_role(int $role): array {
-    if($role < 1) {
-        return perms_get_blank();
-    }
-
-    static $memo = [];
-
-    if(array_key_exists($role, $memo)) {
-        return $memo[$role];
-    }
-
-    $getPerms = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT %s
-            FROM `msz_permissions`
-            WHERE `role_id` = :role_id
-            AND `user_id` IS NULL
-        ',
-        perms_get_select()
-    ));
-    $getPerms->bind('role_id', $role);
-
-    return $memo[$role] = $getPerms->fetch();
-}
-
-function perms_get_user_raw(int|string $user): array {
-    if(is_string($user))
-        $user = (int)$user;
-    if($user < 1)
-        return perms_create();
-
-    $getPerms = \Misuzu\DB::prepare(sprintf('
-        SELECT `%s`
-        FROM `msz_permissions`
-        WHERE `user_id` = :user_id
-        AND `role_id` IS NULL
-    ', implode('`, `', perms_get_keys())));
-    $getPerms->bind('user_id', $user);
-    $perms = $getPerms->fetch();
-
-    if(empty($perms)) {
-        return perms_create();
-    }
-
-    return $perms;
-}
-
-function perms_set_user_raw(int|string $user, array $perms): bool {
-    if(is_string($user))
-        $user = (int)$user;
-    if($user < 1)
-        return false;
-
-    $realPerms = perms_create();
-    $permKeys = array_keys($realPerms);
-
-    foreach($permKeys as $perm) {
-        $realPerms[$perm] = (int)($perms[$perm] ?? 0);
-    }
-
-    $setPermissions = \Misuzu\DB::prepare(sprintf(
-        '
-            REPLACE INTO `msz_permissions`
-                (`role_id`, `user_id`, `%s`)
-            VALUES
-                (NULL, :user_id, :%s)
-        ',
-        implode('`, `', $permKeys),
-        implode(', :', $permKeys)
-    ));
-    $setPermissions->bind('user_id', $user);
-
-    foreach($realPerms as $key => $value) {
-        $setPermissions->bind($key, $value);
-    }
-
-    return $setPermissions->execute();
-}
-
-function perms_get_role_raw(int $role): array {
-    if($role < 1) {
-        return perms_create();
-    }
-
-    $getPerms = \Misuzu\DB::prepare(sprintf('
-        SELECT `%s`
-        FROM `msz_permissions`
-        WHERE `user_id` IS NULL
-        AND `role_id` = :role_id
-    ', implode('`, `', perms_get_keys())));
-    $getPerms->bind('role_id', $role);
-    $perms = $getPerms->fetch();
-
-    if(empty($perms)) {
-        return perms_create();
-    }
-
-    return $perms;
-}
-
-function perms_check(?int $perms, ?int $perm, bool $strict = false): bool {
-    $and = ($perms ?? 0) & ($perm ?? 0);
-    return $strict ? $and === $perm : $and > 0;
-}
-
-function perms_check_user(string $prefix, int|string|null $userId, int $perm, bool $strict = false): bool {
-    if(is_string($userId))
-        $userId = (int)$userId;
-    return $userId > 0 && perms_check(perms_get_user($userId)[$prefix] ?? 0, $perm, $strict);
-}
-
-function perms_check_bulk(int $perms, array $set, bool $strict = false): array {
-    foreach($set as $key => $perm) {
-        $set[$key] = perms_check($perms, $perm, $strict);
-    }
-
-    return $set;
-}
-
-function perms_check_user_bulk(string $prefix, int|string|null $userId, array $set, bool $strict = false): array {
-    $perms = perms_get_user($userId)[$prefix] ?? 0;
-    return perms_check_bulk($perms, $set, $strict);
-}
-
-function perms_for_comments(string|int $userId): array {
-    return perms_check_user_bulk(MSZ_PERMS_COMMENTS, $userId, [
-        'can_comment' => MSZ_PERM_COMMENTS_CREATE,
-        'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY,
-        'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY,
-        'can_pin' => MSZ_PERM_COMMENTS_PIN,
-        'can_lock' => MSZ_PERM_COMMENTS_LOCK,
-        'can_vote' => MSZ_PERM_COMMENTS_VOTE,
-    ]);
-}
-
-function forum_get_parent_id(int $forumId): int {
-    if($forumId < 1)
-        return 0;
-
-    static $memoized = [];
-
-    if(array_key_exists($forumId, $memoized))
-        return $memoized[$forumId];
-
-    $getParent = \Misuzu\DB::prepare('
-        SELECT `forum_parent`
-        FROM `msz_forum_categories`
-        WHERE `forum_id` = :forum_id
-    ');
-    $getParent->bind('forum_id', $forumId);
-
-    return (int)$getParent->fetchColumn();
-}
-
-function forum_perms_get_user(?int $forum, int $user): array {
-    $perms = perms_get_blank(MSZ_FORUM_PERM_MODES);
-
-    if($user < 0 || $forum < 0)
-        return $perms;
-
-    static $memo = [];
-    $memoId = "{$forum}-{$user}";
-
-    if(array_key_exists($memoId, $memo))
-        return $memo[$memoId];
-
-    if($forum > 0)
-        $perms = forum_perms_get_user(
-            forum_get_parent_id($forum),
-            $user
-        );
-
-    $getPerms = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT %s
-            FROM `msz_forum_permissions`
-            WHERE (`forum_id` = :forum_id OR `forum_id` IS NULL)
-            AND (
-                (`user_id` IS NULL AND `role_id` IS NULL)
-                OR (`user_id` = :user_id_1 AND `role_id` IS NULL)
-                OR (
-                    `user_id` IS NULL
-                    AND `role_id` IN (
-                        SELECT `role_id`
-                        FROM `msz_users_roles`
-                        WHERE `user_id` = :user_id_2
-                    )
-                )
-            )
-        ',
-        perms_get_select(MSZ_FORUM_PERM_MODES)
-    ));
-    $getPerms->bind('forum_id', $forum);
-    $getPerms->bind('user_id_1', $user);
-    $getPerms->bind('user_id_2', $user);
-
-    $userPerms = $getPerms->fetch();
-    foreach($perms as $key => $value)
-        $perms[$key] |= $userPerms[$key] ?? 0;
-
-    return $memo[$memoId] = $perms;
-}
-
-function forum_perms_check_user(
-    string $prefix,
-    ?int $forumId,
-    ?int $userId,
-    int $perm,
-    bool $strict = false
-): bool {
-    return perms_check(forum_perms_get_user($forumId, $userId)[$prefix] ?? 0, $perm, $strict);
-}
-
-define('MSZ_MANAGE_PERM_YES', 'yes');
-define('MSZ_MANAGE_PERM_NO', 'no');
-define('MSZ_MANAGE_PERM_NEVER', 'never');
-
-function manage_perms_value(int $perm, int $allow, int $deny): string {
-    if(perms_check($deny, $perm))
-        return MSZ_MANAGE_PERM_NEVER;
-    if(perms_check($allow, $perm))
-        return MSZ_MANAGE_PERM_YES;
-    return MSZ_MANAGE_PERM_NO;
-}
-
-function manage_perms_apply(array $list, array $post, ?array $raw = null): ?array {
-    $perms = $raw !== null ? $raw : perms_create();
-
-    foreach($list as $section) {
-        if(empty($post[$section['section']]) || !is_array($post[$section['section']]))
-            continue;
-
-        $allowKey = perms_get_key($section['section'], MSZ_PERMS_ALLOW);
-        $denyKey = perms_get_key($section['section'], MSZ_PERMS_DENY);
-
-        foreach($section['perms'] as $perm) {
-            if(empty($post[$section['section']][$perm['section']]['value']))
-                continue;
-            $perm['perm'] = (int)$perm['perm']; // makes phpstan happy
-
-            switch($post[$section['section']][$perm['section']]['value']) {
-                case MSZ_MANAGE_PERM_YES:
-                    $perms[$allowKey] |= $perm['perm'];
-                    $perms[$denyKey] &= ~$perm['perm'];
-                    break;
-
-                case MSZ_MANAGE_PERM_NEVER:
-                    $perms[$allowKey] &= ~$perm['perm'];
-                    $perms[$denyKey] |= $perm['perm'];
-                    break;
-
-                case MSZ_MANAGE_PERM_NO:
-                default:
-                    $perms[$allowKey] &= ~$perm['perm'];
-                    $perms[$denyKey] &= ~$perm['perm'];
-                    break;
-            }
-        }
-    }
-
-    $returnNothing = 0;
-    foreach($perms as $perm)
-        $returnNothing |= $perm;
-
-    return $returnNothing === 0 ? null : $perms;
-}
-
-function manage_perms_calculate(array $rawPerms, array $perms): array {
-    for($i = 0; $i < count($perms); $i++) {
-        $section = $perms[$i]['section'];
-        $allowKey = perms_get_key($section, MSZ_PERMS_ALLOW);
-        $denyKey = perms_get_key($section, MSZ_PERMS_DENY);
-
-        for($j = 0; $j < count($perms[$i]['perms']); $j++) {
-            $permission = $perms[$i]['perms'][$j]['perm'];
-            $perms[$i]['perms'][$j]['value'] = manage_perms_value($permission, $rawPerms[$allowKey], $rawPerms[$denyKey]);
-        }
-    }
-
-    return $perms;
-}
-
-function manage_perms_list(array $rawPerms): array {
-    return manage_perms_calculate($rawPerms, [
-        [
-            'section' => MSZ_PERMS_GENERAL,
-            'title' => 'General',
-            'perms' => [
-                [
-                    'section' => 'can-manage',
-                    'title' => 'Can access the management panel.',
-                    'perm' => MSZ_PERM_GENERAL_CAN_MANAGE,
-                ],
-                [
-                    'section' => 'view-logs',
-                    'title' => 'Can view audit logs.',
-                    'perm' => MSZ_PERM_GENERAL_VIEW_LOGS,
-                ],
-                [
-                    'section' => 'manage-emotes',
-                    'title' => 'Can manage emoticons.',
-                    'perm' => MSZ_PERM_GENERAL_MANAGE_EMOTES,
-                ],
-                [
-                    'section' => 'manage-settings',
-                    'title' => 'Can manage general Misuzu settings.',
-                    'perm' => MSZ_PERM_GENERAL_MANAGE_CONFIG,
-                ],
-            ],
-        ],
-        [
-            'section' => MSZ_PERMS_USER,
-            'title' => 'User',
-            'perms' => [
-                [
-                    'section' => 'edit-profile',
-                    'title' => 'Can edit own profile.',
-                    'perm' => MSZ_PERM_USER_EDIT_PROFILE,
-                ],
-                [
-                    'section' => 'change-avatar',
-                    'title' => 'Can change own avatar.',
-                    'perm' => MSZ_PERM_USER_CHANGE_AVATAR,
-                ],
-                [
-                    'section' => 'change-background',
-                    'title' => 'Can change own background.',
-                    'perm' => MSZ_PERM_USER_CHANGE_BACKGROUND,
-                ],
-                [
-                    'section' => 'edit-about',
-                    'title' => 'Can change own about section.',
-                    'perm' => MSZ_PERM_USER_EDIT_ABOUT,
-                ],
-                [
-                    'section' => 'edit-birthdate',
-                    'title' => 'Can change own birthdate.',
-                    'perm' => MSZ_PERM_USER_EDIT_BIRTHDATE,
-                ],
-                [
-                    'section' => 'edit-signature',
-                    'title' => 'Can change own signature.',
-                    'perm' => MSZ_PERM_USER_EDIT_SIGNATURE,
-                ],
-                [
-                    'section' => 'manage-users',
-                    'title' => 'Can manage other users.',
-                    'perm' => MSZ_PERM_USER_MANAGE_USERS,
-                ],
-                [
-                    'section' => 'manage-roles',
-                    'title' => 'Can manage roles.',
-                    'perm' => MSZ_PERM_USER_MANAGE_ROLES,
-                ],
-                [
-                    'section' => 'manage-perms',
-                    'title' => 'Can manage permissions.',
-                    'perm' => MSZ_PERM_USER_MANAGE_PERMS,
-                ],
-                [
-                    'section' => 'manage-reports',
-                    'title' => 'Can handle reports.',
-                    'perm' => MSZ_PERM_USER_MANAGE_REPORTS,
-                ],
-                [
-                    'section' => 'manage-notes',
-                    'title' => 'Can manage user notes.',
-                    'perm' => MSZ_PERM_USER_MANAGE_NOTES,
-                ],
-                [
-                    'section' => 'manage-warnings',
-                    'title' => 'Can manage user warnings.',
-                    'perm' => MSZ_PERM_USER_MANAGE_WARNINGS,
-                ],
-                [
-                    'section' => 'manage-bans',
-                    'title' => 'Can manage user bans.',
-                    'perm' => MSZ_PERM_USER_MANAGE_BANS,
-                ],
-                [
-                    'section' => 'impersonate',
-                    'title' => 'Can impersonate select users.',
-                    'perm' => MSZ_PERM_USER_IMPERSONATE,
-                ],
-            ],
-        ],
-        [
-            'section' => MSZ_PERMS_NEWS,
-            'title' => 'News',
-            'perms' => [
-                [
-                    'section' => 'manage-posts',
-                    'title' => 'Can manage posts.',
-                    'perm' => MSZ_PERM_NEWS_MANAGE_POSTS,
-                ],
-                [
-                    'section' => 'manage-cats',
-                    'title' => 'Can manage catagories.',
-                    'perm' => MSZ_PERM_NEWS_MANAGE_CATEGORIES,
-                ],
-            ],
-        ],
-        [
-            'section' => MSZ_PERMS_FORUM,
-            'title' => 'Forum',
-            'perms' => [
-                [
-                    'section' => 'manage-forums',
-                    'title' => 'Can manage forum sections.',
-                    'perm' => MSZ_PERM_FORUM_MANAGE_FORUMS,
-                ],
-                [
-                    'section' => 'view-leaderboard',
-                    'title' => 'Can view the forum leaderboard live.',
-                    'perm' => MSZ_PERM_FORUM_VIEW_LEADERBOARD,
-                ],
-                [
-                    'section' => 'topic-redirs',
-                    'title' => 'Can create redirects for deleted topics.',
-                    'perm' => MSZ_PERM_FORUM_TOPIC_REDIRS,
-                ],
-            ],
-        ],
-        [
-            'section' => MSZ_PERMS_COMMENTS,
-            'title' => 'Comments',
-            'perms' => [
-                [
-                    'section' => 'create',
-                    'title' => 'Can post comments.',
-                    'perm' => MSZ_PERM_COMMENTS_CREATE,
-                ],
-                [
-                    'section' => 'delete-own',
-                    'title' => 'Can delete own comments.',
-                    'perm' => MSZ_PERM_COMMENTS_DELETE_OWN,
-                ],
-                [
-                    'section' => 'delete-any',
-                    'title' => 'Can delete anyone\'s comments.',
-                    'perm' => MSZ_PERM_COMMENTS_DELETE_ANY,
-                ],
-                [
-                    'section' => 'pin',
-                    'title' => 'Can pin comments.',
-                    'perm' => MSZ_PERM_COMMENTS_PIN,
-                ],
-                [
-                    'section' => 'lock',
-                    'title' => 'Can lock comment threads.',
-                    'perm' => MSZ_PERM_COMMENTS_LOCK,
-                ],
-                [
-                    'section' => 'vote',
-                    'title' => 'Can like or dislike comments.',
-                    'perm' => MSZ_PERM_COMMENTS_VOTE,
-                ],
-            ],
-        ],
-        [
-            'section' => MSZ_PERMS_CHANGELOG,
-            'title' => 'Changelog',
-            'perms' => [
-                [
-                    'section' => 'manage-changes',
-                    'title' => 'Can manage changes.',
-                    'perm' => MSZ_PERM_CHANGELOG_MANAGE_CHANGES,
-                ],
-                [
-                    'section' => 'manage-tags',
-                    'title' => 'Can manage tags.',
-                    'perm' => MSZ_PERM_CHANGELOG_MANAGE_TAGS,
-                ],
-            ],
-        ],
-    ]);
-}
-
-function manage_forum_perms_list(array $rawPerms): array {
-    return manage_perms_calculate($rawPerms, [
-        [
-            'section' => MSZ_FORUM_PERMS_GENERAL,
-            'title' => 'Forum',
-            'perms' => [
-                [
-                    'section' => 'can-list',
-                    'title' => 'Can see the forum listed, but not access it.',
-                    'perm' => MSZ_FORUM_PERM_LIST_FORUM,
-                ],
-                [
-                    'section' => 'can-view',
-                    'title' => 'Can view and access the forum.',
-                    'perm' => MSZ_FORUM_PERM_VIEW_FORUM,
-                ],
-                [
-                    'section' => 'can-create-topic',
-                    'title' => 'Can create topics.',
-                    'perm' => MSZ_FORUM_PERM_CREATE_TOPIC,
-                ],
-                [
-                    'section' => 'can-move-topic',
-                    'title' => 'Can move topics between forums.',
-                    'perm' => MSZ_FORUM_PERM_MOVE_TOPIC,
-                ],
-                [
-                    'section' => 'can-lock-topic',
-                    'title' => 'Can lock topics.',
-                    'perm' => MSZ_FORUM_PERM_LOCK_TOPIC,
-                ],
-                [
-                    'section' => 'can-sticky-topic',
-                    'title' => 'Can make topics sticky.',
-                    'perm' => MSZ_FORUM_PERM_STICKY_TOPIC,
-                ],
-                [
-                    'section' => 'can-announce-topic',
-                    'title' => 'Can make topics announcements.',
-                    'perm' => MSZ_FORUM_PERM_ANNOUNCE_TOPIC,
-                ],
-                [
-                    'section' => 'can-global-announce-topic',
-                    'title' => 'Can make topics global announcements.',
-                    'perm' => MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC,
-                ],
-                [
-                    'section' => 'can-bump-topic',
-                    'title' => 'Can bump topics without posting a reply.',
-                    'perm' => MSZ_FORUM_PERM_BUMP_TOPIC,
-                ],
-                [
-                    'section' => 'can-create-post',
-                    'title' => 'Can make posts (reply only, if create topic is disallowed).',
-                    'perm' => MSZ_FORUM_PERM_CREATE_POST,
-                ],
-                [
-                    'section' => 'can-edit-post',
-                    'title' => 'Can edit their own posts.',
-                    'perm' => MSZ_FORUM_PERM_EDIT_POST,
-                ],
-                [
-                    'section' => 'can-edit-any-post',
-                    'title' => 'Can edit any posts.',
-                    'perm' => MSZ_FORUM_PERM_EDIT_ANY_POST,
-                ],
-                [
-                    'section' => 'can-delete-post',
-                    'title' => 'Can delete own posts.',
-                    'perm' => MSZ_FORUM_PERM_DELETE_POST,
-                ],
-                [
-                    'section' => 'can-delete-any-post',
-                    'title' => 'Can delete any posts.',
-                    'perm' => MSZ_FORUM_PERM_DELETE_ANY_POST,
-                ],
-            ],
-        ],
-    ]);
-}
diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig
index 2ed6df0e..e940fae9 100644
--- a/templates/_layout/comments.twig
+++ b/templates/_layout/comments.twig
@@ -127,7 +127,7 @@
                                     {% endif %}
                                 </a>
                             {% endif %}
-                            {% if perms.can_comment|default(false) %}
+                            {% if perms.can_post|default(false) %}
                                 <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label>
                             {% endif %}
                             {% if perms.can_delete_any|default(false) or (poster.id|default(0) == user.id and perms.can_delete|default(false)) %}
@@ -148,7 +148,7 @@
 
             <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.id }}-replies">
                 {% from _self import comments_entry, comments_input %}
-                {% if user|default(null) is not null and category|default(null) is not null and perms.can_comment|default(false) %}
+                {% if user|default(null) is not null and category|default(null) is not null and perms.can_post|default(false) %}
                     {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }}
                     {{ comments_input(category, user, perms, comment) }}
                 {% endif %}
@@ -183,7 +183,7 @@
                 <div class="comments__notice">
                     This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_format }}</time>.
                 </div>
-            {% elseif not perms.can_comment|default(false) %}
+            {% elseif not perms.can_post|default(false) %}
                 <div class="comments__notice">
                     You are not allowed to post comments.
                 </div>
@@ -199,12 +199,6 @@
             </div>
         {% endif %}
 
-        {#<noscript>
-            <div class="comments__javascript">
-                While the comments work fine without Javascript, it is recommended you enable it as it has a lower bandwidth overhead.
-            </div>
-        </noscript>#}
-
         <div class="comments__listing">
             {% if posts|length > 0 %}
                 {% from _self import comments_entry %}
diff --git a/templates/manage/forum/listing.twig b/templates/manage/forum/listing.twig
index 5435c6cb..a8247d78 100644
--- a/templates/manage/forum/listing.twig
+++ b/templates/manage/forum/listing.twig
@@ -10,17 +10,23 @@
 
         {% if calculated_perms is defined %}
             <table border="1">
+                <tr>
+                    <th style="padding: 2px 5px;">Category</th>
+                    <th style="padding: 2px 5px;">Allow</th>
+                    <th style="padding: 2px 5px;">Deny</th>
+                </tr>
                 {% for key, value in calculated_perms %}
                 <tr>
-                    <th>{{ key }}</th>
-                    <td><code>{{ value }}</code></td>
+                    <th style="padding: 2px 5px;">{{ key }}</th>
+                    <td style="padding: 2px 5px;"><code>{{ value.allow }}</code></td>
+                    <td style="padding: 2px 5px;"><code>{{ value.deny }}</code></td>
                 </tr>
                 {% endfor %}
             </table>
         {% endif %}
 
         <form method="post" action="">
-            {{ permissions_table(perms) }}
+            {{ permissions_table(perms_lists, perms_infos) }}
             <button class="input__button">Calculate</button>
         </form>
     </div>
diff --git a/templates/manage/macros.twig b/templates/manage/macros.twig
index 95e86d45..5c6572a4 100644
--- a/templates/manage/macros.twig
+++ b/templates/manage/macros.twig
@@ -14,14 +14,14 @@
     {% endfor %}
 {% endmacro %}
 
-{% macro permissions_table(permissions, readonly) %}
+{% macro permissions_table(lists, infos, readonly) %}
     {% from '_layout/input.twig' import input_checkbox %}
 
     <div class="permissions">
-        {% for perms in permissions %}
+        {% for list in lists %}
             <div class="permissions__line permissions__line--header">
                 <div class="permissions__title">
-                    {{ perms.title }}
+                    {{ list.title }}
                 </div>
                 <div class="permissions__choice">
                     Yes
@@ -34,19 +34,19 @@
                 </div>
             </div>
 
-            {% for perm in perms.perms %}
+            {% for perm in list.perms %}
                 <div class="permissions__line">
                     <div class="permissions__title">
                         {{ perm.title }}
                     </div>
                     <div class="permissions__choice__wrapper">
-                        {{ input_checkbox('perms[' ~ perms.section ~ '][' ~ perm.section ~ '][value]', '', perm.value == 'yes', 'permissions__choice permissions__choice--radio permissions__choice--yes', 'yes', true, null, readonly) }}
+                        {{ input_checkbox(perm.name, '', infos[perm.category].checkAllow(perm.value) ?? false, 'permissions__choice permissions__choice--radio permissions__choice--yes', 'yes', true, null, readonly) }}
                     </div>
                     <div class="permissions__choice__wrapper">
-                        {{ input_checkbox('perms[' ~ perms.section ~ '][' ~ perm.section ~ '][value]', '', perm.value == 'no', 'permissions__choice permissions__choice--radio permissions__choice--no', 'no', true, null, readonly) }}
+                        {{ input_checkbox(perm.name, '', infos[perm.category].checkNeutral(perm.value) ?? true, 'permissions__choice permissions__choice--radio permissions__choice--no', 'no', true, null, readonly) }}
                     </div>
                     <div class="permissions__choice__wrapper">
-                        {{ input_checkbox('perms[' ~ perms.section ~ '][' ~ perm.section ~ '][value]', '', perm.value == 'never', 'permissions__choice permissions__choice--radio permissions__choice--never', 'never', true, null, readonly) }}
+                        {{ input_checkbox(perm.name, '', infos[perm.category].checkDeny(perm.value) ?? false, 'permissions__choice permissions__choice--radio permissions__choice--never', 'never', true, null, readonly) }}
                     </div>
                 </div>
             {% endfor %}
diff --git a/templates/manage/users/role.twig b/templates/manage/users/role.twig
index 4668cb75..3ee317cd 100644
--- a/templates/manage/users/role.twig
+++ b/templates/manage/users/role.twig
@@ -93,7 +93,7 @@
 
         <div class="container">
             {{ container_title('Permissions') }}
-            {{ permissions_table(permissions, not can_manage_perms) }}
+            {{ permissions_table(perms_lists, perms_infos, not can_edit_perms) }}
         </div>
 
         <button class="input__button">{{ role_info is not null ? 'Update role' : 'Create role' }}</button>
diff --git a/templates/manage/users/user.twig b/templates/manage/users/user.twig
index 49e6e8dd..d718e472 100644
--- a/templates/manage/users/user.twig
+++ b/templates/manage/users/user.twig
@@ -183,11 +183,11 @@
             </form>
         {% endif %}
 
-        {% if permissions is not empty %}
+        {% if perms_lists is defined and perms_infos is defined %}
             <form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">
                 {{ container_title('Permissions for ' ~ user_info.name ~ ' (' ~ user_info.id ~ ')') }}
 
-                {{ permissions_table(permissions, not can_edit_perms) }}
+                {{ permissions_table(perms_lists, perms_infos, not can_edit_perms) }}
 
                 {% if can_edit_perms %}
                     {{ input_csrf() }}
diff --git a/tools/cron b/tools/cron
index bfadfb1c..1a830208 100755
--- a/tools/cron
+++ b/tools/cron
@@ -81,7 +81,8 @@ msz_sched_task_func('Recount forum topics and posts.', true, function() use ($ms
 msz_sched_task_sql('Clean up expired 2fa tokens.', false,
     'DELETE FROM msz_auth_tfa WHERE tfa_created < NOW() - INTERVAL 15 MINUTE');
 
-// make sure this one remains last
+// very heavy stuff that should
+
 msz_sched_task_func('Resync statistics counters.', true, function() use ($msz) {
     $dbConn = $msz->getDbConn();
     $counters = $msz->getCounters();
@@ -139,6 +140,15 @@ msz_sched_task_func('Resync statistics counters.', true, function() use ($msz) {
     }
 });
 
+msz_sched_task_func('Recalculate permissions (maybe)...', false, function() use ($msz) {
+    $needsRecalc = $msz->getConfig()->getBoolean('perms.needsRecalc');
+    if(!$needsRecalc)
+        return;
+
+    $msz->getConfig()->removeValues('perms.needsRecalc');
+    $msz->getPerms()->precalculatePermissions($msz->getForum());
+});
+
 echo 'Running ' . count($schedTasks) . ' tasks...' . PHP_EOL;
 
 $dbConn = $msz->getDbConn();
diff --git a/tools/recalc-perms b/tools/recalc-perms
new file mode 100644
index 00000000..ce94adf5
--- /dev/null
+++ b/tools/recalc-perms
@@ -0,0 +1,8 @@
+#!/usr/bin/env php
+<?php
+namespace Misuzu;
+
+require_once __DIR__ . '/../misuzu.php';
+
+$msz->getConfig()->removeValues('perms.needsRecalc');
+$msz->getPerms()->precalculatePermissions($msz->getForum());