about summary refs log tree commit diff
path: root/gn_auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth')
-rw-r--r--gn_auth/migrations/__init__.py (renamed from gn_auth/migrations.py)3
-rw-r--r--gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py19
-rw-r--r--gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py20
-rw-r--r--gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py19
-rw-r--r--gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py18
-rw-r--r--gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py19
-rw-r--r--gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py25
-rw-r--r--gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py17
-rw-r--r--gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py26
-rw-r--r--gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py19
-rw-r--r--gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py24
-rw-r--r--gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py29
-rw-r--r--gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py22
-rw-r--r--gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py66
-rw-r--r--gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py35
-rw-r--r--gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py41
-rw-r--r--gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py29
-rw-r--r--gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py56
-rw-r--r--gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py29
-rw-r--r--gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py35
-rw-r--r--gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py52
-rw-r--r--gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py25
-rw-r--r--gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py39
-rw-r--r--gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py16
-rw-r--r--gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py25
-rw-r--r--gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py31
-rw-r--r--gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py31
-rw-r--r--gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py40
-rw-r--r--gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py111
-rw-r--r--gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py29
-rw-r--r--gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py22
-rw-r--r--gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py38
-rw-r--r--gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py26
-rw-r--r--gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py42
-rw-r--r--gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py30
-rw-r--r--gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py29
-rw-r--r--gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py29
-rw-r--r--gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py29
-rw-r--r--gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py30
-rw-r--r--gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py28
-rw-r--r--gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py32
-rw-r--r--gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py29
-rw-r--r--gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py325
-rw-r--r--gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py58
-rw-r--r--gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py39
-rw-r--r--gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py227
-rw-r--r--gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py61
-rw-r--r--gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py84
-rw-r--r--gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py40
-rw-r--r--gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py34
-rw-r--r--gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py64
-rw-r--r--gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py94
-rw-r--r--gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py36
-rw-r--r--gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py35
-rw-r--r--gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py26
-rw-r--r--gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py24
-rw-r--r--gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py42
-rw-r--r--gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py49
-rw-r--r--gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py19
-rw-r--r--gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py23
-rw-r--r--gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py27
-rw-r--r--gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py18
-rw-r--r--gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py36
-rw-r--r--gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py31
-rw-r--r--gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py53
-rw-r--r--gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py75
-rw-r--r--gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py70
-rw-r--r--gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py61
-rw-r--r--gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py62
-rw-r--r--gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py66
-rw-r--r--gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py49
-rw-r--r--gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py69
-rw-r--r--gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py185
-rw-r--r--gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py19
-rw-r--r--gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py62
-rw-r--r--gn_auth/migrations/auth/__init__.py1
-rw-r--r--gn_auth/settings.py2
77 files changed, 3478 insertions, 2 deletions
diff --git a/gn_auth/migrations.py b/gn_auth/migrations/__init__.py
index 3451e07..6acb058 100644
--- a/gn_auth/migrations.py
+++ b/gn_auth/migrations/__init__.py
@@ -1,4 +1,5 @@
-"""Run the migrations in the app, rather than with yoyo CLI."""
+"""Migrations package: Provides the migrations, and some utility functions to
+help with dealing with migrations."""
 from pathlib import Path
 from typing import Union
 
diff --git a/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
new file mode 100644
index 0000000..d511f5d
--- /dev/null
+++ b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py
@@ -0,0 +1,19 @@
+"""
+Initialise the auth(entic|oris)ation database.
+"""
+
+from yoyo import step
+
+__depends__ = {} # type: ignore[var-annotated]
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS users(
+            user_id TEXT PRIMARY KEY NOT NULL,
+            email TEXT UNIQUE NOT NULL,
+            name TEXT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS users")
+]
diff --git a/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
new file mode 100644
index 0000000..48bd663
--- /dev/null
+++ b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
@@ -0,0 +1,20 @@
+"""
+create user_credentials table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS user_credentials(
+            user_id TEXT PRIMARY KEY,
+            password TEXT NOT NULL,
+            FOREIGN KEY(user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS user_credentials")
+]
diff --git a/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
new file mode 100644
index 0000000..29f92d4
--- /dev/null
+++ b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
@@ -0,0 +1,19 @@
+"""
+Create the groups table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221103_02_sGrIs-create-user-credentials-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS groups(
+            group_id TEXT PRIMARY KEY NOT NULL,
+            group_name TEXT NOT NULL,
+            group_metadata TEXT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS groups")
+]
diff --git a/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
new file mode 100644
index 0000000..67720b2
--- /dev/null
+++ b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
@@ -0,0 +1,18 @@
+"""
+Create privileges table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221108_01_CoxYh-create-the-groups-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE privileges(
+            privilege_id TEXT PRIMARY KEY,
+            privilege_name TEXT NOT NULL
+        ) WITHOUT ROWID
+        """,
+         "DROP TABLE IF EXISTS privileges")
+]
diff --git a/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
new file mode 100644
index 0000000..ce752ef
--- /dev/null
+++ b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
@@ -0,0 +1,19 @@
+"""
+Create resource_categories table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221108_02_wxTr9-create-privileges-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE resource_categories(
+            resource_category_id TEXT PRIMARY KEY,
+            resource_category_key TEXT NOT NULL,
+            resource_category_description TEXT NOT NULL
+        ) WITHOUT ROWID
+        """,
+    "DROP TABLE IF EXISTS resource_categories")
+]
diff --git a/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
new file mode 100644
index 0000000..76ffbef
--- /dev/null
+++ b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py
@@ -0,0 +1,25 @@
+"""
+Init data in resource_categories table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221108_03_Pbhb1-create-resource-categories-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO resource_categories VALUES
+        ('fad071a3-2fc8-40b8-992b-cdefe7dcac79', 'mrna', 'mRNA Dataset'),
+        ('548d684b-d4d1-46fb-a6d3-51a56b7da1b3', 'phenotype', 'Phenotype (Publish) Dataset'),
+        ('48056f84-a2a6-41ac-8319-0e1e212cba2a', 'genotype', 'Genotype Dataset')
+        """,
+        """
+        DELETE FROM resource_categories WHERE resource_category_id IN
+        (
+            'fad071a3-2fc8-40b8-992b-cdefe7dcac79',
+            '548d684b-d4d1-46fb-a6d3-51a56b7da1b3',
+            '48056f84-a2a6-41ac-8319-0e1e212cba2a'
+        )
+        """)
+]
diff --git a/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
new file mode 100644
index 0000000..6c829b1
--- /dev/null
+++ b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py
@@ -0,0 +1,17 @@
+"""
+Add 'resource_meta' field to 'resource_categories' field.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221108_04_CKcSL-init-data-in-resource-categories-table'}
+
+steps = [
+    step(
+        """
+        ALTER TABLE resource_categories
+        ADD COLUMN
+            resource_meta TEXT NOT NULL DEFAULT '[]'
+        """,
+        "ALTER TABLE resource_categories DROP COLUMN resource_meta")
+]
diff --git a/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
new file mode 100644
index 0000000..abc8895
--- /dev/null
+++ b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
@@ -0,0 +1,26 @@
+"""
+Create 'resources' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS resources(
+            group_id TEXT NOT NULL,
+            resource_id TEXT NOT NULL,
+            resource_name TEXT NOT NULL UNIQUE,
+            resource_category_id TEXT NOT NULL,
+            PRIMARY KEY(group_id, resource_id),
+            FOREIGN KEY(group_id) REFERENCES groups(group_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(resource_category_id)
+              REFERENCES resource_categories(resource_category_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS resources")
+]
diff --git a/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
new file mode 100644
index 0000000..51e19e8
--- /dev/null
+++ b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
@@ -0,0 +1,19 @@
+"""
+Create 'roles' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221110_01_WtZ1I-create-resources-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS roles(
+            role_id TEXT NOT NULL PRIMARY KEY,
+            role_name TEXT NOT NULL,
+            user_editable INTEGER NOT NULL DEFAULT 1 CHECK (user_editable=0 or user_editable=1)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS roles")
+]
diff --git a/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
new file mode 100644
index 0000000..2b55c2b
--- /dev/null
+++ b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
@@ -0,0 +1,24 @@
+"""
+Create 'generic_roles' table
+
+The roles in this table will be template roles, defining some common roles that
+can be used within the groups.
+
+They could also be used to define system-level roles, though those will not be
+provided to the "common" users.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221110_05_BaNtL-create-roles-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS generic_roles(
+            role_id TEXT PRIMARY KEY,
+            role_name TEXT NOT NULL
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS generic_roles")
+]
diff --git a/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
new file mode 100644
index 0000000..0d0eeb9
--- /dev/null
+++ b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
@@ -0,0 +1,29 @@
+"""
+Create 'role_privileges' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221110_06_Pq2kT-create-generic-roles-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS role_privileges(
+            role_id TEXT NOT NULL,
+            privilege_id TEXT NOT NULL,
+            PRIMARY KEY(role_id, privilege_id),
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS role_privileges"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS idx_tbl_role_privileges_cols_role_id
+        ON role_privileges(role_id)
+        """,
+        "DROP INDEX IF EXISTS idx_tbl_role_privileges_cols_role_id")
+]
diff --git a/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
new file mode 100644
index 0000000..077182b
--- /dev/null
+++ b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py
@@ -0,0 +1,22 @@
+"""
+Add 'privilege_category' and 'privilege_description' columns to 'privileges' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221110_07_7WGa1-create-role-privileges-table'}
+
+steps = [
+    step(
+        """
+        ALTER TABLE privileges ADD COLUMN
+            privilege_category TEXT NOT NULL DEFAULT 'common'
+        """,
+        "ALTER TABLE privileges DROP COLUMN privilege_category"),
+    step(
+        """
+        ALTER TABLE privileges ADD COLUMN
+            privilege_description TEXT
+        """,
+        "ALTER TABLE privileges DROP COLUMN privilege_description")
+]
diff --git a/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
new file mode 100644
index 0000000..072f226
--- /dev/null
+++ b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
@@ -0,0 +1,66 @@
+"""
+Enumerate initial privileges
+"""
+
+from yoyo import step
+
+__depends__ = {'20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO
+            privileges(privilege_id, privilege_name, privilege_category,
+                       privilege_description)
+        VALUES
+            -- group-management privileges
+            ('4842e2aa-38b9-4349-805e-0a99a9cf8bff', 'create-group',
+             'group-management', 'Create a group'),
+            ('3ebfe79c-d159-4629-8b38-772cf4bc2261', 'view-group',
+             'group-management', 'View the details of a group'),
+            ('52576370-b3c7-4e6a-9f7e-90e9dbe24d8f', 'edit-group',
+             'group-management', 'Edit the details of a group'),
+            ('13ec2a94-4f1a-442d-aad2-936ad6dd5c57', 'delete-group',
+             'group-management', 'Delete a group'),
+            ('ae4add8c-789a-4d11-a6e9-a306470d83d9', 'add-group-member',
+             'group-management', 'Add a user to a group'),
+            ('f1bd3f42-567e-4965-9643-6d1a52ddee64', 'remove-group-member',
+             'group-management', 'Remove a user from a group'),
+            ('80f11285-5079-4ec0-907c-06509f88a364', 'assign-group-leader',
+             'group-management', 'Assign user group-leader privileges'),
+            ('d4afe2b3-4ca0-4edd-b37d-966535b5e5bd',
+             'transfer-group-leadership', 'group-management',
+             'Transfer leadership of the group to some other member'),
+
+            -- resource-management privileges
+            ('aa25b32a-bff2-418d-b0a2-e26b4a8f089b', 'create-resource',
+             'resource-management', 'Create a resource object'),
+            ('7f261757-3211-4f28-a43f-a09b800b164d', 'view-resource',
+             'resource-management', 'view a resource and use it in computations'),
+            ('2f980855-959b-4339-b80e-25d1ec286e21', 'edit-resource',
+             'resource-management', 'edit/update a resource'),
+            ('d2a070fd-e031-42fb-ba41-d60cf19e5d6d', 'delete-resource',
+             'resource-management', 'Delete a resource'),
+
+            -- role-management privileges
+            ('221660b1-df05-4be1-b639-f010269dbda9', 'create-role',
+             'role-management', 'Create a new role'),
+            ('7bcca363-cba9-4169-9e31-26bdc6179b28', 'edit-role',
+             'role-management', 'edit/update an existing role'),
+            ('5103cc68-96f8-4ebb-83a4-a31692402c9b', 'assign-role',
+             'role-management', 'Assign a role to an existing user'),
+            ('1c59eff5-9336-4ed2-a166-8f70d4cb012e', 'delete-role',
+             'role-management', 'Delete an existing role'),
+
+            -- user-management privileges
+            ('e7252301-6ee0-43ba-93ef-73b607cf06f6', 'reset-any-password',
+             'user-management', 'Reset the password for any user'),
+            ('1fe61370-cae9-4983-bd6c-ce61050c510f', 'delete-any-user',
+             'user-management', 'Delete any user from the system'),
+
+            -- sytem-admin privileges
+            ('519db546-d44e-4fdc-9e4e-25aa67548ab3', 'masquerade',
+             'system-admin', 'Masquerade as some other user')
+        """,
+        "DELETE FROM privileges")
+]
diff --git a/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
new file mode 100644
index 0000000..2048f4a
--- /dev/null
+++ b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py
@@ -0,0 +1,35 @@
+"""
+Create 'generic_role_privileges' table
+
+This table links the generic_roles to the privileges they provide
+"""
+
+from yoyo import step
+
+__depends__ = {'20221113_01_7M0hv-enumerate-initial-privileges'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS generic_role_privileges(
+            generic_role_id TEXT NOT NULL,
+            privilege_id TEXT NOT NULL,
+            PRIMARY KEY(generic_role_id, privilege_id),
+            FOREIGN KEY(generic_role_id) REFERENCES generic_roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS generic_role_privileges"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS
+            idx_tbl_generic_role_privileges_cols_generic_role_id
+        ON generic_role_privileges(generic_role_id)
+        """,
+        """
+        DROP INDEX IF EXISTS
+            idx_tbl_generic_role_privileges_cols_generic_role_id
+        """)
+]
diff --git a/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
new file mode 100644
index 0000000..6bd101b
--- /dev/null
+++ b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
@@ -0,0 +1,41 @@
+"""
+Drop 'generic_role*' tables
+"""
+
+from yoyo import step
+
+__depends__ = {'20221114_01_n8gsF-create-generic-role-privileges-table'}
+
+steps = [
+    step(
+        """
+        DROP INDEX IF EXISTS
+            idx_tbl_generic_role_privileges_cols_generic_role_id
+        """,
+        """
+        CREATE INDEX IF NOT EXISTS
+            idx_tbl_generic_role_privileges_cols_generic_role_id
+        ON generic_role_privileges(generic_role_id)
+        """),
+    step(
+        "DROP TABLE IF EXISTS generic_role_privileges",
+        """
+        CREATE TABLE IF NOT EXISTS generic_role_privileges(
+            generic_role_id TEXT NOT NULL,
+            privilege_id TEXT NOT NULL,
+            PRIMARY KEY(generic_role_id, privilege_id),
+            FOREIGN KEY(generic_role_id) REFERENCES generic_roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """),
+    step(
+        "DROP TABLE IF EXISTS generic_roles",
+        """
+        CREATE TABLE IF NOT EXISTS generic_roles(
+            role_id TEXT PRIMARY KEY,
+            role_name TEXT NOT NULL
+        ) WITHOUT ROWID
+        """)
+]
diff --git a/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
new file mode 100644
index 0000000..a7e7b45
--- /dev/null
+++ b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
@@ -0,0 +1,29 @@
+"""
+Create 'group_roles' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221114_02_DKKjn-drop-generic-role-tables'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS group_roles(
+            group_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            PRIMARY KEY(group_id, role_id),
+            FOREIGN KEY(group_id) REFERENCES groups(group_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_roles"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id
+        ON group_roles(group_id)
+        """,
+        "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id")
+]
diff --git a/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
new file mode 100644
index 0000000..386f481
--- /dev/null
+++ b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
@@ -0,0 +1,56 @@
+"""
+Initialise basic roles
+"""
+
+from yoyo import step
+
+__depends__ = {'20221114_03_PtWjc-create-group-roles-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO roles(role_id, role_name, user_editable) VALUES
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'group-leader', '0'),
+            ('522e4d40-aefc-4a64-b7e0-768b8be517ee', 'resource-owner', '0')
+        """,
+        "DELETE FROM roles"),
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES
+            -- group-management
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '4842e2aa-38b9-4349-805e-0a99a9cf8bff'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '3ebfe79c-d159-4629-8b38-772cf4bc2261'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '52576370-b3c7-4e6a-9f7e-90e9dbe24d8f'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '13ec2a94-4f1a-442d-aad2-936ad6dd5c57'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             'ae4add8c-789a-4d11-a6e9-a306470d83d9'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             'f1bd3f42-567e-4965-9643-6d1a52ddee64'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             'd4afe2b3-4ca0-4edd-b37d-966535b5e5bd'),
+
+            -- resource-management
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             'aa25b32a-bff2-418d-b0a2-e26b4a8f089b'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '7f261757-3211-4f28-a43f-a09b800b164d'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '2f980855-959b-4339-b80e-25d1ec286e21'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             'd2a070fd-e031-42fb-ba41-d60cf19e5d6d'),
+            ('522e4d40-aefc-4a64-b7e0-768b8be517ee',
+             'aa25b32a-bff2-418d-b0a2-e26b4a8f089b'),
+            ('522e4d40-aefc-4a64-b7e0-768b8be517ee',
+             '7f261757-3211-4f28-a43f-a09b800b164d'),
+            ('522e4d40-aefc-4a64-b7e0-768b8be517ee',
+             '2f980855-959b-4339-b80e-25d1ec286e21'),
+            ('522e4d40-aefc-4a64-b7e0-768b8be517ee',
+             'd2a070fd-e031-42fb-ba41-d60cf19e5d6d')
+        """,
+        "DELETE FROM role_privileges")
+]
diff --git a/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
new file mode 100644
index 0000000..e0de751
--- /dev/null
+++ b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
@@ -0,0 +1,29 @@
+"""
+Create 'user_roles' table.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221114_04_tLUzB-initialise-basic-roles'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS user_roles(
+            user_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            PRIMARY KEY(user_id, role_id),
+            FOREIGN KEY(user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS user_roles"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS idx_tbl_user_roles_cols_user_id
+        ON user_roles(user_id)
+        """,
+        "DROP INDEX IF EXISTS idx_tbl_user_roles_cols_user_id")
+]
diff --git a/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
new file mode 100644
index 0000000..2e4ae28
--- /dev/null
+++ b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py
@@ -0,0 +1,35 @@
+"""
+Add privileges to 'group-leader' role.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221114_05_hQun6-create-user-roles-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES
+            -- role management
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '221660b1-df05-4be1-b639-f010269dbda9'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '7bcca363-cba9-4169-9e31-26bdc6179b28'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '5103cc68-96f8-4ebb-83a4-a31692402c9b'),
+            ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+             '1c59eff5-9336-4ed2-a166-8f70d4cb012e')
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE
+            role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+        AND privilege_id IN (
+            '221660b1-df05-4be1-b639-f010269dbda9',
+            '7bcca363-cba9-4169-9e31-26bdc6179b28',
+            '5103cc68-96f8-4ebb-83a4-a31692402c9b',
+            '1c59eff5-9336-4ed2-a166-8f70d4cb012e'
+        )
+        """)
+]
diff --git a/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
new file mode 100644
index 0000000..a4d7806
--- /dev/null
+++ b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py
@@ -0,0 +1,52 @@
+"""
+Modify 'group_roles': add 'group_role_id'
+
+At this point, there is no data in the `group_roles` table  and therefore, it
+should be safe to simply recreate it.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221116_01_nKUmX-add-privileges-to-group-leader-role'}
+
+steps = [
+    step(
+        "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id",
+        """
+        CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id
+        ON group_roles(group_id)
+        """),
+    step(
+        "DROP TABLE IF EXISTS group_roles",
+        """
+        CREATE TABLE IF NOT EXISTS group_roles(
+            group_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            PRIMARY KEY(group_id, role_id),
+            FOREIGN KEY(group_id) REFERENCES groups(group_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """),
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS group_roles(
+            group_role_id TEXT PRIMARY KEY,
+            group_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            UNIQUE (group_id, role_id),
+            FOREIGN KEY(group_id) REFERENCES groups(group_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_roles"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id
+        ON group_roles(group_id)
+        """,
+        "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id")
+]
diff --git a/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
new file mode 100644
index 0000000..92885ef
--- /dev/null
+++ b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
@@ -0,0 +1,25 @@
+"""
+Create 'group_users' table.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221117_01_RDlfx-modify-group-roles-add-group-role-id'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS group_users(
+            group_id TEXT NOT NULL,
+            user_id TEXT NOT NULL UNIQUE, -- user can only be in one group
+            PRIMARY KEY(group_id, user_id)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_users"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS tbl_group_users_cols_group_id
+        ON group_users(group_id)
+        """,
+        "DROP INDEX IF EXISTS tbl_group_users_cols_group_id")
+]
diff --git a/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
new file mode 100644
index 0000000..9aa3667
--- /dev/null
+++ b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py
@@ -0,0 +1,39 @@
+"""
+Create 'group_user_roles_on_resources' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221117_02_fmuZh-create-group-users-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE group_user_roles_on_resources (
+            group_id TEXT NOT NULL,
+            user_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            resource_id TEXT NOT NULL,
+            PRIMARY KEY (group_id, user_id, role_id, resource_id),
+            FOREIGN KEY (user_id)
+              REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY (group_id, role_id)
+              REFERENCES group_roles(group_id, role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY (group_id, resource_id)
+              REFERENCES resources(group_id, resource_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_user_roles_on_resources"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS
+            idx_tbl_group_user_roles_on_resources_group_user_resource
+        ON group_user_roles_on_resources(group_id, user_id, resource_id)
+        """,
+        """
+        DROP INDEX IF EXISTS
+            idx_tbl_group_user_roles_on_resources_group_user_resource""")
+]
diff --git a/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
new file mode 100644
index 0000000..2238069
--- /dev/null
+++ b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py
@@ -0,0 +1,16 @@
+"""
+Add 'public' column to 'resources' table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221206_01_BbeF9-create-group-user-roles-on-resources-table'}
+
+steps = [
+    step(
+        """
+        ALTER TABLE resources ADD COLUMN
+            public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1)
+        """,
+        "ALTER TABLE resources DROP COLUMN public")
+]
diff --git a/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
new file mode 100644
index 0000000..475be01
--- /dev/null
+++ b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
@@ -0,0 +1,25 @@
+"""
+create oauth2_clients table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221208_01_sSdHz-add-public-column-to-resources-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS oauth2_clients(
+            client_id TEXT NOT NULL,
+            client_secret TEXT NOT NULL,
+            client_id_issued_at INTEGER NOT NULL,
+            client_secret_expires_at INTEGER NOT NULL,
+            client_metadata TEXT,
+            user_id TEXT NOT NULL,
+            PRIMARY KEY(client_id),
+            FOREIGN KEY(user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS oauth2_clients")
+]
diff --git a/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
new file mode 100644
index 0000000..778282b
--- /dev/null
+++ b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
@@ -0,0 +1,31 @@
+"""
+create oauth2_tokens table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221219_01_CI3tN-create-oauth2-clients-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE oauth2_tokens(
+            token_id TEXT NOT NULL,
+            client_id TEXT NOT NULL,
+            token_type TEXT NOT NULL,
+            access_token TEXT UNIQUE NOT NULL,
+            refresh_token TEXT,
+            scope TEXT,
+            revoked INTEGER CHECK (revoked = 0 or revoked = 1),
+            issued_at INTEGER NOT NULL,
+            expires_in INTEGER NOT NULL,
+            user_id TEXT NOT NULL,
+            PRIMARY KEY(token_id),
+            FOREIGN KEY (client_id) REFERENCES oauth2_clients(client_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY (user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS oauth2_tokens")
+]
diff --git a/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
new file mode 100644
index 0000000..1683f87
--- /dev/null
+++ b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
@@ -0,0 +1,31 @@
+"""
+create authorisation_code table
+"""
+
+from yoyo import step
+
+__depends__ = {'20221219_02_buSEU-create-oauth2-tokens-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE authorisation_code (
+            code_id TEXT NOT NULL,
+            code TEXT UNIQUE NOT NULL,
+            client_id NOT NULL,
+            redirect_uri TEXT,
+            scope TEXT,
+            nonce TEXT,
+            auth_time INTEGER NOT NULL,
+            code_challenge TEXT,
+            code_challenge_method TEXT,
+            user_id TEXT NOT NULL,
+            PRIMARY KEY (code_id),
+            FOREIGN KEY (client_id) REFERENCES oauth2_clients(client_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY (user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS authorisation_code")
+]
diff --git a/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
new file mode 100644
index 0000000..7e7fda2
--- /dev/null
+++ b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py
@@ -0,0 +1,40 @@
+"""
+remove 'create-group' privilege from group-leader.
+"""
+
+from yoyo import step
+
+__depends__ = {'20221219_03_PcTrb-create-authorisation-code-table'}
+
+steps = [
+    step(
+        """
+        DELETE FROM role_privileges
+        WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+        AND privilege_id='4842e2aa-38b9-4349-805e-0a99a9cf8bff'
+        """,
+        """
+        INSERT INTO role_privileges VALUES
+        ('a0e67630-d502-4b9f-b23f-6805d0f30e30',
+        '4842e2aa-38b9-4349-805e-0a99a9cf8bff')
+        """),
+    step(
+        """
+        INSERT INTO roles(role_id, role_name, user_editable) VALUES
+          ('ade7e6b0-ba9c-4b51-87d0-2af7fe39a347', 'group-creator', '0')
+        """,
+        """
+        DELETE FROM roles WHERE role_id='ade7e6b0-ba9c-4b51-87d0-2af7fe39a347'
+        """),
+    step(
+        """
+        INSERT INTO role_privileges VALUES
+          ('ade7e6b0-ba9c-4b51-87d0-2af7fe39a347',
+           '4842e2aa-38b9-4349-805e-0a99a9cf8bff')
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE role_id='ade7e6b0-ba9c-4b51-87d0-2af7fe39a347'
+        AND privilege_id='4842e2aa-38b9-4349-805e-0a99a9cf8bff'
+        """)
+]
diff --git a/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
new file mode 100644
index 0000000..1ef5ab0
--- /dev/null
+++ b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
@@ -0,0 +1,111 @@
+"""
+rework privileges schema
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader'}
+
+privileges = ( # format: (original_id, original_name, new_id, category)
+    ("13ec2a94-4f1a-442d-aad2-936ad6dd5c57", "delete-group",
+     "system:group:delete-group", "group-management"),
+    ("1c59eff5-9336-4ed2-a166-8f70d4cb012e", "delete-role",
+     "group:role:delete-role", "role-management"),
+    ("1fe61370-cae9-4983-bd6c-ce61050c510f", "delete-any-user",
+     "system:user:delete-user", "user-management"),
+    ("221660b1-df05-4be1-b639-f010269dbda9", "create-role",
+     "group:role:create-role", "role-management"),
+    ("2f980855-959b-4339-b80e-25d1ec286e21", "edit-resource",
+     "group:resource:edit-resource", "resource-management"),
+    ("3ebfe79c-d159-4629-8b38-772cf4bc2261", "view-group",
+     "system:group:view-group", "group-management"),
+    ("4842e2aa-38b9-4349-805e-0a99a9cf8bff", "create-group",
+     "system:group:create-group", "group-management"),
+    ("5103cc68-96f8-4ebb-83a4-a31692402c9b", "assign-role",
+     "group:user:assign-role", "role-management"),
+    ("519db546-d44e-4fdc-9e4e-25aa67548ab3", "masquerade",
+     "system:user:masquerade", "system-admin"),
+    ("52576370-b3c7-4e6a-9f7e-90e9dbe24d8f", "edit-group",
+     "system:group:edit-group", "group-management"),
+    ("7bcca363-cba9-4169-9e31-26bdc6179b28", "edit-role",
+     "group:role:edit-role", "role-management"),
+    ("7f261757-3211-4f28-a43f-a09b800b164d", "view-resource",
+     "group:resource:view-resource", "resource-management"),
+    ("80f11285-5079-4ec0-907c-06509f88a364", "assign-group-leader",
+     "system:user:assign-group-leader", "group-management"),
+    ("aa25b32a-bff2-418d-b0a2-e26b4a8f089b", "create-resource",
+     "group:resource:create-resource", "resource-management"),
+    ("ae4add8c-789a-4d11-a6e9-a306470d83d9", "add-group-member",
+     "group:user:add-group-member", "group-management"),
+    ("d2a070fd-e031-42fb-ba41-d60cf19e5d6d", "delete-resource",
+     "group:resource:delete-resource", "resource-management"),
+    ("d4afe2b3-4ca0-4edd-b37d-966535b5e5bd", "transfer-group-leadership",
+     "system:group:transfer-group-leader", "group-management"),
+    ("e7252301-6ee0-43ba-93ef-73b607cf06f6", "reset-any-password",
+     "system:user:reset-password", "user-management"),
+    ("f1bd3f42-567e-4965-9643-6d1a52ddee64", "remove-group-member",
+     "group:user:remove-group-member", "group-management"))
+
+def rework_privileges_table(cursor):
+    "rework the schema"
+    cursor.executemany(
+        ("UPDATE privileges SET privilege_id=:id "
+         "WHERE privilege_id=:old_id"),
+        ({"id": row[2], "old_id": row[0]} for row in privileges))
+    cursor.execute("ALTER TABLE privileges DROP COLUMN privilege_category")
+    cursor.execute("ALTER TABLE privileges DROP COLUMN privilege_name")
+
+def restore_privileges_table(cursor):
+    "restore the schema"
+    cursor.execute((
+        "CREATE TABLE privileges_restore ("
+        "  privilege_id TEXT PRIMARY KEY,"
+        "  privilege_name TEXT NOT NULL,"
+        "  privilege_category TEXT NOT NULL DEFAULT 'common',"
+        "  privilege_description TEXT"
+        ")"))
+    id_dict = {row[2]: {"id": row[0], "name": row[1], "cat": row[3]}
+               for row in privileges}
+    cursor.execute(
+        "SELECT privilege_id, privilege_description FROM privileges")
+    params = ({**id_dict[row[0]], "desc": row[1]} for row in cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO privileges_restore VALUES (:id, :name, :cat, :desc)",
+        params)
+    cursor.execute("DROP TABLE privileges")
+    cursor.execute("ALTER TABLE privileges_restore RENAME TO privileges")
+
+def update_privilege_ids_in_role_privileges(cursor):
+    """Update the ids to new form."""
+    cursor.executemany(
+        ("UPDATE role_privileges SET privilege_id=:new_id "
+         "WHERE privilege_id=:old_id"),
+        ({"new_id": row[2], "old_id": row[0]} for row in privileges))
+
+def restore_privilege_ids_in_role_privileges(cursor):
+    """Restore original ids"""
+    cursor.executemany(
+        ("UPDATE role_privileges SET privilege_id=:old_id "
+         "WHERE privilege_id=:new_id"),
+        ({"new_id": row[2], "old_id": row[0]} for row in privileges))
+
+def change_schema(conn):
+    """Change the privileges schema and IDs"""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("PRAGMA foreign_keys=OFF")
+        rework_privileges_table(cursor)
+        update_privilege_ids_in_role_privileges(cursor)
+        cursor.execute("PRAGMA foreign_keys=ON")
+
+def restore_schema(conn):
+    """Change the privileges schema and IDs"""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("PRAGMA foreign_keys=OFF")
+        restore_privilege_ids_in_role_privileges(cursor)
+        restore_privileges_table(cursor)
+        cursor.execute("PRAGMA foreign_keys=ON")
+
+steps = [
+    step(change_schema, restore_schema)
+]
diff --git a/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
new file mode 100644
index 0000000..ceae5ea
--- /dev/null
+++ b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py
@@ -0,0 +1,29 @@
+"""
+Create group_requests table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230116_01_KwuJ3-rework-privileges-schema'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS group_join_requests(
+            request_id TEXT NOT NULL,
+            group_id TEXT NOT NULL,
+            requester_id TEXT NOT NULL,
+            timestamp REAL NOT NULL,
+            status TEXT NOT NULL DEFAULT 'PENDING',
+            message TEXT,
+            PRIMARY KEY(request_id, group_id),
+            FOREIGN KEY(group_id) REFERENCES groups(group_id)
+            ON UPDATE CASCADE ON DELETE CASCADE,
+            FOREIGN KEY (requester_id) REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE CASCADE,
+            UNIQUE(group_id, requester_id),
+            CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED'))
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_join_requests")
+]
diff --git a/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
new file mode 100644
index 0000000..8b406a6
--- /dev/null
+++ b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py
@@ -0,0 +1,22 @@
+"""
+System admin privileges for data distribution
+
+These privileges are focussed on allowing the system administrator to link the
+datasets and traits in the main database to specific groups in the auth system.
+"""
+
+from yoyo import step
+
+__depends__ = {'20230207_01_r0bkZ-create-group-join-requests-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges VALUES
+          ('system:data:link-to-group', 'Link a dataset or trait to a group.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN
+         ('system:data:link-to-group')
+        """)
+]
diff --git a/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
new file mode 100644
index 0000000..9b3fc2b
--- /dev/null
+++ b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
@@ -0,0 +1,38 @@
+"""
+Create system-admin role
+"""
+import uuid
+from contextlib import closing
+
+from yoyo import step
+
+__depends__ = {'20230210_01_8xMa1-system-admin-privileges-for-data-distribution'}
+
+def create_sys_admin_role(conn):
+    with closing(conn.cursor()) as cursor:
+        role_id = uuid.uuid4()
+        cursor.execute(
+            "INSERT INTO roles VALUES (?, 'system-administrator', '0')",
+            (str(role_id),))
+
+        cursor.executemany(
+            "INSERT INTO role_privileges VALUES (:role_id, :privilege_id)",
+            ({"role_id": f"{role_id}", "privilege_id": priv}
+         for priv in (
+                 "system:data:link-to-group",
+                 "system:group:create-group",
+                 "system:group:delete-group",
+                 "system:group:edit-group",
+                 "system:group:transfer-group-leader",
+                 "system:group:view-group",
+                 "system:user:assign-group-leader",
+                 "system:user:delete-user",
+                 "system:user:masquerade",
+                 "system:user:reset-password")))
+
+def drop_sys_admin_role(conn):
+    pass
+
+steps = [
+    step(create_sys_admin_role, drop_sys_admin_role)
+]
diff --git a/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
new file mode 100644
index 0000000..84bbd49
--- /dev/null
+++ b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py
@@ -0,0 +1,26 @@
+"""
+Add system:user:list privilege
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20230210_02_lDK14-create-system-admin-role'}
+
+def insert_users_list_priv(conn):
+    """Create a new 'system:user:list' privilege."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO privileges(privilege_id, privilege_description) "
+            "VALUES('system:user:list', 'List users in the system') "
+            "ON CONFLICT (privilege_id) DO NOTHING")
+
+def delete_users_list_priv(conn):
+    """Delete the new 'system:user:list' privilege."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM privileges WHERE privilege_id='system:user:list'")
+
+steps = [
+    step(insert_users_list_priv, delete_users_list_priv)
+]
diff --git a/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
new file mode 100644
index 0000000..3caad55
--- /dev/null
+++ b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py
@@ -0,0 +1,42 @@
+"""
+Add system:user:list privilege to system-administrator and group-leader roles.
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20230306_01_pRfxl-add-system-user-list-privilege'}
+
+def role_ids(cursor):
+    """Get role ids from names"""
+    cursor.execute(
+        "SELECT * FROM roles WHERE role_name IN "
+        "('system-administrator', 'group-leader')")
+    return (uuid.UUID(row[0]) for row in cursor.fetchall())
+
+def add_privilege_to_roles(conn):
+    """
+    Add 'system:user:list' privilege to 'system-administrator' and
+    'group-leader' roles."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id,privilege_id) "
+            "VALUES(?, ?)",
+            tuple((str(role_id), "system:user:list")
+                  for role_id in role_ids(cursor)))
+
+def del_privilege_from_roles(conn):
+    """
+    Delete 'system:user:list' privilege to 'system-administrator' and
+    'group-leader' roles.
+    """
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE "
+            "role_id IN (?, ?) AND privilege_id='system:user:list'",
+            tuple(str(role_id) for role_id in role_ids(cursor)))
+
+steps = [
+    step(add_privilege_to_roles, del_privilege_from_roles)
+]
diff --git a/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
new file mode 100644
index 0000000..647325f
--- /dev/null
+++ b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py
@@ -0,0 +1,30 @@
+"""
+Create linked-phenotype-data table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS linked_phenotype_data
+        -- Link the data in MariaDB to user groups in the auth system
+        (
+          data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system
+          group_id TEXT NOT NULL, -- The user group the data is linked to
+          SpeciesId TEXT NOT NULL, -- The species in MariaDB
+          InbredSetId TEXT NOT NULL, -- The traits group in MariaDB
+          PublishFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB
+          dataset_name TEXT, -- dataset Name in MariaDB
+          dataset_fullname, -- dataset FullName in MariaDB
+          dataset_shortname, -- dataset ShortName in MariaDB
+          PublishXRefId TEXT NOT NULL, -- The trait's ID in MariaDB
+          FOREIGN KEY (group_id)
+            REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT
+          UNIQUE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS linked_phenotype_data")
+]
diff --git a/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
new file mode 100644
index 0000000..7c9e986
--- /dev/null
+++ b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
@@ -0,0 +1,29 @@
+"""
+Create phenotype_resources table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230322_01_0dDZR-create-linked-phenotype-data-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS phenotype_resources
+        -- Link phenotype data to specific resources
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple data items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY(group_id, resource_id, data_link_id),
+          UNIQUE (data_link_id), -- ensure data is linked to only one resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_phenotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS phenotype_resources")
+]
diff --git a/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
new file mode 100644
index 0000000..02e8718
--- /dev/null
+++ b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py
@@ -0,0 +1,29 @@
+"""
+Create linked genotype data table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230322_02_Ll854-create-phenotype-resources-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS linked_genotype_data
+        -- Link genotype data in MariaDB to user groups in auth system
+        (
+          data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system
+          group_id TEXT NOT NULL, -- The user group the data is linked to
+          SpeciesId TEXT NOT NULL, -- The species in MariaDB
+          InbredSetId TEXT NOT NULL, -- The traits group in MariaDB
+          GenoFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB
+          dataset_name TEXT, -- dataset Name in MariaDB
+          dataset_fullname, -- dataset FullName in MariaDB
+          dataset_shortname, -- dataset ShortName in MariaDB
+          FOREIGN KEY (group_id)
+            REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT
+          UNIQUE (SpeciesId, InbredSetId, GenoFreezeId)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS linked_genotype_data")
+]
diff --git a/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
new file mode 100644
index 0000000..1a865e0
--- /dev/null
+++ b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
@@ -0,0 +1,29 @@
+"""
+Create genotype resources table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230404_01_VKxXg-create-linked-genotype-data-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS genotype_resources
+        -- Link genotype data to specific resource
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (group_id, resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_genotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS genotype_resources")
+]
diff --git a/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
new file mode 100644
index 0000000..db9a6bf
--- /dev/null
+++ b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py
@@ -0,0 +1,30 @@
+"""
+Create linked mrna data table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230404_02_la33P-create-genotype-resources-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS linked_mrna_data
+        -- Link mRNA Assay data in MariaDB to user groups in auth system
+        (
+          data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system
+          group_id TEXT NOT NULL, -- The user group the data is linked to
+          SpeciesId TEXT NOT NULL, -- The species in MariaDB
+          InbredSetId TEXT NOT NULL, -- The traits group in MariaDB
+          ProbeFreezeId TEXT NOT NULL, -- The study ID in MariaDB
+          ProbeSetFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB
+          dataset_name TEXT, -- dataset Name in MariaDB
+          dataset_fullname, -- dataset FullName in MariaDB
+          dataset_shortname, -- dataset ShortName in MariaDB
+          FOREIGN KEY (group_id)
+            REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT
+          UNIQUE (SpeciesId, InbredSetId, ProbeFreezeId, ProbeSetFreezeId)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS linked_mrna_data")
+]
diff --git a/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
new file mode 100644
index 0000000..2ad1056
--- /dev/null
+++ b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
@@ -0,0 +1,28 @@
+"""
+Create mRNA resources table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230410_01_8mwaf-create-linked-mrna-data-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS mrna_resources
+        -- Link mRNA data to specific resource
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id) REFERENCES linked_mrna_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS mrna_resources")
+]
diff --git a/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
new file mode 100644
index 0000000..37fcfe7
--- /dev/null
+++ b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py
@@ -0,0 +1,32 @@
+"""
+refactor: add resource_ownership table
+"""
+
+from yoyo import step
+
+__depends__ = {'20230410_02_WZqSf-create-mrna-resources-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS resource_ownership(
+        -- This table links resources to groups, where relevant
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          PRIMARY KEY(group_id, resource_id),
+          FOREIGN KEY(group_id)
+            REFERENCES groups(group_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS resource_ownership"),
+    step(# Copy over data
+        """
+        INSERT INTO resource_ownership
+        SELECT group_id, resource_id FROM resources
+        """
+    )
+]
diff --git a/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
new file mode 100644
index 0000000..c4397c9
--- /dev/null
+++ b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py
@@ -0,0 +1,29 @@
+"""
+refactor: add 'system' and 'group' resource categories
+"""
+
+from yoyo import step
+
+__depends__ = {'20230907_01_pjnxz-refactor-add-resource-ownership-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO resource_categories VALUES
+          ('aa3d787f-af6a-44fa-9b0b-c82d40e54ad2',
+           'system',
+           'The overall system.',
+           '{"default-access-level": "public-read"}'),
+          ('1e0f70ee-add5-4358-8c6c-43de77fa4cce',
+           'group',
+           'A group resource.',
+           '{}')
+        """,
+        """
+        DELETE FROM resource_categories
+        WHERE resource_category_id IN (
+          'aa3d787f-af6a-44fa-9b0b-c82d40e54ad2',
+          '1e0f70ee-add5-4358-8c6c-43de77fa4cce'
+        )
+        """)
+]
diff --git a/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
new file mode 100644
index 0000000..0f491c2
--- /dev/null
+++ b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py
@@ -0,0 +1,325 @@
+"""
+refactor: drop 'group_id' from 'resources' table.
+"""
+
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20230907_02_Enicg-refactor-add-system-and-group-resource-categories'}
+
+def drop_group_id_from_group_user_roles_on_resources(conn):
+    conn.execute(
+        "ALTER TABLE group_user_roles_on_resources "
+        "RENAME TO group_user_roles_on_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE group_user_roles_on_resources (
+          group_id TEXT NOT NULL,
+          user_id TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          PRIMARY KEY (group_id, user_id, role_id, resource_id),
+          FOREIGN KEY (user_id)
+            REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (group_id, role_id)
+            REFERENCES group_roles(group_id, role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO group_user_roles_on_resources "
+        "(group_id, user_id, role_id, resource_id)"
+        "SELECT group_id, user_id, role_id, resource_id "
+        "FROM group_user_roles_on_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS group_user_roles_on_resources_bkp")
+
+def drop_group_id_from_mrna_resources(conn):
+    conn.execute("ALTER TABLE mrna_resources RENAME TO mrna_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS mrna_resources
+        -- Link mRNA data to specific resource
+        (
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id) REFERENCES linked_mrna_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO mrna_resources "
+        "SELECT resource_id, data_link_id FROM mrna_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS mrna_resources_bkp")
+
+def drop_group_id_from_genotype_resources(conn):
+    conn.execute(
+        "ALTER TABLE genotype_resources RENAME TO genotype_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS genotype_resources
+        -- Link genotype data to specific resource
+        (
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_genotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO genotype_resources "
+        "SELECT resource_id, data_link_id FROM genotype_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS genotype_resources_bkp")
+
+def drop_group_id_from_phenotype_resources(conn):
+    conn.execute(
+        "ALTER TABLE phenotype_resources RENAME TO phenotype_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS phenotype_resources
+        -- Link phenotype data to specific resources
+        (
+          resource_id TEXT NOT NULL, -- A resource can have multiple data items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY(resource_id, data_link_id),
+          UNIQUE (data_link_id), -- ensure data is linked to only one resource
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_phenotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO phenotype_resources "
+        "SELECT resource_id, data_link_id FROM phenotype_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS phenotype_resources_bkp")
+
+def drop_group_id_from_resources_table(conn):
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = OFF")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS resources_new(
+          resource_id TEXT NOT NULL,
+          resource_name TEXT NOT NULL UNIQUE,
+          resource_category_id TEXT NOT NULL,
+          public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+          PRIMARY KEY(resource_id),
+          FOREIGN KEY(resource_category_id)
+            REFERENCES resource_categories(resource_category_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO resources_new "
+        "SELECT resource_id, resource_name, resource_category_id, public "
+        "FROM resources")
+    conn.execute("DROP TABLE IF EXISTS resources")
+    conn.execute("ALTER TABLE resources_new RENAME TO resources")
+
+    drop_group_id_from_mrna_resources(conn)
+    drop_group_id_from_genotype_resources(conn)
+    drop_group_id_from_phenotype_resources(conn)
+    drop_group_id_from_group_user_roles_on_resources(conn)
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+def restore_group_id_from_group_user_roles_on_resources(conn):
+    conn.execute(
+        "ALTER TABLE group_user_roles_on_resources "
+        "RENAME TO group_user_roles_on_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE group_user_roles_on_resources (
+          group_id TEXT NOT NULL,
+          user_id TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          PRIMARY KEY (group_id, user_id, role_id, resource_id),
+          FOREIGN KEY (user_id)
+            REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (group_id, role_id)
+            REFERENCES group_roles(group_id, role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO group_user_roles_on_resources "
+        "(group_id, user_id, role_id, resource_id)"
+        "SELECT group_id, user_id, role_id, resource_id "
+        "FROM group_user_roles_on_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS group_user_roles_on_resources_bkp")
+
+def restore_group_id_from_mrna_resources(conn, resource_group_map):
+    conn.execute("ALTER TABLE mrna_resources RENAME TO mrna_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS mrna_resources
+        -- Link mRNA data to specific resource
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id) REFERENCES linked_mrna_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+
+    cursor = conn.cursor()
+    cursor.execute("SELECT * FROM mrna_resources_bkp")
+    resources = tuple({
+        "group_id": resource_group_map[row["resource_id"]],
+        **dict(row)
+    } for row in cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO mrna_resources(group_id, resource_id, data_link_id) "
+        "VALUES(:group_id, :resource_id, :data_link_id)",
+        resources)
+    conn.execute("DROP TABLE IF EXISTS mrna_resources_bkp")
+
+def restore_group_id_from_genotype_resources(conn, resource_group_map):
+    conn.execute(
+        "ALTER TABLE genotype_resources RENAME TO genotype_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS genotype_resources
+        -- Link genotype data to specific resource
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY (group_id, resource_id, data_link_id),
+          UNIQUE (data_link_id) -- ensure data is linked to single resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_genotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+
+    cursor = conn.cursor()
+    cursor.execute("SELECT * FROM genotype_resources_bkp")
+    resources = tuple({
+        "group_id": resource_group_map[row["resource_id"]],
+        **dict(row)
+    } for row in cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO genotype_resources(group_id, resource_id, data_link_id) "
+        "VALUES(:group_id, :resource_id, :data_link_id)",
+        resources)
+    conn.execute("DROP TABLE IF EXISTS genotype_resources_bkp")
+
+def restore_group_id_from_phenotype_resources(conn, resource_group_map):
+    conn.execute(
+        "ALTER TABLE phenotype_resources RENAME TO phenotype_resources_bkp")
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS phenotype_resources
+        -- Link phenotype data to specific resources
+        (
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL, -- A resource can have multiple data items
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY(group_id, resource_id, data_link_id),
+          UNIQUE (data_link_id), -- ensure data is linked to only one resource
+          FOREIGN KEY (group_id, resource_id)
+            REFERENCES resources(group_id, resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (data_link_id)
+            REFERENCES linked_phenotype_data(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+
+    cursor = conn.cursor()
+    cursor.execute("SELECT * FROM phenotype_resources_bkp")
+    resources = tuple({
+        "group_id": resource_group_map[row["resource_id"]],
+        **dict(row)
+    } for row in cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO phenotype_resources(group_id, resource_id, data_link_id) "
+        "VALUES(:group_id, :resource_id, :data_link_id)",
+        resources)
+    conn.execute("DROP TABLE IF EXISTS phenotype_resources_bkp")
+
+def restore_group_id_to_resources_table(conn):
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    cursor = conn.cursor()
+    cursor.execute("ALTER TABLE resources RENAME TO resources_bkp")
+    cursor.execute(
+        "SELECT r.*, ro.group_id FROM resources_bkp AS r "
+        "INNER JOIN resource_ownership AS ro "
+        "ON r.resource_id=ro.resource_id")
+    group_resources = tuple(dict(row) for row in cursor.fetchall())
+    cursor.execute(
+        """
+        CREATE TABLE IF NOT EXISTS resources(
+          group_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          resource_name TEXT NOT NULL UNIQUE,
+          resource_category_id TEXT NOT NULL,
+          public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+          PRIMARY KEY(group_id, resource_id),
+          FOREIGN KEY(group_id)
+            REFERENCES groups(group_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(resource_category_id)
+            REFERENCES resource_categories(resource_category_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    cursor.executemany(
+        "INSERT INTO resources"
+        "(group_id, resource_id, resource_name, resource_category_id)"
+        "VALUES "
+        "(:group_id, :resource_id, :resource_name, :resource_category_id)",
+        group_resources)
+    cursor.execute("DROP TABLE IF EXISTS resources_bkp")
+
+    resource_group_map = {
+        res["resource_id"]: res["group_id"]
+        for res in group_resources
+    }
+    restore_group_id_from_group_user_roles_on_resources(conn)
+    restore_group_id_from_mrna_resources(conn, resource_group_map)
+    restore_group_id_from_genotype_resources(conn, resource_group_map)
+    restore_group_id_from_phenotype_resources(conn, resource_group_map)
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+steps = [
+    step(
+        drop_group_id_from_resources_table, restore_group_id_to_resources_table)
+]
diff --git a/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
new file mode 100644
index 0000000..a26834a
--- /dev/null
+++ b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py
@@ -0,0 +1,58 @@
+"""
+refactor: create 'group_resources' table.
+"""
+
+import uuid
+import random
+import string
+
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20230907_03_BwAmf-refactor-drop-group-id-from-resources-table'}
+
+def randstr(length: int = 5):
+    """Generate random string."""
+    return "".join(random.choices(
+        string.ascii_letters + string.digits, k=length))
+
+def create_and_link_resources_for_existing_groups(conn):
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute("SELECT group_id, group_name FROM groups")
+    resources = tuple({
+        "group_id": row["group_id"],
+        "resource_id": str(uuid.uuid4()),
+        "resource_name": f"{randstr(10)}: {row['group_name']}",
+        "resource_category_id": "1e0f70ee-add5-4358-8c6c-43de77fa4cce"
+    } for row in cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO "
+        "resources(resource_id, resource_name, resource_category_id) "
+        "VALUES (:resource_id, :resource_name, :resource_category_id)",
+        resources)
+    cursor.executemany(
+        "INSERT INTO group_resources(resource_id, group_id) "
+        "VALUES (:resource_id, :group_id)",
+        resources)
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS group_resources(
+        -- Links groups to the resources of type 'group' that control access to
+        -- each group
+        resource_id TEXT NOT NULL,
+        group_id TEXT NOT NULL,
+        PRIMARY KEY(resource_id, group_id),
+        FOREIGN KEY (resource_id)
+          REFERENCES resources(resource_id)
+          ON UPDATE CASCADE ON DELETE RESTRICT,
+        FOREIGN KEY (group_id)
+          REFERENCES groups(group_id)
+          ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS group_resources"),
+    step(create_and_link_resources_for_existing_groups)
+]
diff --git a/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
new file mode 100644
index 0000000..66c6461
--- /dev/null
+++ b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
@@ -0,0 +1,39 @@
+"""
+Add 'system' resource.
+"""
+
+import uuid
+
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20230907_04_3LnrG-refactor-create-group-resources-table'}
+
+def add_system_resource(conn):
+    """Add a system resource."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT resource_category_id FROM resource_categories "
+        "WHERE resource_category_key='system'")
+    category_id = cursor.fetchone()["resource_category_id"]
+    cursor.execute(
+        "INSERT INTO "
+        "resources(resource_id, resource_name, resource_category_id, public) "
+        "VALUES(?, ?, ?, ?)",
+        (str(uuid.uuid4()), "GeneNetwork System", category_id, "1"))
+
+def delete_system_resource(conn):
+    """Add a system resource."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT resource_category_id FROM resource_categories "
+        "WHERE resource_category_key='system'")
+    category_id = cursor.fetchone()["resource_category_id"]
+    cursor.execute("DELETE FROM resources WHERE resource_category_id = ?",
+                   (category_id,))
+
+steps = [
+    step(add_system_resource, delete_system_resource)
+]
diff --git a/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
new file mode 100644
index 0000000..1b3f0b1
--- /dev/null
+++ b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py
@@ -0,0 +1,227 @@
+"""
+Drop 'group_id' and fix foreign key references on 'group_user_roles_on_resources' table
+"""
+
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20230912_01_BxrhE-add-system-resource'}
+
+def drop_group_id(conn):
+    """Drop `group_id` from `group_user_roles_on_resources` table."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    conn.execute(
+        """
+        ALTER TABLE group_user_roles_on_resources
+        RENAME TO group_user_roles_on_resources_bkp
+        """)
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS group_user_roles_on_resources (
+          user_id TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          PRIMARY KEY (user_id, role_id, resource_id),
+          FOREIGN KEY (user_id)
+            REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (role_id)
+            REFERENCES roles(role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        "INSERT INTO group_user_roles_on_resources "
+        "SELECT user_id, role_id, resource_id "
+        "FROM group_user_roles_on_resources_bkp")
+    conn.execute("DROP TABLE IF EXISTS group_user_roles_on_resources_bkp")
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+def restore_group_id(conn):
+    """Restore `group_id` to `group_user_roles_on_resources` table."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    conn.execute(
+        """
+        ALTER TABLE group_user_roles_on_resources
+        RENAME TO group_user_roles_on_resources_bkp
+        """)
+    conn.execute(
+        """
+        CREATE TABLE IF NOT EXISTS group_user_roles_on_resources (
+          group_id TEXT NOT NULL,
+          user_id TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          resource_id TEXT NOT NULL,
+          PRIMARY KEY (group_id, user_id, role_id, resource_id),
+          FOREIGN KEY (user_id)
+            REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (group_id, role_id)
+            REFERENCES group_roles(group_id, role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+
+    cursor = conn.cursor()
+    cursor.execute(
+        """
+        INSERT INTO group_user_roles_on_resources
+          SELECT
+            ro.group_id, gurorb.user_id, gurorb.role_id, gurorb.resource_id
+          FROM resource_ownership AS ro
+          INNER JOIN group_user_roles_on_resources_bkp AS gurorb
+          ON ro.resource_id=gurorb.resource_id
+        """)
+
+    conn.execute("DROP TABLE IF EXISTS group_user_roles_on_resources_bkp")
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+def link_sys_admin_user_roles(conn):
+    """Link system-admins to the system resource."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT ur.* FROM user_roles AS ur "
+        "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+        "WHERE r.role_name='system-administrator'")
+    admins = cursor.fetchall()
+    cursor.execute(
+        "SELECT r.resource_id FROM resources AS r "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE rc.resource_category_key='system'")
+    system_resource_id = cursor.fetchone()["resource_id"]
+    cursor.executemany(
+        "INSERT INTO "
+        "group_user_roles_on_resources(user_id, role_id, resource_id) "
+        "VALUES (:user_id, :role_id, :resource_id)",
+        tuple({**admin, "resource_id": system_resource_id} for admin in admins))
+
+def restore_sys_admin_user_roles(conn):
+    """Restore fields into older `user_roles` table."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT guror.user_id, guror.role_id "
+        "FROM group_user_roles_on_resources AS guror "
+        "INNER JOIN resources AS r "
+        "ON guror.resource_id=r.resource_id "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE rc.resource_category_key='system'")
+    user_roles = tuple(cursor.fetchall())
+    cursor.executemany(
+        "INSERT INTO user_roles(user_id, role_id) "
+        "VALUES (:user_id, :role_id)",
+        user_roles)
+
+def link_group_leader_user_roles(conn):
+    """Link group leaders to their resources."""
+    conn.execute(
+        """
+        INSERT INTO group_user_roles_on_resources(user_id, role_id, resource_id)
+         SELECT gu.user_id, r.role_id, gr.resource_id
+         FROM group_resources AS gr INNER JOIN group_users AS gu
+         ON gr.group_id=gu.group_id INNER JOIN user_roles AS ur
+         ON gu.user_id=ur.user_id INNER JOIN roles AS r
+         ON ur.role_id=r.role_id
+         WHERE r.role_name='group-leader'
+        """)
+
+def restore_group_leader_user_roles(conn):
+    """Restore group admins to older `user_roles` table."""
+    conn.execute(
+        """
+        INSERT INTO user_roles(user_id, role_id)
+         SELECT guror.user_id, guror.role_id
+         FROM group_user_roles_on_resources AS guror
+         INNER JOIN resources AS r ON guror.resource_id=r.resource_id
+         INNER JOIN resource_categories AS rc
+         ON r.resource_category_id=rc.resource_category_id
+         WHERE rc.resource_category_key='group'
+        """)
+
+def link_group_creator_user_roles(conn):
+    """Link group-creators to system."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "SELECT ur.* FROM user_roles AS ur "
+        "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+        "WHERE r.role_name='group_creator'")
+    creators = cursor.fetchall()
+    cursor.execute(
+        "SELECT r.resource_id FROM resources AS r "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE rc.resource_category_key='system'")
+    sys_res_id = cursor.fetchone()["resource_id"]
+    cursor.executemany(
+        "INSERT INTO "
+        "group_user_roles_on_resources(user_id, role_id, resource_id) "
+        "VALUES (:user_id, :role_id, :resource_id)",
+        tuple({**creator, "resource_id": sys_res_id} for creator in creators))
+
+def restore_group_creator_user_roles(conn):
+    "Restore group-creator user roles."
+    conn.execute(
+        """
+        INSERT INTO user_roles
+         SELECT guror.user_id, guror.role_id
+         FROM group_user_roles_on_resources AS guror
+         INNER JOIN roles AS r ON guror.role_id=r.role_id
+         WHERE r.role_name='group-creator'""")
+
+def rename_table(conn):
+    "rename `group_user_roles_on_resources`, drop `user_roles`."
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    conn.execute("DROP TABLE IF EXISTS user_roles")
+    conn.execute(
+        "ALTER TABLE group_user_roles_on_resources RENAME TO user_roles")
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+def restore_tables(conn):
+    "rename to `group_user_roles_on_resources`, recreate original `user_roles`."
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    conn.execute(
+        "ALTER TABLE user_roles RENAME TO group_user_roles_on_resources")
+    conn.execute(
+        """
+        CREATE TABLE user_roles(
+            user_id TEXT NOT NULL,
+            role_id TEXT NOT NULL,
+            PRIMARY KEY(user_id, role_id),
+            FOREIGN KEY(user_id) REFERENCES users(user_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT,
+            FOREIGN KEY(role_id) REFERENCES roles(role_id)
+              ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+steps = [
+    step(drop_group_id, restore_group_id),
+    step(link_sys_admin_user_roles, restore_sys_admin_user_roles),
+    step(link_group_leader_user_roles, restore_group_leader_user_roles),
+    step(link_group_creator_user_roles, restore_group_creator_user_roles),
+    step(rename_table, restore_tables)
+]
+
diff --git a/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
new file mode 100644
index 0000000..1172034
--- /dev/null
+++ b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py
@@ -0,0 +1,61 @@
+"""
+Add new "public-view" role
+"""
+
+import sqlite3
+
+from yoyo import step
+
+__depends__ = {'20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table'}
+
+def grant_to_all_users_public_view_role(conn):
+    """Grant the `public-view` role to all existing users."""
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = ON")
+    cursor = conn.cursor()
+    cursor.execute("SELECT user_id FROM users")
+    user_ids = tuple(row["user_id"] for row in cursor.fetchall())
+
+    cursor.execute("SELECT resource_id FROM resources WHERE public=1")
+    resource_ids = tuple(row["resource_id"] for row in cursor.fetchall())
+
+    params = tuple({
+        "user_id": user_id,
+        "resource_id": resource_id,
+        "role_id": "fd88bfed-d869-4969-87f2-67c4e8446ecb"
+    } for user_id in user_ids for resource_id in resource_ids)
+    cursor.executemany(
+        "INSERT INTO user_roles(user_id, role_id, resource_id) "
+        "VALUES (:user_id, :role_id, :resource_id) ",
+        params)
+
+def revoke_from_all_users_public_view_role(conn):
+    """Revoke the `public-view` role from all existing users."""
+    conn.execute("PRAGMA foreign_keys = ON")
+    conn.execute(
+        "DELETE FROM user_roles "
+        "WHERE role_id='fd88bfed-d869-4969-87f2-67c4e8446ecb'")
+
+steps = [
+    step(
+        """
+        INSERT INTO roles(role_id, role_name, user_editable)
+        VALUES('fd88bfed-d869-4969-87f2-67c4e8446ecb', 'public-view', 0)
+        """,
+        """
+        DELETE FROM roles WHERE role_id='fd88bfed-d869-4969-87f2-67c4e8446ecb'
+        """),
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES(
+          'fd88bfed-d869-4969-87f2-67c4e8446ecb',
+          'group:resource:view-resource')
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE role_id='fd88bfed-d869-4969-87f2-67c4e8446ecb'
+        """),
+    step(grant_to_all_users_public_view_role,
+         revoke_from_all_users_public_view_role)
+]
diff --git a/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
new file mode 100644
index 0000000..402e9a5
--- /dev/null
+++ b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py
@@ -0,0 +1,84 @@
+"""
+link InbredSets to auth system
+"""
+
+from yoyo import step
+
+__depends__ = {'20230925_01_TWJuR-add-new-public-view-role', '__init__'}
+
+steps = [
+    step(
+        """
+        INSERT INTO resource_categories
+        (
+          resource_category_id,
+          resource_category_key,
+          resource_category_description,
+          resource_meta
+        )
+        VALUES
+        (
+          'b3654600-4ab0-4745-8292-5849b34173a7',
+          'inbredset-group',
+          'A resource that controls access to a particular InbredSet group',
+          '{"default-access-level":"public-read"}'
+        )
+        """,
+        """
+        DELETE FROM resource_categories WHERE
+          resource_category_id = 'b3654600-4ab0-4745-8292-5849b34173a7'
+        """
+    ),
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS linked_inbredset_groups
+        -- Link InbredSet groups in MariaDB to auth system
+        (
+          data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system
+          SpeciesId TEXT NOT NULL, -- Species ID in MariaDB
+          InbredSetId TEXT NOT NULL, -- The InbredSet ID in MariaDB
+          InbredSetName TEXT NOT NULL, -- The InbredSet group's name in MariaDB
+          InbredSetFullName TEXT NOT NULL, -- The InbredSet group's full name in MariaDB
+          UNIQUE(SpeciesId, InbredSetId)
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS linked_inbredset_groups"),
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS inbredset_group_resources
+        -- Link the InbredSet data to a specific resource
+        (
+          resource_id TEXT NOT NULL, -- Linked resource: one-to-one
+          data_link_id TEXT NOT NULL,
+          PRIMARY KEY(resource_id, data_link_id),
+          UNIQUE(resource_id), -- resource is linked to only one InbredSet
+          UNIQUE(data_link_id), -- InbredSet is linked to only one resource
+          FOREIGN KEY(resource_id)
+            REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(data_link_id)
+            REFERENCES linked_inbredset_groups(data_link_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS inbredset_group_resources"),
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description) VALUES
+        ('system:inbredset:create-case-attribute', 'Create a new case attribute for an InbredSet group.'),
+        ('system:inbredset:delete-case-attribute', 'Delete an existing case-attribute from an InbredSet group'),
+        ('system:inbredset:edit-case-attribute', 'Edit the values of case-attributes of an InbredSet group'),
+        ('system:inbredset:view-case-attribute', 'View the case-attributes of an InbredSet group'),
+        ('system:inbredset:apply-case-attribute-edit', 'Apply an edit to case-attributes performed by another user for an InbredSet group'),
+        ('system:inbredset:reject-case-attribute-edit', 'Reject an edit to case-attributes performed by another user for an InbredSet group')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN (
+          'system:inbredset:create-case-attribute',
+          'system:inbredset:delete-case-attribute',
+          'system:inbredset:edit-case-attribute',
+          'system:inbredset:view-case-attribute',
+          'system:inbredset:apply-case-attribute-edit',
+          'system:inbredset:reject-case-attribute-edit')
+        """)
+]
diff --git a/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
new file mode 100644
index 0000000..a4238ed
--- /dev/null
+++ b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py
@@ -0,0 +1,40 @@
+"""
+Create new 'inbredset-group-owner' role
+"""
+
+from yoyo import step
+
+__depends__ = {'20231002_01_tzxTf-link-inbredsets-to-auth-system'}
+
+steps = [
+    step(
+        """
+        INSERT INTO roles(role_id, role_name, user_editable)
+        VALUES('bde1c08b-b067-4d56-8353-462fc5928c32', 'inbredset-group-owner', 0)
+        """,
+        """
+        DELETE FROM roles WHERE role_id='bde1c08b-b067-4d56-8353-462fc5928c32'
+        """),
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:apply-case-attribute-edit'),
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:create-case-attribute'),
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:delete-case-attribute'),
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:edit-case-attribute'),
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:reject-case-attribute-edit'),
+          ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:view-case-attribute')
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE (role_id, privilege_id)
+        IN
+          (('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:apply-case-attribute-edit'),
+           ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:create-case-attribute'),
+           ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:delete-case-attribute'),
+           ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:edit-case-attribute'),
+           ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:reject-case-attribute-edit'),
+           ('bde1c08b-b067-4d56-8353-462fc5928c32', 'system:inbredset:view-case-attribute'))
+        """)
+]
diff --git a/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
new file mode 100644
index 0000000..049ac6b
--- /dev/null
+++ b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py
@@ -0,0 +1,34 @@
+"""
+Create jwt_refresh_tokens table
+"""
+
+from yoyo import step
+
+__depends__ = {'20231011_01_CS8NZ-create-new-inbredset-group-owner-role'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS jwt_refresh_tokens
+        -- Store refresh tokens to verify refresh attempts
+        (
+          token TEXT NOT NULL,
+          client_id TEXT NOT NULL,
+          user_id TEXT NOT NULL,
+          issued_with TEXT NOT NULL UNIQUE, -- JWT ID of JWT issued along with this refresh token
+          issued_at INTEGER NOT NULL,
+          expires INTEGER NOT NULL,
+          scope TEXT NOT NULL,
+          revoked INTEGER CHECK (revoked = 0 or revoked = 1),
+          parent_of TEXT UNIQUE,
+          PRIMARY KEY(token),
+          FOREIGN KEY (client_id) REFERENCES oauth2_clients(client_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (user_id) REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY (parent_of) REFERENCES jwt_refresh_tokens(token)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS jwt_refresh_tokens")
+]
diff --git a/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
new file mode 100644
index 0000000..0cab1c3
--- /dev/null
+++ b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py
@@ -0,0 +1,64 @@
+"""
+update schema for user-verification
+"""
+
+from yoyo import step
+
+__depends__ = {'20240506_01_798tW-create-jwt-refresh-tokens-table'}
+
+def add_verification_cols_to_users_table(conn):
+    "add verification columns to users table";
+    conn.execute("PRAGMA foreign_keys = OFF")
+
+    conn.execute(
+        """
+        CREATE TABLE users_new(
+            user_id TEXT PRIMARY KEY NOT NULL,
+            email TEXT UNIQUE NOT NULL,
+            name TEXT,
+            created INTEGER NOT NULL DEFAULT (unixepoch()),
+            verified INTEGER NOT NULL DEFAULT 0 CHECK (verified=0 or verified=1)
+        ) WITHOUT ROWID
+        """)
+    conn.execute(
+        """
+        INSERT INTO users_new(user_id, email, name)
+        SELECT user_id, email, name FROM users
+        """)
+    # the original table `users` has dependents, so we cannot simply do a
+    # `ALTER TABLE … RENAME TO …` since according to
+    # https://sqlite.org/lang_altertable.html#alter_table_rename
+    # from versions 3.26.0 onward, the foreign key references are **ALWAYS**
+    # changed. In this case, we create the new table first, do data transfers,
+    # drop the original and rename the new table to the same name as the
+    # original.
+    conn.execute("DROP TABLE IF EXISTS users")
+    conn.execute("ALTER TABLE users_new RENAME TO users")
+
+    
+    print("turning foreign keys should back on.")
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+def drop_verification_cols_from_users_table(conn):
+    "Drop verification columns from users table"
+    conn.execute("ALTER TABLE users DROP COLUMN created")
+    conn.execute("ALTER TABLE users DROP COLUMN verified")
+
+steps = [
+    step(add_verification_cols_to_users_table,
+         drop_verification_cols_from_users_table),
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS user_verification_codes(
+          user_id TEXT NOT NULL,
+          code TEXT NOT NULL,
+          generated INTEGER NOT NULL,
+          expires INTEGER NOT NULL,
+          PRIMARY KEY(user_id),
+          FOREIGN KEY(user_id) REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE CASCADE
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS verification_codes")
+]
diff --git a/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
new file mode 100644
index 0000000..a45fd30
--- /dev/null
+++ b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py
@@ -0,0 +1,94 @@
+"""
+Move role-manipulation privileges from group to resources
+"""
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20240529_01_ALNWj-update-schema-for-user-verification'}
+
+def role_by_name(cursor, role_name):
+    """Fetch group-admin role"""
+    cursor.execute("SELECT * FROM roles WHERE role_name=?",
+                   (role_name,))
+    return dict(cursor.fetchone())
+
+
+def move_privileges_to_resources(conn):
+    """Move role-manipulation privileges from group to resource."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "DELETE FROM role_privileges WHERE privilege_id IN ("
+        "  'group:role:create-role',"
+        "  'group:role:delete-role',"
+        "  'group:role:edit-role',"
+        "  'group:user:assign-role'"
+        ")")
+    cursor.execute(
+        "DELETE FROM privileges WHERE privilege_id IN ("
+        "  'group:role:create-role',"
+        "  'group:role:delete-role',"
+        "  'group:role:edit-role',"
+        "  'group:user:assign-role'"
+        ")")
+
+    resource_owner_role = role_by_name(cursor, "resource-owner")
+    privileges = (
+        ("resource:role:create-role",
+         "Create a new role on a specific resource"),
+        ("resource:role:delete-role",
+         "Delete an existing role from a specific resource"),
+        ("resource:role:edit-role",
+         "Edit an existing role on a specific resource"),
+        ("resource:user:assign-role",
+         "Assign a user to a role on a specific resource"))
+    cursor.executemany(
+        ("INSERT INTO privileges(privilege_id, privilege_description) "
+         "VALUES (?, ?)"),
+        privileges)
+    cursor.executemany(
+        ("INSERT INTO role_privileges(role_id, privilege_id) "
+         "VALUES(?, ?)"),
+        tuple((resource_owner_role["role_id"], privilege[0])
+              for privilege in privileges))
+    cursor.close()
+
+def move_privileges_to_groups(conn):
+    """Move role-manipulation privileges from resource to group."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        "DELETE FROM role_privileges WHERE privilege_id IN ("
+        "  'resource:role:create-role',"
+        "  'resource:role:delete-role',"
+        "  'resource:role:edit-role',"
+        "  'resource:user:assign-role'"
+        ")")
+    cursor.execute(
+        "DELETE FROM privileges WHERE privilege_id IN ("
+        "  'resource:role:create-role',"
+        "  'resource:role:delete-role',"
+        "  'resource:role:edit-role',"
+        "  'resource:user:assign-role'"
+        ")")
+
+    group_leader_role = role_by_name(cursor, "group-leader")
+    privileges = (
+        ("group:role:create-role", "Create a new role"),
+        ("group:role:delete-role", "Delete an existing role"),
+        ("group:role:edit-role", "edit/update an existing role"),
+        ("group:user:assign-role", "Assign a role to an existing user"))
+    cursor.executemany(
+        ("INSERT INTO privileges(privilege_id, privilege_description) "
+         "VALUES (?, ?)"),
+        privileges)
+    cursor.executemany(
+        ("INSERT INTO role_privileges(role_id, privilege_id) "
+         "VALUES(?, ?)"),
+        tuple((group_leader_role["role_id"], privilege[0])
+              for privilege in privileges))
+    cursor.close()
+
+steps = [
+    step(move_privileges_to_resources, move_privileges_to_groups)
+]
diff --git a/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
new file mode 100644
index 0000000..0695c0e
--- /dev/null
+++ b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
@@ -0,0 +1,36 @@
+"""
+Create 'resource_roles' table.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS resource_roles(
+          resource_id TEXT NOT NULL,
+          role_created_by TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          PRIMARY KEY (resource_id, role_created_by, role_id),
+          FOREIGN KEY(resource_id) REFERENCES resources(resource_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(role_created_by) REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(role_id) REFERENCES roles(role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS resource_roles"),
+    step(
+        """
+        CREATE INDEX IF NOT EXISTS
+        tbl_resource_roles_cols_resource_id_role_created_by
+        ON resource_roles(resource_id, role_created_by)
+        """,
+        """
+        DROP INDEX IF EXISTS
+        tbl_resource_roles_cols_resource_id_role_created_by
+        """)
+]
diff --git a/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
new file mode 100644
index 0000000..45d689c
--- /dev/null
+++ b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
@@ -0,0 +1,35 @@
+"""
+Drop 'group_roles' table.
+"""
+import sqlite3
+from yoyo import step
+
+__depends__ = {'20240606_02_ubZri-create-resource-roles-table'}
+
+def restore_group_roles(conn):
+    """Restore the `group_roles` table."""
+    conn.row_factory = sqlite3.Row
+    cursor = conn.cursor()
+    cursor.execute(
+        """
+        CREATE TABLE group_roles(
+          group_role_id TEXT PRIMARY KEY,
+          group_id TEXT NOT NULL,
+          role_id TEXT NOT NULL,
+          UNIQUE (group_id, role_id),
+          FOREIGN KEY(group_id) REFERENCES groups(group_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT,
+          FOREIGN KEY(role_id) REFERENCES roles(role_id)
+            ON UPDATE CASCADE ON DELETE RESTRICT
+        ) WITHOUT ROWID
+        """)
+    cursor.execute(
+        """
+        CREATE INDEX idx_tbl_group_roles_cols_group_id
+        ON group_roles(group_id)
+        """)
+    cursor.close()
+
+steps = [
+    step("DROP TABLE IF EXISTS group_roles", restore_group_roles)
+]
diff --git a/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
new file mode 100644
index 0000000..44318bd
--- /dev/null
+++ b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py
@@ -0,0 +1,26 @@
+"""
+Create forgot_password_tokens table
+
+This will be used to enable users to validate/verify their password change
+requests.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240606_03_BY7Us-drop-group-roles-table'}
+
+steps = [
+    step(
+        """
+        CREATE TABLE IF NOT EXISTS forgot_password_tokens(
+          user_id TEXT NOT NULL,
+          token TEXT NOT NULL,
+          generated INTEGER NOT NULL,
+          expires INTEGER NOT NULL,
+          PRIMARY KEY(user_id),
+          FOREIGN KEY(user_id) REFERENCES users(user_id)
+            ON UPDATE CASCADE ON DELETE CASCADE
+        ) WITHOUT ROWID
+        """,
+        "DROP TABLE IF EXISTS forgot_password_tokens")
+]
diff --git a/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
new file mode 100644
index 0000000..5c6e81d
--- /dev/null
+++ b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
@@ -0,0 +1,24 @@
+"""
+hooks_for_edu_domains
+"""
+
+from yoyo import step
+
+__depends__ = {'20240819_01_p2vXR-create-forgot-password-tokens-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO roles(role_id, role_name, user_editable) VALUES
+            ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'hook-role-from-edu-domain', '0')
+        """,
+        "DELETE FROM roles WHERE role_name='hook-role-from-edu-domain'"),
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id) VALUES
+            ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:view-resource'),
+            ('9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d', 'group:resource:edit-resource')
+        """,
+        "DELETE FROM role_privileges WHERE role_id='9bb203a2-7897-4fe3-ac4a-75e6a4f96f5d'"
+        )
+]
diff --git a/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
new file mode 100644
index 0000000..d22ad01
--- /dev/null
+++ b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py
@@ -0,0 +1,42 @@
+"""
+add admin ui privilege to system-administrator role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+def get_system_admin_id(cursor):
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    return cursor.fetchone()[0]
+
+def add_admin_ui_privilege(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        # Create admin-ui privilege
+        cursor.execute(
+            "INSERT INTO privileges (privilege_id, privilege_description) "
+            "VALUES(?, ?)",
+            ("system:user:admin-ui", "View UI elements that should only be visible to system administrators"))
+
+        # Add UI privilege to system-administrator role
+        cursor.execute(
+            "INSERT INTO role_privileges (role_id, privilege_id) "
+            "VALUES(?, ?)",
+            (get_system_admin_id(cursor), "system:user:admin-ui")
+        )
+
+def remove_admin_ui_privilege(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        # Remove UI privilege from system-administrator role
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE privilege_id='system:user:admin-ui'")
+        
+        # Remove UI privilege from privileges table
+        cursor.execute(
+            "DELETE FROM privileges WHERE privilege_id='system:user:admin-ui'")
+
+steps = [
+    step(add_admin_ui_privilege, remove_admin_ui_privilege)
+]
diff --git a/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
new file mode 100644
index 0000000..73a4880
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
@@ -0,0 +1,49 @@
+"""
+Add Batch Edit privileges
+"""
+
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role'}
+
+def add_batch_edit_privilege_and_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        # Create batch edit privilege
+        cursor.execute(
+            "INSERT INTO privileges (privilege_id, privilege_description) "
+            "VALUES(?, ?)",
+            ("system:data:batch-edit", "Batch Edit"))
+
+        # Create batch editor role
+        cursor.execute(
+            "INSERT INTO roles (role_id, role_name, user_editable) "
+            "VALUES(?, ?, ?)",
+            ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "Batch Editors", 0))
+
+        # Link role/privilege
+        cursor.execute(
+            "INSERT INTO role_privileges (role_id, privilege_id) "
+            "VALUES(?, ?)",
+            ("0f391910-5225-476a-bb8d-9c0adc9d81cc", "system:data:batch-edit")
+        )
+
+def remove_batch_edit_privilege_and_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        # Remove batch edit role/privilege link
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE privilege_id='system:data:batch-edit'")
+        
+        # Remove Batch Editor role
+        cursor.execute(
+            "DELETE FROM roles WHERE role_id='0f391910-5225-476a-bb8d-9c0adc9d81cc'")
+
+        # Remove Batch Edit privilege
+        cursor.execute(
+            "DELETE FROM privileges WHERE privilege_id='system:data:batch-edit'")
+
+
+steps = [
+    step(add_batch_edit_privilege_and_role, remove_batch_edit_privilege_and_role)
+]
diff --git a/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
new file mode 100644
index 0000000..3b9e928
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py
@@ -0,0 +1,19 @@
+"""
+Add new 'group:data:link-to-group' privilege.
+"""
+
+from yoyo import step
+
+__depends__ = {'20240924_01_thbvh-hooks-for-edu-domains'}
+
+steps = [
+    step(
+        """
+    INSERT INTO privileges(privilege_id, privilege_description)
+    VALUES(
+      'group:data:link-to-group',
+      'Allow linking data to only one specific group.'
+    )
+    """,
+        "DELETE FROM privileges WHERE privilege_id='group:data:link-to-group'")
+]
diff --git a/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
new file mode 100644
index 0000000..5d9c306
--- /dev/null
+++ b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py
@@ -0,0 +1,23 @@
+"""
+Assign 'group:data:link-to-group' privilege to group leader.
+"""
+
+from yoyo import step
+
+__depends__ = {'20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege'}
+
+steps = [
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES(
+          'a0e67630-d502-4b9f-b23f-6805d0f30e30',
+          'group:data:link-to-group'
+        )
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+        AND privilege_id='group:data:link-to-group'
+        """)
+]
diff --git a/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
new file mode 100644
index 0000000..6335152
--- /dev/null
+++ b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py
@@ -0,0 +1,27 @@
+"""
+Add role management privileges to group-leader role
+"""
+
+from yoyo import step
+
+__depends__ = {'20250609_01_LB60X-add-batch-edit-privileges', '20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader'}
+
+steps = [
+    step(
+        """
+        INSERT INTO role_privileges(role_id, privilege_id)
+        VALUES
+          ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:create-role'),
+          ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:delete-role'),
+          ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'resource:role:edit-role')
+        """,
+        """
+        DELETE FROM role_privileges
+        WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30'
+        AND privilege_id IN (
+          'resource:role:create-role',
+          'resource:role:delete-role',
+          'resource:role:edit-role'
+        )
+        """)
+]
diff --git a/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
new file mode 100644
index 0000000..f00ab11
--- /dev/null
+++ b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py
@@ -0,0 +1,18 @@
+"""
+Create new 'system:user:edit' privilege.
+"""
+
+from yoyo import step
+
+__depends__ = {'20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role'}
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES(
+          'system:user:edit',
+          'Allow general user-information edit.')
+        """,
+        "DELETE FROM privileges WHERE privilege_id='system:user:edit'")
+]
diff --git a/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
new file mode 100644
index 0000000..b956bef
--- /dev/null
+++ b/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py
@@ -0,0 +1,36 @@
+"""
+Add 'system:user:edit' privilege to 'system-admin' role.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250722_01_7Gro7-create-new-system-user-edit-privilege'}
+
+
+def system_administrator_role_id(cursor):
+    """Fetch ID for role 'system-administrator'."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    return cursor.fetchone()[0]
+
+
+def add_system_user_edit_privilege(conn):
+    """Add the 'system:user:edit' to the 'system-administrator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES(?, ?)",
+            (system_administrator_role_id(cursor), 'system:user:edit'))
+
+
+def remove_system_user_edit_privilege(conn):
+    """Remove the 'system:user:edit' from the 'system-administrator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            (system_administrator_role_id(cursor), 'system:user:edit'))
+
+steps = [
+    step(add_system_user_edit_privilege, remove_system_user_edit_privilege)
+]
diff --git a/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
new file mode 100644
index 0000000..be0d022
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py
@@ -0,0 +1,31 @@
+"""
+Create initial system-wide resources access privileges
+"""
+
+from yoyo import step
+
+__depends__ = {'20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role'}
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES
+          ("system:resource:view",
+           "View the wrapper resource object (not attached data). This is mostly for administration purposes."),
+          ("system:resource:edit",
+           "Edit/update the wrapper resource object (not attached data). This is mostly for administration purposes."),
+          ("system:resource:delete",
+           "Delete the wrapper resource object (not attached data). This is mostly for administration purposes."),
+          ("system:resource:reassign-group",
+           "Reassign the resource, and its data, to a different user group."),
+          ("system:resource:assign-owner",
+           "Assign ownership of any resource to any user.")
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN
+          ("system:resource:view", "system:resource:edit",
+           "system:resource:delete", "system:resource:reassign-group",
+           "system:resource:assign-owner")
+        """)
+]
diff --git a/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
new file mode 100644
index 0000000..e79ab1c
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py
@@ -0,0 +1,53 @@
+"""
+Assign initial system-wide resources-access privileges to sys-admins.
+"""
+import contextlib
+
+from yoyo import step
+
+def system_administrator_role_id(cursor):
+    """Fetch ID for role 'system-administrator'."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    return cursor.fetchone()[0]
+
+
+def assign_system_wide_resource_access_to_sysadmin(conn):
+    """
+    Assign initial system-wide resources-access privileges to
+    `system-administrator` role.
+    """
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadmin_role_id = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES(?, ?)",
+            ((sysadmin_role_id, "system:resource:view"),
+             (sysadmin_role_id, "system:resource:edit"),
+             (sysadmin_role_id, "system:resource:delete"),
+             (sysadmin_role_id, "system:resource:reassign-group"),
+             (sysadmin_role_id, "system:resource:assign-owner")))
+
+
+def revoke_system_wide_resource_access_from_sysadmin(conn):
+    """
+    Revoke initial system-wide resources-access privileges from
+    `system-administrator` role.
+    """
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadmin_role_id = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "DELETE FROM role_privileges "
+            "WHERE role_id=? AND privilege_id=?",
+            ((sysadmin_role_id, "system:resource:view"),
+             (sysadmin_role_id, "system:resource:edit"),
+             (sysadmin_role_id, "system:resource:delete"),
+             (sysadmin_role_id, "system:resource:reassign-group"),
+             (sysadmin_role_id, "system:resource:assign-owner")))
+
+__depends__ = {'20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges'}
+
+steps = [
+    step(assign_system_wide_resource_access_to_sysadmin,
+         revoke_system_wide_resource_access_from_sysadmin)
+]
diff --git a/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
new file mode 100644
index 0000000..e3bdc8f
--- /dev/null
+++ b/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py
@@ -0,0 +1,75 @@
+"""
+Grant  role to ALL resources to sys-admin users.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins'}
+
+
+def system_administrator_role_id(cursor):
+    """Fetch ID for role 'system-administrator'."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    return cursor.fetchone()[0]
+
+
+def system_resource_id(cursor):
+    cursor.execute(
+        "SELECT resources.resource_id FROM resource_categories "
+        "INNER JOIN resources ON resource_categories.resource_category_id=resources.resource_category_id "
+        "WHERE resource_category_key = 'system'")
+    return cursor.fetchone()[0]
+
+
+def fetch_ids_for_sysadmin_users(cursor):
+    """Fetch all sysadmin users' IDs."""
+    cursor.execute(
+        "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+        "ON roles.role_id=user_roles.role_id "
+        "WHERE role_name='system-administrator' AND resource_id=?",
+        (system_resource_id(cursor),))
+    return tuple(row[0] for row in cursor.fetchall())
+
+
+def fetch_non_system_resources(cursor):
+    """Fetch IDs for all resources that are not of the 'system' category."""
+    cursor.execute(
+        "SELECT resources.resource_id FROM resource_categories "
+        "INNER JOIN resources "
+        "ON resource_categories.resource_category_id=resources.resource_category_id "
+        "WHERE resource_category_key != 'system'")
+    return tuple(row[0] for row in cursor.fetchall())
+
+
+def assign_sysadmin_role_on_non_system_resources(conn):
+    """Assign sysadmins the sysadmin role on all non-system resources."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadminroleid = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO user_roles(user_id, resource_id, role_id) "
+            "VALUES (?, ?, ?)",
+            tuple(item + (sysadminroleid,)
+                  for item in itertools.product(
+                          fetch_ids_for_sysadmin_users(cursor),
+                          fetch_non_system_resources(cursor))))
+
+
+def revoke_sysadmin_role_on_non_system_resources(conn):
+    """Revoke sysadmins the sysadmin role on all non-system resources."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadminroleid = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "DELETE FROM user_roles "
+            "WHERE user_id=? AND resource_id=? AND role_id=?",
+            tuple(item + (sysadminroleid,)
+                  for item in itertools.product(
+                          fetch_ids_for_sysadmin_users(cursor),
+                          fetch_non_system_resources(cursor))))
+
+steps = [
+    step(assign_sysadmin_role_on_non_system_resources,
+         revoke_sysadmin_role_on_non_system_resources)
+]
diff --git a/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
new file mode 100644
index 0000000..95a6fbb
--- /dev/null
+++ b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py
@@ -0,0 +1,70 @@
+"""
+Add sysadmin privileges for acting on groups: mostly handling user management.
+"""
+import itertools
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users'}
+
+
+def system_administrator_role_id(cursor):
+    """Fetch ID for role 'system-administrator'."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    return cursor.fetchone()[0]
+
+
+def add_group_privileges_to_sysadmin_role(conn):
+    """Add group-management privileges to sysadmin role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadminroleid = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+            tuple(itertools.product(
+                (sysadminroleid,),
+                ('system:group:add-group-member',
+                 'system:group:remove-group-member',
+                 'system:group:assign-group-leader',
+                 'system:group:revoke-group-leader'))))
+
+
+def remove_group_privileges_to_sysadmin_role(conn):
+    """Remove group-management privileges from sysadmin role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        sysadminroleid = system_administrator_role_id(cursor)
+        cursor.executemany(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            tuple(itertools.product(
+                (sysadminroleid,),
+                ('system:group:add-group-member',
+                 'system:group:remove-group-member',
+                 'system:group:assign-group-leader',
+                 'system:group:revoke-group-leader'))))
+
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES
+          ('system:group:add-group-member',
+           'Make an existing user a member of a group.'),
+          ('system:group:remove-group-member',
+           'Remove a member user from a group.'),
+          ('system:group:assign-group-leader',
+           'Assign an existing group member the group-leader role'),
+          ('system:group:revoke-group-leader',
+           'Revoke the group-leader role from a group member with the role.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN
+        ('system:group:add-group-member',
+         'system:group:remove-group-member',
+         'system:group:assign-group-leader',
+         'system:group:revoke-group-leader')
+        """),
+    step(add_group_privileges_to_sysadmin_role,
+         remove_group_privileges_to_sysadmin_role)
+]
diff --git a/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
new file mode 100644
index 0000000..63e807a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
@@ -0,0 +1,61 @@
+"""
+add role systemwide-data-curator.
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members'}
+
+
+def create_systemwide_data_curator_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO roles(role_id, role_name, user_editable) "
+            "VALUES (?, 'systemwide-data-curator', 0)",
+            (str(uuid.uuid4()),))
+
+
+def link_privileges_to_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+        role_id = cursor.fetchone()[0]
+        cursor.executemany("INSERT INTO role_privileges(role_id, privilege_id) "
+                           "VALUES (?, ?)",
+                           tuple((role_id, priv) for priv in
+                                 ("system:system-wide:data:edit",
+                                  "system:system-wide:data:delete")))
+
+
+def unlink_privileges_from_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+        role_id = cursor.fetchone()[0]
+        cursor.executemany("DELETE FROM role_privileges "
+                           "WHERE role_id=? AND privilege_id=?",
+                           tuple((role_id, priv) for priv in
+                                 ("system:system-wide:data:edit",
+                                  "system:system-wide:data:delete")))
+
+
+steps = [
+    step(# Add new privileges
+        """
+        INSERT INTO privileges (privilege_id, privilege_description)
+        VALUES
+          ('system:system-wide:data:edit',
+           'A user with this privilege can edit any data on the entire system.'),
+          ('system:system-wide:data:delete',
+           'A user with this privilege can delete any data from the system.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN
+        ('system:system-wide:data:edit', 'system:system-wide:data:delete')"""),
+    step(create_systemwide_data_curator_role,
+         "DELETE FROM roles WHERE role_name='systemwide-data-curator'"),
+    step(link_privileges_to_role, unlink_privileges_from_role)
+]
diff --git a/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
new file mode 100644
index 0000000..d618f14
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
@@ -0,0 +1,62 @@
+"""
+add privilege for gn-docs documentation editing
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260206_01_v3f4P-add-role-systemwide-data-curator'}
+
+ROLE_NAME = 'systemwide-docs-editor'
+
+
+def create_systemwide_docs_editor_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO roles(role_id, role_name, user_editable) "
+            "VALUES (?, ?, 0)",
+            (str(uuid.uuid4()), ROLE_NAME))
+
+
+def delete_systemwide_docs_editor_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("DELETE FROM roles WHERE role_name=?", (ROLE_NAME,))
+
+
+def assign_edit_priv_to_docs_editor(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+                       (ROLE_NAME,))
+        role_id = cursor.fetchone()[0]
+
+        cursor.execute(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES (?, ?)",
+            (role_id, "system:documentation:edit"))
+
+
+def revoke_edit_priv_to_docs_editor(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+                       (ROLE_NAME,))
+        role_id = cursor.fetchone()[0]
+
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            (role_id, "system:documentation:edit"))
+
+
+steps = [
+    step(
+        """INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES(
+        'system:documentation:edit',
+        'Allows the holder to edit documentation presented with the Genenetwork system.'
+        )""",
+        "DELETE FROM privileges WHERE privilege_id='system:documentation:edit'"),
+    step(create_systemwide_docs_editor_role, delete_systemwide_docs_editor_role),
+    step(assign_edit_priv_to_docs_editor, revoke_edit_priv_to_docs_editor)
+]
diff --git a/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
new file mode 100644
index 0000000..e79ef6a
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
@@ -0,0 +1,66 @@
+"""
+Assign 'systemwide-docs-editor' role to sysadmins
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing'}
+
+
+def fetch_docs_editor_role_id(cursor):
+    """Fetch ID of systemwide-docs-editor role"""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='systemwide-docs-editor'")
+    return cursor.fetchone()[0]
+
+
+def fetch_sys_resource_id(cursor):
+    """Fetch the resource ID of the system."""
+    cursor.execute("SELECT resource_id FROM resources "
+                   "WHERE resource_name='GeneNetwork System'")
+    return cursor.fetchone()[0]
+
+
+def fetch_sys_admin_ids(cursor):
+    """Fetch the sysadmins' IDs."""
+    cursor.execute(
+        "SELECT user_roles.user_id FROM resources INNER JOIN user_roles "
+        "ON resources.resource_id=user_roles.resource_id INNER JOIN roles "
+        "ON user_roles.role_id=roles.role_id "
+        "WHERE resources.resource_name='GeneNetwork System' "
+        "AND roles.role_name='system-administrator'")
+    return tuple(row[0] for row in cursor.fetchall())
+
+
+def __build_params__(cursor):
+    sysresourceid = fetch_sys_resource_id(cursor)
+    sysadminids = fetch_sys_admin_ids(cursor)
+    roleid = fetch_docs_editor_role_id(cursor)
+    return tuple({
+        "user_id": userid,
+        "role_id": roleid,
+        "resource_id": sysresourceid
+    } for userid in sysadminids)
+
+
+def assign_systemwide_docs_editor_role_to_sysadmins(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "INSERT INTO user_roles(user_id, role_id, resource_id) "
+            "VALUES(:user_id, :role_id, :resource_id)",
+            __build_params__(cursor))
+
+
+def revoke_systemwide_docs_editor_role_from_sysadmins(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "DELETE FROM user_roles WHERE user_id=:user_id "
+            "AND role_id=:role_id AND resource_id=:resource_id",
+            __build_params__(cursor))
+
+steps = [
+    step(assign_systemwide_docs_editor_role_to_sysadmins,
+         revoke_systemwide_docs_editor_role_from_sysadmins)
+]
diff --git a/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
new file mode 100644
index 0000000..bdf8a56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
@@ -0,0 +1,49 @@
+"""
+Restrict access to resources' 'Make Public' feature.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins'}
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+    "Fetch the role's ID."
+    cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+    return cursor.fetchone()[0]
+
+
+def assign_make_public_to_systemwide_data_curator(conn):
+    """Assign privilege to 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES(?, 'system:resource:make-public')",
+            (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+def revoke_make_public_from_systemwide_data_curator(conn):
+    """Revoke privilege from 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges "
+            "WHERE role_id=? AND privilege_id='system:resource:make-public'",
+            (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES(
+        'system:resource:make-public',
+        'Allow user to make a resource publicly accessible.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id='system:resource:make-public'
+        """),
+    step(assign_make_public_to_systemwide_data_curator,
+         revoke_make_public_from_systemwide_data_curator),
+]
diff --git a/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
new file mode 100644
index 0000000..22863ae
--- /dev/null
+++ b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
@@ -0,0 +1,69 @@
+"""
+Add privileges to role systemwide-data-curator
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_03_vxBCX-restrict-access-to-resources-make-public-feature'}
+
+
+__new_privileges__ = (
+    ("system:system-wide:inbredset:view-case-attribute",
+     "Enable view of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:edit-case-attribute",
+     "Enable edit of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:delete-case-attribute",
+     "Enable deletion of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:apply-case-attribute-edit",
+     "Enable applying changes to any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:reject-case-attribute-edit",
+     "Enable rejecting changes to any and all inbredset case attributes system-wide."))
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+    "Fetch the role's ID."
+    cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+    return cursor.fetchone()[0]
+
+
+def create_new_privileges(conn):
+    """Create new privileges for the system."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "INSERT INTO privileges(privilege_id, privilege_description) "
+            "VALUES (?, ?)",
+            __new_privileges__)
+
+
+def delete_new_privileges(conn):
+    """Delete these new privileges from the system."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany("DELETE FROM privileges WHERE privilege_id=?",
+                           tuple((priv[0],) for priv in __new_privileges__))
+
+
+def assign_new_privileges(conn):
+    """Assign the new privileges to the `systemwide-data-curator` role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_systemwide_data_curator_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+            tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+def revoke_new_privileges(conn):
+    """Revoke the new privileges from the `systemwide-data-curator` role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_systemwide_data_curator_role_id(cursor)
+        cursor.executemany(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+
+steps = [
+    step(create_new_privileges, delete_new_privileges),
+    step(assign_new_privileges, revoke_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
new file mode 100644
index 0000000..702c418
--- /dev/null
+++ b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
@@ -0,0 +1,185 @@
+"""
+Add user and time tracking to resources table
+"""
+import random
+import contextlib
+from datetime import datetime
+
+from yoyo import step
+
+__depends__ = {'20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator'}
+
+GN_AUTH_INIT_TIMESTAMP = 1691130509.0
+__admin_id__ = ""
+
+
+def fetch_acentenos_id(conn):
+    """Fetch the default resource creator."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT user_id FROM users WHERE email=?",
+                       (("acent" "eno@" "uthsc" "." "edu"),))
+        res = cursor.fetchone()
+        return res[0] if bool(res) else None
+
+
+def fetch_a_sysadmin_id(conn, resources_table):
+    """Fetch one ID out of all system administrator users."""
+    global __admin_id__
+
+    def __fetch__():
+        with contextlib.closing(conn.cursor()) as cursor:
+            cursor.execute(
+                f"SELECT ur.user_id FROM {resources_table} AS rsc "
+                "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id "
+                "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+                "WHERE resource_name='GeneNetwork System' "
+                "AND r.role_name='system-administrator'"
+            )
+            return tuple(row[0] for row in cursor.fetchall())
+
+    if not bool(__admin_id__):
+        __admins__ = __fetch__()
+        if len(__admins__) > 0:
+            __admin_id__ = random.choice(__admins__)
+
+    return __admin_id__
+
+
+def add_user_and_time_tracking_columns(conn):
+    """Add user and time tracking columns."""
+    conn.execute(
+        """
+            CREATE TABLE resources_new(
+              resource_id TEXT NOT NULL,
+              resource_name TEXT NOT NULL UNIQUE,
+              resource_category_id TEXT NOT NULL,
+              public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+              created_by TEXT NOT NULL,
+              created_at REAL NOT NULL DEFAULT '1691130509.0',
+              PRIMARY KEY(resource_id),
+              FOREIGN KEY(resource_category_id)
+                REFERENCES resource_categories(resource_category_id)
+                ON UPDATE CASCADE ON DELETE RESTRICT,
+              FOREIGN KEY(created_by)
+                REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE RESTRICT
+            ) WITHOUT ROWID
+            """)
+
+
+def drop_user_and_time_tracking_columns(conn):
+    """Drop user and time tracking columns."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+    conn.execute("DROP TABLE IF EXISTS resources")
+    conn.execute("ALTER TABLE resources_old RENAME TO resources")
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+
+def update_data_for_new_resources_table(conn):
+    """Add creator and time to original data."""
+    __creator__ = (
+        fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT * FROM resources")
+        cursor.executemany(
+            "INSERT INTO resources_new("
+            "  resource_id,"
+            "  resource_name,"
+            "  resource_category_id,"
+            "  public,"
+            "  created_by,"
+            "  created_at"
+            ") VALUES (?, ?, ?, ?, ?, ?)",
+            tuple(
+                tuple(row) + (__creator__, GN_AUTH_INIT_TIMESTAMP)
+                for row in cursor.fetchall()))
+
+
+def restore_data_for_old_resources_table(conn):
+    """Remove creator and time from data."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT * FROM resources")
+        cursor.executemany(
+            "INSERT INTO resources_old("
+            "  resource_id,"
+            "  resource_name,"
+            "  resource_category_id,"
+            "  public"
+            ") VALUES (?, ?, ?, ?)",
+            tuple(tuple(row)[0:4] for row in cursor.fetchall()))
+
+
+def replace_old_table_with_new_table(conn):
+    """Restore old resources table with the new resources table."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+    conn.execute("DROP TABLE resources")
+    conn.execute("ALTER TABLE resources_new RENAME TO resources")
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+
+def restore_old_table(conn):
+    """Restore old 'resources' table schema."""
+    conn.execute(
+        """
+            CREATE TABLE resources_old(
+              resource_id TEXT NOT NULL,
+              resource_name TEXT NOT NULL UNIQUE,
+              resource_category_id TEXT NOT NULL,
+              public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+              PRIMARY KEY(resource_id),
+              FOREIGN KEY(resource_category_id)
+                REFERENCES resource_categories(resource_category_id)
+                ON UPDATE CASCADE ON DELETE RESTRICT
+            ) WITHOUT ROWID
+            """)
+
+
+def parse_creator_and_time(cursor, row):
+    __return__ = None
+
+    __name_parts__ = row[1].split("—")
+    if len(__name_parts__) == 4:
+        __email__, __inbredsetname__, __datetimestr__, count = __name_parts__
+        cursor.execute("SELECT user_id FROM users WHERE email=?",
+                       (__email__.strip(),))
+        results = cursor.fetchone()
+        if bool(results):
+            __return__ = {
+                "resource_id": row[0],
+                "creator": results[0],
+                "created": datetime.fromisoformat(__datetimestr__).timestamp()
+            }
+
+    return __return__
+
+
+def update_creators_and_time(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT resource_id, resource_name FROM resources")
+        cursor.executemany(
+            "UPDATE resources SET created_by=:creator, created_at=:created "
+            "WHERE resource_id=:resource_id",
+            tuple(item for item in
+                  (parse_creator_and_time(cursor, row)
+                   for row in cursor.fetchall())
+                  if item is not None))
+        
+
+
+def restore_default_creators_and_time(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        __creator__ = (
+            fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+        cursor.execute("UPDATE resources SET created_by=?, created_at=?",
+                       (__creator__, GN_AUTH_INIT_TIMESTAMP))
+
+
+steps = [
+    step(add_user_and_time_tracking_columns,
+         drop_user_and_time_tracking_columns),
+    step(update_data_for_new_resources_table,
+         restore_data_for_old_resources_table),
+    step(replace_old_table_with_new_table, restore_old_table),
+    step(update_creators_and_time, restore_default_creators_and_time)
+]
diff --git a/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
new file mode 100644
index 0000000..2dddc56
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
@@ -0,0 +1,19 @@
+"""
+New privilege: system:system-wide:data:view
+"""
+
+from yoyo import step
+
+__depends__ = {'20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES('system:system-wide:data:view',
+        'A user with this privilege can view any data on the entire system.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id='system:system-wide:data:view'
+        """)
+]
diff --git a/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
new file mode 100644
index 0000000..537bf9b
--- /dev/null
+++ b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
@@ -0,0 +1,62 @@
+"""
+Add privileges to batch-editors role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_01_Tak6O-new-privilege-system-system-wide-data-view'}
+
+
+def fetch_batch_editors_role_id(cursor):
+    """Fetch the ID of the batch-editors role."""
+    cursor.execute("SELECT role_id FROM roles WHERE role_name='Batch Editors'")
+    res = cursor.fetchone()
+    if not bool(res):
+        cursor.execute(
+            "SELECT role_id FROM roles WHERE role_name='batch-editors'")
+        res = cursor.fetchone()
+
+    return res[0] if bool(res) else None
+
+
+def rename_role(conn):
+    """Rename role from 'Batch Editors' to 'batch-editors'."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "UPDATE roles SET role_name='batch-editors' WHERE role_id=?",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+def restore_old_role_name(conn):
+    """Rename role from 'batch-editors' to 'Batch Editors'."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "UPDATE roles SET role_name='Batch Editors' WHERE role_id=?",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+def add_new_privileges(conn):
+    """Add new privileges to 'batch-editors' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_batch_editors_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) VALUES(?, ?)",
+            tuple((role_id, priv) for priv in (
+                "system:system-wide:data:view",
+                "system:system-wide:data:edit")))
+
+
+def remove_new_privileges(conn):
+    """Remove new privileges from 'batch-editors' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id IN "
+            "('system:system-wide:data:view', 'system:system-wide:data:edit')",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+steps = [
+    step(rename_role, restore_old_role_name),
+    step(add_new_privileges, remove_new_privileges)
+]
diff --git a/gn_auth/migrations/auth/__init__.py b/gn_auth/migrations/auth/__init__.py
new file mode 100644
index 0000000..1358c9a
--- /dev/null
+++ b/gn_auth/migrations/auth/__init__.py
@@ -0,0 +1 @@
+"Auth(entic|oris)ation package."
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index fe0ac92..f903553 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -14,7 +14,7 @@ SESSION_EXPIRY_MINUTES = 10
 # Database settings
 SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl"
 AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db"
-AUTH_MIGRATIONS = "migrations/auth"
+AUTH_MIGRATIONS = "gn_auth/migrations/auth"
 
 # Redis settings
 REDIS_URI = "redis://localhost:6379/0"