about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.guix/modules/gn-auth.scm2
-rw-r--r--.pylintrc13
-rw-r--r--README.md2
-rw-r--r--gn_auth/__init__.py40
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py6
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py17
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py6
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py7
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py41
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py40
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py81
-rw-r--r--gn_auth/auth/authorisation/data/views.py278
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py40
-rw-r--r--gn_auth/auth/authorisation/resources/views.py41
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py11
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py8
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py5
-rw-r--r--gn_auth/auth/authorisation/users/models.py38
-rw-r--r--gn_auth/auth/db/sqlite3.py61
-rw-r--r--gn_auth/debug.py22
-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.py (renamed from migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py)0
-rw-r--r--gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py (renamed from migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py (renamed from migrations/auth/20221108_01_CoxYh-create-the-groups-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py (renamed from migrations/auth/20221108_02_wxTr9-create-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py (renamed from migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py (renamed from migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py (renamed from migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py (renamed from migrations/auth/20221110_01_WtZ1I-create-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py (renamed from migrations/auth/20221110_05_BaNtL-create-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py (renamed from migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py (renamed from migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py (renamed from migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py (renamed from migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py (renamed from migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py (renamed from migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py (renamed from migrations/auth/20221114_03_PtWjc-create-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py (renamed from migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py (renamed from migrations/auth/20221114_05_hQun6-create-user-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py (renamed from migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py (renamed from migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py)0
-rw-r--r--gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py (renamed from migrations/auth/20221117_02_fmuZh-create-group-users-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py (renamed from migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py (renamed from migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py (renamed from migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py (renamed from migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py (renamed from migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py (renamed from migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py)0
-rw-r--r--gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py (renamed from migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py)0
-rw-r--r--gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py (renamed from migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py (renamed from migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py)0
-rw-r--r--gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py (renamed from migrations/auth/20230210_02_lDK14-create-system-admin-role.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py (renamed from migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py (renamed from migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py (renamed from migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py (renamed from migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py (renamed from migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py (renamed from migrations/auth/20230404_02_la33P-create-genotype-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py (renamed from migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py (renamed from migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py (renamed from migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py (renamed from migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py (renamed from migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py (renamed from migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py (renamed from migrations/auth/20230912_01_BxrhE-add-system-resource.py)0
-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.py (renamed from migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py (renamed from migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py)0
-rw-r--r--gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py (renamed from migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py)0
-rw-r--r--gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py (renamed from migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py)0
-rw-r--r--gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py (renamed from migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py (renamed from migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py (renamed from migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py (renamed from migrations/auth/20240606_02_ubZri-create-resource-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py (renamed from migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py (renamed from migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py)0
-rw-r--r--gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py (renamed from migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py)0
-rw-r--r--gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py (renamed from migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py (renamed from migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py (renamed from migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py (renamed from migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py)0
-rw-r--r--gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py (renamed from migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py (renamed from migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py)0
-rw-r--r--gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py (renamed from migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py (renamed from migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py (renamed from migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py)0
-rw-r--r--gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py (renamed from migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py)0
-rw-r--r--gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py (renamed from migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py)0
-rw-r--r--gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py (renamed from migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py)0
-rw-r--r--gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py (renamed from migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py)0
-rw-r--r--gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py (renamed from migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py)0
-rw-r--r--gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py (renamed from migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py)0
-rw-r--r--gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py (renamed from migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py)0
-rw-r--r--gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py (renamed from migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py)0
-rw-r--r--gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py (renamed from migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py)0
-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__.py (renamed from migrations/auth/__init__.py)0
-rw-r--r--gn_auth/scripts/__init__.py1
-rw-r--r--gn_auth/scripts/assign_data_to_default_admin.py (renamed from scripts/assign_data_to_default_admin.py)0
-rw-r--r--gn_auth/scripts/batch_assign_data_to_default_admin.py (renamed from scripts/batch_assign_data_to_default_admin.py)3
-rw-r--r--gn_auth/scripts/link_inbredsets.py (renamed from scripts/link_inbredsets.py)2
-rw-r--r--gn_auth/scripts/register_sys_admin.py (renamed from scripts/register_sys_admin.py)0
-rw-r--r--gn_auth/scripts/search_phenotypes.py (renamed from scripts/search_phenotypes.py)0
-rw-r--r--gn_auth/scripts/worker.py (renamed from scripts/worker.py)0
-rw-r--r--gn_auth/settings.py4
-rw-r--r--gn_auth/wsgi.py377
-rw-r--r--migrations/__init__.py1
-rw-r--r--pyproject.toml71
-rw-r--r--scripts/__init__.py0
-rw-r--r--setup.cfg4
-rwxr-xr-xsetup.py48
-rw-r--r--setup_commands/__init__.py3
-rw-r--r--setup_commands/run_tests.py40
-rw-r--r--tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py2
116 files changed, 932 insertions, 464 deletions
diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm
index 0d9cbc9..190f695 100644
--- a/.guix/modules/gn-auth.scm
+++ b/.guix/modules/gn-auth.scm
@@ -34,7 +34,7 @@
         #~(modify-phases #$phases
             (add-before 'build 'pylint
               (lambda _
-                (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts")))
+                (invoke "pylint" "tests" "gn_auth")))
             (add-after 'pylint 'mypy
               (lambda _
                 (invoke "mypy" ".")))))))
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 0b11d24..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,13 +0,0 @@
-[SIMILARITIES]
-
-ignore-imports=yes
-
-[MESSAGES CONTROL]
-
-disable=
-	fixme,
-	duplicate-code,
-        no-else-return
-
-load-plugins=
-	pylint.extensions.no_self_use
\ No newline at end of file
diff --git a/README.md b/README.md
index 963b5c5..f6c5f04 100644
--- a/README.md
+++ b/README.md
@@ -268,7 +268,7 @@ The checks we do are
 ### Linting
 
 ```bash
-pylint *py tests gn_auth scripts
+pylint tests gn_auth
 ```
 
 ### Type-Checking
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py
index d6591e5..d03c9ef 100644
--- a/gn_auth/__init__.py
+++ b/gn_auth/__init__.py
@@ -61,33 +61,24 @@ def load_secrets_conf(app: Flask) -> None:
         app.config.from_pyfile(secretsfile)
 
 
-def dev_loggers(appl: Flask) -> None:
+def dev_loggers(appl: Flask) -> logging.Logger:
     """Setup the logging handlers."""
     stderr_handler = logging.StreamHandler(stream=sys.stderr)
     appl.logger.addHandler(stderr_handler)
+    appl.logger.setLevel(appl.config["LOGLEVEL"])
 
-    root_logger = logging.getLogger()
-    root_logger.addHandler(stderr_handler)
-    root_logger.setLevel(appl.config["LOGLEVEL"])
+    return appl.logger
 
 
-def gunicorn_loggers(appl: Flask) -> None:
+def gunicorn_loggers(appl: Flask) -> logging.Logger:
     """Use gunicorn logging handlers for the application."""
     logger = logging.getLogger("gunicorn.error")
     appl.logger.handlers = logger.handlers
     appl.logger.setLevel(logger.level)
+    return appl.logger
 
 
-_LOGGABLE_MODULES_ = (
-    "gn_auth.errors",
-    "gn_auth.errors.common",
-    "gn_auth.errors.authlib",
-    "gn_auth.errors.http.http_4xx_errors",
-    "gn_auth.errors.http.http_5xx_errors"
-)
-
-
-def setup_logging(appl: Flask) -> None:
+def setup_logging(appl: Flask, loggable_modules: tuple[str, ...] = tuple()) -> None:
     """
     Setup the loggers according to the WSGI server used to run the application.
     """
@@ -96,14 +87,11 @@ def setup_logging(appl: Flask) -> None:
     # https://peps.python.org/pep-3333/#id4
     software, *_version_and_comments = os.environ.get(
         "SERVER_SOFTWARE", "").split('/')
-    if bool(software):
-        gunicorn_loggers(appl)
-    else:
-        dev_loggers(appl)
-
-    loglevel = logging.getLevelName(appl.logger.getEffectiveLevel())
-    for module_logger in _LOGGABLE_MODULES_:
-        logging.getLogger(module_logger).setLevel(loglevel)
+    logger = gunicorn_loggers(appl) if bool(software) else dev_loggers(appl)
+    for _logger in (
+            item for item in logger.manager.loggerDict.values()
+            if isinstance(item, logging.Logger)):
+        _logger.addFilter(lambda record: record.name in loggable_modules)
 
 
 def create_app(config: Optional[dict] = None) -> Flask:
@@ -112,18 +100,18 @@ def create_app(config: Optional[dict] = None) -> Flask:
 
     # ====== Setup configuration ======
     app.config.from_object(settings) # Default settings
-    # Override defaults with startup settings
-    app.config.update(config or {})
     # Override app settings with site-local settings
     if "GN_AUTH_CONF" in os.environ:
         app.config.from_envvar("GN_AUTH_CONF")
 
     override_settings_with_envvars(app)
 
+    # Override defaults with startup settings
+    app.config.update(config or {})
     load_secrets_conf(app)
     # ====== END: Setup configuration ======
 
-    setup_logging(app)
+    setup_logging(app, tuple(app.config.get("LOGGABLE_MODULES", [])))
     check_mandatory_settings(app)
 
     setup_oauth2_server(app)
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 200b25d..cebb3be 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -23,7 +23,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
     CLIENT_AUTH_METHODS = ['client_secret_post']
     def query_token(self, token_string: str, token_type_hint: str):
         """Query the token."""
-        return _query_token(self, token_string, token_type_hint)
+        return _query_token(token_string, token_type_hint)
 
     # pylint: disable=[no-self-use]
     def introspect_token(self, token: OAuth2Token) -> dict:
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 80922f1..0979694 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -15,7 +15,7 @@ class RevocationEndpoint(_RevocationEndpoint):
     CLIENT_AUTH_METHODS = ['client_secret_post']
     def query_token(self, token_string: str, token_type_hint: str):
         """Query the token."""
-        return _query_token(self, token_string, token_type_hint)
+        return _query_token(token_string, token_type_hint)
 
     def revoke_token(self, token: OAuth2Token, request):
         """Revoke token `token`."""
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
index 08b2a3b..490c141 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
@@ -1,5 +1,5 @@
 """endpoint utilities"""
-from typing import Any, Optional
+from typing import Optional
 
 from flask import current_app
 from pymonad.maybe import Nothing
@@ -8,9 +8,7 @@ from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.oauth2.models.oauth2token import (
     OAuth2Token, token_by_access_token, token_by_refresh_token)
 
-def query_token(# pylint: disable=[unused-argument]
-        endpoint_object: Any, token_str: str, token_type_hint) -> Optional[
-            OAuth2Token]:
+def query_token(token_str: str, token_type_hint) -> Optional[OAuth2Token]:
     """Retrieve the token from the database."""
     def __identity__(val):
         """Identity function."""
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index c802091..63f979c 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,9 +1,8 @@
 """JWT as Authorisation Grant"""
 import uuid
 import time
-
+import logging
 from typing import Optional
-from flask import current_app as app
 
 from authlib.jose import jwt
 from authlib.common.encoding import to_native
@@ -12,12 +11,17 @@ from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
 from authlib.oauth2.rfc7523.token import (
     JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
 
-from gn_auth.debug import __pk__
+from gn_libs.debug import make_peeker
+
 from gn_auth.auth.db.sqlite3 import with_db_connection
 from gn_auth.auth.authentication.users import User, user_by_id
 from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
 
 
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
 class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
     """
     A JSON Web Token formatted bearer token generator for jwt-bearer grant type.
@@ -149,6 +153,6 @@ class JWTBearerGrant(_JWTBearerGrant):
             include_refresh_token=self.request.client.check_grant_type(
                 "refresh_token")
         )
-        app.logger.debug('Issue token %r to %r', token, self.request.client)
+        logger.debug('Issue token %r to %r', token, self.request.client)
         self.save_token(token)
         return 200, token, self.TOKEN_RESPONSE_HEADER
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index fe12ff9..dfe5d79 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,5 +1,6 @@
 """OAuth2 Client model."""
 import json
+import logging
 import datetime
 from uuid import UUID
 from urllib.parse import urlparse
@@ -8,13 +9,12 @@ from dataclasses import asdict, dataclass
 from typing import Any, Sequence, Optional
 
 import requests
-from flask import current_app as app
 from requests.exceptions import JSONDecodeError
 from authlib.jose import KeySet, JsonWebKey
 from authlib.oauth2.rfc6749 import ClientMixin
 from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs.debug import make_peeker
 
-from gn_auth.debug import __pk__
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.errors import NotFoundError
 from gn_auth.auth.authentication.users import (User,
@@ -23,6 +23,10 @@ from gn_auth.auth.authentication.users import (User,
                                                same_password)
 
 
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
 @dataclass(frozen=True)
 class OAuth2Client(ClientMixin):
     """
@@ -66,7 +70,7 @@ class OAuth2Client(ClientMixin):
         jwksuri = self.client_metadata.get("public-jwks-uri")
         __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
         if not bool(jwksuri):
-            app.logger.debug("No Public JWKs URI set for client!")
+            logger.debug("No Public JWKs URI set for client!")
             return KeySet([])
         try:
             ## IMPORTANT: This can cause a deadlock if the client is working in
@@ -78,13 +82,12 @@ class OAuth2Client(ClientMixin):
                                    timeout=300,
                                    allow_redirects=True).json()["jwks"]])
         except requests.ConnectionError as _connerr:
-            app.logger.debug(
+            logger.debug(
                 "Could not connect to provided URI: %s", jwksuri, exc_info=True)
         except JSONDecodeError as _jsonerr:
-            app.logger.debug(
-                "Could not convert response to JSON", exc_info=True)
+            logger.debug("Could not convert response to JSON", exc_info=True)
         except Exception as _exc:# pylint: disable=[broad-except]
-            app.logger.debug(
+            logger.debug(
                 "Error retrieving the JWKs for the client.", exc_info=True)
         return KeySet([])
 
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 8ecf923..edab02c 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,4 +1,5 @@
 """Protect the resources endpoints"""
+import logging
 from datetime import datetime, timezone, timedelta
 
 from flask import current_app as app
@@ -16,6 +17,9 @@ from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
 from gn_auth.auth.authentication.oauth2.models.oauth2token import (
     token_by_access_token)
 
+logger = logging.getLogger(__name__)
+
+
 class BearerTokenValidator(_BearerTokenValidator):
     """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
     def authenticate_token(self, token_string: str):
@@ -66,7 +70,7 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
                 claims.validate()
                 return claims
             except JoseError as error:
-                app.logger.debug('Authenticate token failed. %r', error)
+                logger.debug('Authenticate token failed. %r', error)
 
         return None
 
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index 6c3de51..8cc123f 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -1,5 +1,6 @@
 """Endpoints for the oauth2 server"""
 import uuid
+import logging
 import traceback
 from urllib.parse import urlparse
 
@@ -27,8 +28,10 @@ from .endpoints.revocation import RevocationEndpoint
 from .endpoints.introspection import IntrospectionEndpoint
 
 
+logger = logging.getLogger(__name__)
 auth = Blueprint("auth", __name__)
 
+
 @auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"])
 def delete_client(client_id: uuid.UUID):
     """Delete an OAuth2 client."""
@@ -91,11 +94,11 @@ def authorise():
                 flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
             except EmailNotValidError as _enve:
-                app.logger.debug(traceback.format_exc())
+                logger.debug(traceback.format_exc())
                 flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
             except NotFoundError as _nfe:
-                app.logger.debug(traceback.format_exc())
+                logger.debug(traceback.format_exc())
                 flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
 
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index ddb0add..d44cbfb 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -1,7 +1,9 @@
 """Handle linking of Genotype data to the Auth(entic|oris)ation system."""
 import uuid
-from dataclasses import asdict
+import logging
 from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
 
 from gn_libs import mysqldb as gn3db
 from MySQLdb.cursors import DictCursor
@@ -11,6 +13,9 @@ from gn_auth.auth.db import sqlite3 as authdb
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.authorisation.resources.groups.models import Group
 
+
+logger = logging.getLogger(__name__)
+
 def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
     """Retrieve genotype data that is linked to user groups."""
     with authdb.cursor(conn) as cursor:
@@ -95,3 +100,37 @@ def link_genotype_data(
             "group": asdict(group),
             "datasets": datasets
         }
+
+
+def resources_by_datasets_and_traits(
+        authconn: authdb.DbConnection,
+        dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+    """Fetch resources by their attached datasets and traits."""
+    traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+        lambda acc, curr: {
+            **acc,
+            curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+        },
+        dsets_traits,
+        {})
+    paramstr = ", ".join(["?"] * len(dsets_traits))
+    query = (
+        "SELECT r.*, rc.*, lgd.dataset_name FROM linked_genotype_data AS lgd "
+        "INNER JOIN genotype_resources AS mr ON lgd.data_link_id=mr.data_link_id "
+        "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE lgd.dataset_name "
+        f"IN ({paramstr})")
+    logger.debug("QUERY: %s", query)
+    with authdb.cursor(authconn) as cursor:
+        params = tuple(traits_by_datasets.keys())
+        logger.debug("QUERY PARAMS: %s", params)
+        cursor.execute(query, tuple(traits_by_datasets.keys()))
+        return tuple({
+            "resource_id": row["resource_id"],
+            "resource_data": tuple(
+                f'{row["dataset_name"]}::{trait_id}'
+                for trait_id in traits_by_datasets[row["dataset_name"]])
+        } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 0cc644e..fcf6ea3 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -1,7 +1,9 @@
 """Handle linking of mRNA Assay data to the Auth(entic|oris)ation system."""
 import uuid
-from dataclasses import asdict
+import logging
 from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
 
 from gn_libs import mysqldb as gn3db
 from MySQLdb.cursors import DictCursor
@@ -11,6 +13,10 @@ from gn_auth.auth.db import sqlite3 as authdb
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.authorisation.resources.groups.models import Group
 
+
+logger = logging.getLogger(__name__)
+
+
 def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
     """Retrieve mRNA Assay data that is linked to user groups."""
     with authdb.cursor(conn) as cursor:
@@ -100,3 +106,35 @@ def link_mrna_data(
             "group": asdict(group),
             "datasets": datasets
         }
+
+
+def resources_by_datasets_and_traits(
+        authconn: authdb.DbConnection,
+        dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+    """Fetch resources by their attached datasets and traits."""
+    traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+        lambda acc, curr: {
+            **acc,
+            curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+        },
+        dsets_traits,
+        {})
+    paramstr = ", ".join(["?"] * len(dsets_traits))
+    query = (
+        "SELECT r.*, rc.*, lmd.dataset_name FROM linked_mrna_data AS lmd "
+        "INNER JOIN mrna_resources AS mr ON lmd.data_link_id=mr.data_link_id "
+        "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE lmd.dataset_name "
+        f"IN ({paramstr})")
+    logger.debug("QUERY: %s", query)
+    with authdb.cursor(authconn) as cursor:
+        cursor.execute(query, tuple(traits_by_datasets.keys()))
+        return tuple({
+            "resource_id": row["resource_id"],
+            "resource_data": tuple(
+                f'{row["dataset_name"]}::{trait_id}'
+                for trait_id in traits_by_datasets[row["dataset_name"]])
+        } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 788b9e7..92cbe89 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -1,5 +1,7 @@
 """Handle linking of Phenotype data to the Auth(entic|oris)ation system."""
 import uuid
+import logging
+from functools import reduce
 from dataclasses import asdict
 from typing import Any, Iterable
 
@@ -11,7 +13,6 @@ from flask import request, jsonify, Response, Blueprint, current_app as app
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 
 from gn_auth.auth.errors import AuthorisationError
-from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.authorisation.resources.checks import can_delete
 from gn_auth.auth.authorisation.resources.system.models import system_resource
 from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
@@ -20,8 +21,10 @@ from gn_auth.auth.authorisation.resources.groups.models import Group, group_reso
 from gn_auth.auth.authorisation.checks import require_json
 from gn_auth.auth.authorisation.resources.checks import authorised_for2
 
+logger = logging.getLogger(__name__)
 phenosbp = Blueprint("phenotypes", __name__)
 
+
 def linked_phenotype_data(
         authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
         species: str = "") -> Iterable[dict[str, Any]]:
@@ -58,41 +61,6 @@ def linked_phenotype_data(
         gn3cursor.execute(query, params)
         return (item for item in gn3cursor.fetchall())
 
-@authorised_p(("system:data:link-to-group",),
-              error_description=(
-                  "You do not have sufficient privileges to link data to (a) "
-                  "group(s)."),
-              oauth2_scope="profile group resource")
-def ungrouped_phenotype_data(
-        authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
-    """Retrieve phenotype data that is not linked to any user group."""
-    with gn3conn.cursor() as cursor:
-        params = tuple(
-            (row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"],
-             row["PublishXRefId"])
-            for row in linked_phenotype_data(authconn, gn3conn))
-        paramstr = ", ".join(["(?, ?, ?, ?)"] * len(params))
-        query = (
-            "SELECT spc.SpeciesId, spc.SpeciesName, iset.InbredSetId, "
-            "iset.InbredSetName, pf.Id AS PublishFreezeId, "
-            "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, "
-            "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
-            "FROM "
-            "Species AS spc "
-            "INNER JOIN InbredSet AS iset "
-            "ON spc.SpeciesId=iset.SpeciesId "
-            "INNER JOIN PublishFreeze AS pf "
-            "ON iset.InbredSetId=pf.InbredSetId "
-            "INNER JOIN PublishXRef AS pxr "
-            "ON pf.InbredSetId=pxr.InbredSetId")
-        if len(params) > 0:
-            query = query + (
-                f" WHERE (iset.InbredSetId, pf.Id, pxr.Id) NOT IN ({paramstr})")
-
-        cursor.execute(query, params)
-        return tuple(dict(row) for row in cursor.fetchall())
-
-    return tuple()
 
 def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
     """An internal utility function. Don't use outside of this module."""
@@ -274,3 +242,44 @@ def delete_linked_phenotypes_data(
             "requested": len(xref_ids),
             "deleted": _deleted
         })
+
+
+def __organise_resources_data__(acc, curr) -> dict:
+    logger.debug("ORGANISING... %s", dict(curr))
+    resource_row = acc.get(curr["resource_id"], {
+        "resource_id": curr["resource_id"],
+        "resource_data": tuple(),
+    })
+    return {
+        **acc,
+        curr["resource_id"]: {
+            **resource_row,
+            "resource_data": resource_row["resource_data"] + (
+                f'{curr["dataset_name"]}::{curr["trait_id"]}',)
+        }
+    }
+
+
+def resources_by_datasets_and_traits(
+        authconn: authdb.DbConnection,
+        dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+    """Fetch resources by their attached datasets and traits."""
+    paramstr = ", ".join(["(?, ?)"] * len(dsets_traits))
+    query = (
+        "SELECT r.*, rc.*, lpd.dataset_name, lpd.PublishXRefId AS trait_id "
+        "FROM linked_phenotype_data AS lpd "
+        "INNER JOIN phenotype_resources AS pr "
+        "ON lpd.data_link_id=pr.data_link_id "
+        "INNER JOIN resources AS r ON pr.resource_id=r.resource_id "
+        "INNER JOIN resource_categories AS rc "
+        "ON r.resource_category_id=rc.resource_category_id "
+        "WHERE (lpd.dataset_name, lpd.PublishXRefId) "
+        f"IN ({paramstr})")
+    with authdb.cursor(authconn) as cursor:
+        cursor.execute(
+            query, tuple(item for row in dsets_traits for item in row))
+        return tuple(reduce(
+            __organise_resources_data__,
+            cursor.fetchall(),
+            {}).values())
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 1526070..1184d63 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -2,9 +2,9 @@
 import sys
 import uuid
 import json
-from dataclasses import asdict
+import logging
 from typing import Any
-from functools import partial
+from functools import reduce, partial
 
 import redis
 from MySQLdb.cursors import DictCursor
@@ -13,6 +13,7 @@ from flask import request, jsonify, Response, Blueprint, current_app as app
 
 
 from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as db
 
 from gn_auth import jobs
 from gn_auth.commands import run_async_cmd
@@ -21,54 +22,32 @@ from gn_auth.auth.requests import request_json
 from gn_auth.auth.errors import InvalidData, NotFoundError
 from gn_auth.auth.authorisation.resources.groups.models import group_by_id
 
-from ...db import sqlite3 as db
-from ...db.sqlite3 import with_db_connection
+from gn_auth.auth.db.sqlite3 import with_db_connection # Replace this with gn_libs alternative
 
 from ..checks import require_json
 
-from ..users.models import user_resource_roles
-
-from ..resources.checks import authorised_for
-from ..resources.models import (
-    user_resources, public_resources, attach_resources_data)
-
 from ...authentication.users import User
 from ...authentication.oauth2.resource_server import require_oauth
 
-from .mrna import link_mrna_data, ungrouped_mrna_data
-from .genotypes import link_genotype_data, ungrouped_genotype_data
-from .phenotypes import phenosbp, link_phenotype_data, pheno_traits_from_db
-
+from .mrna import (
+    link_mrna_data,
+    ungrouped_mrna_data,
+    resources_by_datasets_and_traits as mrna_resources_by_datasets_and_traits)
+from .genotypes import (
+    link_genotype_data,
+    ungrouped_genotype_data,
+    resources_by_datasets_and_traits as geno_resources_by_datasets_and_traits)
+from .phenotypes import (
+    phenosbp,
+    link_phenotype_data,
+    pheno_traits_from_db,
+    resources_by_datasets_and_traits as pheno_resources_by_datasets_and_traits)
+
+
+logger = logging.getLogger(__name__)
 data = Blueprint("data", __name__)
 data.register_blueprint(phenosbp, url_prefix="/phenotypes")
 
-def build_trait_name(trait_fullname):
-    """
-    Initialises the trait's name, and other values from the search data provided
-
-    This is a copy of `gn3.db.traits.build_trait_name` function.
-    """
-    def dataset_type(dset_name):
-        if dset_name.find('Temp') >= 0:
-            return "Temp"
-        if dset_name.find('Geno') >= 0:
-            return "Geno"
-        if dset_name.find('Publish') >= 0:
-            return "Publish"
-        return "ProbeSet"
-
-    name_parts = trait_fullname.split("::")
-    assert len(name_parts) >= 2, f"Name format error: '{trait_fullname}'"
-    dataset_name = name_parts[0]
-    dataset_type = dataset_type(dataset_name)
-    return {
-        "db": {
-            "dataset_name": dataset_name,
-            "dataset_type": dataset_type},
-        "trait_fullname": trait_fullname,
-        "trait_name": name_parts[1],
-        "cellid": name_parts[2] if len(name_parts) == 3 else ""
-    }
 
 @data.route("species")
 def list_species() -> Response:
@@ -80,101 +59,144 @@ def list_species() -> Response:
 
 @data.route("/authorisation", methods=["POST"])
 @require_json
-def authorisation() -> Response:
+def authorisation() -> Response:# pylint: disable=[too-many-locals]
     """Retrieve the authorisation level for datasets/traits for the user."""
     # Access endpoint with something like:
-    # curl -X POST http://127.0.0.1:8080/api/oauth2/data/authorisation \
+    # curl -X POST http://127.0.0.1:8081/auth/data/authorisation \
     #    -H "Content-Type: application/json" \
     #    -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695",
     #        "BXDPublish::10001"]}'
+    def __organise_traits__(acc, curr):
+        dset, _trt = curr
+        key = "ProbeSet"
+        if dset.endswith("Publish"):
+            key = "Publish"
+        elif dset.endswith("Geno"):
+            key="Geno"
+        elif dset.endswith("Temp"):
+            key = "Temp"
+        else:
+            key = "ProbeSet"
+
+        return {
+            **acc,
+            key: acc.get(key, tuple()) + (curr,)
+        }
+    _dset_traits: dict[str, tuple[tuple[str, str], ...]] = reduce(
+        __organise_traits__,
+        (
+            (dset.strip(), trt.strip()) for dset, trt in
+            (trtstr.split("::") for trtstr in
+             request_json().get("traits", []))),
+        {key: tuple() for key in ("Publish", "ProbeSet", "Geno", "Temp")})
+
     db_uri = app.config["AUTH_DB"]
-    privileges = {}
     user = User(uuid.uuid4(), "anon@ymous.user", "Anonymous User")
-    with db.connection(db_uri) as auth_conn:
-        try:
-            with require_oauth.acquire("profile group resource") as _token:
-                user = _token.user
-                resources = attach_resources_data(
-                    auth_conn, user_resources(auth_conn, _token.user)[0])
-                resources_roles = user_resource_roles(auth_conn, _token.user)
-                privileges = {
-                    resource_id: tuple(
-                        privilege.privilege_id
-                        for roles in resources_roles[resource_id]
-                        for privilege in roles.privileges)#("group:resource:view-resource",)
-                    for resource_id, is_authorised
-                    in authorised_for(
-                        auth_conn, _token.user,
-                        ("group:resource:view-resource",), tuple(
-                            resource.resource_id for resource in resources)).items()
-                    if is_authorised
-                }
-        except _HTTPException as exc:
-            err_msg = json.loads(exc.body)
-            if err_msg["error"] == "missing_authorization":
-                resources = attach_resources_data(
-                    auth_conn, public_resources(auth_conn))
-            else:
-                raise exc from None
-
-        def __gen_key__(resource, data_item):
-            if resource.resource_category.resource_category_key.lower() == "phenotype":
-                return (
-                    f"{resource.resource_category.resource_category_key.lower()}::"
-                    f"{data_item['dataset_name']}::{data_item['PublishXRefId']}")
-            return (
-                f"{resource.resource_category.resource_category_key.lower()}::"
-                f"{data_item['dataset_name']}")
-
-        data_to_resource_map = {
-            __gen_key__(resource, data_item): resource.resource_id
-            for resource in resources
-            for data_item in resource.resource_data
+    with (db.connection(db_uri) as authconn, db.cursor(authconn) as cursor):
+        _all_resources = {
+            _rrow["resource_id"]: _rrow
+            for _rtypes in (
+                    pheno_resources_by_datasets_and_traits(
+                        authconn, _dset_traits["Publish"]),
+                    geno_resources_by_datasets_and_traits(
+                        authconn, _dset_traits["Geno"]),
+                    mrna_resources_by_datasets_and_traits(
+                        authconn, _dset_traits["ProbeSet"]))
+            for _rrow in _rtypes
         }
-        privileges = {
-            **{
-                resource.resource_id: ("system:resource:public-read",)
-                for resource in resources if resource.public
-            },
-            **privileges}
-
-        args = request.get_json()
-        traits_names = args["traits"] # type: ignore[index]
-        def __translate__(val):
+        if (len(_all_resources.keys()) == 0 and
+                len(_dset_traits.get("Temp", tuple())) == 0):
+            raise NotFoundError(
+                "No resource(s) found for specified trait(s). Do(es) the "
+                "trait(s) actually exist?")
+
+        # Handle Temp traits specially - they should be public/anonymous resources
+        if len(_dset_traits.get("Temp", tuple())) > 0:
+            # Create a synthetic public resource for Temp traits
+            # Use a predictable ID to identify synthetic temp resources
+            temp_resource_id = "gn-auth-temp-traits"
+            _all_resources[temp_resource_id] = {
+                "resource_id": temp_resource_id,
+                "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"])
+            }
+
+        _resource_ids = tuple(_all_resources.keys())
+
+
+        def __explode_resource_data__(trait_fullname):
+            _dset, _trt = trait_fullname.split("::")
             return {
-                "Temp": "Temp",
-                "ProbeSet": "mRNA",
-                "Geno": "Genotype",
-                "Publish": "Phenotype"
-            }[val]
-
-        def __trait_key__(trait):
-            dataset_type = __translate__(trait['db']['dataset_type']).lower()
-            dataset_name = trait["db"]["dataset_name"]
-            if dataset_type == "phenotype":
-                return f"{dataset_type}::{dataset_name}::{trait['trait_name']}"
-            return f"{dataset_type}::{dataset_name}"
-
-        return jsonify(tuple(
-            {
-                "user": asdict(user),
-                **{key:trait[key] for key in ("trait_fullname", "trait_name")},
-                "dataset_name": trait["db"]["dataset_name"],
-                "dataset_type": __translate__(trait["db"]["dataset_type"]),
-                "resource_id": data_to_resource_map.get(__trait_key__(trait)),
-                "privileges": privileges.get(
-                    data_to_resource_map.get(
-                        __trait_key__(trait),
-                        uuid.UUID("4afa415e-94cb-4189-b2c6-f9ce2b6a878d")),
-                    tuple()) + (
-                        # Temporary traits do not exist in db: Set them
-                        # as public-read
-                        ("system:resource:public-read",)
-                        if trait["db"]["dataset_type"] == "Temp"
-                        else tuple())
-            } for trait in
-            (build_trait_name(trait_fullname)
-             for trait_fullname in traits_names)))
+                "dataset_name": _dset,
+                "dataset_type": (
+                    "Phenotype" if _dset.endswith("Publish")
+                    else ("Genotype" if _dset.endswith("Geno")
+                          else ("Temporary" if _dset.endswith("Temp")
+                                else "mRNA"))),
+                "trait_name": _trt,
+                "trait_fullname": trait_fullname
+            }
+
+        _paramstr = ", ".join(["?"] * len(_resource_ids))
+        _privileges_by_resource: dict[str, tuple[str, ...]] = {}
+
+        # Separate synthetic temp resources from real resources
+        temp_resource_id = "gn-auth-temp-traits"
+        real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id)
+
+        # Query privileges only for real resources
+        if len(real_resource_ids) > 0:
+            real_paramstr = ", ".join(["?"] * len(real_resource_ids))
+            try:
+                with require_oauth.acquire("profile group resource") as _token:
+                    user = _token.user
+                    cursor.execute(
+                        "SELECT ur.resource_id, r.role_id, rp.privilege_id "
+                        "FROM user_roles AS ur "
+                        "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+                        "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+                        "WHERE ur.user_id = ? "
+                        f"AND ur.resource_id IN ({real_paramstr})",
+                        (str(user.user_id),) + real_resource_ids
+                    )
+                    _privileges_by_resource = reduce(
+                        lambda acc, curr: {
+                            **acc,
+                            curr["resource_id"]: (
+                                acc.get(curr["resource_id"], tuple())
+                                + (curr["privilege_id"],))
+                        },
+                        cursor.fetchall(),
+                        {})
+            except _HTTPException as exc:
+                err_msg = json.loads(exc.body)
+                if err_msg["error"] == "missing_authorization":
+                    cursor.execute(
+                        "SELECT rsc.resource_id "
+                        "FROM resources AS rsc "
+                        "WHERE rsc.public = '1' "
+                        f"AND rsc.resource_id IN ({real_paramstr}) ",
+                        real_resource_ids)
+                    _privileges_by_resource = {
+                        row["resource_id"]: ('group:resource:view-resource',)
+                        for row in cursor.fetchall()
+                    }
+                else:
+                    raise exc from None
+
+        # Temp resources are always publicly viewable
+        if temp_resource_id in _resource_ids:
+            _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',)
+
+        return jsonify({
+            "authorisation": [{
+                **resource,
+                "resource_data": [
+                    __explode_resource_data__(item)
+                            for item in resource["resource_data"]],
+                "privileges": _privileges_by_resource.get(resource["resource_id"], tuple())
+            } for resource in _all_resources.values()]
+        })
+
 
 def __search_mrna__():
     query = __request_key__("query", "")
@@ -219,7 +241,7 @@ def __search_phenotypes__():
         job_id = uuid.uuid4()
         selected = __request_key__("selected_traits", [])
         command =[
-            sys.executable, "-m", "scripts.search_phenotypes",
+            sys.executable, "-m", "gn_auth.scripts.search_phenotypes",
             __request_key__("species_name"),
             __request_key__("query"),
             str(job_id),
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index 004c780..252df2f 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -135,6 +135,11 @@ def can_delete(
         resource_id: uuid.UUID
 ) -> bool:
     """Check whether user is allowed delete a resource and/or its data."""
+    warnings.warn(
+        (f"Function '{__name__}.can_delete' is deprecated. "
+         "Use `gn_libs.privileges.resources.can_delete` instead."),
+        category=DeprecationWarning,
+        stacklevel=2)
     return (
         authorised_for_spec(# resource-level delete access
             conn,
@@ -149,42 +154,17 @@ def can_delete(
             "(AND system:system-wide:data:delete)"))
 
 
-def can_view(
-        conn: authdb.DbConnection,
-        user_id: uuid.UUID,
-        resource_id: uuid.UUID
-) -> bool:
-    """Check whether user is allowed view a resource and/or its data."""
-    with authdb.cursor(conn) as cursor:
-        cursor.execute("SELECT public FROM resources WHERE resource_id=?",
-                       (str(resource_id),))
-        row = cursor.fetchone()
-        is_public = bool(row) and bool(int(row["public"]))
-
-    return (
-        is_public# The resource is public, everyone can view!
-        or
-        authorised_for_spec(
-            # resource-level view access: user has view access to his resource.
-            conn,
-            user_id,
-            resource_id,
-            "(OR group:resource:view-resource system:resource:view)")
-        or
-        authorised_for_spec(
-            # system-wide view access: user can view any/all resource(s).
-            conn,
-            user_id,
-            system_resource(conn).resource_id,
-            "(OR system:system-wide:data:view system:resource:view)"))
-
-
 def can_edit(
         conn: authdb.DbConnection,
         user_id: uuid.UUID,
         resource_id: uuid.UUID
 ) -> bool:
     """Check whether user is allowed edit a resource and/or its data."""
+    warnings.warn(
+        (f"Function '{__name__}.can_edit' is deprecated. "
+         "Use `gn_libs.privileges.resources.can_edit` instead."),
+        category=DeprecationWarning,
+        stacklevel=2)
     return (
         authorised_for_spec(
             # resource-level edit access: user has edit access to his resource.
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index ab44795..f114476 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -127,17 +127,23 @@ def edit_resource(resource_id: UUID) -> Response:
     db_uri = app.config["AUTH_DB"]
     with (require_oauth.acquire("profile group resource") as _token,
           db.connection(db_uri) as conn):
-        _privileges = tuple(
-            privilege.privilege_id
-            for role in (
-                    role for resource in user_roles_on_resources(
-                        conn,
-                        _token.user,
-                        (resource_id, system_resource(conn).resource_id)
-                    ).values()
-                    for role in resource.get("roles", tuple()))
-            for privilege in role.privileges)
-        if not gn_libs.privileges.resources.can_edit(_privileges):
+        def __extract_privileges__(roles: tuple[Role, ...]) -> tuple[str, ...]:
+            return tuple(
+                priv.privilege_id for role in roles
+                for priv in role.privileges)
+
+        _sys_resource = system_resource(conn)
+        _privileges = {
+            ("system_privileges"
+             if _rid == _sys_resource.resource_id
+             else "resource_privileges"): __extract_privileges__(_rroles)
+            for _rid, _rroles in user_roles_on_resources(
+                conn,
+                _token.user,
+                (resource_id, _sys_resource.resource_id)
+            ).items()
+        }
+        if not gn_libs.privileges.resources.can_edit(**_privileges):
             return make_response(jsonify({
                 "error": "AuthorisationError",
                 "error_description": "You are not allowed to edit this resource."
@@ -274,9 +280,11 @@ def resource_users(resource_id: UUID):
                             **users_n_roles,
                             user_id: {
                                 "user": user,
-                                "user_group": Group(
-                                    UUID(row["group_id"]), row["group_name"],
-                                    json.loads(row["group_metadata"])),
+                                "user_group": (
+                                    Group(UUID(row["group_id"]),
+                                          row["group_name"],
+                                          json.loads(row["group_metadata"]))
+                                    if bool(row["group_id"]) else False) ,
                                 "roles": users_n_roles.get(
                                     user_id, {}).get("roles", tuple()) + (role,)
                             }
@@ -284,7 +292,7 @@ def resource_users(resource_id: UUID):
                     cursor.execute(
                         "SELECT g.*, u.*, r.* "
                         "FROM groups AS g INNER JOIN group_users AS gu "
-                        "ON g.group_id=gu.group_id INNER JOIN users AS u "
+                        "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
                         "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
                         "ON u.user_id=ur.user_id INNER JOIN roles AS r "
                         "ON ur.role_id=r.role_id "
@@ -297,7 +305,8 @@ def resource_users(resource_id: UUID):
         results = (
             {
                 "user": asdict(row["user"]),
-                "user_group": asdict(row["user_group"]),
+                "user_group": (
+                    asdict(row["user_group"]) if row["user_group"] else False),
                 "roles": tuple(asdict(role) for role in row["roles"])
             } for row in (
                 user_row for user_id, user_row
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 3d68932..0594864 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -4,6 +4,7 @@ import warnings
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User
 from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
+from gn_auth.auth.authorisation.resources.system.models import system_resource
 
 
 def sysadmin_role(conn: db.DbConnection) -> Role:
@@ -28,14 +29,14 @@ def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
     cursor.execute(
             "SELECT * FROM roles WHERE role_name='system-administrator'")
     admin_role = cursor.fetchone()
-    cursor.execute("SELECT resources.resource_id FROM resources")
-    cursor.executemany(
+    sysresource = system_resource(cursor)
+    cursor.execute(
         "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
-        tuple({
+        {
             "user_id": str(user.user_id),
             "role_id": admin_role["role_id"],
-            "resource_id": resource_id
-        } for resource_id in cursor.fetchall()))
+            "resource_id": str(sysresource.resource_id)
+        })
     return user
 
 
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 9bc1c36..62eccfd 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -1,6 +1,5 @@
 """UI for admin stuff"""
 import uuid
-import json
 import random
 import string
 from typing import Optional
@@ -240,13 +239,6 @@ def register_client():
         client_secret = raw_client_secret)
 
 
-def __parse_client__(sqlite3_row) -> dict:
-    """Parse the client details into python datatypes."""
-    return {
-        **dict(sqlite3_row),
-        "client_metadata": json.loads(sqlite3_row["client_metadata"])
-    }
-
 @admin.route("/list-client", methods=["GET"])
 @is_admin
 def list_clients():
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index f619c3d..5ed2c23 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -1,4 +1,5 @@
 """Views regarding user collections."""
+import logging
 from uuid import UUID
 
 from redis import Redis
@@ -25,8 +26,10 @@ from .models import (
     REDIS_COLLECTIONS_KEY,
     delete_collections as _delete_collections)
 
+logger = logging.getLogger(__name__)
 collections = Blueprint("collections", __name__)
 
+
 @collections.route("/list")
 @require_oauth("profile user")
 def list_user_collections() -> Response:
@@ -44,7 +47,7 @@ def list_anonymous_collections(anon_id: UUID) -> Response:
         def __list__(conn: db.DbConnection) -> tuple:
             try:
                 _user = user_by_id(conn, anon_id)
-                current_app.logger.warning(
+                logger.warning(
                     "Fetch collections for authenticated user using the "
                     "`list_user_collections()` endpoint.")
                 return tuple()
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index d30bfd0..ab7a980 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,5 +1,6 @@
 """Functions for acting on users."""
 import uuid
+import warnings
 from functools import reduce
 from datetime import datetime, timedelta
 
@@ -128,3 +129,40 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu
             (str(user.user_id),))
         return __build_resource_roles__(
             (dict(row) for row in cursor.fetchall()))
+
+
+def delete_users_by_id(
+        conn: db.DbConnection,
+        user_ids: tuple[uuid.UUID, ...]
+) -> int:
+    """Delete users unconditionally by ID, removing all dependent data.
+
+    Unlike the HTTP endpoint, this bypasses all policy checks — users are
+    deleted regardless of their roles or group memberships. Returns the
+    number of users removed from the users table.
+    """
+    warnings.warn(
+        (f"Running dangerous function `{__name__}.delete_users_by_id`. "
+         "Do ensure that is what you actually want."),
+        category=RuntimeWarning)
+    if not user_ids:
+        return 0
+    _ids = tuple(str(uid) for uid in user_ids)
+    _paramstr = ", ".join(["?"] * len(_ids))
+    _dependent_tables = (
+        ("authorisation_code", "user_id"),
+        ("forgot_password_tokens", "user_id"),
+        ("group_join_requests", "requester_id"),
+        ("jwt_refresh_tokens", "user_id"),
+        ("oauth2_tokens", "user_id"),
+        ("user_credentials", "user_id"),
+        ("user_roles", "user_id"),
+        ("user_verification_codes", "user_id"),
+    )
+    with db.cursor(conn) as cursor:
+        for table, col in _dependent_tables:
+            cursor.execute(
+                f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids)
+        cursor.execute(
+            f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids)
+        return cursor.rowcount
diff --git a/gn_auth/auth/db/sqlite3.py b/gn_auth/auth/db/sqlite3.py
index 12a46c7..5f54752 100644
--- a/gn_auth/auth/db/sqlite3.py
+++ b/gn_auth/auth/db/sqlite3.py
@@ -1,63 +1,28 @@
 """Handle connection to auth database."""
-import sqlite3
-import logging
-import contextlib
-from typing import Any, Protocol, Callable, Iterator
-
-import traceback
+import warnings
+from typing import Any, Callable
 
 from flask import current_app
 
-from .protocols import DbCursor
-
-class DbConnection(Protocol):
-    """Type annotation for a generic database connection object."""
-    def cursor(self) -> Any:
-        """A cursor object"""
-
-    def commit(self) -> Any:
-        """Commit the transaction."""
-
-    def rollback(self) -> Any:
-        """Rollback the transaction."""
+from gn_libs.sqlite3 import cursor, connection # pylint: disable=[unused-import]
+from gn_libs.protocols import DbCursor, DbConnection # pylint: disable=[unused-import]
 
-@contextlib.contextmanager
-def connection(db_path: str, row_factory: Callable = sqlite3.Row) -> Iterator[DbConnection]:
-    """Create the connection to the auth database."""
-    logging.debug("SQLite3 DB Path: '%s'.", db_path)
-    conn = sqlite3.connect(db_path)
-    conn.row_factory = row_factory
-    conn.set_trace_callback(logging.debug)
-    conn.execute("PRAGMA foreign_keys = ON")
-    try:
-        yield conn
-    except sqlite3.Error as exc:
-        conn.rollback()
-        logging.debug(traceback.format_exc())
-        raise exc
-    finally:
-        conn.commit()
-        conn.close()
+warnings.warn(
+    f"Module '{__name__}' is deprecated. Use `gn_libs.sqlite3` instead.",
+    category=DeprecationWarning,
+    stacklevel=2)
 
-@contextlib.contextmanager
-def cursor(conn: DbConnection) -> Iterator[DbCursor]:
-    """Get a cursor from the given connection to the auth database."""
-    cur = conn.cursor()
-    try:
-        yield cur
-        conn.commit()
-    except sqlite3.Error as exc:
-        conn.rollback()
-        logging.debug(traceback.format_exc())
-        raise exc
-    finally:
-        cur.close()
 
 def with_db_connection(func: Callable[[DbConnection], Any]) -> Any:
     """
     Takes a function of one argument `func`, whose one argument is a database
     connection.
     """
+    warnings.warn(
+        (f"Function '{__name__}.with_db_connection' is deprecated. "
+         "Use `gn_libs.sqlite3.with_db_connection` instead."),
+        category=DeprecationWarning,
+        stacklevel=2)
     db_uri = current_app.config["AUTH_DB"]
     with connection(db_uri) as conn:
         return func(conn)
diff --git a/gn_auth/debug.py b/gn_auth/debug.py
deleted file mode 100644
index 6b7173b..0000000
--- a/gn_auth/debug.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Debug utilities"""
-import logging
-from flask import current_app
-
-__this_module_name__ = __name__
-
-
-# pylint: disable=invalid-name
-def getLogger(name: str):
-    """Return a logger"""
-    return (
-        logging.getLogger(name)
-        if not bool(current_app)
-        else current_app.logger)
-
-def __pk__(*args):
-    """Format log entry"""
-    value = args[-1]
-    title_vals = " => ".join(args[0:-1])
-    logger = getLogger(__this_module_name__)
-    logger.debug("%s: %s", title_vals, value)
-    return value
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/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
index d511f5d..d511f5d 100644
--- a/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
diff --git a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
index 48bd663..48bd663 100644
--- a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
+++ b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py
diff --git a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
index 29f92d4..29f92d4 100644
--- a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
+++ b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py
diff --git a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
index 67720b2..67720b2 100644
--- a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
+++ b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py
diff --git a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
index ce752ef..ce752ef 100644
--- a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
+++ b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py
diff --git a/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
index 76ffbef..76ffbef 100644
--- a/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
diff --git a/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
index 6c829b1..6c829b1 100644
--- a/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
diff --git a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
index abc8895..abc8895 100644
--- a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
+++ b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py
diff --git a/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
index 51e19e8..51e19e8 100644
--- a/migrations/auth/20221110_05_BaNtL-create-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py
diff --git a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
index 2b55c2b..2b55c2b 100644
--- a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
+++ b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py
diff --git a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
index 0d0eeb9..0d0eeb9 100644
--- a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
+++ b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py
diff --git a/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
index 077182b..077182b 100644
--- a/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
diff --git a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
index 072f226..072f226 100644
--- a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
+++ b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py
diff --git a/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
index 2048f4a..2048f4a 100644
--- a/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
diff --git a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
index 6bd101b..6bd101b 100644
--- a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
+++ b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py
diff --git a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
index a7e7b45..a7e7b45 100644
--- a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py
diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
index 386f481..386f481 100644
--- a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
+++ b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py
diff --git a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
index e0de751..e0de751 100644
--- a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
+++ b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py
diff --git a/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
index 2e4ae28..2e4ae28 100644
--- a/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
diff --git a/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
index a4d7806..a4d7806 100644
--- a/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
diff --git a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
index 92885ef..92885ef 100644
--- a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
+++ b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py
diff --git a/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
index 9aa3667..9aa3667 100644
--- a/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
diff --git a/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
index 2238069..2238069 100644
--- a/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
diff --git a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
index 475be01..475be01 100644
--- a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
+++ b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py
diff --git a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
index 778282b..778282b 100644
--- a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
+++ b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py
diff --git a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
index 1683f87..1683f87 100644
--- a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
+++ b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py
diff --git a/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
index 7e7fda2..7e7fda2 100644
--- a/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
diff --git a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
index 1ef5ab0..1ef5ab0 100644
--- a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
+++ b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py
diff --git a/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
index ceae5ea..ceae5ea 100644
--- a/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
diff --git a/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
index 8b406a6..8b406a6 100644
--- a/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
diff --git a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
index 9b3fc2b..9b3fc2b 100644
--- a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
+++ b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py
diff --git a/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
index 84bbd49..84bbd49 100644
--- a/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
diff --git a/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
index 3caad55..3caad55 100644
--- a/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
diff --git a/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
index 647325f..647325f 100644
--- a/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
diff --git a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
index 7c9e986..7c9e986 100644
--- a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py
diff --git a/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
index 02e8718..02e8718 100644
--- a/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
diff --git a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
index 1a865e0..1a865e0 100644
--- a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
+++ b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py
diff --git a/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
index db9a6bf..db9a6bf 100644
--- a/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
diff --git a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
index 2ad1056..2ad1056 100644
--- a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
+++ b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py
diff --git a/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
index 37fcfe7..37fcfe7 100644
--- a/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
diff --git a/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
index c4397c9..c4397c9 100644
--- a/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
diff --git a/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
index 0f491c2..0f491c2 100644
--- a/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
diff --git a/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
index a26834a..a26834a 100644
--- a/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
diff --git a/migrations/auth/20230912_01_BxrhE-add-system-resource.py b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
index 66c6461..66c6461 100644
--- a/migrations/auth/20230912_01_BxrhE-add-system-resource.py
+++ b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py
diff --git a/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
index 1b3f0b1..1b3f0b1 100644
--- a/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
diff --git a/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
index 1172034..1172034 100644
--- a/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
diff --git a/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
index 402e9a5..402e9a5 100644
--- a/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
diff --git a/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
index a4238ed..a4238ed 100644
--- a/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
diff --git a/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
index 049ac6b..049ac6b 100644
--- a/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
diff --git a/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
index 0cab1c3..0cab1c3 100644
--- a/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
diff --git a/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
index a45fd30..a45fd30 100644
--- a/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
diff --git a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
index 0695c0e..0695c0e 100644
--- a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py
diff --git a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
index 45d689c..45d689c 100644
--- a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
+++ b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py
diff --git a/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
index 44318bd..44318bd 100644
--- a/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
diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
index 5c6e81d..5c6e81d 100644
--- a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
+++ b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py
diff --git a/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
index d22ad01..d22ad01 100644
--- a/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
diff --git a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
index 73a4880..73a4880 100644
--- a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
+++ b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py
diff --git a/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
index 3b9e928..3b9e928 100644
--- a/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
diff --git a/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
index 5d9c306..5d9c306 100644
--- a/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
diff --git a/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
index 6335152..6335152 100644
--- a/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
diff --git a/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
index f00ab11..f00ab11 100644
--- a/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
diff --git a/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
index b956bef..b956bef 100644
--- a/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
diff --git a/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
index be0d022..be0d022 100644
--- a/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
diff --git a/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
index e79ab1c..e79ab1c 100644
--- a/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
diff --git a/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
index e3bdc8f..e3bdc8f 100644
--- a/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
diff --git a/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
index 95a6fbb..95a6fbb 100644
--- a/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
diff --git a/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
index 63e807a..63e807a 100644
--- a/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
diff --git a/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
index d618f14..d618f14 100644
--- a/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
diff --git a/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
index e79ef6a..e79ef6a 100644
--- a/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
diff --git a/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
index bdf8a56..bdf8a56 100644
--- a/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
diff --git a/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
index 22863ae..22863ae 100644
--- a/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
diff --git a/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
index 702c418..702c418 100644
--- a/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
diff --git a/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
index 2dddc56..2dddc56 100644
--- a/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
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/migrations/auth/__init__.py b/gn_auth/migrations/auth/__init__.py
index 1358c9a..1358c9a 100644
--- a/migrations/auth/__init__.py
+++ b/gn_auth/migrations/auth/__init__.py
diff --git a/gn_auth/scripts/__init__.py b/gn_auth/scripts/__init__.py
new file mode 100644
index 0000000..5be56d8
--- /dev/null
+++ b/gn_auth/scripts/__init__.py
@@ -0,0 +1 @@
+"""These are command-line scripts to be run manually or in the background."""
diff --git a/scripts/assign_data_to_default_admin.py b/gn_auth/scripts/assign_data_to_default_admin.py
index 69fc50c..69fc50c 100644
--- a/scripts/assign_data_to_default_admin.py
+++ b/gn_auth/scripts/assign_data_to_default_admin.py
diff --git a/scripts/batch_assign_data_to_default_admin.py b/gn_auth/scripts/batch_assign_data_to_default_admin.py
index a468019..95d9794 100644
--- a/scripts/batch_assign_data_to_default_admin.py
+++ b/gn_auth/scripts/batch_assign_data_to_default_admin.py
@@ -15,8 +15,7 @@ from gn_auth.auth.db import sqlite3 as authdb
 from gn_auth.auth.authentication.users import User
 from gn_auth.auth.authorisation.resources.groups.models import (
     Group, db_row_to_group)
-
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
     default_resources, assign_data_to_resource)
 
 
diff --git a/scripts/link_inbredsets.py b/gn_auth/scripts/link_inbredsets.py
index c78a050..ad743f5 100644
--- a/scripts/link_inbredsets.py
+++ b/gn_auth/scripts/link_inbredsets.py
@@ -10,7 +10,7 @@ from gn_libs import mysqldb as biodb
 
 import gn_auth.auth.db.sqlite3 as authdb
 
-from scripts.assign_data_to_default_admin import (
+from gn_auth.scripts.assign_data_to_default_admin import (
     sys_admins, admin_group, select_sys_admin)
 
 def linked_inbredsets(conn):
diff --git a/scripts/register_sys_admin.py b/gn_auth/scripts/register_sys_admin.py
index 06aa845..06aa845 100644
--- a/scripts/register_sys_admin.py
+++ b/gn_auth/scripts/register_sys_admin.py
diff --git a/scripts/search_phenotypes.py b/gn_auth/scripts/search_phenotypes.py
index eee112d..eee112d 100644
--- a/scripts/search_phenotypes.py
+++ b/gn_auth/scripts/search_phenotypes.py
diff --git a/scripts/worker.py b/gn_auth/scripts/worker.py
index 0a77d41..0a77d41 100644
--- a/scripts/worker.py
+++ b/gn_auth/scripts/worker.py
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index d59e997..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"
@@ -49,3 +49,5 @@ EMAIL_ADDRESS = "no-reply@uthsc.edu"
 
 ## Variable settings for various emails going out to users
 AUTH_EMAILS_EXPIRY_MINUTES = 15
+
+LOGGABLE_MODULES = ["gn_auth"]
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index e05ef0d..bab9991 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -1,10 +1,13 @@
 """Main entry point for project"""
+import os
+import re
+import secrets
 import sys
 import uuid
 import json
 from math import ceil
 from pathlib import Path
-from datetime import datetime
+from datetime import datetime, timezone
 
 import click
 from yoyo import get_backend, read_migrations
@@ -14,10 +17,16 @@ from gn_auth import create_app
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.errors import NotFoundError
-from gn_auth.auth.authentication.users import user_by_id, hash_password
-from gn_auth.auth.authorisation.users.admin.models import make_sys_admin
-
-from scripts import register_sys_admin as rsysadm# type: ignore[import]
+from gn_auth.auth.authentication.users import (
+    user_by_id, hash_password, save_user, set_user_password)
+from gn_auth.auth.authorisation.roles.models import assign_default_roles
+from gn_auth.auth.authorisation.users.admin.models import (
+    make_sys_admin, grant_sysadmin_role)
+from gn_auth.auth.authorisation.users.models import delete_users_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+    OAuth2Client, save_client, delete_client,
+    client as oauth2_client_by_id)
+from gn_auth.scripts import register_sys_admin as rsysadm# type: ignore[import]
 
 
 app = create_app()
@@ -127,6 +136,364 @@ def register_admin():
     """Register the administrator."""
     rsysadm.register_admin(Path(app.config["AUTH_DB"]))
 
+
+_VALID_ROLES_ = ("system-admin", "none")
+
+_TEST_EMAIL_DOMAIN_ = "regression-tests.genenetwork.org"
+
+
+def __normalise_name_for_email__(name: str) -> str:
+    """Lowercase and strip non-alphanumeric characters for use in an email."""
+    return re.sub(r"[^a-z0-9]", "", name.lower())
+
+
+def __create_one_user__(cursor, name: str, email: str, password: str, role: str) -> dict:
+    """Create a single user in the DB and return their credential record."""
+    user = save_user(cursor, email, name, verified=True)
+    set_user_password(cursor, user, password)
+    assign_default_roles(cursor, user)
+    if role == "system-admin":
+        grant_sysadmin_role(cursor, user)
+    return {
+        "user_id": str(user.user_id),
+        "name": user.name,
+        "email": user.email,
+        "password": password,
+        "role": role,
+    }
+
+
+def __parse_user_spec__(spec: str) -> dict:
+    """Parse 'key=value,key=value,...' into a dict."""
+    result = {}
+    for part in spec.split(","):
+        key, _, value = part.partition("=")
+        if key.strip():
+            result[key.strip()] = value.strip()
+    return result
+
+
+def __write_output__(data: dict, output_path) -> None:
+    """Write JSON data to a file with 0644 permissions, or stdout."""
+    text = json.dumps(data, indent=2)
+    if output_path is None:
+        print(text)
+        return
+    fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
+    with os.fdopen(fd, "w") as outfile:
+        outfile.write(text)
+
+
+@app.cli.command()
+@click.option("--user", "user_specs", multiple=True,
+              help='User spec: "name=...,email=...,password=...,role=..."')
+@click.option("--output", "output_path", type=click.Path(), default=None,
+              help="Write credentials as JSON to this file (default: stdout)")
+def create_users(user_specs, output_path):
+    """Create one or more users with specified credentials and roles.
+
+    Each --user option takes a comma-separated key=value string with the
+    following keys: name, email, password, role.
+
+    Valid roles: system-admin, none.
+    """
+    if not user_specs:
+        print("No users specified.", file=sys.stderr)
+        sys.exit(1)
+
+    records = []
+    with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+        for spec_str in user_specs:
+            spec = __parse_user_spec__(spec_str)
+            name = spec.get("name", "").strip()
+            email = spec.get("email", "").strip()
+            password = spec.get("password", "").strip()
+            role = spec.get("role", "none").strip()
+
+            if not name:
+                print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if not email:
+                print(f"Missing 'email' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if not password:
+                print(f"Missing 'password' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if role not in _VALID_ROLES_:
+                print(
+                    f"Invalid role {role!r} in spec: {spec_str!r}. "
+                    f"Valid roles: {_VALID_ROLES_}",
+                    file=sys.stderr)
+                sys.exit(1)
+
+            records.append(
+                __create_one_user__(cursor, name, email, password, role))
+
+    __write_output__({"users": records}, output_path)
+
+
+@app.cli.command()
+@click.option("--user-id", "user_ids", multiple=True, type=click.UUID,
+              help="UUID of a user to delete (repeatable)")
+def delete_users(user_ids):
+    """Delete one or more users by ID, bypassing policy checks.
+
+    Removes users unconditionally regardless of their roles or group
+    memberships. Use with care — intended for test teardown and administration.
+    """
+    if not user_ids:
+        print("No user IDs specified.", file=sys.stderr)
+        sys.exit(1)
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        deleted = delete_users_by_id(conn, tuple(user_ids))
+        print(f"Deleted {deleted} user(s).")
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+              help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--user", "user_specs", multiple=True,
+              help='User spec: "name=...,role=..."')
+@click.option("--output", "output_path", required=True, type=click.Path(),
+              help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_users(session_timestamp, user_specs, output_path):
+    """Create ephemeral test users with auto-generated email and password.
+
+    Each --user option takes a comma-separated key=value string with the
+    following keys: name, role.
+
+    Email: <normalised-name><timestamp>@regression-tests.genenetwork.org
+    Password: randomly generated.
+
+    Output is written with 0600 permissions. Valid roles: system-admin, none.
+    """
+    if not user_specs:
+        print("No users specified.", file=sys.stderr)
+        sys.exit(1)
+
+    records = []
+    with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor:
+        for spec_str in user_specs:
+            spec = __parse_user_spec__(spec_str)
+            name = spec.get("name", "").strip()
+            role = spec.get("role", "none").strip()
+
+            if not name:
+                print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr)
+                sys.exit(1)
+            if role not in _VALID_ROLES_:
+                print(
+                    f"Invalid role {role!r} in spec: {spec_str!r}. "
+                    f"Valid roles: {_VALID_ROLES_}",
+                    file=sys.stderr)
+                sys.exit(1)
+
+            email = (f"{__normalise_name_for_email__(name)}"
+                     f"{session_timestamp}@{_TEST_EMAIL_DOMAIN_}")
+            password = secrets.token_urlsafe(32)
+
+            records.append(
+                __create_one_user__(cursor, name, email, password, role))
+
+    __write_output__(
+        {"session_timestamp": session_timestamp, "users": records},
+        output_path)
+
+
+_DEFAULT_GRANT_TYPES_ = (
+    "password",
+    "authorization_code",
+    "refresh_token",
+    "urn:ietf:params:oauth:grant-type:jwt-bearer",
+)
+
+_DEFAULT_SCOPES_ = (
+    "profile", "group", "role", "resource",
+    "register-client", "user", "masquerade",
+    "migrate-data", "introspect",
+)
+
+
+def __create_one_client__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+        conn,
+        client_name: str,
+        owner_user,
+        redirect_uris: tuple,
+        scopes: tuple = _DEFAULT_SCOPES_,
+        grant_types: tuple = _DEFAULT_GRANT_TYPES_,
+        jwks_uri: str = "",
+) -> dict:
+    """Create a single OAuth2 client and return its credential record."""
+    raw_secret = secrets.token_urlsafe(32)
+    the_client = OAuth2Client(
+        client_id=uuid.uuid4(),
+        client_secret=hash_password(raw_secret),
+        client_id_issued_at=datetime.now(tz=timezone.utc),
+        client_secret_expires_at=datetime.fromtimestamp(0),
+        client_metadata={
+            "client_name": client_name,
+            "token_endpoint_auth_method": [
+                "client_secret_post", "client_secret_basic"],
+            "client_type": "confidential",
+            "grant_types": list(grant_types),
+            "default_redirect_uri": redirect_uris[0] if redirect_uris else "",
+            "redirect_uris": list(redirect_uris),
+            "response_type": ["code", "token"],
+            "scope": list(scopes),
+            "public-jwks-uri": jwks_uri,
+        },
+        user=owner_user)
+    save_client(conn, the_client)
+    return {
+        "client_id": str(the_client.client_id),
+        "client_secret": raw_secret,
+        "client_name": client_name,
+    }
+
+
+@app.cli.command()
+@click.option("--name", "client_name", required=True,
+              help="Human-readable name for the OAuth2 client")
+@click.option("--owner-id", required=True, type=click.UUID,
+              help="UUID of the user who owns this client")
+@click.option("--redirect-uri", "redirect_uris", multiple=True,
+              help="Allowed redirect URI (repeatable)")
+@click.option("--scope", "scopes", multiple=True,
+              default=_DEFAULT_SCOPES_, show_default=False,
+              help="OAuth2 scope (repeatable; defaults to full scope set)")
+@click.option("--grant-type", "grant_types", multiple=True,
+              default=_DEFAULT_GRANT_TYPES_, show_default=False,
+              help="Grant type (repeatable; defaults to all standard types)")
+@click.option("--jwks-uri", default="",
+              help="URI to the client's public JWKS (optional)")
+@click.option("--output", "output_path", type=click.Path(), default=None,
+              help="Write credentials as JSON to this file (default: stdout)")
+def create_oauth2_client(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+        client_name,
+        owner_id,
+        redirect_uris,
+        scopes,
+        grant_types,
+        jwks_uri,
+        output_path
+):
+    """Create an OAuth2 client with specified parameters.
+
+    Scopes and grant types default to the full standard set if not provided.
+    """
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        try:
+            owner = user_by_id(conn, owner_id)
+        except NotFoundError:
+            print(f"No user found with ID {owner_id}", file=sys.stderr)
+            sys.exit(1)
+        record = __create_one_client__(
+            conn, client_name, owner, redirect_uris, scopes, grant_types,
+            jwks_uri)
+
+    __write_output__({"client": record}, output_path)
+
+
+@app.cli.command()
+@click.option("--session-timestamp", required=True,
+              help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)")
+@click.option("--users-file", required=True, type=click.Path(exists=True),
+              help="Credentials file produced by create-test-users")
+@click.option("--owner-role", default="system-admin", show_default=True,
+              help="Role of the user in users-file to assign as client owner")
+@click.option("--output", "output_path", required=True, type=click.Path(),
+              help="Write credentials as JSON to this file (0600 permissions)")
+def create_test_oauth2_client(session_timestamp, users_file, owner_role,
+                              output_path):
+    """Create an ephemeral OAuth2 client for a test session.
+
+    Reads the credentials file produced by create-test-users to find the
+    owner. Client name and secret are auto-generated using the session
+    timestamp. Output is written with 0600 permissions.
+    """
+    with open(users_file, encoding="utf8") as f:
+        users_data = json.load(f)
+
+    owner_record = next(
+        (u for u in users_data.get("users", []) if u["role"] == owner_role),
+        None)
+    if owner_record is None:
+        print(
+            f"No user with role {owner_role!r} found in {users_file}",
+            file=sys.stderr)
+        sys.exit(1)
+
+    client_name = f"gn-test-client-{session_timestamp}"
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        try:
+            owner = user_by_id(conn, uuid.UUID(owner_record["user_id"]))
+        except NotFoundError:
+            print(
+                f"Owner user {owner_record['user_id']!r} not found in DB",
+                file=sys.stderr)
+            sys.exit(1)
+        record = __create_one_client__(conn, client_name, owner, tuple())
+
+    __write_output__(
+        {"session_timestamp": session_timestamp, "client": record},
+        output_path)
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+              type=click.Path(exists=True),
+              help="Credentials file produced by create-oauth2-client or "
+                   "create-test-oauth2-client")
+def delete_oauth2_client(credentials_path):
+    """Delete an OAuth2 client using a credentials file.
+
+    Reads the client_id from the given credentials file and removes the
+    client and all associated tokens from the database.
+    """
+    with open(credentials_path, encoding="utf8") as f:
+        data = json.load(f)
+
+    client_id_str = data.get("client", {}).get("client_id")
+    if not client_id_str:
+        print("No client_id found in credentials file.", file=sys.stderr)
+        sys.exit(1)
+
+    client_id = uuid.UUID(client_id_str)
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        the_client = oauth2_client_by_id(conn, client_id)
+        if the_client.is_nothing():
+            print(f"No client found with ID {client_id}", file=sys.stderr)
+            sys.exit(1)
+        delete_client(conn, the_client.value)
+        print(f"Deleted OAuth2 client {client_id}.")
+
+
+@app.cli.command()
+@click.option("--credentials", "credentials_path", required=True,
+              type=click.Path(exists=True),
+              help="Credentials file produced by create-test-users")
+def delete_test_users(credentials_path):
+    """Delete ephemeral test users using a credentials file.
+
+    Reads the credentials file produced by create-test-users and deletes
+    all listed users unconditionally, bypassing policy checks. Intended
+    for CI test teardown.
+    """
+    with open(credentials_path, encoding="utf8") as f:
+        data = json.load(f)
+
+    user_ids = tuple(
+        uuid.UUID(u["user_id"]) for u in data.get("users", []))
+    if not user_ids:
+        print("No users found in credentials file.", file=sys.stderr)
+        sys.exit(1)
+
+    with db.connection(app.config["AUTH_DB"]) as conn:
+        deleted = delete_users_by_id(conn, user_ids)
+        print(f"Deleted {deleted} user(s).")
+
 ##### END: CLI Commands #####
 
 if __name__ == '__main__':
diff --git a/migrations/__init__.py b/migrations/__init__.py
deleted file mode 100644
index cedf48d..0000000
--- a/migrations/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Migrations package"""
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f5f624d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "gn-auth"
+# version = "1.0.1"
+dynamic = ["version"] # Read from git, or elsewhere
+description = "Authentication/Authorisation server for GeneNetwork Services."
+requires-python = ">= 3.10"
+authors = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+dependencies = [
+"argon2-cffi>= 20.1.0",
+"click",
+"Flask>= 1.1.2",
+"mypy>= 0.790",
+"mypy-extensions>= 0.4.3",
+"mysqlclient>= 2.0.1",
+"pylint>= 2.5.3",
+"pymonad",
+"redis>= 3.5.3",
+"requests>= 2.25.1",
+"flask-cors", # with the `>= 3.0.9` specification, it breaks the build
+"gn-libs @ git+https://git.genenetwork.org/gn-libs"
+]
+maintainers = [
+{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"},
+]
+license = "AGPL-3.0"
+readme = {file = "README.md", content-type = "text/markdown"}
+
+[project.urls]
+Homepage = "https://git.genenetwork.org/gn-auth/"
+Repository = "https://git.genenetwork.org/gn-auth/"
+
+[dependency-groups]# PEP 735
+tests = ["pytest"]
+checks = [{include-group = "tests"}, "mypy", "pylint", "vulture"]
+
+[tool.pylint.main]
+ignore = ["tests", "venv"]
+ignore-paths = ["^gn_auth/migrations/auth/.*"]
+ignore-imports = true
+disable = ["fixme", "duplicate-code", "no-else-return"]
+load-plugins = ["pylint.extensions.no_self_use"]
+
+[tool.vulture]
+ignore_decorators = [
+"@admin.before_request",
+"@admin.route",
+"@app.cli.command",
+"@auth.route",
+"@collections.route",
+"@data.route",
+"@genobp.route",
+"@groups.route",
+"@masq.route",
+"@misc.route",
+"@phenobp.route",
+"@phenosbp.route",
+"@popbp.route",
+"@privileges.route",
+"@resources.route",
+"@roles.route",
+"@system.route",
+"@users.route"
+]
+exclude = ["*/tests/unit/*", "*/gn_auth/settings.py", "*/gn_auth/migrations/*"]
+min_confidence = 60
\ No newline at end of file
diff --git a/scripts/__init__.py b/scripts/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/scripts/__init__.py
+++ /dev/null
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 41d118e..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[aliases]
-run_unit_tests = run_tests --type=unit
-run_integration_tests = run_tests --type=integration
-run_performance_tests = run_tests --type=performance
diff --git a/setup.py b/setup.py
deleted file mode 100755
index c7339e2..0000000
--- a/setup.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-"""Setup script for GeneNetwork Auth package."""
-from setuptools import setup, find_packages
-from setup_commands import RunTests
-
-LONG_DESCRIPTION = """
-gn-auth project is the authentication/authorisation server to be used
-across all GeneNetwork services.
-"""
-
-setup(author="Frederick M. Muriithi",
-      author_email="fredmanglis@gmail.com",
-      description=(
-          "Authentication/Authorisation server for GeneNetwork Services."),
-      install_requires=[
-          "argon2-cffi>=20.1.0",
-          "click",
-          "Flask>=1.1.2",
-          "mypy>=0.790",
-          "mypy-extensions>=0.4.3",
-          "mysqlclient>=2.0.1",
-          "pylint>=2.5.3",
-          "pymonad",
-          "redis>=3.5.3",
-          "requests>=2.25.1",
-          "flask-cors", # with the `>=3.0.9` specification, it breaks the build
-          "gn-libs>=0.0.0"
-      ],
-      include_package_data=True,
-      packages=find_packages(
-          where=".",
-          exclude=(
-              "tests",
-              "tests.*",
-              "setup_commands",
-              "setup_commands.*")),
-      # `package_data` doesn't seem to work. Use MANIFEST.in instead
-      scripts=[],
-      license="AGPLV3",
-      long_description=LONG_DESCRIPTION,
-      long_description_content_type="text/markdown",
-      name="gn-auth",
-      url="https://github.com/genenetwork/gn-auth",
-      version="0.0.1",
-      tests_require=["pytest", "hypothesis"],
-      cmdclass={
-          "run_tests": RunTests  # type: ignore[dict-item]
-      })
diff --git a/setup_commands/__init__.py b/setup_commands/__init__.py
deleted file mode 100644
index 967bb11..0000000
--- a/setup_commands/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Module for custom setup commands."""
-
-from .run_tests import RunTests
diff --git a/setup_commands/run_tests.py b/setup_commands/run_tests.py
deleted file mode 100644
index 1bb5dab..0000000
--- a/setup_commands/run_tests.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import os
-import sys
-from distutils.core import Command
-
-class RunTests(Command):
-    """
-    A custom command to run tests.
-    """
-    description = "Run the tests"
-    test_types = (
-        "all", "unit", "integration", "performance")
-    user_options = [
-        ("type=", None,
-         f"""Specify the type of tests to run.
-         Valid types are {tuple(test_types)}.
-         Default is `all`.""")]
-
-    def __init__(self, dist):
-        """Initialise the command."""
-        super().__init__(dist)
-        self.command = "pytest"
-
-    def initialize_options(self):
-        """Initialise the default values of all the options."""
-        self.type = "all"
-
-    def finalize_options(self):
-        """Set final value of all the options once they are processed."""
-        if self.type not in RunTests.test_types:
-            raise Exception(f"""
-            Invalid test type (self.type) requested!
-            Valid types are
-            {tuple(RunTests.test_types)}""")
-
-        if self.type != "all":
-            self.command = f"pytest -m {self.type}_test"
-    def run(self):
-        """Run the chosen tests"""
-        print(f"Running {self.type} tests")
-        os.system(self.command)
diff --git a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
index c34a549..a32cacb 100644
--- a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
+++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py
@@ -8,7 +8,7 @@ from gn_auth.migrations import get_migration, apply_migrations, rollback_migrati
 from tests.unit.auth.conftest import (
     apply_single_migration, rollback_single_migration, migrations_up_to)
 
-MIGRATION_PATH = "migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
+MIGRATION_PATH = "gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py"
 
 @pytest.mark.unit_test
 def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend):