about summary refs log tree commit diff
path: root/gn_auth/auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth')
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py2
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py36
-rw-r--r--gn_auth/auth/authorisation/data/views.py28
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py81
-rw-r--r--gn_auth/auth/authorisation/resources/common.py28
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py9
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py214
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py169
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py49
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py46
-rw-r--r--gn_auth/auth/authorisation/resources/models.py96
-rw-r--r--gn_auth/auth/authorisation/resources/mrna.py9
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/models.py9
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py21
-rw-r--r--gn_auth/auth/authorisation/resources/views.py66
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py56
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py10
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py16
-rw-r--r--gn_auth/auth/authorisation/users/models.py70
-rw-r--r--gn_auth/auth/authorisation/users/views.py207
-rw-r--r--gn_auth/auth/requests.py8
21 files changed, 1054 insertions, 176 deletions
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index d0b55b4..0e2c4eb 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -77,7 +77,7 @@ def authorise():
             try:
                 email = validate_email(
                     form.get("user:email"), check_deliverability=False)
-                user = user_by_email(conn, email["email"])
+                user = user_by_email(conn, email["email"])  # type: ignore
                 if valid_login(conn, user, form.get("user:password", "")):
                     if not user.verified:
                         return redirect(
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 08a0524..3e45af3 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -8,8 +8,12 @@ from MySQLdb.cursors import DictCursor
 
 from gn_auth.auth.db import sqlite3 as authdb
 
+from gn_auth.auth.errors import AuthorisationError
 from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
 
 def linked_phenotype_data(
         authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
@@ -83,7 +87,7 @@ def ungrouped_phenotype_data(
 
     return tuple()
 
-def __traits__(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
+def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
     """An internal utility function. Don't use outside of this module."""
     if len(params) < 1:
         return tuple()
@@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dic
                 for itm in sublist))
         return cursor.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 link_phenotype_data(
-        authconn:authdb.DbConnection, gn3conn: gn3db.Connection, group: Group,
-        traits: tuple[dict, ...]) -> dict:
+        authconn: authdb.DbConnection,
+        user,
+        group: Group,
+        traits: tuple[dict, ...]
+) -> dict:
     """Link phenotype traits to a user group."""
+    if not (authorised_for2(authconn,
+                            user,
+                            system_resource(authconn),
+                            ("system:data:link-to-group",))
+            or
+            authorised_for2(authconn,
+                            user,
+                            group_resource(authconn, group.group_id),
+                            ("group:data:link-to-group",))
+            ):
+        raise AuthorisationError(
+            "You do not have sufficient privileges to link data to group "
+            f"'{group.group_name}'.")
     with authdb.cursor(authconn) as cursor:
         params = tuple({
             "data_link_id": str(uuid.uuid4()),
             "group_id": str(group.group_id),
             **item
-        } for item in __traits__(gn3conn, traits))
+        } for item in traits)
         cursor.executemany(
             "INSERT INTO linked_phenotype_data "
             "VALUES ("
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 6d66788..9123949 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -35,8 +35,8 @@ from ..resources.models import (
 from ...authentication.users import User
 from ...authentication.oauth2.resource_server import require_oauth
 
-from ..data.phenotypes import link_phenotype_data
 from ..data.mrna import link_mrna_data, ungrouped_mrna_data
+from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
 from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
 
 data = Blueprint("data", __name__)
@@ -312,6 +312,7 @@ def link_mrna() -> Response:
         partial(__link__, **__values__(request_json()))))
 
 @data.route("/link/phenotype", methods=["POST"])
+@require_oauth("profile group resource")
 def link_phenotype() -> Response:
     """Link phenotype data to group."""
     def __values__(form):
@@ -327,14 +328,27 @@ def link_phenotype() -> Response:
             raise InvalidData("Expected at least one dataset to be provided.")
         return {
             "group_id": uuid.UUID(form["group_id"]),
-            "traits": form["selected"]
+            "traits": form["selected"],
+            "using_raw_ids": bool(form.get("using-raw-ids") == "on")
         }
 
-    with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn:
-        def __link__(conn: db.DbConnection, group_id: uuid.UUID,
-                     traits: tuple[dict, ...]) -> dict:
-            return link_phenotype_data(
-                conn, gn3conn, group_by_id(conn, group_id), traits)
+    with (require_oauth.acquire("profile group resource") as token,
+          gn3db.database_connection(app.config["SQL_URI"]) as gn3conn):
+        def __link__(
+                conn: db.DbConnection,
+                group_id: uuid.UUID,
+                traits: tuple[dict, ...],
+                using_raw_ids: bool = False
+        ) -> dict:
+            if using_raw_ids:
+                return link_phenotype_data(conn,
+                                           token.user,
+                                           group_by_id(conn, group_id),
+                                           traits)
+            return link_phenotype_data(conn,
+                                       token.user,
+                                       group_by_id(conn, group_id),
+                                       pheno_traits_from_db(gn3conn, traits))
 
         return jsonify(with_db_connection(
             partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index d8e3a9f..ce2b821 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,14 +1,21 @@
 """Handle authorisation checks for resources"""
-from uuid import UUID
+import uuid
+import warnings
 from functools import reduce
 from typing import Sequence
 
+from gn_libs.privileges import check
+
+from .base import Resource
+
 from ...db import sqlite3 as db
 from ...authentication.users import User
 
+from ..privileges.models import db_row_to_privilege
+
 def __organise_privileges_by_resource_id__(rows):
     def __organise__(privs, row):
-        resource_id = UUID(row["resource_id"])
+        resource_id = uuid.UUID(row["resource_id"])
         return {
             **privs,
             resource_id: (row["privilege_id"],) + privs.get(
@@ -16,14 +23,18 @@ def __organise_privileges_by_resource_id__(rows):
         }
     return reduce(__organise__, rows, {})
 
+
 def authorised_for(conn: db.DbConnection,
                    user: User,
                    privileges: tuple[str, ...],
-                   resource_ids: Sequence[UUID]) -> dict[UUID, bool]:
+                   resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]:
     """
     Check whether `user` is authorised to access `resources` according to given
     `privileges`.
     """
+    warnings.warn(DeprecationWarning(
+        f"The function `{__name__}.authorised_for` is deprecated. Please use "
+        f"`{__name__}.authorised_for_spec`"))
     with db.cursor(conn) as cursor:
         cursor.execute(
             ("SELECT ur.*, rp.privilege_id FROM "
@@ -45,3 +56,67 @@ def authorised_for(conn: db.DbConnection,
             resource_id: resource_id in authorised
             for resource_id in resource_ids
         }
+
+
+def authorised_for2(
+        conn: db.DbConnection,
+        user: User,
+        resource: Resource,
+        privileges: tuple[str, ...]
+) -> bool:
+    """
+    Check that `user` has **ALL** the specified privileges for the resource.
+    """
+    warnings.warn(DeprecationWarning(
+        f"The function `{__name__}.authorised_for2` is deprecated. Please use "
+        f"`{__name__}.authorised_for_spec`"))
+    with db.cursor(conn) as cursor:
+        _query = (
+            "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+            "privileges.* "
+            "FROM resources INNER JOIN user_roles "
+            "ON resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+            "INNER JOIN privileges "
+            "ON role_privileges.privilege_id=privileges.privilege_id "
+            "WHERE resources.resource_id=? "
+            "AND user_roles.user_id=?")
+        cursor.execute(
+            _query,
+            (str(resource.resource_id), str(user.user_id)))
+        _db_privileges = tuple(
+            db_row_to_privilege(row) for row in cursor.fetchall())
+
+    str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
+    return all((requested_privilege in str_privileges)
+               for requested_privilege in privileges)
+
+
+def authorised_for_spec(
+        conn: db.DbConnection,
+        user_id: uuid.UUID,
+        resource_id: uuid.UUID,
+        auth_spec: str
+) -> bool:
+    """
+    Check that a user, identified with `user_id`, has a set of privileges that
+    satisfy the `auth_spec` for the resource identified with `resource_id`.
+    """
+    with db.cursor(conn) as cursor:
+        _query = (
+            "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+            "privileges.* "
+            "FROM resources INNER JOIN user_roles "
+            "ON resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+            "INNER JOIN privileges "
+            "ON role_privileges.privilege_id=privileges.privilege_id "
+            "WHERE resources.resource_id=? "
+            "AND user_roles.user_id=?")
+        cursor.execute(
+            _query,
+            (str(resource_id), str(user_id)))
+        _privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
+    return check(auth_spec, _privileges)
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
index 5d2b72b..fd358f1 100644
--- a/gn_auth/auth/authorisation/resources/common.py
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -1,10 +1,10 @@
 """Utilities common to more than one resource."""
 import uuid
 
-from sqlite3 import Cursor
+from gn_auth.auth.db import sqlite3 as db
 
 def assign_resource_owner_role(
-        cursor: Cursor,
+        cursor: db.DbCursor,
         resource_id: uuid.UUID,
         user_id: uuid.UUID
 ) -> dict:
@@ -22,3 +22,27 @@ def assign_resource_owner_role(
         "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
         params)
     return params
+
+
+def grant_access_to_sysadmins(
+        cursor: db.DbCursor,
+        resource_id: uuid.UUID,
+        system_resource_id: uuid.UUID
+):
+    """Grant sysadmins access to resource identified by `resource_id`."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    sysadminroleid = cursor.fetchone()[0]
+
+    cursor.execute(# Fetch sysadmin IDs.
+        "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+        "ON roles.role_id=user_roles.role_id "
+        "WHERE role_name='system-administrator' AND resource_id=?",
+        (str(system_resource_id),))
+
+    cursor.executemany(
+        "INSERT INTO user_roles(user_id, role_id, resource_id) "
+        "VALUES (?, ?, ?) "
+        "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+        tuple((row["user_id"], sysadminroleid, str(resource_id))
+              for row in cursor.fetchall()))
diff --git a/gn_auth/auth/authorisation/resources/genotypes/models.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index 464537e..762ee7c 100644
--- a/gn_auth/auth/authorisation/resources/genotypes/models.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -27,14 +27,15 @@ def resource_data(
 def link_data_to_resource(
         conn: db.DbConnection,
         resource: Resource,
-        data_link_id: uuid.UUID) -> dict:
+        data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
     """Link Genotype data with a resource using the GUI."""
     with db.cursor(conn) as cursor:
-        params = {
+        params = tuple({
             "resource_id": str(resource.resource_id),
             "data_link_id": str(data_link_id)
-        }
-        cursor.execute(
+        } for data_link_id in data_link_ids)
+        cursor.executemany(
             "INSERT INTO genotype_resources VALUES"
             "(:resource_id, :data_link_id)",
             params)
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index fa25594..a1937ce 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -16,8 +16,13 @@ from gn_auth.auth.authentication.users import User, user_by_id
 
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
 from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.common import (
+    grant_access_to_sysadmins)
+from gn_auth.auth.authorisation.resources.base import (
+    Resource,
+    resource_from_dbrow)
 from gn_auth.auth.errors import (
     NotFoundError, AuthorisationError, InconsistencyError)
 from gn_auth.auth.authorisation.roles.models import (
@@ -120,9 +125,10 @@ def create_group(
             cursor, group_name, (
                 {"group_description": group_description}
                 if group_description else {}))
-        group_resource = {
+        _group_resource_id = uuid4()
+        _group_resource = {
             "group_id": str(new_group.group_id),
-            "resource_id": str(uuid4()),
+            "resource_id": str(_group_resource_id),
             "resource_name": group_name,
             "resource_category_id": str(
                 resource_category_by_key(
@@ -133,18 +139,20 @@ def create_group(
         cursor.execute(
             "INSERT INTO resources VALUES "
             "(:resource_id, :resource_name, :resource_category_id, :public)",
-            group_resource)
+            _group_resource)
         cursor.execute(
             "INSERT INTO group_resources(resource_id, group_id) "
             "VALUES(:resource_id, :group_id)",
-            group_resource)
+            _group_resource)
+        grant_access_to_sysadmins(cursor,
+                                  _group_resource_id,
+                                  system_resource(conn).resource_id)
         add_user_to_group(cursor, new_group, group_leader)
         revoke_user_role_by_name(cursor, group_leader, "group-creator")
-        assign_user_role_by_name(
-            cursor,
-            group_leader,
-            UUID(str(group_resource["resource_id"])),
-            "group-leader")
+        assign_user_role_by_name(cursor,
+                                 group_leader,
+                                 _group_resource_id,
+                                 "group-leader")
         return new_group
 
 
@@ -235,15 +243,56 @@ def is_group_leader(conn: db.DbConnection, user: User, group: Group) -> bool:
         return "group-leader" in role_names
 
 
-def all_groups(conn: db.DbConnection) -> Maybe[Sequence[Group]]:
+def __build_groups_list_query__(
+        base: str,
+        search: Optional[str] = None
+) -> tuple[str, tuple[Optional[str], ...]]:
+    """Build up the query from given search terms."""
+    if search is not None and search.strip() != "":
+        _search = search.strip()
+        return ((f"{base} WHERE groups.group_name LIKE ? "
+                 "OR groups.group_metadata LIKE ?"),
+                (f"%{search}%", f"%{search}%"))
+    return base, tuple()
+
+
+def __limit_results_length__(base: str, start: int = 0, length: int = 0) -> str:
+    """Add the `LIMIT … OFFSET …` clause to query `base`."""
+    if length > 0:
+        return f"{base} LIMIT {length} OFFSET {start}"
+    return base
+
+
+def all_groups(
+        conn: db.DbConnection,
+        search: Optional[str] = None,
+        start: int = 0,
+        length: int = 0
+) -> Maybe[tuple[tuple[Group, ...], int, int]]:
     """Retrieve all existing groups"""
     with db.cursor(conn) as cursor:
-        cursor.execute("SELECT * FROM groups")
+        cursor.execute("SELECT COUNT(*) FROM groups")
+        _groups_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _qdets = __build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search)
+        cursor.execute(*__build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search))
+        _filtered_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _query, _params = __build_groups_list_query__(
+            "SELECT * FROM groups", search)
+
+        cursor.execute(__limit_results_length__(_query, start, length),
+                       _params)
         res = cursor.fetchall()
         if res:
-            return Just(tuple(
-                Group(row["group_id"], row["group_name"],
-                      json.loads(row["group_metadata"])) for row in res))
+            return Just((
+                tuple(
+                    Group(row["group_id"], row["group_name"],
+                          json.loads(row["group_metadata"])) for row in res),
+                _groups_total_count,
+                _filtered_total_count))
 
     return Nothing
 
@@ -270,6 +319,56 @@ def add_user_to_group(cursor: db.DbCursor, the_group: Group, user: User):
         ("INSERT INTO group_users VALUES (:group_id, :user_id) "
          "ON CONFLICT (group_id, user_id) DO NOTHING"),
         {"group_id": str(the_group.group_id), "user_id": str(user.user_id)})
+    revoke_user_role_by_name(cursor, user, "group-creator")
+
+
+def resource_from_group(conn: db.DbConnection, the_group: Group) -> Resource:
+    """Get the resource object that wraps the group for auth purposes."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT "
+            "resources.resource_id, resources.resource_name, "
+            "resources.public, resource_categories.* "
+            "FROM group_resources "
+            "INNER JOIN resources "
+            "ON group_resources.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_resources.group_id=?",
+            (str(the_group.group_id),))
+        results = tuple(resource_from_dbrow(row) for row in cursor.fetchall())
+        match len(results):
+            case 0:
+                raise InconsistencyError("The group lacks a wrapper resource.")
+            case 1:
+                return results[0]
+            case _:
+                raise InconsistencyError(
+                    "The group has more than one wrapper resource.")
+
+
+def remove_user_from_group(
+        conn: db.DbConnection,
+        group: Group,
+        user: User,
+        grp_resource: Resource
+):
+    """Add `user` to `group` as a member."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "DELETE FROM group_users "
+            "WHERE group_id=:group_id AND user_id=:user_id",
+            {"group_id": str(group.group_id), "user_id": str(user.user_id)})
+        cursor.execute(
+            "DELETE FROM user_roles WHERE user_id=? AND resource_id=?",
+            (str(user.user_id), str(grp_resource.resource_id)))
+        assign_user_role_by_name(cursor,
+                                 user,
+                                 grp_resource.resource_id,
+                                 "group-creator")
+        grant_access_to_sysadmins(cursor,
+                                  grp_resource.resource_id,
+                                  system_resource(conn).resource_id)
 
 
 @authorised_p(
@@ -519,3 +618,88 @@ def admin_group(conn: db.DbConnection) -> Either:
                 row["group_name"],
                 json.loads(row["group_metadata"]))),
             cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+    """Retrieve the system resource."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT group_resources.group_id, resource_categories.*, "
+            "resources.resource_id, resources.resource_name, resources.public "
+            "FROM group_resources INNER JOIN resources "
+            "ON group_resources.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_resources.group_id=? "
+            "AND resource_categories.resource_category_key='group'",
+            (str(group_id),))
+        row = cursor.fetchone()
+        if row:
+            return resource_from_dbrow(row)
+
+    raise NotFoundError("Could not find a resource for group with ID "
+                        f"{group_id}")
+
+
+def data_resources(
+        conn: db.DbConnection, group_id: UUID) -> Iterable[Resource]:
+    """Fetch a group's data resources."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT resource_ownership.group_id, resources.resource_id, "
+            "resources.resource_name, resources.public, resource_categories.* "
+            "FROM resource_ownership INNER JOIN resources "
+            "ON resource_ownership.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_id=?",
+            (str(group_id),))
+        yield from (resource_from_dbrow(row) for row in cursor.fetchall())
+
+
+def group_leaders(conn: db.DbConnection, group_id: UUID) -> Iterable[User]:
+    """Fetch all of a group's group leaders."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT users.* FROM group_users INNER JOIN group_resources "
+            "ON group_users.group_id=group_resources.group_id "
+            "INNER JOIN user_roles "
+            "ON group_resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles "
+            "ON user_roles.role_id=roles.role_id "
+            "INNER JOIN users "
+            "ON user_roles.user_id=users.user_id "
+            "WHERE group_users.group_id=? "
+            "AND roles.role_name='group-leader'",
+            (str(group_id),))
+        yield from (User.from_sqlite3_row(row) for row in cursor.fetchall())
+
+
+def delete_group(conn: db.DbConnection, group_id: UUID):
+    """
+    Delete the group with the given ID
+
+    Parameters:
+        conn (db.DbConnection): an open connection to an SQLite3 database.
+        group_id (uuid.UUID): The identifier for the group to delete.
+
+    Returns:
+        None: It does not return a value.
+
+    Raises:
+        sqlite3.IntegrityError: if the group has members or linked resources, or
+        both.
+    """
+    rsc = group_resource(conn, group_id)
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM group_join_requests WHERE group_id=?",
+                       (str(group_id),))
+        cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute(
+            "DELETE FROM group_resources WHERE group_id=? AND resource_id=?",
+            (str(group_id), str(rsc.resource_id)))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute("DELETE FROM groups WHERE group_id=?",
+                       (str(group_id),))
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 368284f..2aa115a 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -6,6 +6,7 @@ import datetime
 from functools import partial
 from dataclasses import asdict
 
+import sqlite3
 from MySQLdb.cursors import DictCursor
 from flask import jsonify, Response, Blueprint, current_app
 
@@ -18,16 +19,31 @@ from gn_auth.auth.db.sqlite3 import with_db_connection
 from gn_auth.auth.authorisation.privileges import privileges_by_ids
 from gn_auth.auth.errors import InvalidData, NotFoundError, AuthorisationError
 
-from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authentication.users import User, user_by_id
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+from gn_auth.auth.authorisation.resources.groups.models import (resource_from_group,
+                                                                remove_user_from_group)
+
 from .data import link_data_to_group
-from .models import (
-    Group, user_group, all_groups, DUMMY_GROUP, GroupRole, group_by_id,
-    join_requests, group_role_by_id, GroupCreationError,
-    accept_reject_join_request, group_users as _group_users,
-    create_group as _create_group, add_privilege_to_group_role,
-    delete_privilege_from_group_role)
+from .models import (Group,
+                     GroupRole,
+                     user_group,
+                     all_groups,
+                     DUMMY_GROUP,
+                     group_by_id,
+                     group_leaders,
+                     join_requests,
+                     data_resources,
+                     group_role_by_id,
+                     GroupCreationError,
+                     accept_reject_join_request,
+                     add_privilege_to_group_role,
+                     group_users as _group_users,
+                     create_group as _create_group,
+                     delete_group as _delete_group,
+                     delete_privilege_from_group_role)
 
 groups = Blueprint("groups", __name__)
 
@@ -35,11 +51,31 @@ groups = Blueprint("groups", __name__)
 @require_oauth("profile group")
 def list_groups():
     """Return the list of groups that exist."""
+    _kwargs = request_json()
+    def __add_total_group_count__(groups_info):
+        return {
+            "groups": groups_info[0],
+            "total-groups": groups_info[1],
+            "total-filtered": groups_info[2]
+        }
+
     with db.connection(current_app.config["AUTH_DB"]) as conn:
-        the_groups = all_groups(conn)
+        return jsonify(all_groups(
+            conn,
+            search=_kwargs.get("search"),
+            start=int(_kwargs.get("start", "0")),
+            length=int(_kwargs.get("length", "0"))
+        ).then(
+            __add_total_group_count__
+        ).maybe(
+            {
+                "groups": [],
+                "message": "No groups found!",
+                "total-groups": 0,
+                "total-filtered": 0
+            },
+            lambda _grpdata: _grpdata))
 
-    return jsonify(the_groups.maybe(
-        [], lambda grps: [asdict(grp) for grp in grps]))
 
 @groups.route("/create", methods=["POST"])
 @require_oauth("profile group")
@@ -235,7 +271,7 @@ def unlinked_data(resource_type: str) -> Response:
     if resource_type in ("system", "group"):
         return jsonify(tuple())
 
-    if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+    if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
         raise AuthorisationError(f"Invalid resource type {resource_type}")
 
     with require_oauth.acquire("profile group resource") as the_token:
@@ -253,7 +289,8 @@ def unlinked_data(resource_type: str) -> Response:
                 "genotype": unlinked_genotype_data,
                 "phenotype": lambda conn, grp: partial(
                     unlinked_phenotype_data, gn3conn=gn3conn)(
-                        authconn=conn, group=grp)
+                        authconn=conn, group=grp),
+                "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
             }
             return jsonify(tuple(
                 dict(row) for row in unlinked_fns[resource_type](
@@ -347,3 +384,111 @@ def delete_priv_from_role(group_role_id: uuid.UUID) -> Response:
                 direction="DELETE", user=the_token.user))),
             "description": "Privilege deleted successfully"
         })
+
+
+@groups.route("/<uuid:group_id>", methods=["GET"])
+@require_oauth("profile group")
+def view_group(group_id: uuid.UUID) -> Response:
+    """View a particular group's details."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(group_by_id(conn, group_id))
+
+
+@groups.route("/<uuid:group_id>/data-resources", methods=["GET"])
+@require_oauth("profile group")
+def view_group_data_resources(group_id: uuid.UUID) -> Response:
+    """View data resources linked to the group."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(data_resources(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/leaders", methods=["GET"])
+@require_oauth("profile group")
+def view_group_leaders(group_id: uuid.UUID) -> Response:
+    """View a group's leaders."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(group_leaders(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/remove-member", methods=["POST"])
+@require_oauth("profile group")
+def remove_group_member(group_id: uuid.UUID):
+    """Remove a user as member of this group."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(OR group:user:remove-group-member system:group:remove-group-member)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to remove a user from this "
+                "group.")
+
+        form = request_json()
+        if not bool(form.get("user_id")):
+            response = jsonify({
+                "error": "MissingUserId",
+                "error-description": (
+                    "Expected 'user_id' value/parameter was not provided.")
+            })
+            response.status_code = 400
+            return response
+
+        try:
+            user = user_by_id(conn, uuid.UUID(form["user_id"]))
+            remove_user_from_group(conn, group, user, grp_resource)
+            success_msg = (
+                f"User '{user.name} ({user.email})' is no longer a member of "
+                f"group '{group.group_name}'.\n"
+                "They could, however, still have access to resources owned by "
+                "the group.")
+            return jsonify({
+                "description": success_msg,
+                "message": success_msg
+            })
+        except ValueError as _verr:
+            response = jsonify({
+                "error": "InvalidUserId",
+                "error-description": "The 'user_id' provided was invalid"
+            })
+            response.status_code = 400
+            return response
+
+
+@groups.route("/<uuid:group_id>/delete", methods=["DELETE"])
+@require_oauth("profile group")
+def delete_group(group_id: uuid.UUID) -> Response:
+    """Delete group with the specified `group_id`."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(AND system:group:delete-group)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to delete this group.")
+        try:
+            _delete_group(conn, group.group_id)
+            return Response(status=204)
+        except sqlite3.IntegrityError as _s3ie:
+            response = jsonify({
+                "error": "IntegrityError",
+                "error-description": (
+                    "A group that has members, linked resources, or both, "
+                    "cannot be deleted from the system. Remove any members and "
+                    "unlink any linked resources, and try again.")
+            })
+            response.status_code = 400
+            return response
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
index 64d41e3..2626f3e 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/models.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -1,39 +1,12 @@
 """Functions to handle the low-level details regarding populations auth."""
 from uuid import UUID, uuid4
+from typing import Sequence, Optional
 
 import sqlite3
 
-from gn_auth.auth.errors import NotFoundError
+import gn_auth.auth.db.sqlite3 as db
 from gn_auth.auth.authentication.users import User
-from gn_auth.auth.authorisation.resources.groups.models import Group
-from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
-from gn_auth.auth.authorisation.resources.models import (
-    create_resource as _create_resource)
-
-def create_resource(
-        cursor: sqlite3.Cursor,
-        resource_name: str,
-        user: User,
-        group: Group,
-        public: bool
-) -> Resource:
-    """Convenience function to create a resource of type 'inbredset-group'."""
-    cursor.execute("SELECT * FROM resource_categories "
-                   "WHERE resource_category_key='inbredset-group'")
-    category = cursor.fetchone()
-    if category:
-        return _create_resource(cursor,
-                                resource_name,
-                                ResourceCategory(
-                                    resource_category_id=UUID(
-                                        category["resource_category_id"]),
-                                    resource_category_key="inbredset-group",
-                                    resource_category_description=category[
-                                        "resource_category_description"]),
-                                user,
-                                group,
-                                public)
-    raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+from gn_auth.auth.authorisation.resources.base import Resource
 
 
 def assign_inbredset_group_owner_role(
@@ -94,3 +67,19 @@ def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positi
         "VALUES (:resource_id, :data_link_id)",
         params)
     return params
+
+
+def resource_data(
+        cursor: db.DbCursor,
+        resource_id: UUID,
+        offset: int = 0,
+        limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
+    """Fetch data linked to a inbred-set resource"""
+    cursor.execute(
+        ("SELECT * FROM inbredset_group_resources AS igr "
+         "INNER JOIN linked_inbredset_groups AS lig "
+         "ON igr.data_link_id=lig.data_link_id "
+         "WHERE igr.resource_id=?") + (
+             f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
+        (str(resource_id),))
+    return cursor.fetchall()
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index 40dd38d..9603b5b 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,20 +1,54 @@
 """Views for InbredSet resources."""
+import uuid
+
 from pymonad.either import Left, Right, Either
 from flask import jsonify, Response, Blueprint, current_app as app
 
 
 from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
 from gn_auth.auth.requests import request_json
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.users import User
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
-from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group
-
-from .models import (create_resource,
-                     link_data_to_resource,
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.groups.models import (Group,
+                                                                user_group,
+                                                                admin_group)
+from gn_auth.auth.authorisation.resources.models import (
+    create_resource as _create_resource)
+
+from .models import (link_data_to_resource,
                      assign_inbredset_group_owner_role)
 
 popbp = Blueprint("populations", __name__)
 
+
+def create_resource(
+        cursor: db.DbCursor,
+        resource_name: str,
+        user: User,
+        group: Group,
+        public: bool
+) -> Resource:
+    """Convenience function to create a resource of type 'inbredset-group'."""
+    cursor.execute("SELECT * FROM resource_categories "
+                   "WHERE resource_category_key='inbredset-group'")
+    category = cursor.fetchone()
+    if category:
+        return _create_resource(cursor,
+                                resource_name,
+                                ResourceCategory(
+                                    resource_category_id=uuid.UUID(
+                                        category["resource_category_id"]),
+                                    resource_category_key="inbredset-group",
+                                    resource_category_description=category[
+                                        "resource_category_description"]),
+                                user,
+                                group,
+                                public)
+    raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+
+
 @popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>",
             methods=["GET"])
 def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
@@ -30,7 +64,7 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
                 (speciesid, inbredsetid))
             return cursor.fetchone()
 
-    res = with_db_connection(__res_by_iset_id__)
+    res = db.with_db_connection(__res_by_iset_id__)
     if res:
         resp = jsonify({"status": "success", "resource-id": res["resource_id"]})
     else:
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index d136fec..31371fd 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -4,8 +4,6 @@ from uuid import UUID, uuid4
 from functools import reduce, partial
 from typing import Dict, Sequence, Optional
 
-import sqlite3
-
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User
 from gn_auth.auth.db.sqlite3 import with_db_connection
@@ -15,10 +13,12 @@ from gn_auth.auth.authorisation.privileges import Privilege
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.errors import NotFoundError, AuthorisationError
 
-from .checks import authorised_for
+from .system.models import system_resource
+from .checks import authorised_for, authorised_for_spec
 from .base import Resource, ResourceCategory, resource_from_dbrow
-from .common import assign_resource_owner_role
+from .common import assign_resource_owner_role, grant_access_to_sysadmins
 from .groups.models import Group, is_group_leader
+from .inbredset.models import resource_data as inbredset_resource_data
 from .mrna import (
     resource_data as mrna_resource_data,
     attach_resources_data as mrna_attach_resources_data,
@@ -40,7 +40,7 @@ from .phenotypes.models import (
               error_description="Insufficient privileges to create a resource",
               oauth2_scope="profile resource")
 def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
-        cursor: sqlite3.Cursor,
+        conn: db.DbConnection,
         resource_name: str,
         resource_category: ResourceCategory,
         user: User,
@@ -48,29 +48,48 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
         public: bool
 ) -> Resource:
     """Create a resource item."""
-    resource = Resource(uuid4(), resource_name, resource_category, public)
-    cursor.execute(
-        "INSERT INTO resources VALUES (?, ?, ?, ?)",
-        (str(resource.resource_id),
-         resource_name,
-         str(resource.resource_category.resource_category_id),
-         1 if resource.public else 0))
-    # TODO: @fredmanglis,@rookie101
-    # 1. Move the actions below into a (the?) hooks system
-    # 2. Do more checks: A resource can have varying hooks depending on type
-    #    e.g. if mRNA, pheno or geno resource, assign:
-    #           - "resource-owner"
-    #         if inbredset-group, assign:
-    #           - "resource-owner",
-    #           - "inbredset-group-owner" etc.
-    #         if resource is of type "group", assign:
-    #           - group-leader
-    cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
-                   "VALUES (?, ?)",
-                   (str(group.group_id), str(resource.resource_id)))
-    assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
-
-    return resource
+    with db.cursor(conn) as cursor:
+        resource = Resource(uuid4(), resource_name, resource_category, public)
+        cursor.execute(
+            "INSERT INTO resources VALUES (?, ?, ?, ?)",
+            (str(resource.resource_id),
+             resource_name,
+             str(resource.resource_category.resource_category_id),
+             1 if resource.public else 0))
+        # TODO: @fredmanglis,@rookie101
+        # 1. Move the actions below into a (the?) hooks system
+        # 2. Do more checks: A resource can have varying hooks depending on type
+        #    e.g. if mRNA, pheno or geno resource, assign:
+        #           - "resource-owner"
+        #         if inbredset-group, assign:
+        #           - "resource-owner",
+        #           - "inbredset-group-owner" etc.
+        #         if resource is of type "group", assign:
+        #           - group-leader
+        cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
+                       "VALUES (?, ?)",
+                       (str(group.group_id), str(resource.resource_id)))
+        assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
+        grant_access_to_sysadmins(
+            cursor, resource.resource_id, system_resource(conn).resource_id)
+
+        return resource
+
+
+def delete_resource(conn: db.DbConnection, resource_id: UUID):
+    """Delete a resource."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resource_roles WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM group_resources WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resource_ownership WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (str(resource_id),))
+
 
 def resource_category_by_id(
         conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
@@ -159,7 +178,8 @@ def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None)
         "genotype-metadata": lambda *args: tuple(),
         "mrna-metadata": lambda *args: tuple(),
         "system": lambda *args: tuple(),
-        "group": lambda *args: tuple()
+        "group": lambda *args: tuple(),
+        "inbredset-group": inbredset_resource_data,
     }
     with db.cursor(conn) as cursor:
         return tuple(
@@ -187,9 +207,11 @@ def attach_resource_data(cursor: db.DbCursor, resource: Resource) -> Resource:
 def resource_by_id(
         conn: db.DbConnection, user: User, resource_id: UUID) -> Resource:
     """Retrieve a resource by its ID."""
-    if not authorised_for(
-            conn, user, ("group:resource:view-resource",),
-            (resource_id,))[resource_id]:
+    if not authorised_for_spec(
+            conn,
+            user.user_id,
+            resource_id,
+            "(OR group:resource:view-resource system:resource:view)"):
         raise AuthorisationError(
             "You are not authorised to access resource with id "
             f"'{resource_id}'.")
@@ -207,8 +229,12 @@ def resource_by_id(
     raise NotFoundError(f"Could not find a resource with id '{resource_id}'")
 
 def link_data_to_resource(
-        conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str,
-        data_link_id: UUID) -> dict:
+        conn: db.DbConnection,
+        user: User,
+        resource_id: UUID,
+        dataset_type: str,
+        data_link_ids: tuple[UUID, ...]
+) -> tuple[dict, ...]:
     """Link data to resource."""
     if not authorised_for(
             conn, user, ("group:resource:edit-resource",),
@@ -223,7 +249,7 @@ def link_data_to_resource(
         "mrna": mrna_link_data_to_resource,
         "genotype": genotype_link_data_to_resource,
         "phenotype": phenotype_link_data_to_resource,
-    }[dataset_type.lower()](conn, resource, data_link_id)
+    }[dataset_type.lower()](conn, resource, data_link_ids)
 
 def unlink_data_from_resource(
         conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py
index 7fce227..66f8824 100644
--- a/gn_auth/auth/authorisation/resources/mrna.py
+++ b/gn_auth/auth/authorisation/resources/mrna.py
@@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor,
 def link_data_to_resource(
         conn: db.DbConnection,
         resource: Resource,
-        data_link_id: uuid.UUID) -> dict:
+        data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
     """Link mRNA Assay data with a resource."""
     with db.cursor(conn) as cursor:
-        params = {
+        params = tuple({
             "resource_id": str(resource.resource_id),
             "data_link_id": str(data_link_id)
-        }
-        cursor.execute(
+        } for data_link_id in data_link_ids)
+        cursor.executemany(
             "INSERT INTO mrna_resources VALUES"
             "(:resource_id, :data_link_id)",
             params)
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py
index d4a516a..0ef91ab 100644
--- a/gn_auth/auth/authorisation/resources/phenotypes/models.py
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -29,14 +29,15 @@ def resource_data(
 def link_data_to_resource(
         conn: db.DbConnection,
         resource: Resource,
-        data_link_id: uuid.UUID) -> dict:
+        data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
     """Link Phenotype data with a resource."""
     with db.cursor(conn) as cursor:
-        params = {
+        params = tuple({
             "resource_id": str(resource.resource_id),
             "data_link_id": str(data_link_id)
-        }
-        cursor.execute(
+        } for data_link_id in data_link_ids)
+        cursor.executemany(
             "INSERT INTO phenotype_resources VALUES"
             "(:resource_id, :data_link_id)",
             params)
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 7c176aa..303b0ac 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -4,11 +4,15 @@ from functools import reduce
 from typing import Sequence
 
 from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
 
 from gn_auth.auth.authentication.users import User
 
 from gn_auth.auth.authorisation.roles import Role
 from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.resources.base import (
+    Resource,
+    resource_from_dbrow)
 
 def __organise_privileges__(acc, row):
     role_id = UUID(row["role_id"])
@@ -24,6 +28,7 @@ def __organise_privileges__(acc, row):
              (Privilege(row["privilege_id"], row["privilege_description"]),)))
     }
 
+
 def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
     """
     Retrieve all roles assigned to the `user` that act on `system` resources.
@@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
         return tuple(reduce(
             __organise_privileges__, cursor.fetchall(), {}).values())
     return tuple()
+
+
+def system_resource(conn: db.DbConnection) -> Resource:
+    """Retrieve the system resource."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT resource_categories.*, resources.resource_id, "
+            "resources.resource_name, resources.public "
+            "FROM resource_categories INNER JOIN resources "
+            "ON resource_categories.resource_category_id=resources.resource_category_id "
+            "WHERE resource_categories.resource_category_key='system'")
+        row = cursor.fetchone()
+        if row:
+            return resource_from_dbrow(row)
+
+    raise NotFoundError("Could not find a system resource!")
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 29ab3ed..a960ca3 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -39,18 +39,18 @@ from gn_auth.auth.authorisation.roles.models import (
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
 
-from .checks import authorised_for
 from .inbredset.views import popbp
 from .genotypes.views import genobp
 from .phenotypes.views import phenobp
 from .errors import MissingGroupError
 from .groups.models import Group, user_group
+from .checks import authorised_for, authorised_for_spec
 from .models import (
     Resource, resource_data, resource_by_id, public_resources,
     resource_categories, assign_resource_user, link_data_to_resource,
     unassign_resource_user, resource_category_by_id, user_roles_on_resources,
     unlink_data_from_resource, create_resource as _create_resource,
-    get_resource_id)
+    get_resource_id, delete_resource as _delete_resource)
 
 resources = Blueprint("resources", __name__)
 resources.register_blueprint(popbp, url_prefix="/")
@@ -75,8 +75,7 @@ def create_resource() -> Response:
         resource_name = form.get("resource_name")
         resource_category_id = UUID(form.get("resource_category"))
         db_uri = app.config["AUTH_DB"]
-        with (db.connection(db_uri) as conn,
-              db.cursor(conn) as cursor):
+        with db.connection(db_uri) as conn:
             try:
                 group = user_group(conn, the_token.user).maybe(
                     False, lambda grp: grp)# type: ignore[misc, arg-type]
@@ -84,7 +83,7 @@ def create_resource() -> Response:
                     raise MissingGroupError(# Not all resources require an owner group
                         "User with no group cannot create a resource.")
                 resource = _create_resource(
-                    cursor,
+                    conn,
                     resource_name,
                     resource_category_by_id(conn, resource_category_id),
                     the_token.user,
@@ -100,7 +99,9 @@ def create_resource() -> Response:
                     f"{type(sql3ie)=}: {sql3ie=}")
                 raise
 
+
 @resources.route("/view/<uuid:resource_id>")
+@resources.route("/<uuid:resource_id>/view")
 @require_oauth("profile group resource")
 def view_resource(resource_id: UUID) -> Response:
     """View a particular resource's details."""
@@ -153,7 +154,7 @@ def link_data():
     try:
         form = request_json()
         assert "resource_id" in form, "Resource ID not provided."
-        assert "data_link_id" in form, "Data Link ID not provided."
+        assert "data_link_ids" in form, "Data Link IDs not provided."
         assert "dataset_type" in form, "Dataset type not specified"
         assert form["dataset_type"].lower() in (
             "mrna", "genotype", "phenotype"), "Invalid dataset type provided."
@@ -161,8 +162,11 @@ def link_data():
         with require_oauth.acquire("profile group resource") as the_token:
             def __link__(conn: db.DbConnection):
                 return link_data_to_resource(
-                    conn, the_token.user, UUID(form["resource_id"]),
-                    form["dataset_type"], UUID(form["data_link_id"]))
+                    conn,
+                    the_token.user,
+                    UUID(form["resource_id"]),
+                    form["dataset_type"],
+                    tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"]))
 
             return jsonify(with_db_connection(__link__))
     except AssertionError as aserr:
@@ -670,3 +674,49 @@ def user_resource_roles(resource_id: UUID, user_id: UUID):
 
         return jsonify([asdict(role) for role in
                         _user_resource_roles(conn, _token.user, _resource)])
+
+
+@resources.route("/delete", methods=["POST"])
+@require_oauth("profile group resource")
+def delete_resource():
+    """Delete the specified resource, if possible."""
+    with (require_oauth.acquire("profile group resource") as the_token,
+          db.connection(app.config["AUTH_DB"]) as conn):
+        form = request_json()
+        try:
+            resource_id = UUID(form.get("resource_id"))
+            if not authorised_for_spec(
+                    conn,
+                    the_token.user.user_id,
+                    resource_id,
+                    "(OR group:resource:delete-resource system:resource:delete)"):
+                raise AuthorisationError("You do not have the appropriate "
+                                         "privileges to delete this resource.")
+
+            data = resource_data(
+                conn,
+                resource_by_id(conn, the_token.user, resource_id),
+                0,
+                10)
+            if bool(data):
+                return jsonify({
+                    "error": "NonEmptyResouce",
+                    "error-description": "Cannot delete a resource with linked data"
+                }), 400
+
+            _delete_resource(conn, resource_id)
+            return jsonify({
+                "description": f"Successfully deleted resource with ID '{resource_id}'."
+            })
+        except ValueError as _verr:
+            app.logger.debug("Error!", exc_info=True)
+            return jsonify({
+                "error": "ValueError",
+                "error-description": "An invalid identifier was provided"
+            }), 400
+        except TypeError as _terr:
+            app.logger.debug("Error!", exc_info=True)
+            return jsonify({
+                "error": "TypeError",
+                "error-description": "An invalid identifier was provided"
+            }), 400
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 36f3c09..3d68932 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -1,23 +1,55 @@
 """Major function for handling admin users."""
+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
 
-def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
-    """Make a given user into an system admin."""
+
+def sysadmin_role(conn: db.DbConnection) -> Role:
+    """Fetch the `system-administrator` role details."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT roles.*, privileges.* "
+            "FROM roles INNER JOIN role_privileges "
+            "ON roles.role_id=role_privileges.role_id "
+            "INNER JOIN privileges "
+            "ON role_privileges.privilege_id=privileges.privilege_id "
+            "WHERE role_name='system-administrator'")
+        results = db_rows_to_roles(cursor.fetchall())
+
+    assert len(results) == 1, (
+        "There should only ever be one 'system-administrator' role.")
+    return results[0]
+
+
+def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
+    """Grant `system-administrator` role to `user`."""
     cursor.execute(
             "SELECT * FROM roles WHERE role_name='system-administrator'")
     admin_role = cursor.fetchone()
-    cursor.execute(
-            "SELECT * FROM resources AS r "
-            "INNER JOIN resource_categories AS rc "
-            "ON r.resource_category_id=rc.resource_category_id "
-            "WHERE resource_category_key='system'")
-    the_system = cursor.fetchone()
-    cursor.execute(
+    cursor.execute("SELECT resources.resource_id FROM resources")
+    cursor.executemany(
         "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": the_system["resource_id"]
-        })
+            "resource_id": resource_id
+        } for resource_id in cursor.fetchall()))
     return user
+
+
+def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
+    """Make a given user into an system admin."""
+    warnings.warn(
+        DeprecationWarning(
+            f"The function `{__name__}.make_sys_admin` will be removed soon"),
+        stacklevel=1)
+    return grant_sysadmin_role(cursor, user)
+
+
+def revoke_sysadmin_role(conn: db.DbConnection, user: User):
+    """Revoke `system-administrator` role from `user`."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?",
+                       (str(user.user_id), str(sysadmin_role(conn).role_id)))
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index f0a7fa2..63443ef 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool:
 def __toggle_boolean_field__(
         rconn: Redis, email: str, field: str):
     """Toggle the valuen of a boolean field"""
-    mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")
+    mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")  # type: ignore
     if bool(mig_dict):
         rconn.hset("migratable-accounts", email,
                    json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
@@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis):
             "resources_migrated": False
         } for account in (
             acct for acct in
-            (json.loads(usr) for usr in rconn.hgetall("users").values())
+            (json.loads(usr) for usr in rconn.hgetall("users").values())  # type: ignore
             if (bool(acct.get("email_address", False)) and
                 __valid_email__(acct["email_address"])))
     }
@@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
     accounts = rconn.hgetall("migratable-accounts")
     if accounts:
         return {
-            key: json.loads(value) for key, value in accounts.items()
+            key: json.loads(value) for key, value in accounts.items()  # type: ignore
         }
     return __build_email_uuid_bridge__(rconn)
 
@@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple:
     """Retrieve any old collections relating to the user."""
     return tuple(parse_collection(coll) for coll in
                  json.loads(rconn.hget(
-                     __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))
+                     __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))  # type: ignore
 
 def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]:
     """Retrieve current user collections."""
     collections = tuple(parse_collection(coll) for coll in json.loads(
         rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or
-        "[]"))
+        "[]"))  # type: ignore
     old_accounts = __retrieve_old_accounts__(rconn)
     if (user.email in old_accounts and
         not old_accounts[user.email]["collections-migrated"]):
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 8b897f2..12a8c97 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -1,14 +1,14 @@
 """Endpoints for user masquerade"""
 from dataclasses import asdict
 from uuid import UUID
-from functools import partial
 
-from flask import request, jsonify, Response, Blueprint
+from flask import request, jsonify, Response, Blueprint, current_app
 
 from gn_auth.auth.errors import InvalidData
+from gn_auth.auth.authorisation.resources.groups.models import user_group
 
+from ....db import sqlite3 as db
 from ...checks import require_json
-from ....db.sqlite3 import with_db_connection
 from ....authentication.users import user_by_id
 from ....authentication.oauth2.resource_server import require_oauth
 
@@ -21,13 +21,13 @@ masq = Blueprint("masquerade", __name__)
 @require_json
 def masquerade() -> Response:
     """Masquerade as a particular user."""
-    with require_oauth.acquire("profile user masquerade") as token:
+    with (require_oauth.acquire("profile user masquerade") as token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
         masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index]
         if masqueradee_id == token.user.user_id:
             raise InvalidData("You are not allowed to masquerade as yourself.")
 
-        masq_user = with_db_connection(partial(
-            user_by_id, user_id=masqueradee_id))
+        masq_user = user_by_id(conn, user_id=masqueradee_id)
 
         def __masq__(conn):
             new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
@@ -39,6 +39,8 @@ def masquerade() -> Response:
             },
             "masquerade_as": {
                 "user": asdict(masq_user),
-                "token": with_db_connection(__masq__)
+                "token": __masq__(conn),
+                **(user_group(conn, masq_user).maybe(# type: ignore[misc]
+                    {}, lambda grp: {"group": grp}))
             }
         })
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..d30bfd0 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,6 +1,7 @@
 """Functions for acting on users."""
 import uuid
 from functools import reduce
+from datetime import datetime, timedelta
 
 from ..roles.models import Role
 from ..checks import authorised_p
@@ -9,14 +10,79 @@ from ..privileges import Privilege
 from ...db import sqlite3 as db
 from ...authentication.users import User
 
+
+def __process_age_clause__(age_desc: str) -> tuple[str, int]:
+    """Process the age clause and parameter for 'LIST USERS' query."""
+    _today = datetime.now()
+    _clause = "created"
+    _parts = age_desc.split(" ")
+    _multipliers = {
+        # Temporary hack before dateutil module can make it to our deployment.
+        "days": 1,
+        "months": 30,
+        "years": 365
+    }
+    assert len(_parts) in (3, 4), "Invalid age descriptor!"
+
+    _param = int((
+        _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]})
+    ).timestamp())
+
+    match _parts[0]:
+        case "older":
+            return "created < :created", _param
+        case "younger":
+            return "created > :created", _param
+        case "exactly":
+            return "created = :created", _param
+        case _:
+            raise Exception("Invalid age descriptor.")# pylint: disable=[broad-exception-raised]
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[str, dict[str, str]]:
+    """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+    clauses = ""
+    params = {}
+    if bool(kwargs.get("email", "").strip()) and bool(kwargs.get("name", "").strip()):
+        clauses = "(email LIKE :email OR name LIKE :name)"
+        params = {
+            "email": f'%{kwargs["email"].strip()}%',
+            "name": f'%{kwargs["name"].strip()}%'
+        }
+    elif bool(kwargs.get("email", "").strip()):
+        clauses = "email LIKE :email"
+        params["email"] = f'%{kwargs["email"].strip()}%'
+    elif bool(kwargs.get("name", "").strip()):
+        clauses = "name LIKE :name"
+        params["name"] = f'%{kwargs["name"].strip()}%'
+    else:
+        clauses = ""
+
+    if bool(kwargs.get("verified", "").strip()):
+        clauses = clauses + (" AND " if len(clauses) > 0 else "") + "verified=:verified"
+        params["verified"] = "1" if kwargs["verified"].strip() == "yes" else "0"
+
+    if bool(kwargs.get("age", "").strip()):
+        _clause, _param = __process_age_clause__(kwargs["age"].strip())
+        clauses = clauses + (" AND " if len(clauses) > 0 else "") + _clause
+        params["created"] = str(_param)
+
+    return clauses, params
+
+
 @authorised_p(
     ("system:user:list",),
     "You do not have the appropriate privileges to list users.",
     oauth2_scope="profile user")
-def list_users(conn: db.DbConnection) -> tuple[User, ...]:
+def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]:
     """List out all users."""
+    _query = "SELECT * FROM users"
+    _clauses, _params = __list_user_clauses_and_params__(**kwargs)
+    if len(_clauses) > 0:
+        _query = _query + " WHERE " + _clauses
+
     with db.cursor(conn) as cursor:
-        cursor.execute("SELECT * FROM users")
+        cursor.execute(_query, _params)
         return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
 
 def __build_resource_roles__(rows):
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 6183388..4061e07 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -3,10 +3,10 @@ import uuid
 import sqlite3
 import secrets
 import traceback
-from typing import Any
-from functools import partial
 from dataclasses import asdict
+from typing import Any, Sequence
 from urllib.parse import urljoin
+from functools import reduce, partial
 from datetime import datetime, timedelta
 from email.headerregistry import Address
 from email_validator import validate_email, EmailNotValidError
@@ -28,6 +28,9 @@ from gn_auth.auth.requests import request_json
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.db.sqlite3 import with_db_connection
 
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
 from gn_auth.auth.authorisation.resources.models import (
     user_resources as _user_resources)
 from gn_auth.auth.authorisation.roles.models import (
@@ -39,6 +42,7 @@ from gn_auth.auth.errors import (
     NotFoundError,
     UsernameError,
     PasswordError,
+    AuthorisationError,
     UserRegistrationError)
 
 
@@ -71,7 +75,7 @@ def user_details() -> Response:
                 False, lambda grp: grp)# type: ignore[arg-type]
             return jsonify({
                 **user_dets,
-                "group": asdict(the_group) if the_group else False
+                **({"group": asdict(the_group)} if the_group else {})
             })
 
 @users.route("/roles", methods=["GET"])
@@ -130,7 +134,8 @@ def display_minutes_for_humans(minutes):
     if _remainder_ >= _day_:
         _days_ = _remainder_ // _day_
         _remainder_ = _remainder_ % _day_
-        _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_days_} day" + ("s" if _days_ > 1 else "")
+        _human_readable_ += (" " if bool(_human_readable_) else "") + \
+            f"{_days_} day" + ("s" if _days_ > 1 else "")
 
     if _remainder_ > 0:
         _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
@@ -204,7 +209,7 @@ def register_user() -> Response:
             with db.cursor(conn) as cursor:
                 user, _hashed_password = set_user_password(
                     cursor, save_user(
-                        cursor, email["email"], user_name), password)
+                        cursor, email["email"], user_name), password)  # type: ignore
                 assign_default_roles(cursor, user)
                 send_verification_email(conn,
                                         user,
@@ -330,9 +335,33 @@ def user_join_request_exists():
 @require_oauth("profile user")
 def list_all_users() -> Response:
     """List all the users."""
-    with require_oauth.acquire("profile group") as _the_token:
-        return jsonify(tuple(
-            asdict(user) for user in with_db_connection(list_users)))
+    _kwargs = (
+        {
+            key: value
+            for key, value in request_json().items()
+            if key in ("email", "name", "verified", "age")
+        }
+        or
+        {
+            "email": "", "name": "", "verified": "", "age": ""
+        }
+    )
+
+    with (require_oauth.acquire("profile group") as _the_token,
+          db.connection(current_app.config["AUTH_DB"]) as conn,
+          db.cursor(conn) as cursor):
+        _users = list_users(conn, **_kwargs)
+        _start = int(_kwargs.get("start", "0"))
+        _length = int(_kwargs.get("length", "0"))
+        cursor.execute("SELECT COUNT(*) FROM users")
+        _total_users = int(cursor.fetchone()["COUNT(*)"])
+        return jsonify({
+            "users": tuple(asdict(user) for user in
+                           (_users[_start:_start+_length]
+                            if _length else _users)),
+            "total-users": _total_users,
+            "total-filtered": len(_users)
+        })
 
 @users.route("/handle-unverified", methods=["POST"])
 def handle_unverified():
@@ -529,3 +558,165 @@ def change_password(forgot_password_token):
         flash("Both the password and its confirmation MUST be provided!",
               "alert-danger")
         return change_password_page
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+    """Recovery function with dismal performance."""
+    _errors = tuple()
+    for _user_id in user_ids:
+        for _table, _col in tables:
+            try:
+                cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col}=?",
+                        (str(_user_id),))
+            except sqlite3.IntegrityError:
+                _errors = _errors + (
+                    (("user_id", _user_id),
+                     ("reason", f"User has data in table {_table}")),)
+
+    return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+    """Fetch detail for non-deletable users."""
+    def __merge__(acc, curr):
+        _curr = dict(curr)
+        _this_dict = acc.get(
+            curr["user_id"], {"reasons": tuple()})
+        _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+        return {**acc, curr["user_id"]: _this_dict}
+
+    _reasons_by_id = reduce(__merge__,
+                            (dict(row) for row in ids_and_reasons),
+                            {})
+    _user_ids = tuple(_reasons_by_id.keys())
+    _paramstr = ", ".join(["?"] * len(_user_ids))
+    cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+                   _user_ids)
+    return tuple({
+        "user": dict(row),
+        "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+    } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+        user_ids: tuple[str, ...],
+        dbrows: Sequence[sqlite3.Row],
+        reason: str
+    ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+    """Build a list of 'non-deletable' user objects."""
+    return tuple((("user_id", _uid), ("reason", reason))
+                 for _uid in user_ids
+                 if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+    """Delete the specified user."""
+    with (require_oauth.acquire("profile") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn,
+          db.cursor(conn) as cursor):
+        if not authorised_for2(conn,
+                               _token.user,
+                               system_resource(conn),
+                               ("system:user:delete-user",)):
+            raise AuthorisationError(
+                "You need the `system:user:delete-user` privilege to delete "
+                "users from the system.")
+
+        _form = request_json()
+        _user_ids = _form.get("user_ids", [])
+        _non_deletable = set()
+        if str(_token.user.user_id) in _user_ids:
+            _non_deletable.add(
+            (("user_id", str(_token.user.user_id),),
+             ("reason", "You are not allowed to delete yourself.")))
+
+        cursor.execute("SELECT user_id FROM group_users")
+        _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is member of a user group."))
+
+        cursor.execute("SELECT user_id FROM oauth2_clients;")
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is registered owner of an OAuth client."))
+
+        _important_roles = (
+            "group-leader",
+            "resource-owner",
+            "system-administrator",
+            "inbredset-group-owner")
+        _paramstr = ",".join(["?"] * len(_important_roles))
+        cursor.execute(
+            "SELECT DISTINCT user_roles.user_id FROM user_roles "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            f"WHERE roles.role_name IN ({_paramstr})",
+            _important_roles)
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            f"User holds on of the following roles: {_important_roles}"))
+
+        _delete = tuple(uid for uid in _user_ids if uid not in
+                        (dict(row)["user_id"] for row in _non_deletable))
+        _paramstr = ", ".join(["?"] * len(_delete))
+        if len(_delete) > 0:
+            _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"))
+            try:
+                for _table, _col in _dependent_tables:
+                    cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+                        _delete)
+            except sqlite3.IntegrityError:
+                _non_deletable.update(__delete_users_individually__(
+                    cursor, _delete, _dependent_tables))
+
+            _not_deleted = __fetch_non_deletable_users__(
+                cursor, _non_deletable)
+            _delete = tuple(# rebuild with those that failed.
+                _user_id for _user_id in _delete if _user_id not in
+                tuple(row["user"]["user_id"] for row in _not_deleted))
+            _paramstr = ", ".join(["?"] * len(_delete))
+            cursor.execute(
+                f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+                _delete)
+            _deleted_rows = cursor.rowcount
+            return jsonify({
+                "total-requested": len(_user_ids),
+                "total-deleted": _deleted_rows,
+                "not-deleted": _not_deleted,
+                "deleted": _deleted_rows,
+                "message": (
+                    f"Successfully deleted {_deleted_rows} users." +
+                    (" Some users could not be deleted."
+                     if len(_user_ids) - _deleted_rows > 0
+                     else ""))
+            })
+
+        _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+    return jsonify({
+        "total-requested": len(_user_ids),
+        "total-deleted": 0,
+        "not-deleted": _not_deleted,
+        "deleted": 0,
+        "error": "Zero users were deleted",
+        "error_description": (
+            "No users were selected for deletion."
+            if len(_user_ids) == 0
+            else ("The selected users are system administrators, group "
+                  "members, or resource owners."))
+    }), 400
diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py
index 00e9b35..01ff765 100644
--- a/gn_auth/auth/requests.py
+++ b/gn_auth/auth/requests.py
@@ -3,4 +3,10 @@ from flask import request
 
 def request_json() -> dict:
     """Retrieve the JSON sent in a request."""
-    return request.json or dict(request.form) or {}
+    if request.headers.get("Content-Type") == "application/json":
+        # KLUDGE: We have this check here since request.json has the
+        # type Any | None; see:
+        # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545>
+        return request.json or {}
+    else:
+        return dict(request.args) or dict(request.form) or {}