about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.guix-channel4
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py5
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py8
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py121
-rw-r--r--gn_auth/auth/authorisation/data/views.py7
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py80
-rw-r--r--gn_auth/auth/authorisation/resources/models.py15
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py17
-rw-r--r--gn_auth/auth/authorisation/resources/views.py14
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py4
-rw-r--r--gn_auth/auth/authorisation/users/views.py4
-rw-r--r--migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py61
-rwxr-xr-xsetup.py2
13 files changed, 310 insertions, 32 deletions
diff --git a/.guix-channel b/.guix-channel
index 2c37401..bfc31db 100644
--- a/.guix-channel
+++ b/.guix-channel
@@ -34,11 +34,12 @@
   (channel
    (name guix-bioinformatics)
    (url "https://git.genenetwork.org/guix-bioinformatics")
-   (commit "903465c85c9b2ae28480b236c3364da873ca8f51"))
+   (commit "9b0955f14ec725990abb1f6af3b9f171e4943f77"))
   (channel
    (name guix-past)
    (url "https://codeberg.org/guix-science/guix-past")
    (branch "master")
+   (commit "473c942b509ab3ead35159d27dfbf2031a36cd4d")
    (introduction
     (channel-introduction
      (version 0)
@@ -49,6 +50,7 @@
    (name guix-rust-past-crates)
    (url "https://codeberg.org/guix/guix-rust-past-crates.git")
    (branch "trunk")
+   (commit "b8b7ffbd1cec9f56f93fae4da3a74163bbc9c570")
    (introduction
     (channel-introduction
      (version 0)
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 1639e2e..fe12ff9 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -2,6 +2,7 @@
 import json
 import datetime
 from uuid import UUID
+from urllib.parse import urlparse
 from functools import cached_property
 from dataclasses import asdict, dataclass
 from typing import Any, Sequence, Optional
@@ -135,7 +136,9 @@ class OAuth2Client(ClientMixin):
         """
         Check whether the given `redirect_uri` is one of the expected ones.
         """
-        return redirect_uri in self.redirect_uris
+        uri = urlparse(redirect_uri)._replace(
+            query="")._replace(fragment="").geturl()
+        return uri in self.redirect_uris
 
     @cached_property
     def response_types(self) -> Sequence[str]:
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index 0e2c4eb..6c3de51 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -44,7 +44,7 @@ def authorise():
                               or str(uuid.uuid4()))
         client = server.query_client(client_id)
         if not bool(client):
-            flash("Invalid OAuth2 client.", "alert-danger")
+            flash("Invalid OAuth2 client.", "alert alert-danger")
 
         if request.method == "GET":
             def __forgot_password_table_exists__(conn):
@@ -88,15 +88,15 @@ def authorise():
                                     email=email["email"]),
                             code=307)
                     return server.create_authorization_response(request=request, grant_user=user)
-                flash(email_passwd_msg, "alert-danger")
+                flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
             except EmailNotValidError as _enve:
                 app.logger.debug(traceback.format_exc())
-                flash(email_passwd_msg, "alert-danger")
+                flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
             except NotFoundError as _nfe:
                 app.logger.debug(traceback.format_exc())
-                flash(email_passwd_msg, "alert-danger")
+                flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
 
         return with_db_connection(__authorise__)
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 3e45af3..788b9e7 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -4,17 +4,24 @@ from dataclasses import asdict
 from typing import Any, Iterable
 
 from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
 from MySQLdb.cursors import DictCursor
+from flask import request, jsonify, Response, Blueprint, current_app as app
 
-from gn_auth.auth.db import sqlite3 as authdb
+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
 
+
+from gn_auth.auth.authorisation.checks import require_json
 from gn_auth.auth.authorisation.resources.checks import authorised_for2
 
+phenosbp = Blueprint("phenotypes", __name__)
+
 def linked_phenotype_data(
         authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
         species: str = "") -> Iterable[dict[str, Any]]:
@@ -155,3 +162,115 @@ def link_phenotype_data(
             "group": asdict(group),
             "traits": params
         }
+
+
+def unlink_from_resources(
+        cursor: authdb.DbCursor,
+        data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+    """Unlink phenotypes from resources."""
+    # TODO: Delete in batches
+    cursor.executemany("DELETE FROM phenotype_resources "
+                       "WHERE data_link_id=? RETURNING resource_id",
+                       tuple((str(_id),) for _id in data_link_ids))
+    return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def delete_resources(
+        cursor: authdb.DbCursor,
+        resource_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+    """Delete the specified phenotype resources."""
+    # TODO: Delete in batches
+    cursor.executemany("DELETE FROM resources "
+                       "WHERE resource_id=? RETURNING resource_id",
+                       tuple((str(_id),) for _id in resource_ids))
+    return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def fetch_data_link_ids(
+        cursor: authdb.DbCursor,
+        species_id: int,
+        population_id: int,
+        dataset_id: int,
+        xref_ids: tuple[int, ...]
+) -> tuple[uuid.UUID, ...]:
+    """Fetch `data_link_id` values for phenotypes."""
+    paramstr = ", ".join(["(?, ?, ?, ?)"] * len(xref_ids))
+    cursor.execute(
+        "SELECT data_link_id FROM linked_phenotype_data "
+        "WHERE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId) IN "
+        f"({paramstr})",
+        tuple(str(field) for arow in
+              ((species_id, population_id, dataset_id, xref_id)
+             for xref_id in xref_ids)
+            for field in arow))
+    return tuple(uuid.UUID(row["data_link_id"]) for row in cursor.fetchall())
+
+
+def fetch_resource_id(cursor: authdb.DbCursor,
+                      data_link_ids: tuple[uuid.UUID, ...]) -> uuid.UUID:
+    """Retrieve the ID of the resource where the data is linked to.
+
+    RAISES: InvalidResourceError in the case where more the data_link_ids belong
+      to more than one resource."""
+    _paramstr = ", ".join(["?"] * len(data_link_ids))
+    cursor.execute(
+        "SELECT DISTINCT(resource_id) FROM phenotype_resources "
+        f"WHERE data_link_id IN ({_paramstr})",
+        tuple(str(_id) for _id in data_link_ids))
+    _ids = tuple(uuid.UUID(row['resource_id']) for row in cursor.fetchall())
+    if len(_ids) != 1:
+        raise AuthorisationError(
+            f"Expected data from 1 resource, got {len(_ids)} resources.")
+    return _ids[0]
+
+
+def delete_linked_data(
+        cursor: authdb.DbCursor,
+        data_link_ids: tuple[uuid.UUID, ...]
+) -> int:
+    """Delete the actual linked data."""
+    # TODO: Delete in batches
+    cursor.executemany("DELETE FROM linked_phenotype_data "
+                       "WHERE data_link_id=?",
+                       tuple((str(_id),) for _id in data_link_ids))
+    return cursor.rowcount
+
+
+@phenosbp.route("/<int:species_id>/<int:population_id>/<int:dataset_id>/delete",
+                methods=["POST"])
+@require_json
+def delete_linked_phenotypes_data(
+        species_id: int,
+        population_id: int,
+        dataset_id: int
+) -> Response:
+    """Delete the linked phenotypes data from the database."""
+    db_uri = app.config["AUTH_DB"]
+    with (require_oauth.acquire("profile group resource") as _token,
+          authdb.connection(db_uri) as auth_conn,
+          authdb.cursor(auth_conn) as cursor):
+        _deleted = 0
+        xref_ids = tuple(request.json.get("xref_ids", []))#type: ignore[union-attr]
+        if len(xref_ids) > 0:
+            # TODO: Use background job, for huge number of xref_ids
+            data_link_ids = fetch_data_link_ids(
+                cursor, species_id, population_id, dataset_id, xref_ids)
+            resource_id = fetch_resource_id(cursor, data_link_ids)
+            # - Does user have DELETE privilege on the data
+            if not can_delete(auth_conn, _token.user.user_id, resource_id):
+                # - No: Raise `AuthorisationError` and bail!
+                raise AuthorisationError(
+                    "You are not allowed to delete this resource's data.")
+            # - YES: go ahead and delete data as below.
+            _resources_ids = unlink_from_resources(cursor, data_link_ids)
+            delete_resources(cursor, _resources_ids)
+            _deleted = delete_linked_data(cursor, data_link_ids)
+
+        return jsonify({
+            # TODO: "status": "sent-to-background"/"completed"/"failed"
+            # TODO: "status-url": <status-check-uri>
+            "requested": len(xref_ids),
+            "deleted": _deleted
+        })
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 9123949..4bf6746 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -35,11 +35,12 @@ from ..resources.models import (
 from ...authentication.users import User
 from ...authentication.oauth2.resource_server import require_oauth
 
-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
+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
 
 data = Blueprint("data", __name__)
+data.register_blueprint(phenosbp, url_prefix="/phenotypes")
 
 def build_trait_name(trait_fullname):
     """
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index ce2b821..bc9e4da 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,18 +1,25 @@
 """Handle authorisation checks for resources"""
 import uuid
+import logging
 import warnings
 from functools import reduce
 from typing import Sequence
 
+import gn_libs.sqlite3 as authdb
 from gn_libs.privileges import check
 
 from .base import Resource
+from .system.models import system_resource
 
 from ...db import sqlite3 as db
 from ...authentication.users import User
 
 from ..privileges.models import db_row_to_privilege
 
+
+logger = logging.getLogger(__name__)
+
+
 def __organise_privileges_by_resource_id__(rows):
     def __organise__(privs, row):
         resource_id = uuid.UUID(row["resource_id"])
@@ -120,3 +127,76 @@ def authorised_for_spec(
             (str(resource_id), str(user_id)))
         _privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
     return check(auth_spec, _privileges)
+
+
+def can_delete(
+        conn: authdb.DbConnection,
+        user_id: uuid.UUID,
+        resource_id: uuid.UUID
+) -> bool:
+    """Check whether user is allowed delete a resource and/or its data."""
+    return (
+        authorised_for_spec(# resource-level delete access
+            conn,
+            user_id,
+            resource_id,
+            "(OR group:resource:delete-resource system:resource:delete)")
+        or
+        authorised_for_spec(# system-wide delete access
+            conn,
+            user_id,
+            system_resource(conn).resource_id,
+            "(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."""
+    return (
+        authorised_for_spec(
+            # resource-level edit access: user has edit access to his resource.
+            conn,
+            user_id,
+            resource_id,
+            "(OR group:resource:edit-resource system:resource:edit)")
+        or
+        authorised_for_spec(
+            # system-wide edit access: user can edit any/all resource(s).
+            conn,
+            user_id,
+            system_resource(conn).resource_id,
+            "(OR system:system-wide:data:edit system:resource:edit)"))
+
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index 31371fd..a4df363 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -2,9 +2,10 @@
 from dataclasses import asdict
 from uuid import UUID, uuid4
 from functools import reduce, partial
-from typing import Dict, Sequence, Optional
+from typing import Dict, Union, Sequence, Optional
+
+from gn_libs import sqlite3 as db
 
-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
 
@@ -40,7 +41,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]
-        conn: db.DbConnection,
+        conn: Union[db.DbConnection, db.DbCursor],
         resource_name: str,
         resource_category: ResourceCategory,
         user: User,
@@ -48,7 +49,7 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
         public: bool
 ) -> Resource:
     """Create a resource item."""
-    with db.cursor(conn) as cursor:
+    def __create_resource__(cursor: db.DbCursor) -> Resource:
         resource = Resource(uuid4(), resource_name, resource_category, public)
         cursor.execute(
             "INSERT INTO resources VALUES (?, ?, ?, ?)",
@@ -75,6 +76,12 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
 
         return resource
 
+    if hasattr(conn, "cursor"): # This is a connection: get its cursor.
+        with db.cursor(conn) as cursor:
+            return __create_resource__(cursor)
+    else:
+        return __create_resource__(conn)
+
 
 def delete_resource(conn: db.DbConnection, resource_id: UUID):
     """Delete a resource."""
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 303b0ac..25089fa 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -1,9 +1,10 @@
 """Base functions and utilities for system resources."""
 from uuid import UUID
 from functools import reduce
-from typing import Sequence
+from typing import Union, Sequence
+
+from gn_libs import sqlite3 as db
 
-from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.errors import NotFoundError
 
 from gn_auth.auth.authentication.users import User
@@ -52,9 +53,9 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
     return tuple()
 
 
-def system_resource(conn: db.DbConnection) -> Resource:
+def system_resource(conn: Union[db.DbConnection, db.DbCursor]) -> Resource:
     """Retrieve the system resource."""
-    with db.cursor(conn) as cursor:
+    def __fetch_sys_resource__(cursor: db.DbCursor) -> Resource:
         cursor.execute(
             "SELECT resource_categories.*, resources.resource_id, "
             "resources.resource_name, resources.public "
@@ -65,4 +66,10 @@ def system_resource(conn: db.DbConnection) -> Resource:
         if row:
             return resource_from_dbrow(row)
 
-    raise NotFoundError("Could not find a system resource!")
+        raise NotFoundError("Could not find a system resource!")
+
+    if hasattr(conn, "cursor"): # is connection
+        with db.cursor(conn) as cursor:
+            return __fetch_sys_resource__(cursor)
+    else:
+        return __fetch_sys_resource__(conn)
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index a960ca3..e4401c5 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -39,12 +39,14 @@ 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 .system.models import system_resource
+
 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 .checks import can_delete, authorised_for
 from .models import (
     Resource, resource_data, resource_by_id, public_resources,
     resource_categories, assign_resource_user, link_data_to_resource,
@@ -685,13 +687,9 @@ def delete_resource():
         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.")
+            if not can_delete(conn, the_token.user.user_id, resource_id):
+                raise AuthorisationError(
+                    "You are not allowed to delete this resource.")
 
             data = resource_data(
                 conn,
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index 63443ef..30242c2 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -72,8 +72,8 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
 
 def parse_collection(coll: dict) -> dict:
     """Parse the collection as persisted in redis to a usable python object."""
-    created = coll.get("created", coll.get("created_timestamp"))
-    changed = coll.get("changed", coll.get("changed_timestamp"))
+    created = coll.get("created", coll.get("created_timestamp", ""))
+    changed = coll.get("changed", coll.get("changed_timestamp", ""))
     return {
         "id": UUID(coll["id"]),
         "name": coll["name"],
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index cae2605..c248ac3 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -4,9 +4,9 @@ import sqlite3
 import secrets
 import traceback
 from dataclasses import asdict
-from typing import Any, Sequence
 from urllib.parse import urljoin
 from functools import reduce, partial
+from typing import Any, Union, Sequence
 from datetime import datetime, timedelta
 from email.headerregistry import Address
 from email_validator import validate_email, EmailNotValidError
@@ -80,7 +80,7 @@ def user_details() -> Response:
             })
 
 @users.route("/<user_id>", methods=["GET"])
-def get_user(user_id: str) -> Response:
+def get_user(user_id: str) -> Union[Response, tuple[Response, int]]:
     """Fetch user details by user_id."""
     try:
         with db.connection(current_app.config["AUTH_DB"]) as conn:
diff --git a/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
new file mode 100644
index 0000000..63e807a
--- /dev/null
+++ b/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py
@@ -0,0 +1,61 @@
+"""
+add role systemwide-data-curator.
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members'}
+
+
+def create_systemwide_data_curator_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO roles(role_id, role_name, user_editable) "
+            "VALUES (?, 'systemwide-data-curator', 0)",
+            (str(uuid.uuid4()),))
+
+
+def link_privileges_to_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+        role_id = cursor.fetchone()[0]
+        cursor.executemany("INSERT INTO role_privileges(role_id, privilege_id) "
+                           "VALUES (?, ?)",
+                           tuple((role_id, priv) for priv in
+                                 ("system:system-wide:data:edit",
+                                  "system:system-wide:data:delete")))
+
+
+def unlink_privileges_from_role(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+        role_id = cursor.fetchone()[0]
+        cursor.executemany("DELETE FROM role_privileges "
+                           "WHERE role_id=? AND privilege_id=?",
+                           tuple((role_id, priv) for priv in
+                                 ("system:system-wide:data:edit",
+                                  "system:system-wide:data:delete")))
+
+
+steps = [
+    step(# Add new privileges
+        """
+        INSERT INTO privileges (privilege_id, privilege_description)
+        VALUES
+          ('system:system-wide:data:edit',
+           'A user with this privilege can edit any data on the entire system.'),
+          ('system:system-wide:data:delete',
+           'A user with this privilege can delete any data from the system.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id IN
+        ('system:system-wide:data:edit', 'system:system-wide:data:delete')"""),
+    step(create_systemwide_data_curator_role,
+         "DELETE FROM roles WHERE role_name='systemwide-data-curator'"),
+    step(link_privileges_to_role, unlink_privileges_from_role)
+]
diff --git a/setup.py b/setup.py
index 59cd86f..c7339e2 100755
--- a/setup.py
+++ b/setup.py
@@ -44,5 +44,5 @@ setup(author="Frederick M. Muriithi",
       version="0.0.1",
       tests_require=["pytest", "hypothesis"],
       cmdclass={
-          "run_tests": RunTests  # testing
+          "run_tests": RunTests  # type: ignore[dict-item]
       })