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/authentication/users.py4
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py121
-rw-r--r--gn_auth/auth/authorisation/data/views.py9
-rw-r--r--gn_auth/auth/authorisation/resources/base.py52
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py79
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py17
-rw-r--r--gn_auth/auth/authorisation/resources/models.py144
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py17
-rw-r--r--gn_auth/auth/authorisation/resources/system/views.py27
-rw-r--r--gn_auth/auth/authorisation/resources/views.py71
-rw-r--r--gn_auth/auth/authorisation/users/views.py22
-rw-r--r--gn_auth/auth/errors.py2
-rw-r--r--gn_auth/errors/authlib.py2
-rw-r--r--migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py61
-rw-r--r--migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py62
-rw-r--r--migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py66
-rw-r--r--migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py49
-rw-r--r--migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py69
-rw-r--r--migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py185
-rw-r--r--migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py19
-rw-r--r--migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py62
-rw-r--r--tests/unit/auth/fixtures/group_fixtures.py21
-rw-r--r--tests/unit/auth/fixtures/resource_fixtures.py48
-rw-r--r--tests/unit/auth/fixtures/role_fixtures.py2
-rw-r--r--tests/unit/auth/fixtures/user_fixtures.py21
-rw-r--r--tests/unit/auth/test_groups.py2
-rw-r--r--tests/unit/auth/test_resources.py18
30 files changed, 1153 insertions, 116 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/authentication/users.py b/gn_auth/auth/authentication/users.py
index 140ce36..fded79f 100644
--- a/gn_auth/auth/authentication/users.py
+++ b/gn_auth/auth/authentication/users.py
@@ -1,6 +1,6 @@
 """User-specific code and data structures."""
 import datetime
-from typing import Tuple
+from typing import Tuple, Union
 from uuid import UUID, uuid4
 from dataclasses import dataclass
 
@@ -26,7 +26,7 @@ class User:
         return self.user_id
 
     @staticmethod
-    def from_sqlite3_row(row: sqlite3.Row):
+    def from_sqlite3_row(row: Union[sqlite3.Row, dict]):
         """Generate a user from a row in an SQLite3 resultset"""
         return User(user_id=UUID(row["user_id"]),
                     email=row["email"],
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..1526070 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):
     """
@@ -94,7 +95,7 @@ def authorisation() -> Response:
             with require_oauth.acquire("profile group resource") as _token:
                 user = _token.user
                 resources = attach_resources_data(
-                    auth_conn, user_resources(auth_conn, _token.user))
+                    auth_conn, user_resources(auth_conn, _token.user)[0])
                 resources_roles = user_resource_roles(auth_conn, _token.user)
                 privileges = {
                     resource_id: tuple(
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index 333ba0d..e4a1239 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -1,10 +1,17 @@
 """Base types for resources."""
+import logging
+import datetime
 from uuid import UUID
 from dataclasses import dataclass
-from typing import Any, Sequence
+from typing import Any, Sequence, Optional
 
 import sqlite3
 
+from gn_auth.auth.authentication.users import User
+
+
+logger = logging.getLogger(__name__)
+
 
 @dataclass(frozen=True)
 class ResourceCategory:
@@ -22,10 +29,49 @@ class Resource:
     resource_category: ResourceCategory
     public: bool
     resource_data: Sequence[dict[str, Any]] = tuple()
+    created_by: Optional[User] = None
+    created_at: datetime.datetime = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+    @staticmethod
+    def from_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+            resource,
+            resource_id: Optional[UUID] = None,
+            resource_name: Optional[str] = None,
+            resource_category: Optional[ResourceCategory] = None,
+            public: Optional[bool] = None,
+            resource_data: Optional[Sequence[dict[str, Any]]] = None,
+            created_by: Optional[User] = None,
+            created_at: Optional[datetime.datetime] = None
+    ):
+        """Takes a Resource object `resource` and updates the attributes specified in `kwargs`."""
+        return Resource(
+            resource_id=resource_id or resource.resource_id,
+            resource_name=resource_name or resource.resource_name,
+            resource_category=resource_category or resource.resource_category,
+            public=bool(public) or resource.public,
+            resource_data=resource_data or resource.resource_data,
+            created_by=created_by or resource.created_by,
+            created_at=created_at or resource.created_at)
 
 
 def resource_from_dbrow(row: sqlite3.Row):
     """Convert an SQLite3 resultset row into a resource."""
+    try:
+        created_at = datetime.datetime.fromtimestamp(row["created_at"])
+    except IndexError as _ie:
+        created_at = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+    try:
+        created_by = User.from_sqlite3_row({
+            "user_id": row["creator_user_id"],
+            "email": row["creator_email"],
+            "name": row["creator_name"],
+            "verified": row["creator_verified"],
+            "created": row["creator_created"]
+        })
+    except IndexError as _ie:
+        created_by = None
+
     return Resource(
         resource_id=UUID(row["resource_id"]),
         resource_name=row["resource_name"],
@@ -33,4 +79,6 @@ def resource_from_dbrow(row: sqlite3.Row):
             UUID(row["resource_category_id"]),
             row["resource_category_key"],
             row["resource_category_description"]),
-        public=bool(int(row["public"])))
+        public=bool(int(row["public"])),
+        created_by=created_by,
+        created_at=created_at)
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index ce2b821..004c780 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,75 @@ 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/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 6a7af4c..07e6dbe 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -1,5 +1,6 @@
 """Handle the management of resource/user groups."""
 import json
+import datetime
 from uuid import UUID, uuid4
 from functools import reduce
 from dataclasses import dataclass
@@ -100,8 +101,12 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
         "create a new group."),
     oauth2_scope="profile group")
 def create_group(
-        conn: db.DbConnection, group_name: str, group_leader: User,
-        group_description: Optional[str] = None) -> Group:
+        conn: db.DbConnection,
+        group_name: str,
+        group_leader: User,
+        group_description: Optional[str] = None,
+        creator: Optional[User] = None
+) -> Group:
     """Create a new group."""
     def resource_category_by_key(
             cursor: db.DbCursor, category_key: str):
@@ -134,11 +139,15 @@ def create_group(
                 resource_category_by_key(
                     cursor, "group")["resource_category_id"]
             ),
-            "public": 0
+            "public": 0,
+            "created_by": str(
+                creator.user_id if creator else group_leader.user_id),
+            "created_at": datetime.datetime.now().timestamp()
         }
         cursor.execute(
             "INSERT INTO resources VALUES "
-            "(:resource_id, :resource_name, :resource_category_id, :public)",
+            "(:resource_id, :resource_name, :resource_category_id, :public, "
+            ":created_by, :created_at)",
             _group_resource)
         cursor.execute(
             "INSERT INTO group_resources(resource_id, group_id) "
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index a4df363..27ef183 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -1,4 +1,6 @@
 """Handle the management of resources."""
+import logging
+from datetime import datetime
 from dataclasses import asdict
 from uuid import UUID, uuid4
 from functools import reduce, partial
@@ -14,10 +16,9 @@ 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 .system.models import system_resource
-from .checks import authorised_for, authorised_for_spec
+from .common import assign_resource_owner_role
+from .checks import can_edit, authorised_for_spec
 from .base import Resource, ResourceCategory, resource_from_dbrow
-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 (
@@ -37,6 +38,9 @@ from .phenotypes.models import (
     unlink_data_from_resource as phenotype_unlink_data_from_resource)
 
 
+logger = logging.getLogger(__name__)
+
+
 @authorised_p(("group:resource:create-resource",),
               error_description="Insufficient privileges to create a resource",
               oauth2_scope="profile resource")
@@ -46,17 +50,20 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
         resource_category: ResourceCategory,
         user: User,
         group: Group,
-        public: bool
+        public: bool,
+        created_at: datetime = datetime.now()
 ) -> Resource:
     """Create a resource item."""
     def __create_resource__(cursor: db.DbCursor) -> Resource:
         resource = Resource(uuid4(), resource_name, resource_category, public)
         cursor.execute(
-            "INSERT INTO resources VALUES (?, ?, ?, ?)",
+            "INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?)",
             (str(resource.resource_id),
              resource_name,
              str(resource.resource_category.resource_category_id),
-             1 if resource.public else 0))
+             1 if resource.public else 0,
+             str(user.user_id),
+             created_at.timestamp()))
         # 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
@@ -71,8 +78,6 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
                        "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
 
@@ -98,6 +103,27 @@ def delete_resource(conn: db.DbConnection, resource_id: UUID):
                        (str(resource_id),))
 
 
+def edit_resource(conn: db.DbConnection, resource_id: UUID, name: str) -> Resource:
+    """Edit basic resource details."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("UPDATE resources SET resource_name=? "
+                       "WHERE resource_id=?",
+                       (name, str(resource_id)))
+        cursor.execute(
+            "SELECT r.*, rc.* FROM resources AS r "
+            "INNER JOIN resource_categories AS rc "
+            "ON r.resource_category_id=rc.resource_category_id "
+            "WHERE r.resource_id=?",
+            (str(resource_id),))
+        _resource = resource_from_dbrow(cursor.fetchone())
+        cursor.execute(
+            "SELECT u.* FROM resources AS r INNER JOIN users AS u "
+            "ON r.created_by=u.user_id WHERE r.resource_id=?",
+            (str(resource_id),))
+        return Resource.from_resource(
+            _resource, created_by=User.from_sqlite3_row(cursor.fetchone()))
+
+
 def resource_category_by_id(
         conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
     """Retrieve a resource category by its ID."""
@@ -125,6 +151,18 @@ def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]:
             for row in cursor.fetchall())
     return tuple()
 
+
+def __fetch_creators__(cursor, creators_ids: tuple[str, ...]):
+    cursor.execute(
+            ("SELECT * FROM users "
+             f"WHERE user_id IN ({', '.join(['?'] * len(creators_ids))})"),
+            creators_ids)
+    return {
+        row["user_id"]: User.from_sqlite3_row(row)
+        for row in cursor.fetchall()
+    }
+
+
 def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
     """List all resources marked as public"""
     categories = {
@@ -132,10 +170,19 @@ def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
     }
     with db.cursor(conn) as cursor:
         cursor.execute("SELECT * FROM resources WHERE public=1")
-        results = cursor.fetchall()
+        resource_rows = tuple(cursor.fetchall())
+        _creators_ = __fetch_creators__(
+            cursor, tuple(row["created_by"] for row in resource_rows))
         return tuple(
-            Resource(UUID(row[0]), row[1], categories[row[2]], bool(row[3]))
-            for row in results)
+            Resource(
+                UUID(row[0]),
+                row[1],
+                categories[row[2]],
+                bool(row[3]),
+                created_by=_creators_[row["created_by"]],
+                created_at=datetime.fromtimestamp(row["created_at"]))
+            for row in resource_rows)
+
 
 def group_leader_resources(
         conn: db.DbConnection, user: User, group: Group,
@@ -155,22 +202,63 @@ def group_leader_resources(
                 for row in cursor.fetchall())
     return tuple()
 
-def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
+
+def user_resources(
+        conn: db.DbConnection,
+        user: User,
+        start_at: int = 0,
+        count: int = 0,
+        text_filter: str = ""
+) -> tuple[Sequence[Resource], int]:
     """List the resources available to the user"""
-    with db.cursor(conn) as cursor:
-        cursor.execute(
-            ("SELECT DISTINCT(r.resource_id), r.resource_name,  "
-             "r.resource_category_id, r.public, rc.resource_category_key, "
-             "rc.resource_category_description "
+    text_filter = text_filter.strip()
+    query_template = ("SELECT %%COLUMNS%%  "
              "FROM user_roles AS ur "
              "INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
              "INNER JOIN resource_categories AS rc "
              "ON r.resource_category_id=rc.resource_category_id "
-             "WHERE ur.user_id=?"),
+             "WHERE ur.user_id=? %%LIKE%% %%LIMITS%%")
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            query_template.replace(
+                "%%COLUMNS%%", "COUNT(DISTINCT(r.resource_id)) AS count"
+            ).replace(
+                "%%LIKE%%", ""
+            ).replace(
+                "%%LIMITS%%", ""),
             (str(user.user_id),))
+        _total_records = int(cursor.fetchone()["count"])
+        cursor.execute(
+            query_template.replace(
+                "%%COLUMNS%%",
+                "DISTINCT(r.resource_id), r.resource_name, "
+                "r.resource_category_id, r.public, r.created_by, r.created_at, "
+                "rc.resource_category_key, rc.resource_category_description"
+            ).replace(
+                "%%LIKE%%",
+                ("" if text_filter == "" else (
+                    "AND (r.resource_name LIKE ? OR "
+                    "rc.resource_category_key LIKE ? OR "
+                    "rc.resource_category_description LIKE ? )"))
+            ).replace(
+                "%%LIMITS%%",
+                ("" if count <= 0 else f"LIMIT {count} OFFSET {start_at}")),
+            (str(user.user_id),) + (
+                tuple() if text_filter == "" else
+                tuple(f"%{text_filter}%" for _ in range(0, 3))
+            ))
         rows = cursor.fetchall() or []
 
-    return tuple(resource_from_dbrow(row) for row in rows)
+        _creators_ = __fetch_creators__(
+            cursor, tuple(row["created_by"] for row in rows))
+
+    return tuple(
+        Resource.from_resource(
+            resource_from_dbrow(row),
+            created_by=_creators_[row["created_by"]],
+            created_at=datetime.fromtimestamp(row["created_at"])
+        ) for row in rows), _total_records
+
 
 
 def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]:
@@ -243,12 +331,9 @@ def link_data_to_resource(
         data_link_ids: tuple[UUID, ...]
 ) -> tuple[dict, ...]:
     """Link data to resource."""
-    if not authorised_for(
-            conn, user, ("group:resource:edit-resource",),
-            (resource_id,))[resource_id]:
+    if not can_edit(conn, user.user_id, resource_id):
         raise AuthorisationError(
-            "You are not authorised to link data to resource with id "
-            f"{resource_id}")
+            "You are not authorised to link/unlink data to this resource.")
 
     resource = with_db_connection(partial(
         resource_by_id, user=user, resource_id=resource_id))
@@ -261,12 +346,9 @@ def link_data_to_resource(
 def unlink_data_from_resource(
         conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
     """Unlink data from resource."""
-    if not authorised_for(
-            conn, user, ("group:resource:edit-resource",),
-            (resource_id,))[resource_id]:
+    if not can_edit(conn, user.user_id, resource_id):
         raise AuthorisationError(
-            "You are not authorised to link data to resource with id "
-            f"{resource_id}")
+            "You are not authorised to link/unlink data this resource.")
 
     resource = with_db_connection(partial(
         resource_by_id, user=user, resource_id=resource_id))
@@ -359,9 +441,7 @@ def save_resource(
         conn: db.DbConnection, user: User, resource: Resource) -> Resource:
     """Update an existing resource."""
     resource_id = resource.resource_id
-    authorised = authorised_for(
-        conn, user, ("group:resource:edit-resource",), (resource_id,))
-    if authorised[resource_id]:
+    if can_edit(conn, user.user_id, resource_id):
         with db.cursor(conn) as cursor:
             cursor.execute(
                 "UPDATE resources SET "
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/system/views.py b/gn_auth/auth/authorisation/resources/system/views.py
index b0d40c2..d7a57a9 100644
--- a/gn_auth/auth/authorisation/resources/system/views.py
+++ b/gn_auth/auth/authorisation/resources/system/views.py
@@ -1,19 +1,34 @@
 """Views relating to `System` resource(s)."""
+import logging
 from dataclasses import asdict
-from flask import jsonify, Blueprint
+from flask import request, jsonify, Blueprint, current_app as app
 
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_libs import sqlite3 as authdb
 
+from gn_auth.auth.authorisation.roles.models import db_rows_to_roles
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 
 from .models import user_roles_on_system
 
+logger = logging.getLogger(__name__)
 system = Blueprint("system", __name__)
 
+
 @system.route("/roles")
 def system_roles():
     """Get the roles that a user has that act on the system."""
-    with require_oauth.acquire("profile group") as the_token:
-        roles = with_db_connection(
-            lambda conn: user_roles_on_system(conn, the_token.user))
-        return jsonify(tuple(asdict(role) for role in roles))
+    with (authdb.connection(app.config["AUTH_DB"]) as conn,
+          authdb.cursor(conn) as cursor):
+        if not bool(request.headers.get("Authorization", False)):
+            cursor.execute(
+                "SELECT r.*, p.* FROM roles AS r "
+                "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+                "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+                "WHERE r.role_name='public-view'")
+            return jsonify(tuple(
+                asdict(role) for role in db_rows_to_roles(cursor.fetchall())))
+
+        with require_oauth.acquire("profile group") as the_token:
+            return jsonify(tuple(
+                asdict(role) for role in
+                user_roles_on_system(conn, the_token.user)))
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index a960ca3..ab44795 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -1,9 +1,10 @@
 """The views/routes for the resources package"""
-from uuid import UUID, uuid4
+import time
 import json
+import logging
 import operator
 import sqlite3
-import time
+from uuid import UUID, uuid4
 
 from dataclasses import asdict
 from functools import reduce
@@ -13,6 +14,7 @@ from authlib.jose import jwt
 from authlib.integrations.flask_oauth2.errors import _HTTPException
 from flask import (make_response, request, jsonify, Response,
                    Blueprint, current_app as app)
+import gn_libs.privileges.resources
 
 from gn_auth.auth.requests import request_json
 
@@ -43,14 +45,18 @@ from .inbredset.views import popbp
 from .genotypes.views import genobp
 from .phenotypes.views import phenobp
 from .errors import MissingGroupError
+from .system.models import system_resource
 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,
     unassign_resource_user, resource_category_by_id, user_roles_on_resources,
     unlink_data_from_resource, create_resource as _create_resource,
-    get_resource_id, delete_resource as _delete_resource)
+    get_resource_id, delete_resource as _delete_resource,
+    edit_resource as _edit_resource)
+
+logger = logging.getLogger(__name__)
 
 resources = Blueprint("resources", __name__)
 resources.register_blueprint(popbp, url_prefix="/")
@@ -95,8 +101,7 @@ def create_resource() -> Response:
                                       "resources.resource_name"):
                     raise InconsistencyError(
                         "You cannot have duplicate resource names.") from sql3ie
-                app.logger.debug(
-                    f"{type(sql3ie)=}: {sql3ie=}")
+                logger.debug("type(sql3ie)=%s: sql3ie=%s", type(sql3ie), sql3ie)
                 raise
 
 
@@ -114,6 +119,43 @@ def view_resource(resource_id: UUID) -> Response:
                 )
             )
 
+
+@resources.route("/<uuid:resource_id>/edit", methods=["POST"])
+@require_oauth("profile group resource")
+def edit_resource(resource_id: UUID) -> Response:
+    """Update/edit basic details regarding a resource."""
+    db_uri = app.config["AUTH_DB"]
+    with (require_oauth.acquire("profile group resource") as _token,
+          db.connection(db_uri) as conn):
+        _privileges = tuple(
+            privilege.privilege_id
+            for role in (
+                    role for resource in user_roles_on_resources(
+                        conn,
+                        _token.user,
+                        (resource_id, system_resource(conn).resource_id)
+                    ).values()
+                    for role in resource.get("roles", tuple()))
+            for privilege in role.privileges)
+        if not gn_libs.privileges.resources.can_edit(_privileges):
+            return make_response(jsonify({
+                "error": "AuthorisationError",
+                "error_description": "You are not allowed to edit this resource."
+            }), 401)
+
+        name = (request_json().get("resource_name") or "").strip()
+        if bool(name):
+            return jsonify({
+                "resource": asdict(_edit_resource(conn, resource_id, name)),
+                "message": "Resource updated successfully",
+                "status": "success"
+            })
+
+        return make_response(jsonify({
+            "error_description": "Expected `resource_name` to be provided.",
+            "error": "InvalidInput"
+        }), 400)
+
 def __safe_get_requests_page__(key: str = "page") -> int:
     """Get the results page if it exists or default to the first page."""
     try:
@@ -468,7 +510,7 @@ def resources_authorisation():
         })
         resp.status_code = 400
     except Exception as _exc:#pylint: disable=[broad-except]
-        app.logger.debug("Generic exception.", exc_info=True)
+        logger.debug("Generic exception.", exc_info=True)
         resp = jsonify({
             "status": "general-exception",
             "error_description": (
@@ -506,7 +548,6 @@ def get_user_roles_on_resource(name) -> Response:
         response = make_response({
             # Flatten this list
             "roles": roles,
-            "silly": "ausah",
         })
         iat = int(time.time())
         jose_header = {
@@ -685,13 +726,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,
@@ -709,13 +746,13 @@ def delete_resource():
                 "description": f"Successfully deleted resource with ID '{resource_id}'."
             })
         except ValueError as _verr:
-            app.logger.debug("Error!", exc_info=True)
+            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)
+            logger.debug("Error!", exc_info=True)
             return jsonify({
                 "error": "TypeError",
                 "error-description": "An invalid identifier was provided"
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index c248ac3..a706067 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,5 +1,6 @@
 """User authorisation endpoints."""
 import uuid
+import logging
 import sqlite3
 import secrets
 import traceback
@@ -57,6 +58,8 @@ from .models import list_users
 from .masquerade.views import masq
 from .collections.views import collections
 
+logger = logging.getLogger(__name__)
+
 users = Blueprint("users", __name__)
 users.register_blueprint(masq, url_prefix="/masquerade")
 users.register_blueprint(collections, url_prefix="/collections")
@@ -235,11 +238,11 @@ def register_user() -> Response:
                                         redirect_uri=form["redirect_uri"])
                 return jsonify(asdict(user))
         except sqlite3.IntegrityError as sq3ie:
-            current_app.logger.error(traceback.format_exc())
+            logger.error(traceback.format_exc())
             raise UserRegistrationError(
                 "A user with that email already exists") from sq3ie
         except EmailNotValidError as enve:
-            current_app.logger.error(traceback.format_exc())
+            logger.error(traceback.format_exc())
             raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
 
     raise Exception(# pylint: disable=[broad-exception-raised]
@@ -317,12 +320,21 @@ def user_group() -> Response:
 @require_oauth("profile resource")
 def user_resources() -> Response:
     """Retrieve the resources a user has access to."""
+    _request_params = request_json()
     with require_oauth.acquire("profile resource") as the_token:
         db_uri = current_app.config["AUTH_DB"]
         with db.connection(db_uri) as conn:
-            return jsonify([
-                asdict(resource) for resource in
-                _user_resources(conn, the_token.user)])
+            _resources, _total_records = _user_resources(
+                conn,
+                the_token.user,
+                start_at=int(_request_params.get("start", 0)),
+                count=int(_request_params.get("length", 0)),
+                text_filter=_request_params.get("text_filter", ""))
+            return jsonify({
+                "resources": [asdict(resource) for resource in _resources],
+                "total-records": _total_records,
+                "filtered-records": len(_resources)
+            })
 
 @users.route("group/join-request", methods=["GET"])
 @require_oauth("profile group")
diff --git a/gn_auth/auth/errors.py b/gn_auth/auth/errors.py
index 77b73aa..c499e86 100644
--- a/gn_auth/auth/errors.py
+++ b/gn_auth/auth/errors.py
@@ -6,7 +6,7 @@ class AuthorisationError(Exception):
 
     All exceptions in this package should inherit from this class.
     """
-    error_code: int = 400
+    error_code: int = 401
 
 class ForbiddenAccess(AuthorisationError):
     """Raised for forbidden access."""
diff --git a/gn_auth/errors/authlib.py b/gn_auth/errors/authlib.py
index 09862e3..c85b67c 100644
--- a/gn_auth/errors/authlib.py
+++ b/gn_auth/errors/authlib.py
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
 
 def __description__(body):
     """Improve description for errors in authlib.oauth2.rfc6749.errors"""
-    _desc = body["error_description"]
+    _desc = body.get("error_description", body["error"])
     match body["error"]:
         case "missing_authorization":
             return (
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/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py b/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
new file mode 100644
index 0000000..d618f14
--- /dev/null
+++ b/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py
@@ -0,0 +1,62 @@
+"""
+add privilege for gn-docs documentation editing
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260206_01_v3f4P-add-role-systemwide-data-curator'}
+
+ROLE_NAME = 'systemwide-docs-editor'
+
+
+def create_systemwide_docs_editor_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO roles(role_id, role_name, user_editable) "
+            "VALUES (?, ?, 0)",
+            (str(uuid.uuid4()), ROLE_NAME))
+
+
+def delete_systemwide_docs_editor_role(conn):
+    """Create a new 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("DELETE FROM roles WHERE role_name=?", (ROLE_NAME,))
+
+
+def assign_edit_priv_to_docs_editor(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+                       (ROLE_NAME,))
+        role_id = cursor.fetchone()[0]
+
+        cursor.execute(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES (?, ?)",
+            (role_id, "system:documentation:edit"))
+
+
+def revoke_edit_priv_to_docs_editor(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT role_id FROM roles WHERE role_name=?",
+                       (ROLE_NAME,))
+        role_id = cursor.fetchone()[0]
+
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            (role_id, "system:documentation:edit"))
+
+
+steps = [
+    step(
+        """INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES(
+        'system:documentation:edit',
+        'Allows the holder to edit documentation presented with the Genenetwork system.'
+        )""",
+        "DELETE FROM privileges WHERE privilege_id='system:documentation:edit'"),
+    step(create_systemwide_docs_editor_role, delete_systemwide_docs_editor_role),
+    step(assign_edit_priv_to_docs_editor, revoke_edit_priv_to_docs_editor)
+]
diff --git a/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py b/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
new file mode 100644
index 0000000..e79ef6a
--- /dev/null
+++ b/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py
@@ -0,0 +1,66 @@
+"""
+Assign 'systemwide-docs-editor' role to sysadmins
+"""
+import uuid
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing'}
+
+
+def fetch_docs_editor_role_id(cursor):
+    """Fetch ID of systemwide-docs-editor role"""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='systemwide-docs-editor'")
+    return cursor.fetchone()[0]
+
+
+def fetch_sys_resource_id(cursor):
+    """Fetch the resource ID of the system."""
+    cursor.execute("SELECT resource_id FROM resources "
+                   "WHERE resource_name='GeneNetwork System'")
+    return cursor.fetchone()[0]
+
+
+def fetch_sys_admin_ids(cursor):
+    """Fetch the sysadmins' IDs."""
+    cursor.execute(
+        "SELECT user_roles.user_id FROM resources INNER JOIN user_roles "
+        "ON resources.resource_id=user_roles.resource_id INNER JOIN roles "
+        "ON user_roles.role_id=roles.role_id "
+        "WHERE resources.resource_name='GeneNetwork System' "
+        "AND roles.role_name='system-administrator'")
+    return tuple(row[0] for row in cursor.fetchall())
+
+
+def __build_params__(cursor):
+    sysresourceid = fetch_sys_resource_id(cursor)
+    sysadminids = fetch_sys_admin_ids(cursor)
+    roleid = fetch_docs_editor_role_id(cursor)
+    return tuple({
+        "user_id": userid,
+        "role_id": roleid,
+        "resource_id": sysresourceid
+    } for userid in sysadminids)
+
+
+def assign_systemwide_docs_editor_role_to_sysadmins(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "INSERT INTO user_roles(user_id, role_id, resource_id) "
+            "VALUES(:user_id, :role_id, :resource_id)",
+            __build_params__(cursor))
+
+
+def revoke_systemwide_docs_editor_role_from_sysadmins(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "DELETE FROM user_roles WHERE user_id=:user_id "
+            "AND role_id=:role_id AND resource_id=:resource_id",
+            __build_params__(cursor))
+
+steps = [
+    step(assign_systemwide_docs_editor_role_to_sysadmins,
+         revoke_systemwide_docs_editor_role_from_sysadmins)
+]
diff --git a/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py b/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
new file mode 100644
index 0000000..bdf8a56
--- /dev/null
+++ b/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py
@@ -0,0 +1,49 @@
+"""
+Restrict access to resources' 'Make Public' feature.
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins'}
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+    "Fetch the role's ID."
+    cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+    return cursor.fetchone()[0]
+
+
+def assign_make_public_to_systemwide_data_curator(conn):
+    """Assign privilege to 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "INSERT INTO role_privileges(role_id, privilege_id) "
+            "VALUES(?, 'system:resource:make-public')",
+            (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+def revoke_make_public_from_systemwide_data_curator(conn):
+    """Revoke privilege from 'systemwide-data-curator' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges "
+            "WHERE role_id=? AND privilege_id='system:resource:make-public'",
+            (fetch_systemwide_data_curator_role_id(cursor),))
+
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES(
+        'system:resource:make-public',
+        'Allow user to make a resource publicly accessible.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id='system:resource:make-public'
+        """),
+    step(assign_make_public_to_systemwide_data_curator,
+         revoke_make_public_from_systemwide_data_curator),
+]
diff --git a/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py b/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
new file mode 100644
index 0000000..22863ae
--- /dev/null
+++ b/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py
@@ -0,0 +1,69 @@
+"""
+Add privileges to role systemwide-data-curator
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260311_03_vxBCX-restrict-access-to-resources-make-public-feature'}
+
+
+__new_privileges__ = (
+    ("system:system-wide:inbredset:view-case-attribute",
+     "Enable view of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:edit-case-attribute",
+     "Enable edit of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:delete-case-attribute",
+     "Enable deletion of any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:apply-case-attribute-edit",
+     "Enable applying changes to any and all inbredset case attributes system-wide."),
+    ("system:system-wide:inbredset:reject-case-attribute-edit",
+     "Enable rejecting changes to any and all inbredset case attributes system-wide."))
+
+
+def fetch_systemwide_data_curator_role_id(cursor):
+    "Fetch the role's ID."
+    cursor.execute("SELECT role_id FROM roles "
+                       "WHERE role_name='systemwide-data-curator'")
+    return cursor.fetchone()[0]
+
+
+def create_new_privileges(conn):
+    """Create new privileges for the system."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany(
+            "INSERT INTO privileges(privilege_id, privilege_description) "
+            "VALUES (?, ?)",
+            __new_privileges__)
+
+
+def delete_new_privileges(conn):
+    """Delete these new privileges from the system."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.executemany("DELETE FROM privileges WHERE privilege_id=?",
+                           tuple((priv[0],) for priv in __new_privileges__))
+
+
+def assign_new_privileges(conn):
+    """Assign the new privileges to the `systemwide-data-curator` role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_systemwide_data_curator_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)",
+            tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+def revoke_new_privileges(conn):
+    """Revoke the new privileges from the `systemwide-data-curator` role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_systemwide_data_curator_role_id(cursor)
+        cursor.executemany(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?",
+            tuple((role_id, privilege[0]) for privilege in __new_privileges__))
+
+
+
+steps = [
+    step(create_new_privileges, delete_new_privileges),
+    step(assign_new_privileges, revoke_new_privileges)
+]
diff --git a/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py b/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
new file mode 100644
index 0000000..702c418
--- /dev/null
+++ b/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py
@@ -0,0 +1,185 @@
+"""
+Add user and time tracking to resources table
+"""
+import random
+import contextlib
+from datetime import datetime
+
+from yoyo import step
+
+__depends__ = {'20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator'}
+
+GN_AUTH_INIT_TIMESTAMP = 1691130509.0
+__admin_id__ = ""
+
+
+def fetch_acentenos_id(conn):
+    """Fetch the default resource creator."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT user_id FROM users WHERE email=?",
+                       (("acent" "eno@" "uthsc" "." "edu"),))
+        res = cursor.fetchone()
+        return res[0] if bool(res) else None
+
+
+def fetch_a_sysadmin_id(conn, resources_table):
+    """Fetch one ID out of all system administrator users."""
+    global __admin_id__
+
+    def __fetch__():
+        with contextlib.closing(conn.cursor()) as cursor:
+            cursor.execute(
+                f"SELECT ur.user_id FROM {resources_table} AS rsc "
+                "INNER JOIN user_roles AS ur ON rsc.resource_id=ur.resource_id "
+                "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+                "WHERE resource_name='GeneNetwork System' "
+                "AND r.role_name='system-administrator'"
+            )
+            return tuple(row[0] for row in cursor.fetchall())
+
+    if not bool(__admin_id__):
+        __admins__ = __fetch__()
+        if len(__admins__) > 0:
+            __admin_id__ = random.choice(__admins__)
+
+    return __admin_id__
+
+
+def add_user_and_time_tracking_columns(conn):
+    """Add user and time tracking columns."""
+    conn.execute(
+        """
+            CREATE TABLE resources_new(
+              resource_id TEXT NOT NULL,
+              resource_name TEXT NOT NULL UNIQUE,
+              resource_category_id TEXT NOT NULL,
+              public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+              created_by TEXT NOT NULL,
+              created_at REAL NOT NULL DEFAULT '1691130509.0',
+              PRIMARY KEY(resource_id),
+              FOREIGN KEY(resource_category_id)
+                REFERENCES resource_categories(resource_category_id)
+                ON UPDATE CASCADE ON DELETE RESTRICT,
+              FOREIGN KEY(created_by)
+                REFERENCES users(user_id) ON UPDATE CASCADE ON DELETE RESTRICT
+            ) WITHOUT ROWID
+            """)
+
+
+def drop_user_and_time_tracking_columns(conn):
+    """Drop user and time tracking columns."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+    conn.execute("DROP TABLE IF EXISTS resources")
+    conn.execute("ALTER TABLE resources_old RENAME TO resources")
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+
+def update_data_for_new_resources_table(conn):
+    """Add creator and time to original data."""
+    __creator__ = (
+        fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT * FROM resources")
+        cursor.executemany(
+            "INSERT INTO resources_new("
+            "  resource_id,"
+            "  resource_name,"
+            "  resource_category_id,"
+            "  public,"
+            "  created_by,"
+            "  created_at"
+            ") VALUES (?, ?, ?, ?, ?, ?)",
+            tuple(
+                tuple(row) + (__creator__, GN_AUTH_INIT_TIMESTAMP)
+                for row in cursor.fetchall()))
+
+
+def restore_data_for_old_resources_table(conn):
+    """Remove creator and time from data."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT * FROM resources")
+        cursor.executemany(
+            "INSERT INTO resources_old("
+            "  resource_id,"
+            "  resource_name,"
+            "  resource_category_id,"
+            "  public"
+            ") VALUES (?, ?, ?, ?)",
+            tuple(tuple(row)[0:4] for row in cursor.fetchall()))
+
+
+def replace_old_table_with_new_table(conn):
+    """Restore old resources table with the new resources table."""
+    conn.execute("PRAGMA foreign_keys = OFF")
+    conn.execute("DROP TABLE resources")
+    conn.execute("ALTER TABLE resources_new RENAME TO resources")
+    conn.execute("PRAGMA foreign_key_check")
+    conn.execute("PRAGMA foreign_keys = ON")
+
+
+def restore_old_table(conn):
+    """Restore old 'resources' table schema."""
+    conn.execute(
+        """
+            CREATE TABLE resources_old(
+              resource_id TEXT NOT NULL,
+              resource_name TEXT NOT NULL UNIQUE,
+              resource_category_id TEXT NOT NULL,
+              public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1),
+              PRIMARY KEY(resource_id),
+              FOREIGN KEY(resource_category_id)
+                REFERENCES resource_categories(resource_category_id)
+                ON UPDATE CASCADE ON DELETE RESTRICT
+            ) WITHOUT ROWID
+            """)
+
+
+def parse_creator_and_time(cursor, row):
+    __return__ = None
+
+    __name_parts__ = row[1].split("—")
+    if len(__name_parts__) == 4:
+        __email__, __inbredsetname__, __datetimestr__, count = __name_parts__
+        cursor.execute("SELECT user_id FROM users WHERE email=?",
+                       (__email__.strip(),))
+        results = cursor.fetchone()
+        if bool(results):
+            __return__ = {
+                "resource_id": row[0],
+                "creator": results[0],
+                "created": datetime.fromisoformat(__datetimestr__).timestamp()
+            }
+
+    return __return__
+
+
+def update_creators_and_time(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute("SELECT resource_id, resource_name FROM resources")
+        cursor.executemany(
+            "UPDATE resources SET created_by=:creator, created_at=:created "
+            "WHERE resource_id=:resource_id",
+            tuple(item for item in
+                  (parse_creator_and_time(cursor, row)
+                   for row in cursor.fetchall())
+                  if item is not None))
+        
+
+
+def restore_default_creators_and_time(conn):
+    with contextlib.closing(conn.cursor()) as cursor:
+        __creator__ = (
+            fetch_acentenos_id(conn) or fetch_a_sysadmin_id(conn, "resources"))
+        cursor.execute("UPDATE resources SET created_by=?, created_at=?",
+                       (__creator__, GN_AUTH_INIT_TIMESTAMP))
+
+
+steps = [
+    step(add_user_and_time_tracking_columns,
+         drop_user_and_time_tracking_columns),
+    step(update_data_for_new_resources_table,
+         restore_data_for_old_resources_table),
+    step(replace_old_table_with_new_table, restore_old_table),
+    step(update_creators_and_time, restore_default_creators_and_time)
+]
diff --git a/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py b/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
new file mode 100644
index 0000000..2dddc56
--- /dev/null
+++ b/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py
@@ -0,0 +1,19 @@
+"""
+New privilege: system:system-wide:data:view
+"""
+
+from yoyo import step
+
+__depends__ = {'20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table'}
+
+steps = [
+    step(
+        """
+        INSERT INTO privileges(privilege_id, privilege_description)
+        VALUES('system:system-wide:data:view',
+        'A user with this privilege can view any data on the entire system.')
+        """,
+        """
+        DELETE FROM privileges WHERE privilege_id='system:system-wide:data:view'
+        """)
+]
diff --git a/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py b/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
new file mode 100644
index 0000000..537bf9b
--- /dev/null
+++ b/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py
@@ -0,0 +1,62 @@
+"""
+Add privileges to batch-editors role
+"""
+import contextlib
+
+from yoyo import step
+
+__depends__ = {'20260428_01_Tak6O-new-privilege-system-system-wide-data-view'}
+
+
+def fetch_batch_editors_role_id(cursor):
+    """Fetch the ID of the batch-editors role."""
+    cursor.execute("SELECT role_id FROM roles WHERE role_name='Batch Editors'")
+    res = cursor.fetchone()
+    if not bool(res):
+        cursor.execute(
+            "SELECT role_id FROM roles WHERE role_name='batch-editors'")
+        res = cursor.fetchone()
+
+    return res[0] if bool(res) else None
+
+
+def rename_role(conn):
+    """Rename role from 'Batch Editors' to 'batch-editors'."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "UPDATE roles SET role_name='batch-editors' WHERE role_id=?",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+def restore_old_role_name(conn):
+    """Rename role from 'batch-editors' to 'Batch Editors'."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "UPDATE roles SET role_name='Batch Editors' WHERE role_id=?",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+def add_new_privileges(conn):
+    """Add new privileges to 'batch-editors' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        role_id = fetch_batch_editors_role_id(cursor)
+        cursor.executemany(
+            "INSERT INTO role_privileges(role_id, privilege_id) VALUES(?, ?)",
+            tuple((role_id, priv) for priv in (
+                "system:system-wide:data:view",
+                "system:system-wide:data:edit")))
+
+
+def remove_new_privileges(conn):
+    """Remove new privileges from 'batch-editors' role."""
+    with contextlib.closing(conn.cursor()) as cursor:
+        cursor.execute(
+            "DELETE FROM role_privileges WHERE role_id=? AND privilege_id IN "
+            "('system:system-wide:data:view', 'system:system-wide:data:edit')",
+            (fetch_batch_editors_role_id(cursor),))
+
+
+steps = [
+    step(rename_role, restore_old_role_name),
+    step(add_new_privileges, remove_new_privileges)
+]
diff --git a/tests/unit/auth/fixtures/group_fixtures.py b/tests/unit/auth/fixtures/group_fixtures.py
index 2e8cd9a..da1c4cd 100644
--- a/tests/unit/auth/fixtures/group_fixtures.py
+++ b/tests/unit/auth/fixtures/group_fixtures.py
@@ -1,5 +1,6 @@
 """Fixtures and utilities for group-related tests"""
 import uuid
+import datetime
 
 import pytest
 
@@ -7,8 +8,12 @@ from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authorisation.resources.groups import Group
 from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
 
+from .user_fixtures import TEST_USERS
 from .resource_fixtures import TEST_RESOURCES
 
+
+_created_ = datetime.datetime.now()
+
 TEST_GROUP_01 = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"),
                       "TheTestGroup", {})
 TEST_GROUP_02 = Group(uuid.UUID("e37d59d7-c05e-4d67-b479-81e627d8d634"),
@@ -24,16 +29,20 @@ GROUPS_AS_RESOURCES = tuple({
     "resource_id": res_id,
     "resource_name": group.group_name,
     "category_id": str(GROUP_CATEGORY.resource_category_id),
-    "public": "0"
+    "public": "0",
+    "created_by": str(TEST_USERS[0].user_id),
+    "created_at": _created_.timestamp()
 } for res_id, group in zip(
     ("38d1807d-105f-44a7-8327-7e2d973b6d8d",
      "89458ef6-e090-4b53-8c2c-59eaf2785f11"),
     TEST_GROUPS))
 GROUP_RESOURCES = tuple(
-    Resource(uuid.UUID(row["resource_id"]),
-             row["resource_name"],
+    Resource(uuid.UUID(row["resource_id"]),# type: ignore[arg-type]
+             row["resource_name"],# type: ignore[arg-type]
              GROUP_CATEGORY,
-             False)
+             False,
+             created_by=TEST_USERS[0],
+             created_at=_created_)
     for row in GROUPS_AS_RESOURCES)
 
 
@@ -46,7 +55,7 @@ def __gtuple__(cursor):
     return tuple(dict(row) for row in cursor.fetchall())
 
 @pytest.fixture(scope="function")
-def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name]
+def fxtr_group(conn_after_auth_migrations, fxtr_users):# pylint: disable=[redefined-outer-name, unused-argument]
     """Fixture: setup a test group."""
     with db.cursor(conn_after_auth_migrations) as cursor:
         cursor.executemany(
@@ -57,7 +66,7 @@ def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-na
 
         cursor.executemany(
             "INSERT INTO resources "
-            "VALUES(:resource_id, :resource_name, :category_id, :public)",
+            "VALUES(:resource_id, :resource_name, :category_id, :public, :created_by, :created_at)",
             GROUPS_AS_RESOURCES)
 
         cursor.executemany(
diff --git a/tests/unit/auth/fixtures/resource_fixtures.py b/tests/unit/auth/fixtures/resource_fixtures.py
index e06f64e..b570a49 100644
--- a/tests/unit/auth/fixtures/resource_fixtures.py
+++ b/tests/unit/auth/fixtures/resource_fixtures.py
@@ -1,11 +1,15 @@
 """Fixtures and utilities for resource-related tests"""
 import uuid
+import datetime
 
 import pytest
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authorisation.resources import Resource, ResourceCategory
 
+from .user_fixtures import TEST_USERS
+
+_created_ = datetime.datetime.now()
 
 SYSTEM_CATEGORY = ResourceCategory(
     uuid.UUID("aa3d787f-af6a-44fa-9b0b-c82d40e54ad2"),
@@ -15,48 +19,74 @@ SYSTEM_RESOURCE = Resource(
     uuid.UUID("0248b289-b277-4eaa-8c94-88a434d14b6e"),
     "GeneNetwork System",
     SYSTEM_CATEGORY,
-    True)
+    True,
+    resource_data=tuple(),
+    created_by=TEST_USERS[4],
+    created_at=_created_)
 
 TEST_RESOURCES = (
     Resource(uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"),
              "ResourceG01R01",
              ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
                               "genotype", "Genotype Dataset"),
-             True),
+             True,
+             resource_data=tuple(),
+             created_by=TEST_USERS[0],
+             created_at=_created_),
     Resource(uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"),
              "ResourceG01R02",
              ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"),
                               "phenotype", "Phenotype (Publish) Dataset"),
-             False),
+             False,
+             resource_data=tuple(),
+             created_by=TEST_USERS[0],
+             created_at=_created_),
     Resource(uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"),
              "ResourceG01R03",
              ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
                               "mrna", "mRNA Dataset"),
-             False),
+             False,
+             resource_data=tuple(),
+             created_by=TEST_USERS[0],
+             created_at=_created_),
     Resource(uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"),
              "ResourceG02R01",
              ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"),
                               "genotype", "Genotype Dataset"),
-             False),
+             False,
+             resource_data=tuple(),
+             created_by=TEST_USERS[0],
+             created_at=_created_),
     Resource(uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"),
              "ResourceG02R02",
              ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"),
                               "mrna", "mRNA Dataset"),
-             True))
+             True,
+             resource_data=tuple(),
+             created_by=TEST_USERS[0],
+             created_at=_created_))
 
 TEST_RESOURCES_PUBLIC = (SYSTEM_RESOURCE, TEST_RESOURCES[0], TEST_RESOURCES[4])
 
 
 @pytest.fixture(scope="function")
-def fxtr_resources(conn_after_auth_migrations):
+def fxtr_resources(conn_after_auth_migrations, fxtr_users):# pylint: disable=[unused-argument]
     """fixture: setup test resources in the database"""
     conn = conn_after_auth_migrations
     with db.cursor(conn) as cursor:
         cursor.executemany(
-            "INSERT INTO resources VALUES (?,?,?,?)",
+            "INSERT INTO resources VALUES (?,?,?,?,?,?)",
             ((str(res.resource_id), res.resource_name,
               str(res.resource_category.resource_category_id),
-              1 if res.public else 0) for res in TEST_RESOURCES))
+              1 if res.public else 0,
+              str(res.created_by.user_id),
+              res.created_at.timestamp()) for res in TEST_RESOURCES))
+        cursor.execute(
+            "UPDATE resources SET created_by=?, created_at=? "
+            "WHERE resource_id=?",
+            (str(SYSTEM_RESOURCE.created_by.user_id),
+             SYSTEM_RESOURCE.created_at.timestamp(),
+             str(SYSTEM_RESOURCE.resource_id)))
 
     yield (conn, TEST_RESOURCES)
 
diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py
index 63a3fca..24e8e9f 100644
--- a/tests/unit/auth/fixtures/role_fixtures.py
+++ b/tests/unit/auth/fixtures/role_fixtures.py
@@ -108,7 +108,7 @@ def fxtr_resource_roles(fxtr_resources, fxtr_roles):# pylint: disable=[redefined
 
 
 @pytest.fixture(scope="function")
-def fxtr_setup_group_leaders(fxtr_users):
+def fxtr_setup_group_leaders(fxtr_users, fxtr_group):# pylint: disable=[unused-argument]
     """Define what roles users have that target resources of type 'Group'."""
     conn, users = fxtr_users
     with db.cursor(conn) as cursor:
diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py
index 1cf0e20..0872142 100644
--- a/tests/unit/auth/fixtures/user_fixtures.py
+++ b/tests/unit/auth/fixtures/user_fixtures.py
@@ -1,28 +1,35 @@
 """Fixtures and utilities for user-related tests"""
 import uuid
+import datetime
 
 import pytest
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User, hash_password
 
+_created_ = datetime.datetime.now()
+
 TEST_USERS = (
         User(uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er",
-             "Group Leader"),
+             "Group Leader", created=_created_),
         User(uuid.UUID("21351b66-8aad-475b-84ac-53ce528451e3"),
-             "group@mem.ber01", "Group Member 01"),
+             "group@mem.ber01", "Group Member 01", created=_created_),
         User(uuid.UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"),
-             "group@mem.ber02", "Group Member 02"),
+             "group@mem.ber02", "Group Member 02", created=_created_),
         User(uuid.UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"),
-             "unaff@iliated.user", "Unaffiliated User"))
+             "unaff@iliated.user", "Unaffiliated User", created=_created_),
+        User(uuid.UUID("60faf8a7-832b-471e-b6a0-bd4013f1fa0e"),
+             "sys@admin.user", "System Admin User", created=_created_))
 
 @pytest.fixture(scope="function")
-def fxtr_users(conn_after_auth_migrations, fxtr_group):# pylint: disable=[redefined-outer-name, unused-argument]
+def fxtr_users(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name, unused-argument]
     """Fixture: setup test users."""
-    query = "INSERT INTO users(user_id, email, name) VALUES (?, ?, ?)"
+    query = (
+        "INSERT INTO users(user_id, email, name, created) VALUES (?, ?, ?, ?)")
     with db.cursor(conn_after_auth_migrations) as cursor:
         cursor.executemany(query, (
-            (str(user.user_id), user.email, user.name) for user in TEST_USERS))
+            (str(user.user_id), user.email, user.name, user.created.timestamp())
+            for user in TEST_USERS))
 
     yield (conn_after_auth_migrations, TEST_USERS)
 
diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py
index 346beb9..6f1e8cd 100644
--- a/tests/unit/auth/test_groups.py
+++ b/tests/unit/auth/test_groups.py
@@ -61,6 +61,8 @@ def __cleanup_create_group__(conn, user, group):
             (str(user.user_id), str(grp_rsc["resource_id"])))
         cursor.execute("DELETE FROM group_resources WHERE group_id=?",
                        (str(group.group_id),))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (grp_rsc["resource_id"],))
         cursor.execute("DELETE FROM groups WHERE group_id=?",
                        (str(group.group_id),))
 
diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py
index 04da6df..81f967e 100644
--- a/tests/unit/auth/test_resources.py
+++ b/tests/unit/auth/test_resources.py
@@ -114,19 +114,19 @@ def test_public_resources(fxtr_resources):
     "user,expected",
     tuple(zip(
         conftest.TEST_USERS,
-        (sorted(
+        ((sorted(
             {res.resource_id: res for res in
              ((conftest.GROUP_RESOURCES[0],) +
               conftest.TEST_RESOURCES_GROUP_01 +
               conftest.TEST_RESOURCES_PUBLIC)}.values(),
-            key=sort_key_resources),
-         sorted(
+            key=sort_key_resources), 6),
+         (sorted(
              {res.resource_id: res for res in
               ((conftest.TEST_RESOURCES_GROUP_01[1],) +
                conftest.TEST_RESOURCES_PUBLIC)}.values()
              ,
-             key=sort_key_resources),
-         PUBLIC_RESOURCES, PUBLIC_RESOURCES))))
+             key=sort_key_resources), 4),
+         (PUBLIC_RESOURCES, 3), (PUBLIC_RESOURCES, 3)))))
 def test_user_resources(fxtr_resource_user_roles, user, expected):
     """
     GIVEN: some resources in the database
@@ -134,6 +134,10 @@ def test_user_resources(fxtr_resource_user_roles, user, expected):
     THEN: list only the resources for which the user can access
     """
     conn, *_others = fxtr_resource_user_roles
+    uresources, count = user_resources(conn, user)
+    eresources, ecount = expected
+    assert count == ecount
     assert sorted(
-        {res.resource_id: res for res in user_resources(conn, user)
-         }.values(), key=sort_key_resources) == expected
+        {res.resource_id: res for res in  uresources}.values(),
+        key=sort_key_resources
+    ) == eresources