diff options
27 files changed, 1076 insertions, 160 deletions
diff --git a/.guix-channel b/.guix-channel index 9476e74..bfc31db 100644 --- a/.guix-channel +++ b/.guix-channel @@ -3,18 +3,57 @@ (directory ".guix/modules") (dependencies (channel + (name gn-machines) + (url "https://git.genenetwork.org/gn-machines") + (branch "main")) + ;; Until https://issues.guix.gnu.org/68797 is resolved, we need to + ;; explicitly list guix-bioinformatics, guix-forge, guix-past and + ;; guix-rust-past-crates—the dependencies of the gn-machines channel—here. + (channel + (name guix) + (url "https://codeberg.org/guix/guix") + (branch "master") + (commit "0a4740705090acc4c8a10d4f53afc58c9f62e980") + (introduction + (channel-introduction + (version 0) + (commit "9edb3f66fd807b096b48283debdcddccfea34bad") + (signer + "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA")))) + (channel + (name guix-forge) + (url "https://git.systemreboot.net/guix-forge/") + (branch "main") + (commit "e43fd9a4d73654d3876e2c698af7da89f3408f89") + (introduction + (channel-introduction + (version 0) + (commit "0432e37b20dd678a02efee21adf0b9525a670310") + (signer + "7F73 0343 F2F0 9F3C 77BF 79D3 2E25 EE8B 6180 2BB3")))) + (channel (name guix-bioinformatics) (url "https://git.genenetwork.org/guix-bioinformatics") - (branch "master")) - ;; FIXME: guix-bioinformatics depends on guix-past. So, there - ;; should be no reason to explicitly depend on guix-past. But, the - ;; channel does not build otherwise. This is probably a guix bug. + (commit "9b0955f14ec725990abb1f6af3b9f171e4943f77")) (channel (name guix-past) - (url "https://gitlab.inria.fr/guix-hpc/guix-past") + (url "https://codeberg.org/guix-science/guix-past") + (branch "master") + (commit "473c942b509ab3ead35159d27dfbf2031a36cd4d") + (introduction + (channel-introduction + (version 0) + (commit "c3bc94ee752ec545e39c1b8a29f739405767b51c") + (signer + "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5")))) + (channel + (name guix-rust-past-crates) + (url "https://codeberg.org/guix/guix-rust-past-crates.git") + (branch "trunk") + (commit "b8b7ffbd1cec9f56f93fae4da3a74163bbc9c570") (introduction (channel-introduction (version 0) - (commit "0c119db2ea86a389769f4d2b9c6f5c41c027e336") + (commit "1db24ca92c28255b28076792b93d533eabb3dc6a") (signer - "3CE4 6455 8A84 FDC6 9DB4 0CFB 090B 1199 3D9A EBB5")))))) + "F4C2 D1DF 3FDE EA63 D1D3 0776 ACC6 6D09 CA52 8292")))))) diff --git a/.guix/modules/gn-auth.scm b/.guix/modules/gn-auth.scm index 0dab8d9..0d9cbc9 100644 --- a/.guix/modules/gn-auth.scm +++ b/.guix/modules/gn-auth.scm @@ -1,5 +1,5 @@ (define-module (gn-auth) - #:use-module ((gn packages genenetwork) + #:use-module ((gn-machines genenetwork) #:select (gn-auth) #:prefix gn:) #:use-module ((gnu packages check) #:select (python-pylint)) #:use-module ((gnu packages python-check) #:select (python-mypy)) diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py index aebc63b..d6591e5 100644 --- a/gn_auth/__init__.py +++ b/gn_auth/__init__.py @@ -2,6 +2,7 @@ import os import sys import logging +import warnings from pathlib import Path from typing import Optional, Callable @@ -18,9 +19,16 @@ from gn_auth.auth.authentication.oauth2.server import setup_oauth2_server from . import settings from .errors import register_error_handlers +## Configure warnings: ## +# https://docs.python.org/3/library/warnings.html#the-warnings-filter +# filters form: (action, message, category, module, lineno) +warnings.filterwarnings(action="always", category=DeprecationWarning) + + class ConfigurationError(Exception): """Raised in case of a configuration error.""" + def check_mandatory_settings(app: Flask) -> None: """Verify that mandatory settings are defined in the application""" undefined = tuple( diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py index 3e45af3..1f79e0e 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.system.models import system_resource +from gn_auth.auth.authorisation.resources.checks import authorised_for_spec 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,128 @@ 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): + # - Does user have DELETE privilege on system (i.e. is data curator)? + # YES: go ahead and delete data as below. + # - Does user have DELETE privilege on resource(s)? + # YES: Delete phenotypes by resource, checking privileges for each + # resource. + # - Neither: Raise `AuthorisationError` and bail! + _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) + if not (authorised_for_spec( + auth_conn, + _token.user.user_id, + resource_id, + "(OR group:resource:delete-resource system:resource:delete)") + or + authorised_for_spec( + auth_conn, + _token.user.user_id, + system_resource(auth_conn).resource_id, + "(AND system:system-wide:data:delete)")): + raise AuthorisationError( + "You are not allowed to delete this resource's data.") + _resources_ids = unlink_from_resources(cursor, data_link_ids) + delete_resources(cursor, _resources_ids) + _deleted = delete_linked_data(cursor, data_link_ids) + + return jsonify({ + # TODO: "status": "sent-to-background"/"completed"/"failed" + # TODO: "status-url": <status-check-uri> + "requested": len(xref_ids), + "deleted": _deleted + }) diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py index 9123949..4bf6746 100644 --- a/gn_auth/auth/authorisation/data/views.py +++ b/gn_auth/auth/authorisation/data/views.py @@ -35,11 +35,12 @@ from ..resources.models import ( from ...authentication.users import User from ...authentication.oauth2.resource_server import require_oauth -from ..data.mrna import link_mrna_data, ungrouped_mrna_data -from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db -from ..data.genotypes import link_genotype_data, ungrouped_genotype_data +from .mrna import link_mrna_data, ungrouped_mrna_data +from .genotypes import link_genotype_data, ungrouped_genotype_data +from .phenotypes import phenosbp, link_phenotype_data, pheno_traits_from_db data = Blueprint("data", __name__) +data.register_blueprint(phenosbp, url_prefix="/phenotypes") def build_trait_name(trait_fullname): """ diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py index 5484dbf..59bf90c 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -1,8 +1,12 @@ """Handle authorisation checks for resources""" -from uuid import UUID +import uuid +import logging +import warnings from functools import reduce from typing import Sequence +from gn_libs.privileges import check + from .base import Resource from ...db import sqlite3 as db @@ -10,9 +14,13 @@ 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(row["resource_id"]) + resource_id = uuid.UUID(row["resource_id"]) return { **privs, resource_id: (row["privilege_id"],) + privs.get( @@ -24,11 +32,14 @@ def __organise_privileges_by_resource_id__(rows): def authorised_for(conn: db.DbConnection, user: User, privileges: tuple[str, ...], - resource_ids: Sequence[UUID]) -> dict[UUID, bool]: + resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]: """ Check whether `user` is authorised to access `resources` according to given `privileges`. """ + warnings.warn(DeprecationWarning( + f"The function `{__name__}.authorised_for` is deprecated. Please use " + f"`{__name__}.authorised_for_spec`")) with db.cursor(conn) as cursor: cursor.execute( ("SELECT ur.*, rp.privilege_id FROM " @@ -61,6 +72,9 @@ def authorised_for2( """ Check that `user` has **ALL** the specified privileges for the resource. """ + warnings.warn(DeprecationWarning( + f"The function `{__name__}.authorised_for2` is deprecated. Please use " + f"`{__name__}.authorised_for_spec`")) with db.cursor(conn) as cursor: _query = ( "SELECT resources.resource_id, user_roles.user_id, roles.role_id, " @@ -82,3 +96,32 @@ def authorised_for2( str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges) return all((requested_privilege in str_privileges) for requested_privilege in privileges) + + +def authorised_for_spec( + conn: db.DbConnection, + user_id: uuid.UUID, + resource_id: uuid.UUID, + auth_spec: str +) -> bool: + """ + Check that a user, identified with `user_id`, has a set of privileges that + satisfy the `auth_spec` for the resource identified with `resource_id`. + """ + with db.cursor(conn) as cursor: + _query = ( + "SELECT resources.resource_id, user_roles.user_id, roles.role_id, " + "privileges.* " + "FROM resources INNER JOIN user_roles " + "ON resources.resource_id=user_roles.resource_id " + "INNER JOIN roles ON user_roles.role_id=roles.role_id " + "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id " + "INNER JOIN privileges " + "ON role_privileges.privilege_id=privileges.privilege_id " + "WHERE resources.resource_id=? " + "AND user_roles.user_id=?") + cursor.execute( + _query, + (str(resource_id), str(user_id))) + _privileges = tuple(row["privilege_id"] for row in cursor.fetchall()) + return check(auth_spec, _privileges) diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py index 5d2b72b..fd358f1 100644 --- a/gn_auth/auth/authorisation/resources/common.py +++ b/gn_auth/auth/authorisation/resources/common.py @@ -1,10 +1,10 @@ """Utilities common to more than one resource.""" import uuid -from sqlite3 import Cursor +from gn_auth.auth.db import sqlite3 as db def assign_resource_owner_role( - cursor: Cursor, + cursor: db.DbCursor, resource_id: uuid.UUID, user_id: uuid.UUID ) -> dict: @@ -22,3 +22,27 @@ def assign_resource_owner_role( "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", params) return params + + +def grant_access_to_sysadmins( + cursor: db.DbCursor, + resource_id: uuid.UUID, + system_resource_id: uuid.UUID +): + """Grant sysadmins access to resource identified by `resource_id`.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + sysadminroleid = cursor.fetchone()[0] + + cursor.execute(# Fetch sysadmin IDs. + "SELECT user_roles.user_id FROM roles INNER JOIN user_roles " + "ON roles.role_id=user_roles.role_id " + "WHERE role_name='system-administrator' AND resource_id=?", + (str(system_resource_id),)) + + cursor.executemany( + "INSERT INTO user_roles(user_id, role_id, resource_id) " + "VALUES (?, ?, ?) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + tuple((row["user_id"], sysadminroleid, str(resource_id)) + for row in cursor.fetchall())) diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py index a4aacc7..6a7af4c 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -17,6 +17,9 @@ 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.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) @@ -122,9 +125,10 @@ def create_group( cursor, group_name, ( {"group_description": group_description} if group_description else {})) + _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( @@ -140,13 +144,15 @@ def create_group( "INSERT INTO group_resources(resource_id, group_id) " "VALUES(:resource_id, :group_id)", _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 @@ -313,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( @@ -372,8 +428,8 @@ gjr.status='PENDING'", return tuple(dict(row)for row in cursor.fetchall()) raise AuthorisationError( - "You do not have the appropriate authorisation to access the " - "group's join requests.") + "You need to be the group's leader in order to access the group's join " + "requests.") @authorised_p(("system:group:view-group", "system:group:edit-group"), @@ -617,3 +673,33 @@ def group_leaders(conn: db.DbConnection, group_id: UUID) -> Iterable[User]: "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 28f0645..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,9 +19,13 @@ 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, GroupRole, @@ -37,6 +42,7 @@ from .models import (Group, 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__) @@ -408,3 +414,81 @@ def view_group_leaders(group_id: uuid.UUID) -> Response: with (require_oauth.acquire("profile group") as _token, db.connection(current_app.config["AUTH_DB"]) as conn): return jsonify(tuple(group_leaders(conn, group_id))) + + +@groups.route("/<uuid:group_id>/remove-member", methods=["POST"]) +@require_oauth("profile group") +def remove_group_member(group_id: uuid.UUID): + """Remove a user as member of this group.""" + with (require_oauth.acquire("profile group") as _token, + db.connection(current_app.config["AUTH_DB"]) as conn): + group = group_by_id(conn, group_id) + grp_resource = resource_from_group(conn, group) + if not authorised_for_spec( + conn, + _token.user.user_id, + grp_resource.resource_id, + "(OR group:user:remove-group-member system:group:remove-group-member)"): + raise AuthorisationError( + "You do not have appropriate privileges to remove a user from this " + "group.") + + form = request_json() + if not bool(form.get("user_id")): + response = jsonify({ + "error": "MissingUserId", + "error-description": ( + "Expected 'user_id' value/parameter was not provided.") + }) + response.status_code = 400 + return response + + try: + user = user_by_id(conn, uuid.UUID(form["user_id"])) + remove_user_from_group(conn, group, user, grp_resource) + success_msg = ( + f"User '{user.name} ({user.email})' is no longer a member of " + f"group '{group.group_name}'.\n" + "They could, however, still have access to resources owned by " + "the group.") + return jsonify({ + "description": success_msg, + "message": success_msg + }) + except ValueError as _verr: + response = jsonify({ + "error": "InvalidUserId", + "error-description": "The 'user_id' provided was invalid" + }) + response.status_code = 400 + return response + + +@groups.route("/<uuid:group_id>/delete", methods=["DELETE"]) +@require_oauth("profile group") +def delete_group(group_id: uuid.UUID) -> Response: + """Delete group with the specified `group_id`.""" + with (require_oauth.acquire("profile group") as _token, + db.connection(current_app.config["AUTH_DB"]) as conn): + group = group_by_id(conn, group_id) + grp_resource = resource_from_group(conn, group) + if not authorised_for_spec( + conn, + _token.user.user_id, + grp_resource.resource_id, + "(AND system:group:delete-group)"): + raise AuthorisationError( + "You do not have appropriate privileges to delete this group.") + try: + _delete_group(conn, group.group_id) + return Response(status=204) + except sqlite3.IntegrityError as _s3ie: + response = jsonify({ + "error": "IntegrityError", + "error-description": ( + "A group that has members, linked resources, or both, " + "cannot be deleted from the system. Remove any members and " + "unlink any linked resources, and try again.") + }) + response.status_code = 400 + return response diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py index 64d41e3..2626f3e 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/models.py +++ b/gn_auth/auth/authorisation/resources/inbredset/models.py @@ -1,39 +1,12 @@ """Functions to handle the low-level details regarding populations auth.""" from uuid import UUID, uuid4 +from typing import Sequence, Optional import sqlite3 -from gn_auth.auth.errors import NotFoundError +import gn_auth.auth.db.sqlite3 as db from gn_auth.auth.authentication.users import User -from gn_auth.auth.authorisation.resources.groups.models import Group -from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory -from gn_auth.auth.authorisation.resources.models import ( - create_resource as _create_resource) - -def create_resource( - cursor: sqlite3.Cursor, - resource_name: str, - user: User, - group: Group, - public: bool -) -> Resource: - """Convenience function to create a resource of type 'inbredset-group'.""" - cursor.execute("SELECT * FROM resource_categories " - "WHERE resource_category_key='inbredset-group'") - category = cursor.fetchone() - if category: - return _create_resource(cursor, - resource_name, - ResourceCategory( - resource_category_id=UUID( - category["resource_category_id"]), - resource_category_key="inbredset-group", - resource_category_description=category[ - "resource_category_description"]), - user, - group, - public) - raise NotFoundError("Could not find a 'inbredset-group' resource category.") +from gn_auth.auth.authorisation.resources.base import Resource def assign_inbredset_group_owner_role( @@ -94,3 +67,19 @@ def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positi "VALUES (:resource_id, :data_link_id)", params) return params + + +def resource_data( + cursor: db.DbCursor, + resource_id: UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a inbred-set resource""" + cursor.execute( + ("SELECT * FROM inbredset_group_resources AS igr " + "INNER JOIN linked_inbredset_groups AS lig " + "ON igr.data_link_id=lig.data_link_id " + "WHERE igr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), + (str(resource_id),)) + return cursor.fetchall() diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py index 40dd38d..9603b5b 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/views.py +++ b/gn_auth/auth/authorisation/resources/inbredset/views.py @@ -1,20 +1,54 @@ """Views for InbredSet resources.""" +import uuid + from pymonad.either import Left, Right, Either from flask import jsonify, Response, Blueprint, current_app as app from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.errors import NotFoundError from gn_auth.auth.requests import request_json -from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.authentication.users import User from gn_auth.auth.authentication.oauth2.resource_server import require_oauth -from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group - -from .models import (create_resource, - link_data_to_resource, +from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory +from gn_auth.auth.authorisation.resources.groups.models import (Group, + user_group, + admin_group) +from gn_auth.auth.authorisation.resources.models import ( + create_resource as _create_resource) + +from .models import (link_data_to_resource, assign_inbredset_group_owner_role) popbp = Blueprint("populations", __name__) + +def create_resource( + cursor: db.DbCursor, + resource_name: str, + user: User, + group: Group, + public: bool +) -> Resource: + """Convenience function to create a resource of type 'inbredset-group'.""" + cursor.execute("SELECT * FROM resource_categories " + "WHERE resource_category_key='inbredset-group'") + category = cursor.fetchone() + if category: + return _create_resource(cursor, + resource_name, + ResourceCategory( + resource_category_id=uuid.UUID( + category["resource_category_id"]), + resource_category_key="inbredset-group", + resource_category_description=category[ + "resource_category_description"]), + user, + group, + public) + raise NotFoundError("Could not find a 'inbredset-group' resource category.") + + @popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>", methods=["GET"]) def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: @@ -30,7 +64,7 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: (speciesid, inbredsetid)) return cursor.fetchone() - res = with_db_connection(__res_by_iset_id__) + res = db.with_db_connection(__res_by_iset_id__) if res: resp = jsonify({"status": "success", "resource-id": res["resource_id"]}) else: diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index e538a87..a4df363 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -2,11 +2,10 @@ from dataclasses import asdict from uuid import UUID, uuid4 from functools import reduce, partial -from typing import Dict, Sequence, Optional +from typing import Dict, Union, Sequence, Optional -import sqlite3 +from gn_libs import sqlite3 as db -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.users import User from gn_auth.auth.db.sqlite3 import with_db_connection @@ -15,10 +14,12 @@ from gn_auth.auth.authorisation.privileges import Privilege from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.errors import NotFoundError, AuthorisationError -from .checks import authorised_for +from .system.models import system_resource +from .checks import authorised_for, authorised_for_spec from .base import Resource, ResourceCategory, resource_from_dbrow -from .common import assign_resource_owner_role +from .common import assign_resource_owner_role, grant_access_to_sysadmins from .groups.models import Group, is_group_leader +from .inbredset.models import resource_data as inbredset_resource_data from .mrna import ( resource_data as mrna_resource_data, attach_resources_data as mrna_attach_resources_data, @@ -40,7 +41,7 @@ from .phenotypes.models import ( error_description="Insufficient privileges to create a resource", oauth2_scope="profile resource") def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] - cursor: sqlite3.Cursor, + conn: Union[db.DbConnection, db.DbCursor], resource_name: str, resource_category: ResourceCategory, user: User, @@ -48,29 +49,54 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a public: bool ) -> Resource: """Create a resource item.""" - resource = Resource(uuid4(), resource_name, resource_category, public) - cursor.execute( - "INSERT INTO resources VALUES (?, ?, ?, ?)", - (str(resource.resource_id), - resource_name, - str(resource.resource_category.resource_category_id), - 1 if resource.public else 0)) - # TODO: @fredmanglis,@rookie101 - # 1. Move the actions below into a (the?) hooks system - # 2. Do more checks: A resource can have varying hooks depending on type - # e.g. if mRNA, pheno or geno resource, assign: - # - "resource-owner" - # if inbredset-group, assign: - # - "resource-owner", - # - "inbredset-group-owner" etc. - # if resource is of type "group", assign: - # - group-leader - cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " - "VALUES (?, ?)", - (str(group.group_id), str(resource.resource_id))) - assign_resource_owner_role(cursor, resource.resource_id, user.user_id) - - return resource + def __create_resource__(cursor: db.DbCursor) -> Resource: + resource = Resource(uuid4(), resource_name, resource_category, public) + cursor.execute( + "INSERT INTO resources VALUES (?, ?, ?, ?)", + (str(resource.resource_id), + resource_name, + str(resource.resource_category.resource_category_id), + 1 if resource.public else 0)) + # TODO: @fredmanglis,@rookie101 + # 1. Move the actions below into a (the?) hooks system + # 2. Do more checks: A resource can have varying hooks depending on type + # e.g. if mRNA, pheno or geno resource, assign: + # - "resource-owner" + # if inbredset-group, assign: + # - "resource-owner", + # - "inbredset-group-owner" etc. + # if resource is of type "group", assign: + # - group-leader + cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " + "VALUES (?, ?)", + (str(group.group_id), str(resource.resource_id))) + assign_resource_owner_role(cursor, resource.resource_id, user.user_id) + grant_access_to_sysadmins( + cursor, resource.resource_id, system_resource(conn).resource_id) + + return resource + + if hasattr(conn, "cursor"): # This is a connection: get its cursor. + with db.cursor(conn) as cursor: + return __create_resource__(cursor) + else: + return __create_resource__(conn) + + +def delete_resource(conn: db.DbConnection, resource_id: UUID): + """Delete a resource.""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM user_roles WHERE resource_id=?", + (str(resource_id),)) + cursor.execute("DELETE FROM resource_roles WHERE resource_id=?", + (str(resource_id),)) + cursor.execute("DELETE FROM group_resources WHERE resource_id=?", + (str(resource_id),)) + cursor.execute("DELETE FROM resource_ownership WHERE resource_id=?", + (str(resource_id),)) + cursor.execute("DELETE FROM resources WHERE resource_id=?", + (str(resource_id),)) + def resource_category_by_id( conn: db.DbConnection, category_id: UUID) -> ResourceCategory: @@ -159,7 +185,8 @@ def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) "genotype-metadata": lambda *args: tuple(), "mrna-metadata": lambda *args: tuple(), "system": lambda *args: tuple(), - "group": lambda *args: tuple() + "group": lambda *args: tuple(), + "inbredset-group": inbredset_resource_data, } with db.cursor(conn) as cursor: return tuple( @@ -187,9 +214,11 @@ def attach_resource_data(cursor: db.DbCursor, resource: Resource) -> Resource: def resource_by_id( conn: db.DbConnection, user: User, resource_id: UUID) -> Resource: """Retrieve a resource by its ID.""" - if not authorised_for( - conn, user, ("group:resource:view-resource",), - (resource_id,))[resource_id]: + if not authorised_for_spec( + conn, + user.user_id, + resource_id, + "(OR group:resource:view-resource system:resource:view)"): raise AuthorisationError( "You are not authorised to access resource with id " f"'{resource_id}'.") diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py index 303b0ac..25089fa 100644 --- a/gn_auth/auth/authorisation/resources/system/models.py +++ b/gn_auth/auth/authorisation/resources/system/models.py @@ -1,9 +1,10 @@ """Base functions and utilities for system resources.""" from uuid import UUID from functools import reduce -from typing import Sequence +from typing import Union, Sequence + +from gn_libs import sqlite3 as db -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import User @@ -52,9 +53,9 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]: return tuple() -def system_resource(conn: db.DbConnection) -> Resource: +def system_resource(conn: Union[db.DbConnection, db.DbCursor]) -> Resource: """Retrieve the system resource.""" - with db.cursor(conn) as cursor: + def __fetch_sys_resource__(cursor: db.DbCursor) -> Resource: cursor.execute( "SELECT resource_categories.*, resources.resource_id, " "resources.resource_name, resources.public " @@ -65,4 +66,10 @@ def system_resource(conn: db.DbConnection) -> Resource: if row: return resource_from_dbrow(row) - raise NotFoundError("Could not find a system resource!") + raise NotFoundError("Could not find a system resource!") + + if hasattr(conn, "cursor"): # is connection + with db.cursor(conn) as cursor: + return __fetch_sys_resource__(cursor) + else: + return __fetch_sys_resource__(conn) diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 0a68927..a960ca3 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -39,18 +39,18 @@ from gn_auth.auth.authorisation.roles.models import ( from gn_auth.auth.authentication.oauth2.resource_server import require_oauth from gn_auth.auth.authentication.users import User, user_by_id, user_by_email -from .checks import authorised_for from .inbredset.views import popbp from .genotypes.views import genobp from .phenotypes.views import phenobp from .errors import MissingGroupError from .groups.models import Group, user_group +from .checks import authorised_for, authorised_for_spec from .models import ( Resource, resource_data, resource_by_id, public_resources, resource_categories, assign_resource_user, link_data_to_resource, unassign_resource_user, resource_category_by_id, user_roles_on_resources, unlink_data_from_resource, create_resource as _create_resource, - get_resource_id) + get_resource_id, delete_resource as _delete_resource) resources = Blueprint("resources", __name__) resources.register_blueprint(popbp, url_prefix="/") @@ -75,8 +75,7 @@ def create_resource() -> Response: resource_name = form.get("resource_name") resource_category_id = UUID(form.get("resource_category")) db_uri = app.config["AUTH_DB"] - with (db.connection(db_uri) as conn, - db.cursor(conn) as cursor): + with db.connection(db_uri) as conn: try: group = user_group(conn, the_token.user).maybe( False, lambda grp: grp)# type: ignore[misc, arg-type] @@ -84,7 +83,7 @@ def create_resource() -> Response: raise MissingGroupError(# Not all resources require an owner group "User with no group cannot create a resource.") resource = _create_resource( - cursor, + conn, resource_name, resource_category_by_id(conn, resource_category_id), the_token.user, @@ -100,7 +99,9 @@ def create_resource() -> Response: f"{type(sql3ie)=}: {sql3ie=}") raise + @resources.route("/view/<uuid:resource_id>") +@resources.route("/<uuid:resource_id>/view") @require_oauth("profile group resource") def view_resource(resource_id: UUID) -> Response: """View a particular resource's details.""" @@ -673,3 +674,49 @@ def user_resource_roles(resource_id: UUID, user_id: UUID): return jsonify([asdict(role) for role in _user_resource_roles(conn, _token.user, _resource)]) + + +@resources.route("/delete", methods=["POST"]) +@require_oauth("profile group resource") +def delete_resource(): + """Delete the specified resource, if possible.""" + with (require_oauth.acquire("profile group resource") as the_token, + db.connection(app.config["AUTH_DB"]) as conn): + form = request_json() + try: + resource_id = UUID(form.get("resource_id")) + if not authorised_for_spec( + conn, + the_token.user.user_id, + resource_id, + "(OR group:resource:delete-resource system:resource:delete)"): + raise AuthorisationError("You do not have the appropriate " + "privileges to delete this resource.") + + data = resource_data( + conn, + resource_by_id(conn, the_token.user, resource_id), + 0, + 10) + if bool(data): + return jsonify({ + "error": "NonEmptyResouce", + "error-description": "Cannot delete a resource with linked data" + }), 400 + + _delete_resource(conn, resource_id) + return jsonify({ + "description": f"Successfully deleted resource with ID '{resource_id}'." + }) + except ValueError as _verr: + app.logger.debug("Error!", exc_info=True) + return jsonify({ + "error": "ValueError", + "error-description": "An invalid identifier was provided" + }), 400 + except TypeError as _terr: + app.logger.debug("Error!", exc_info=True) + return jsonify({ + "error": "TypeError", + "error-description": "An invalid identifier was provided" + }), 400 diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py index 36f3c09..3d68932 100644 --- a/gn_auth/auth/authorisation/users/admin/models.py +++ b/gn_auth/auth/authorisation/users/admin/models.py @@ -1,23 +1,55 @@ """Major function for handling admin users.""" +import warnings + from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles -def make_sys_admin(cursor: db.DbCursor, user: User) -> User: - """Make a given user into an system admin.""" + +def sysadmin_role(conn: db.DbConnection) -> Role: + """Fetch the `system-administrator` role details.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT roles.*, privileges.* " + "FROM roles INNER JOIN role_privileges " + "ON roles.role_id=role_privileges.role_id " + "INNER JOIN privileges " + "ON role_privileges.privilege_id=privileges.privilege_id " + "WHERE role_name='system-administrator'") + results = db_rows_to_roles(cursor.fetchall()) + + assert len(results) == 1, ( + "There should only ever be one 'system-administrator' role.") + return results[0] + + +def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User: + """Grant `system-administrator` role to `user`.""" cursor.execute( "SELECT * FROM roles WHERE role_name='system-administrator'") admin_role = cursor.fetchone() - cursor.execute( - "SELECT * FROM resources AS r " - "INNER JOIN resource_categories AS rc " - "ON r.resource_category_id=rc.resource_category_id " - "WHERE resource_category_key='system'") - the_system = cursor.fetchone() - cursor.execute( + cursor.execute("SELECT resources.resource_id FROM resources") + cursor.executemany( "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)", - { + tuple({ "user_id": str(user.user_id), "role_id": admin_role["role_id"], - "resource_id": the_system["resource_id"] - }) + "resource_id": resource_id + } for resource_id in cursor.fetchall())) return user + + +def make_sys_admin(cursor: db.DbCursor, user: User) -> User: + """Make a given user into an system admin.""" + warnings.warn( + DeprecationWarning( + f"The function `{__name__}.make_sys_admin` will be removed soon"), + stacklevel=1) + return grant_sysadmin_role(cursor, user) + + +def revoke_sysadmin_role(conn: db.DbConnection, user: User): + """Revoke `system-administrator` role from `user`.""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?", + (str(user.user_id), str(sysadmin_role(conn).role_id))) diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index 63443ef..30242c2 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -72,8 +72,8 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict: def parse_collection(coll: dict) -> dict: """Parse the collection as persisted in redis to a usable python object.""" - created = coll.get("created", coll.get("created_timestamp")) - changed = coll.get("changed", coll.get("changed_timestamp")) + created = coll.get("created", coll.get("created_timestamp", "")) + changed = coll.get("changed", coll.get("changed_timestamp", "")) return { "id": UUID(coll["id"]), "name": coll["name"], diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 8b897f2..12a8c97 100644 --- a/gn_auth/auth/authorisation/users/masquerade/views.py +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -1,14 +1,14 @@ """Endpoints for user masquerade""" from dataclasses import asdict from uuid import UUID -from functools import partial -from flask import request, jsonify, Response, Blueprint +from flask import request, jsonify, Response, Blueprint, current_app from gn_auth.auth.errors import InvalidData +from gn_auth.auth.authorisation.resources.groups.models import user_group +from ....db import sqlite3 as db from ...checks import require_json -from ....db.sqlite3 import with_db_connection from ....authentication.users import user_by_id from ....authentication.oauth2.resource_server import require_oauth @@ -21,13 +21,13 @@ masq = Blueprint("masquerade", __name__) @require_json def masquerade() -> Response: """Masquerade as a particular user.""" - with require_oauth.acquire("profile user masquerade") as token: + with (require_oauth.acquire("profile user masquerade") as token, + db.connection(current_app.config["AUTH_DB"]) as conn): masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index] if masqueradee_id == token.user.user_id: raise InvalidData("You are not allowed to masquerade as yourself.") - masq_user = with_db_connection(partial( - user_by_id, user_id=masqueradee_id)) + masq_user = user_by_id(conn, user_id=masqueradee_id) def __masq__(conn): new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user) @@ -39,6 +39,8 @@ def masquerade() -> Response: }, "masquerade_as": { "user": asdict(masq_user), - "token": with_db_connection(__masq__) + "token": __masq__(conn), + **(user_group(conn, masq_user).maybe(# type: ignore[misc] + {}, lambda grp: {"group": grp})) } }) diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 91e459d..c248ac3 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -4,9 +4,9 @@ import sqlite3 import secrets import traceback from dataclasses import asdict -from typing import Any, Sequence from urllib.parse import urljoin from functools import reduce, partial +from typing import Any, Union, Sequence from datetime import datetime, timedelta from email.headerregistry import Address from email_validator import validate_email, EmailNotValidError @@ -46,7 +46,8 @@ from gn_auth.auth.errors import ( UserRegistrationError) -from gn_auth.auth.authentication.users import valid_login, user_by_email +from gn_auth.auth.authentication.users import ( + valid_login, user_by_email, user_by_id) from gn_auth.auth.authentication.oauth2.resource_server import require_oauth from gn_auth.auth.authentication.users import User, save_user, set_user_password from gn_auth.auth.authentication.oauth2.models.oauth2token import ( @@ -75,9 +76,25 @@ def user_details() -> Response: False, lambda grp: grp)# type: ignore[arg-type] return jsonify({ **user_dets, - "group": asdict(the_group) if the_group else False + **({"group": asdict(the_group)} if the_group else {}) }) +@users.route("/<user_id>", methods=["GET"]) +def get_user(user_id: str) -> Union[Response, tuple[Response, int]]: + """Fetch user details by user_id.""" + try: + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_id(conn, uuid.UUID(user_id)) + return jsonify({ + "user_id": str(user.user_id), + "email": user.email, + "name": user.name + }) + except ValueError: + return jsonify({"error": "Invalid user ID format"}), 400 + except NotFoundError: + return jsonify({"error": "User not found"}), 404 + @users.route("/roles", methods=["GET"]) @require_oauth("role") def user_roles() -> Response: diff --git a/gn_auth/errors/common.py b/gn_auth/errors/common.py index 7ef18cf..8dc0373 100644 --- a/gn_auth/errors/common.py +++ b/gn_auth/errors/common.py @@ -1,8 +1,9 @@ """Common utilities.""" import logging import traceback +from typing import Callable -from flask import request, jsonify, render_template +from flask import request, Response, make_response, render_template logger = logging.getLogger(__name__) @@ -14,28 +15,44 @@ def add_trace(exc: Exception, errobj: dict) -> dict: "error-trace": "".join(traceback.format_exception(exc)) } +def __status_code__(exc: Exception): + """Fetch the error code for exceptions that have them.""" + error_code_attributes = ( + "code", "error_code", "errorcode", "status_code", "status_code") + for attr in error_code_attributes: + if hasattr(exc, attr): + return getattr(exc, attr) -def build_handler(description: str): + return 500 + + +def build_handler(description: str) -> Callable[[Exception], Response]: """Generic utility to build error handlers.""" - def __handler__(exc: Exception): + def __handler__(exc: Exception) -> Response: + """Handle the exception as appropriate for requests of different mimetypes.""" error = (exc.name if hasattr(exc, "name") else exc.__class__.__name__) - status_code = exc.code if hasattr(exc, "code") else 500 + status_code = __status_code__(exc) content_type = request.content_type if bool(content_type) and content_type.lower() == "application/json": - return ( - jsonify(add_trace(exc, - { - "requested-uri": request.url, - "error": error, - "error_description": description - })), - status_code) - - return (render_template(f"http-error-{str(status_code)[0:-2]}xx.html", - error=exc, - page=request.url, - description=description, - trace=traceback.format_exception(exc)), - status_code) + return make_response(( + add_trace( + exc, + { + "requested-uri": request.url, + "error": error, + "error_description": description + }), + status_code, + {"Content-Type": "application/json"})) + + return make_response(( + render_template( + f"http-error-{str(status_code)[0:-2]}xx.html", + error=exc, + page=request.url, + description=description, + trace=traceback.format_exception(exc)), + status_code, + {"Content-Type": "text/html"})) return __handler__ diff --git a/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py b/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py new file mode 100644 index 0000000..b956bef --- /dev/null +++ b/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py @@ -0,0 +1,36 @@ +""" +Add 'system:user:edit' privilege to 'system-admin' role. +""" +import contextlib + +from yoyo import step + +__depends__ = {'20250722_01_7Gro7-create-new-system-user-edit-privilege'} + + +def system_administrator_role_id(cursor): + """Fetch ID for role 'system-administrator'.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + return cursor.fetchone()[0] + + +def add_system_user_edit_privilege(conn): + """Add the 'system:user:edit' to the 'system-administrator' role.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "INSERT INTO role_privileges(role_id, privilege_id) " + "VALUES(?, ?)", + (system_administrator_role_id(cursor), 'system:user:edit')) + + +def remove_system_user_edit_privilege(conn): + """Remove the 'system:user:edit' from the 'system-administrator' role.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?", + (system_administrator_role_id(cursor), 'system:user:edit')) + +steps = [ + step(add_system_user_edit_privilege, remove_system_user_edit_privilege) +] diff --git a/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py b/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py new file mode 100644 index 0000000..be0d022 --- /dev/null +++ b/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py @@ -0,0 +1,31 @@ +""" +Create initial system-wide resources access privileges +""" + +from yoyo import step + +__depends__ = {'20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role'} + +steps = [ + step( + """ + INSERT INTO privileges(privilege_id, privilege_description) + VALUES + ("system:resource:view", + "View the wrapper resource object (not attached data). This is mostly for administration purposes."), + ("system:resource:edit", + "Edit/update the wrapper resource object (not attached data). This is mostly for administration purposes."), + ("system:resource:delete", + "Delete the wrapper resource object (not attached data). This is mostly for administration purposes."), + ("system:resource:reassign-group", + "Reassign the resource, and its data, to a different user group."), + ("system:resource:assign-owner", + "Assign ownership of any resource to any user.") + """, + """ + DELETE FROM privileges WHERE privilege_id IN + ("system:resource:view", "system:resource:edit", + "system:resource:delete", "system:resource:reassign-group", + "system:resource:assign-owner") + """) +] diff --git a/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py b/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py new file mode 100644 index 0000000..e79ab1c --- /dev/null +++ b/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py @@ -0,0 +1,53 @@ +""" +Assign initial system-wide resources-access privileges to sys-admins. +""" +import contextlib + +from yoyo import step + +def system_administrator_role_id(cursor): + """Fetch ID for role 'system-administrator'.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + return cursor.fetchone()[0] + + +def assign_system_wide_resource_access_to_sysadmin(conn): + """ + Assign initial system-wide resources-access privileges to + `system-administrator` role. + """ + with contextlib.closing(conn.cursor()) as cursor: + sysadmin_role_id = system_administrator_role_id(cursor) + cursor.executemany( + "INSERT INTO role_privileges(role_id, privilege_id) " + "VALUES(?, ?)", + ((sysadmin_role_id, "system:resource:view"), + (sysadmin_role_id, "system:resource:edit"), + (sysadmin_role_id, "system:resource:delete"), + (sysadmin_role_id, "system:resource:reassign-group"), + (sysadmin_role_id, "system:resource:assign-owner"))) + + +def revoke_system_wide_resource_access_from_sysadmin(conn): + """ + Revoke initial system-wide resources-access privileges from + `system-administrator` role. + """ + with contextlib.closing(conn.cursor()) as cursor: + sysadmin_role_id = system_administrator_role_id(cursor) + cursor.executemany( + "DELETE FROM role_privileges " + "WHERE role_id=? AND privilege_id=?", + ((sysadmin_role_id, "system:resource:view"), + (sysadmin_role_id, "system:resource:edit"), + (sysadmin_role_id, "system:resource:delete"), + (sysadmin_role_id, "system:resource:reassign-group"), + (sysadmin_role_id, "system:resource:assign-owner"))) + +__depends__ = {'20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges'} + +steps = [ + step(assign_system_wide_resource_access_to_sysadmin, + revoke_system_wide_resource_access_from_sysadmin) +] diff --git a/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py b/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py new file mode 100644 index 0000000..e3bdc8f --- /dev/null +++ b/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py @@ -0,0 +1,75 @@ +""" +Grant role to ALL resources to sys-admin users. +""" +import itertools +import contextlib + +from yoyo import step + +__depends__ = {'20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins'} + + +def system_administrator_role_id(cursor): + """Fetch ID for role 'system-administrator'.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + return cursor.fetchone()[0] + + +def system_resource_id(cursor): + cursor.execute( + "SELECT resources.resource_id FROM resource_categories " + "INNER JOIN resources ON resource_categories.resource_category_id=resources.resource_category_id " + "WHERE resource_category_key = 'system'") + return cursor.fetchone()[0] + + +def fetch_ids_for_sysadmin_users(cursor): + """Fetch all sysadmin users' IDs.""" + cursor.execute( + "SELECT user_roles.user_id FROM roles INNER JOIN user_roles " + "ON roles.role_id=user_roles.role_id " + "WHERE role_name='system-administrator' AND resource_id=?", + (system_resource_id(cursor),)) + return tuple(row[0] for row in cursor.fetchall()) + + +def fetch_non_system_resources(cursor): + """Fetch IDs for all resources that are not of the 'system' category.""" + cursor.execute( + "SELECT resources.resource_id FROM resource_categories " + "INNER JOIN resources " + "ON resource_categories.resource_category_id=resources.resource_category_id " + "WHERE resource_category_key != 'system'") + return tuple(row[0] for row in cursor.fetchall()) + + +def assign_sysadmin_role_on_non_system_resources(conn): + """Assign sysadmins the sysadmin role on all non-system resources.""" + with contextlib.closing(conn.cursor()) as cursor: + sysadminroleid = system_administrator_role_id(cursor) + cursor.executemany( + "INSERT INTO user_roles(user_id, resource_id, role_id) " + "VALUES (?, ?, ?)", + tuple(item + (sysadminroleid,) + for item in itertools.product( + fetch_ids_for_sysadmin_users(cursor), + fetch_non_system_resources(cursor)))) + + +def revoke_sysadmin_role_on_non_system_resources(conn): + """Revoke sysadmins the sysadmin role on all non-system resources.""" + with contextlib.closing(conn.cursor()) as cursor: + sysadminroleid = system_administrator_role_id(cursor) + cursor.executemany( + "DELETE FROM user_roles " + "WHERE user_id=? AND resource_id=? AND role_id=?", + tuple(item + (sysadminroleid,) + for item in itertools.product( + fetch_ids_for_sysadmin_users(cursor), + fetch_non_system_resources(cursor)))) + +steps = [ + step(assign_sysadmin_role_on_non_system_resources, + revoke_sysadmin_role_on_non_system_resources) +] diff --git a/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py b/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py new file mode 100644 index 0000000..95a6fbb --- /dev/null +++ b/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py @@ -0,0 +1,70 @@ +""" +Add sysadmin privileges for acting on groups: mostly handling user management. +""" +import itertools +import contextlib + +from yoyo import step + +__depends__ = {'20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users'} + + +def system_administrator_role_id(cursor): + """Fetch ID for role 'system-administrator'.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name='system-administrator'") + return cursor.fetchone()[0] + + +def add_group_privileges_to_sysadmin_role(conn): + """Add group-management privileges to sysadmin role.""" + with contextlib.closing(conn.cursor()) as cursor: + sysadminroleid = system_administrator_role_id(cursor) + cursor.executemany( + "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)", + tuple(itertools.product( + (sysadminroleid,), + ('system:group:add-group-member', + 'system:group:remove-group-member', + 'system:group:assign-group-leader', + 'system:group:revoke-group-leader')))) + + +def remove_group_privileges_to_sysadmin_role(conn): + """Remove group-management privileges from sysadmin role.""" + with contextlib.closing(conn.cursor()) as cursor: + sysadminroleid = system_administrator_role_id(cursor) + cursor.executemany( + "DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?", + tuple(itertools.product( + (sysadminroleid,), + ('system:group:add-group-member', + 'system:group:remove-group-member', + 'system:group:assign-group-leader', + 'system:group:revoke-group-leader')))) + + +steps = [ + step( + """ + INSERT INTO privileges(privilege_id, privilege_description) + VALUES + ('system:group:add-group-member', + 'Make an existing user a member of a group.'), + ('system:group:remove-group-member', + 'Remove a member user from a group.'), + ('system:group:assign-group-leader', + 'Assign an existing group member the group-leader role'), + ('system:group:revoke-group-leader', + 'Revoke the group-leader role from a group member with the role.') + """, + """ + DELETE FROM privileges WHERE privilege_id IN + ('system:group:add-group-member', + 'system:group:remove-group-member', + 'system:group:assign-group-leader', + 'system:group:revoke-group-leader') + """), + step(add_group_privileges_to_sysadmin_role, + remove_group_privileges_to_sysadmin_role) +] diff --git a/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py new file mode 100644 index 0000000..63e807a --- /dev/null +++ b/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py @@ -0,0 +1,61 @@ +""" +add role systemwide-data-curator. +""" +import uuid +import contextlib + +from yoyo import step + +__depends__ = {'20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members'} + + +def create_systemwide_data_curator_role(conn): + """Create a new 'systemwide-data-curator' role.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "INSERT INTO roles(role_id, role_name, user_editable) " + "VALUES (?, 'systemwide-data-curator', 0)", + (str(uuid.uuid4()),)) + + +def link_privileges_to_role(conn): + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute("SELECT role_id FROM roles " + "WHERE role_name='systemwide-data-curator'") + role_id = cursor.fetchone()[0] + cursor.executemany("INSERT INTO role_privileges(role_id, privilege_id) " + "VALUES (?, ?)", + tuple((role_id, priv) for priv in + ("system:system-wide:data:edit", + "system:system-wide:data:delete"))) + + +def unlink_privileges_from_role(conn): + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute("SELECT role_id FROM roles " + "WHERE role_name='systemwide-data-curator'") + role_id = cursor.fetchone()[0] + cursor.executemany("DELETE FROM role_privileges " + "WHERE role_id=? AND privilege_id=?", + tuple((role_id, priv) for priv in + ("system:system-wide:data:edit", + "system:system-wide:data:delete"))) + + +steps = [ + step(# Add new privileges + """ + INSERT INTO privileges (privilege_id, privilege_description) + VALUES + ('system:system-wide:data:edit', + 'A user with this privilege can edit any data on the entire system.'), + ('system:system-wide:data:delete', + 'A user with this privilege can delete any data from the system.') + """, + """ + DELETE FROM privileges WHERE privilege_id IN + ('system:system-wide:data:edit', 'system:system-wide:data:delete')"""), + step(create_systemwide_data_curator_role, + "DELETE FROM roles WHERE role_name='systemwide-data-curator'"), + step(link_privileges_to_role, unlink_privileges_from_role) +] diff --git a/setup.py b/setup.py index 77e5eb3..c7339e2 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setup(author="Frederick M. Muriithi", "pymonad", "redis>=3.5.3", "requests>=2.25.1", - "flask-cors>=3.0.9", + "flask-cors", # with the `>=3.0.9` specification, it breaks the build "gn-libs>=0.0.0" ], include_package_data=True, @@ -44,5 +44,5 @@ setup(author="Frederick M. Muriithi", version="0.0.1", tests_require=["pytest", "hypothesis"], cmdclass={ - "run_tests": RunTests # testing + "run_tests": RunTests # type: ignore[dict-item] }) diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py index 292f7dc..04da6df 100644 --- a/tests/unit/auth/test_resources.py +++ b/tests/unit/auth/test_resources.py @@ -50,7 +50,7 @@ def test_create_resource(# pylint: disable=[too-many-arguments, too-many-positio with db.cursor(conn) as cursor: resource = create_resource( - cursor, "test_resource", resource_category, user, _group, False) + conn, "test_resource", resource_category, user, _group, False) assert resource == expected # Cleanup cursor.execute( @@ -82,15 +82,14 @@ def test_create_resource_raises_for_unauthorised_users( tuple(client for client in clients if client.user == user)[0])) conn, _group, _users = fxtr_users_in_group with pytest.raises(AuthorisationError): - with db.cursor(conn) as cursor: - assert create_resource( - cursor, - "test_resource", - resource_category, - user, - _group, - False - ) == expected + assert create_resource( + conn, + "test_resource", + resource_category, + user, + _group, + False + ) == expected def sort_key_resources(resource): """Sort-key for resources.""" |
