about summary refs log tree commit diff
path: root/gn_auth/auth/authorisation/resources/groups
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth/authorisation/resources/groups')
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py236
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py169
2 files changed, 378 insertions, 27 deletions
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 3263e37..a1937ce 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -8,14 +8,21 @@ from typing import Any, Sequence, Iterable, Optional
 import sqlite3
 from flask import g
 from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User, user_by_id
 
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
 from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.common import (
+    grant_access_to_sysadmins)
+from gn_auth.auth.authorisation.resources.base import (
+    Resource,
+    resource_from_dbrow)
 from gn_auth.auth.errors import (
     NotFoundError, AuthorisationError, InconsistencyError)
 from gn_auth.auth.authorisation.roles.models import (
@@ -118,9 +125,10 @@ def create_group(
             cursor, group_name, (
                 {"group_description": group_description}
                 if group_description else {}))
-        group_resource = {
+        _group_resource_id = uuid4()
+        _group_resource = {
             "group_id": str(new_group.group_id),
-            "resource_id": str(uuid4()),
+            "resource_id": str(_group_resource_id),
             "resource_name": group_name,
             "resource_category_id": str(
                 resource_category_by_key(
@@ -131,18 +139,20 @@ def create_group(
         cursor.execute(
             "INSERT INTO resources VALUES "
             "(:resource_id, :resource_name, :resource_category_id, :public)",
-            group_resource)
+            _group_resource)
         cursor.execute(
             "INSERT INTO group_resources(resource_id, group_id) "
             "VALUES(:resource_id, :group_id)",
-            group_resource)
+            _group_resource)
+        grant_access_to_sysadmins(cursor,
+                                  _group_resource_id,
+                                  system_resource(conn).resource_id)
         add_user_to_group(cursor, new_group, group_leader)
         revoke_user_role_by_name(cursor, group_leader, "group-creator")
-        assign_user_role_by_name(
-            cursor,
-            group_leader,
-            UUID(str(group_resource["resource_id"])),
-            "group-leader")
+        assign_user_role_by_name(cursor,
+                                 group_leader,
+                                 _group_resource_id,
+                                 "group-leader")
         return new_group
 
 
@@ -233,15 +243,56 @@ def is_group_leader(conn: db.DbConnection, user: User, group: Group) -> bool:
         return "group-leader" in role_names
 
 
-def all_groups(conn: db.DbConnection) -> Maybe[Sequence[Group]]:
+def __build_groups_list_query__(
+        base: str,
+        search: Optional[str] = None
+) -> tuple[str, tuple[Optional[str], ...]]:
+    """Build up the query from given search terms."""
+    if search is not None and search.strip() != "":
+        _search = search.strip()
+        return ((f"{base} WHERE groups.group_name LIKE ? "
+                 "OR groups.group_metadata LIKE ?"),
+                (f"%{search}%", f"%{search}%"))
+    return base, tuple()
+
+
+def __limit_results_length__(base: str, start: int = 0, length: int = 0) -> str:
+    """Add the `LIMIT … OFFSET …` clause to query `base`."""
+    if length > 0:
+        return f"{base} LIMIT {length} OFFSET {start}"
+    return base
+
+
+def all_groups(
+        conn: db.DbConnection,
+        search: Optional[str] = None,
+        start: int = 0,
+        length: int = 0
+) -> Maybe[tuple[tuple[Group, ...], int, int]]:
     """Retrieve all existing groups"""
     with db.cursor(conn) as cursor:
-        cursor.execute("SELECT * FROM groups")
+        cursor.execute("SELECT COUNT(*) FROM groups")
+        _groups_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _qdets = __build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search)
+        cursor.execute(*__build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search))
+        _filtered_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _query, _params = __build_groups_list_query__(
+            "SELECT * FROM groups", search)
+
+        cursor.execute(__limit_results_length__(_query, start, length),
+                       _params)
         res = cursor.fetchall()
         if res:
-            return Just(tuple(
-                Group(row["group_id"], row["group_name"],
-                      json.loads(row["group_metadata"])) for row in res))
+            return Just((
+                tuple(
+                    Group(row["group_id"], row["group_name"],
+                          json.loads(row["group_metadata"])) for row in res),
+                _groups_total_count,
+                _filtered_total_count))
 
     return Nothing
 
@@ -268,6 +319,56 @@ def add_user_to_group(cursor: db.DbCursor, the_group: Group, user: User):
         ("INSERT INTO group_users VALUES (:group_id, :user_id) "
          "ON CONFLICT (group_id, user_id) DO NOTHING"),
         {"group_id": str(the_group.group_id), "user_id": str(user.user_id)})
+    revoke_user_role_by_name(cursor, user, "group-creator")
+
+
+def resource_from_group(conn: db.DbConnection, the_group: Group) -> Resource:
+    """Get the resource object that wraps the group for auth purposes."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT "
+            "resources.resource_id, resources.resource_name, "
+            "resources.public, resource_categories.* "
+            "FROM group_resources "
+            "INNER JOIN resources "
+            "ON group_resources.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_resources.group_id=?",
+            (str(the_group.group_id),))
+        results = tuple(resource_from_dbrow(row) for row in cursor.fetchall())
+        match len(results):
+            case 0:
+                raise InconsistencyError("The group lacks a wrapper resource.")
+            case 1:
+                return results[0]
+            case _:
+                raise InconsistencyError(
+                    "The group has more than one wrapper resource.")
+
+
+def remove_user_from_group(
+        conn: db.DbConnection,
+        group: Group,
+        user: User,
+        grp_resource: Resource
+):
+    """Add `user` to `group` as a member."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "DELETE FROM group_users "
+            "WHERE group_id=:group_id AND user_id=:user_id",
+            {"group_id": str(group.group_id), "user_id": str(user.user_id)})
+        cursor.execute(
+            "DELETE FROM user_roles WHERE user_id=? AND resource_id=?",
+            (str(user.user_id), str(grp_resource.resource_id)))
+        assign_user_role_by_name(cursor,
+                                 user,
+                                 grp_resource.resource_id,
+                                 "group-creator")
+        grant_access_to_sysadmins(cursor,
+                                  grp_resource.resource_id,
+                                  system_resource(conn).resource_id)
 
 
 @authorised_p(
@@ -497,3 +598,108 @@ def add_resources_to_group(conn: db.DbConnection,
                 "group_id": str(group.group_id),
                 "resource_id": str(rsc.resource_id)
             } for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+    """Return a group where at least one system admin is a member."""
+    query = (
+        "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+        "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+        "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+        "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+        "WHERE role_name='system-administrator'")
+    with db.cursor(conn) as cursor:
+        cursor.execute(query)
+        return monad_from_none_or_value(
+            Left("There is no group of which the system admininstrator is a "
+                 "member."),
+            lambda row: Right(Group(
+                UUID(row["group_id"]),
+                row["group_name"],
+                json.loads(row["group_metadata"]))),
+            cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+    """Retrieve the system resource."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT group_resources.group_id, resource_categories.*, "
+            "resources.resource_id, resources.resource_name, resources.public "
+            "FROM group_resources INNER JOIN resources "
+            "ON group_resources.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_resources.group_id=? "
+            "AND resource_categories.resource_category_key='group'",
+            (str(group_id),))
+        row = cursor.fetchone()
+        if row:
+            return resource_from_dbrow(row)
+
+    raise NotFoundError("Could not find a resource for group with ID "
+                        f"{group_id}")
+
+
+def data_resources(
+        conn: db.DbConnection, group_id: UUID) -> Iterable[Resource]:
+    """Fetch a group's data resources."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT resource_ownership.group_id, resources.resource_id, "
+            "resources.resource_name, resources.public, resource_categories.* "
+            "FROM resource_ownership INNER JOIN resources "
+            "ON resource_ownership.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_id=?",
+            (str(group_id),))
+        yield from (resource_from_dbrow(row) for row in cursor.fetchall())
+
+
+def group_leaders(conn: db.DbConnection, group_id: UUID) -> Iterable[User]:
+    """Fetch all of a group's group leaders."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT users.* FROM group_users INNER JOIN group_resources "
+            "ON group_users.group_id=group_resources.group_id "
+            "INNER JOIN user_roles "
+            "ON group_resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles "
+            "ON user_roles.role_id=roles.role_id "
+            "INNER JOIN users "
+            "ON user_roles.user_id=users.user_id "
+            "WHERE group_users.group_id=? "
+            "AND roles.role_name='group-leader'",
+            (str(group_id),))
+        yield from (User.from_sqlite3_row(row) for row in cursor.fetchall())
+
+
+def delete_group(conn: db.DbConnection, group_id: UUID):
+    """
+    Delete the group with the given ID
+
+    Parameters:
+        conn (db.DbConnection): an open connection to an SQLite3 database.
+        group_id (uuid.UUID): The identifier for the group to delete.
+
+    Returns:
+        None: It does not return a value.
+
+    Raises:
+        sqlite3.IntegrityError: if the group has members or linked resources, or
+        both.
+    """
+    rsc = group_resource(conn, group_id)
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM group_join_requests WHERE group_id=?",
+                       (str(group_id),))
+        cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute(
+            "DELETE FROM group_resources WHERE group_id=? AND resource_id=?",
+            (str(group_id), str(rsc.resource_id)))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute("DELETE FROM groups WHERE group_id=?",
+                       (str(group_id),))
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 368284f..2aa115a 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -6,6 +6,7 @@ import datetime
 from functools import partial
 from dataclasses import asdict
 
+import sqlite3
 from MySQLdb.cursors import DictCursor
 from flask import jsonify, Response, Blueprint, current_app
 
@@ -18,16 +19,31 @@ from gn_auth.auth.db.sqlite3 import with_db_connection
 from gn_auth.auth.authorisation.privileges import privileges_by_ids
 from gn_auth.auth.errors import InvalidData, NotFoundError, AuthorisationError
 
-from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authentication.users import User, user_by_id
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+from gn_auth.auth.authorisation.resources.groups.models import (resource_from_group,
+                                                                remove_user_from_group)
+
 from .data import link_data_to_group
-from .models import (
-    Group, user_group, all_groups, DUMMY_GROUP, GroupRole, group_by_id,
-    join_requests, group_role_by_id, GroupCreationError,
-    accept_reject_join_request, group_users as _group_users,
-    create_group as _create_group, add_privilege_to_group_role,
-    delete_privilege_from_group_role)
+from .models import (Group,
+                     GroupRole,
+                     user_group,
+                     all_groups,
+                     DUMMY_GROUP,
+                     group_by_id,
+                     group_leaders,
+                     join_requests,
+                     data_resources,
+                     group_role_by_id,
+                     GroupCreationError,
+                     accept_reject_join_request,
+                     add_privilege_to_group_role,
+                     group_users as _group_users,
+                     create_group as _create_group,
+                     delete_group as _delete_group,
+                     delete_privilege_from_group_role)
 
 groups = Blueprint("groups", __name__)
 
@@ -35,11 +51,31 @@ groups = Blueprint("groups", __name__)
 @require_oauth("profile group")
 def list_groups():
     """Return the list of groups that exist."""
+    _kwargs = request_json()
+    def __add_total_group_count__(groups_info):
+        return {
+            "groups": groups_info[0],
+            "total-groups": groups_info[1],
+            "total-filtered": groups_info[2]
+        }
+
     with db.connection(current_app.config["AUTH_DB"]) as conn:
-        the_groups = all_groups(conn)
+        return jsonify(all_groups(
+            conn,
+            search=_kwargs.get("search"),
+            start=int(_kwargs.get("start", "0")),
+            length=int(_kwargs.get("length", "0"))
+        ).then(
+            __add_total_group_count__
+        ).maybe(
+            {
+                "groups": [],
+                "message": "No groups found!",
+                "total-groups": 0,
+                "total-filtered": 0
+            },
+            lambda _grpdata: _grpdata))
 
-    return jsonify(the_groups.maybe(
-        [], lambda grps: [asdict(grp) for grp in grps]))
 
 @groups.route("/create", methods=["POST"])
 @require_oauth("profile group")
@@ -235,7 +271,7 @@ def unlinked_data(resource_type: str) -> Response:
     if resource_type in ("system", "group"):
         return jsonify(tuple())
 
-    if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+    if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
         raise AuthorisationError(f"Invalid resource type {resource_type}")
 
     with require_oauth.acquire("profile group resource") as the_token:
@@ -253,7 +289,8 @@ def unlinked_data(resource_type: str) -> Response:
                 "genotype": unlinked_genotype_data,
                 "phenotype": lambda conn, grp: partial(
                     unlinked_phenotype_data, gn3conn=gn3conn)(
-                        authconn=conn, group=grp)
+                        authconn=conn, group=grp),
+                "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
             }
             return jsonify(tuple(
                 dict(row) for row in unlinked_fns[resource_type](
@@ -347,3 +384,111 @@ def delete_priv_from_role(group_role_id: uuid.UUID) -> Response:
                 direction="DELETE", user=the_token.user))),
             "description": "Privilege deleted successfully"
         })
+
+
+@groups.route("/<uuid:group_id>", methods=["GET"])
+@require_oauth("profile group")
+def view_group(group_id: uuid.UUID) -> Response:
+    """View a particular group's details."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(group_by_id(conn, group_id))
+
+
+@groups.route("/<uuid:group_id>/data-resources", methods=["GET"])
+@require_oauth("profile group")
+def view_group_data_resources(group_id: uuid.UUID) -> Response:
+    """View data resources linked to the group."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(data_resources(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/leaders", methods=["GET"])
+@require_oauth("profile group")
+def view_group_leaders(group_id: uuid.UUID) -> Response:
+    """View a group's leaders."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(group_leaders(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/remove-member", methods=["POST"])
+@require_oauth("profile group")
+def remove_group_member(group_id: uuid.UUID):
+    """Remove a user as member of this group."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(OR group:user:remove-group-member system:group:remove-group-member)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to remove a user from this "
+                "group.")
+
+        form = request_json()
+        if not bool(form.get("user_id")):
+            response = jsonify({
+                "error": "MissingUserId",
+                "error-description": (
+                    "Expected 'user_id' value/parameter was not provided.")
+            })
+            response.status_code = 400
+            return response
+
+        try:
+            user = user_by_id(conn, uuid.UUID(form["user_id"]))
+            remove_user_from_group(conn, group, user, grp_resource)
+            success_msg = (
+                f"User '{user.name} ({user.email})' is no longer a member of "
+                f"group '{group.group_name}'.\n"
+                "They could, however, still have access to resources owned by "
+                "the group.")
+            return jsonify({
+                "description": success_msg,
+                "message": success_msg
+            })
+        except ValueError as _verr:
+            response = jsonify({
+                "error": "InvalidUserId",
+                "error-description": "The 'user_id' provided was invalid"
+            })
+            response.status_code = 400
+            return response
+
+
+@groups.route("/<uuid:group_id>/delete", methods=["DELETE"])
+@require_oauth("profile group")
+def delete_group(group_id: uuid.UUID) -> Response:
+    """Delete group with the specified `group_id`."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(AND system:group:delete-group)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to delete this group.")
+        try:
+            _delete_group(conn, group.group_id)
+            return Response(status=204)
+        except sqlite3.IntegrityError as _s3ie:
+            response = jsonify({
+                "error": "IntegrityError",
+                "error-description": (
+                    "A group that has members, linked resources, or both, "
+                    "cannot be deleted from the system. Remove any members and "
+                    "unlink any linked resources, and try again.")
+            })
+            response.status_code = 400
+            return response