diff options
139 files changed, 2829 insertions, 658 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..190f695 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)) @@ -34,7 +34,7 @@ #~(modify-phases #$phases (add-before 'build 'pylint (lambda _ - (invoke "pylint" "setup.py" "tests" "gn_auth" "scripts"))) + (invoke "pylint" "tests" "gn_auth"))) (add-after 'pylint 'mypy (lambda _ (invoke "mypy" "."))))))) diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 0b11d24..0000000 --- a/.pylintrc +++ /dev/null @@ -1,13 +0,0 @@ -[SIMILARITIES] - -ignore-imports=yes - -[MESSAGES CONTROL] - -disable= - fixme, - duplicate-code, - no-else-return - -load-plugins= - pylint.extensions.no_self_use \ No newline at end of file diff --git a/README.md b/README.md index 963b5c5..f6c5f04 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ The checks we do are ### Linting ```bash -pylint *py tests gn_auth scripts +pylint tests gn_auth ``` ### Type-Checking diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py index aebc63b..d03c9ef 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( @@ -53,33 +61,24 @@ def load_secrets_conf(app: Flask) -> None: app.config.from_pyfile(secretsfile) -def dev_loggers(appl: Flask) -> None: +def dev_loggers(appl: Flask) -> logging.Logger: """Setup the logging handlers.""" stderr_handler = logging.StreamHandler(stream=sys.stderr) appl.logger.addHandler(stderr_handler) + appl.logger.setLevel(appl.config["LOGLEVEL"]) - root_logger = logging.getLogger() - root_logger.addHandler(stderr_handler) - root_logger.setLevel(appl.config["LOGLEVEL"]) + return appl.logger -def gunicorn_loggers(appl: Flask) -> None: +def gunicorn_loggers(appl: Flask) -> logging.Logger: """Use gunicorn logging handlers for the application.""" logger = logging.getLogger("gunicorn.error") appl.logger.handlers = logger.handlers appl.logger.setLevel(logger.level) + return appl.logger -_LOGGABLE_MODULES_ = ( - "gn_auth.errors", - "gn_auth.errors.common", - "gn_auth.errors.authlib", - "gn_auth.errors.http.http_4xx_errors", - "gn_auth.errors.http.http_5xx_errors" -) - - -def setup_logging(appl: Flask) -> None: +def setup_logging(appl: Flask, loggable_modules: tuple[str, ...] = tuple()) -> None: """ Setup the loggers according to the WSGI server used to run the application. """ @@ -88,14 +87,11 @@ def setup_logging(appl: Flask) -> None: # https://peps.python.org/pep-3333/#id4 software, *_version_and_comments = os.environ.get( "SERVER_SOFTWARE", "").split('/') - if bool(software): - gunicorn_loggers(appl) - else: - dev_loggers(appl) - - loglevel = logging.getLevelName(appl.logger.getEffectiveLevel()) - for module_logger in _LOGGABLE_MODULES_: - logging.getLogger(module_logger).setLevel(loglevel) + logger = gunicorn_loggers(appl) if bool(software) else dev_loggers(appl) + for _logger in ( + item for item in logger.manager.loggerDict.values() + if isinstance(item, logging.Logger)): + _logger.addFilter(lambda record: record.name in loggable_modules) def create_app(config: Optional[dict] = None) -> Flask: @@ -104,18 +100,18 @@ def create_app(config: Optional[dict] = None) -> Flask: # ====== Setup configuration ====== app.config.from_object(settings) # Default settings - # Override defaults with startup settings - app.config.update(config or {}) # Override app settings with site-local settings if "GN_AUTH_CONF" in os.environ: app.config.from_envvar("GN_AUTH_CONF") override_settings_with_envvars(app) + # Override defaults with startup settings + app.config.update(config or {}) load_secrets_conf(app) # ====== END: Setup configuration ====== - setup_logging(app) + setup_logging(app, tuple(app.config.get("LOGGABLE_MODULES", []))) check_mandatory_settings(app) setup_oauth2_server(app) diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py index 200b25d..cebb3be 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py @@ -23,7 +23,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint): CLIENT_AUTH_METHODS = ['client_secret_post'] def query_token(self, token_string: str, token_type_hint: str): """Query the token.""" - return _query_token(self, token_string, token_type_hint) + return _query_token(token_string, token_type_hint) # pylint: disable=[no-self-use] def introspect_token(self, token: OAuth2Token) -> dict: diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py index 80922f1..0979694 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py @@ -15,7 +15,7 @@ class RevocationEndpoint(_RevocationEndpoint): CLIENT_AUTH_METHODS = ['client_secret_post'] def query_token(self, token_string: str, token_type_hint: str): """Query the token.""" - return _query_token(self, token_string, token_type_hint) + return _query_token(token_string, token_type_hint) def revoke_token(self, token: OAuth2Token, request): """Revoke token `token`.""" diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py index 08b2a3b..490c141 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py @@ -1,5 +1,5 @@ """endpoint utilities""" -from typing import Any, Optional +from typing import Optional from flask import current_app from pymonad.maybe import Nothing @@ -8,9 +8,7 @@ from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.oauth2.models.oauth2token import ( OAuth2Token, token_by_access_token, token_by_refresh_token) -def query_token(# pylint: disable=[unused-argument] - endpoint_object: Any, token_str: str, token_type_hint) -> Optional[ - OAuth2Token]: +def query_token(token_str: str, token_type_hint) -> Optional[OAuth2Token]: """Retrieve the token from the database.""" def __identity__(val): """Identity function.""" diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py index c802091..63f979c 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -1,9 +1,8 @@ """JWT as Authorisation Grant""" import uuid import time - +import logging from typing import Optional -from flask import current_app as app from authlib.jose import jwt from authlib.common.encoding import to_native @@ -12,12 +11,17 @@ from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant from authlib.oauth2.rfc7523.token import ( JWTBearerTokenGenerator as _JWTBearerTokenGenerator) -from gn_auth.debug import __pk__ +from gn_libs.debug import make_peeker + from gn_auth.auth.db.sqlite3 import with_db_connection from gn_auth.auth.authentication.users import User, user_by_id from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client +logger = logging.getLogger(__name__) +__pk__ = make_peeker(logger) + + class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): """ A JSON Web Token formatted bearer token generator for jwt-bearer grant type. @@ -149,6 +153,6 @@ class JWTBearerGrant(_JWTBearerGrant): include_refresh_token=self.request.client.check_grant_type( "refresh_token") ) - app.logger.debug('Issue token %r to %r', token, self.request.client) + logger.debug('Issue token %r to %r', token, self.request.client) self.save_token(token) return 200, token, self.TOKEN_RESPONSE_HEADER diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py index 1639e2e..dfe5d79 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,19 +1,20 @@ """OAuth2 Client model.""" import json +import logging 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 import requests -from flask import current_app as app from requests.exceptions import JSONDecodeError from authlib.jose import KeySet, JsonWebKey from authlib.oauth2.rfc6749 import ClientMixin from pymonad.maybe import Just, Maybe, Nothing +from gn_libs.debug import make_peeker -from gn_auth.debug import __pk__ from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import (User, @@ -22,6 +23,10 @@ from gn_auth.auth.authentication.users import (User, same_password) +logger = logging.getLogger(__name__) +__pk__ = make_peeker(logger) + + @dataclass(frozen=True) class OAuth2Client(ClientMixin): """ @@ -65,7 +70,7 @@ class OAuth2Client(ClientMixin): jwksuri = self.client_metadata.get("public-jwks-uri") __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri) if not bool(jwksuri): - app.logger.debug("No Public JWKs URI set for client!") + logger.debug("No Public JWKs URI set for client!") return KeySet([]) try: ## IMPORTANT: This can cause a deadlock if the client is working in @@ -77,13 +82,12 @@ class OAuth2Client(ClientMixin): timeout=300, allow_redirects=True).json()["jwks"]]) except requests.ConnectionError as _connerr: - app.logger.debug( + logger.debug( "Could not connect to provided URI: %s", jwksuri, exc_info=True) except JSONDecodeError as _jsonerr: - app.logger.debug( - "Could not convert response to JSON", exc_info=True) + logger.debug("Could not convert response to JSON", exc_info=True) except Exception as _exc:# pylint: disable=[broad-except] - app.logger.debug( + logger.debug( "Error retrieving the JWKs for the client.", exc_info=True) return KeySet([]) @@ -135,7 +139,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/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 8ecf923..edab02c 100644 --- a/gn_auth/auth/authentication/oauth2/resource_server.py +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -1,4 +1,5 @@ """Protect the resources endpoints""" +import logging from datetime import datetime, timezone, timedelta from flask import current_app as app @@ -16,6 +17,9 @@ from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import ( from gn_auth.auth.authentication.oauth2.models.oauth2token import ( token_by_access_token) +logger = logging.getLogger(__name__) + + class BearerTokenValidator(_BearerTokenValidator): """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" def authenticate_token(self, token_string: str): @@ -66,7 +70,7 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator): claims.validate() return claims except JoseError as error: - app.logger.debug('Authenticate token failed. %r', error) + logger.debug('Authenticate token failed. %r', error) return None diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py index 0e2c4eb..8cc123f 100644 --- a/gn_auth/auth/authentication/oauth2/views.py +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -1,5 +1,6 @@ """Endpoints for the oauth2 server""" import uuid +import logging import traceback from urllib.parse import urlparse @@ -27,8 +28,10 @@ from .endpoints.revocation import RevocationEndpoint from .endpoints.introspection import IntrospectionEndpoint +logger = logging.getLogger(__name__) auth = Blueprint("auth", __name__) + @auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"]) def delete_client(client_id: uuid.UUID): """Delete an OAuth2 client.""" @@ -44,7 +47,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 +91,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") + logger.debug(traceback.format_exc()) + 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") + logger.debug(traceback.format_exc()) + 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/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py index ddb0add..d44cbfb 100644 --- a/gn_auth/auth/authorisation/data/genotypes.py +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -1,7 +1,9 @@ """Handle linking of Genotype data to the Auth(entic|oris)ation system.""" import uuid -from dataclasses import asdict +import logging from typing import Iterable +from functools import reduce +from dataclasses import asdict from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor @@ -11,6 +13,9 @@ from gn_auth.auth.db import sqlite3 as authdb from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.resources.groups.models import Group + +logger = logging.getLogger(__name__) + def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]: """Retrieve genotype data that is linked to user groups.""" with authdb.cursor(conn) as cursor: @@ -95,3 +100,37 @@ def link_genotype_data( "group": asdict(group), "datasets": datasets } + + +def resources_by_datasets_and_traits( + authconn: authdb.DbConnection, + dsets_traits: tuple[tuple[str, str], ...] +) -> tuple[dict, ...]: + """Fetch resources by their attached datasets and traits.""" + traits_by_datasets: dict[str, tuple[str, ...]] = reduce( + lambda acc, curr: { + **acc, + curr[0]: acc.get(curr[0], tuple()) + (curr[1],) + }, + dsets_traits, + {}) + paramstr = ", ".join(["?"] * len(dsets_traits)) + query = ( + "SELECT r.*, rc.*, lgd.dataset_name FROM linked_genotype_data AS lgd " + "INNER JOIN genotype_resources AS mr ON lgd.data_link_id=mr.data_link_id " + "INNER JOIN resources AS r ON mr.resource_id=r.resource_id " + "INNER JOIN resource_categories AS rc " + "ON r.resource_category_id=rc.resource_category_id " + "WHERE lgd.dataset_name " + f"IN ({paramstr})") + logger.debug("QUERY: %s", query) + with authdb.cursor(authconn) as cursor: + params = tuple(traits_by_datasets.keys()) + logger.debug("QUERY PARAMS: %s", params) + cursor.execute(query, tuple(traits_by_datasets.keys())) + return tuple({ + "resource_id": row["resource_id"], + "resource_data": tuple( + f'{row["dataset_name"]}::{trait_id}' + for trait_id in traits_by_datasets[row["dataset_name"]]) + } for row in cursor.fetchall()) diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py index 0cc644e..fcf6ea3 100644 --- a/gn_auth/auth/authorisation/data/mrna.py +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -1,7 +1,9 @@ """Handle linking of mRNA Assay data to the Auth(entic|oris)ation system.""" import uuid -from dataclasses import asdict +import logging from typing import Iterable +from functools import reduce +from dataclasses import asdict from gn_libs import mysqldb as gn3db from MySQLdb.cursors import DictCursor @@ -11,6 +13,10 @@ from gn_auth.auth.db import sqlite3 as authdb from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.resources.groups.models import Group + +logger = logging.getLogger(__name__) + + def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]: """Retrieve mRNA Assay data that is linked to user groups.""" with authdb.cursor(conn) as cursor: @@ -100,3 +106,35 @@ def link_mrna_data( "group": asdict(group), "datasets": datasets } + + +def resources_by_datasets_and_traits( + authconn: authdb.DbConnection, + dsets_traits: tuple[tuple[str, str], ...] +) -> tuple[dict, ...]: + """Fetch resources by their attached datasets and traits.""" + traits_by_datasets: dict[str, tuple[str, ...]] = reduce( + lambda acc, curr: { + **acc, + curr[0]: acc.get(curr[0], tuple()) + (curr[1],) + }, + dsets_traits, + {}) + paramstr = ", ".join(["?"] * len(dsets_traits)) + query = ( + "SELECT r.*, rc.*, lmd.dataset_name FROM linked_mrna_data AS lmd " + "INNER JOIN mrna_resources AS mr ON lmd.data_link_id=mr.data_link_id " + "INNER JOIN resources AS r ON mr.resource_id=r.resource_id " + "INNER JOIN resource_categories AS rc " + "ON r.resource_category_id=rc.resource_category_id " + "WHERE lmd.dataset_name " + f"IN ({paramstr})") + logger.debug("QUERY: %s", query) + with authdb.cursor(authconn) as cursor: + cursor.execute(query, tuple(traits_by_datasets.keys())) + return tuple({ + "resource_id": row["resource_id"], + "resource_data": tuple( + f'{row["dataset_name"]}::{trait_id}' + for trait_id in traits_by_datasets[row["dataset_name"]]) + } for row in cursor.fetchall()) diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py index 3e45af3..92cbe89 100644 --- a/gn_auth/auth/authorisation/data/phenotypes.py +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -1,20 +1,30 @@ """Handle linking of Phenotype data to the Auth(entic|oris)ation system.""" import uuid +import logging +from functools import reduce 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 +logger = logging.getLogger(__name__) +phenosbp = Blueprint("phenotypes", __name__) + + def linked_phenotype_data( authconn: authdb.DbConnection, gn3conn: gn3db.Connection, species: str = "") -> Iterable[dict[str, Any]]: @@ -51,41 +61,6 @@ def linked_phenotype_data( gn3cursor.execute(query, params) return (item for item in gn3cursor.fetchall()) -@authorised_p(("system:data:link-to-group",), - error_description=( - "You do not have sufficient privileges to link data to (a) " - "group(s)."), - oauth2_scope="profile group resource") -def ungrouped_phenotype_data( - authconn: authdb.DbConnection, gn3conn: gn3db.Connection): - """Retrieve phenotype data that is not linked to any user group.""" - with gn3conn.cursor() as cursor: - params = tuple( - (row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"], - row["PublishXRefId"]) - for row in linked_phenotype_data(authconn, gn3conn)) - paramstr = ", ".join(["(?, ?, ?, ?)"] * len(params)) - query = ( - "SELECT spc.SpeciesId, spc.SpeciesName, iset.InbredSetId, " - "iset.InbredSetName, pf.Id AS PublishFreezeId, " - "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, " - "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId " - "FROM " - "Species AS spc " - "INNER JOIN InbredSet AS iset " - "ON spc.SpeciesId=iset.SpeciesId " - "INNER JOIN PublishFreeze AS pf " - "ON iset.InbredSetId=pf.InbredSetId " - "INNER JOIN PublishXRef AS pxr " - "ON pf.InbredSetId=pxr.InbredSetId") - if len(params) > 0: - query = query + ( - f" WHERE (iset.InbredSetId, pf.Id, pxr.Id) NOT IN ({paramstr})") - - cursor.execute(query, params) - return tuple(dict(row) for row in cursor.fetchall()) - - return tuple() def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]: """An internal utility function. Don't use outside of this module.""" @@ -155,3 +130,156 @@ 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 + }) + + +def __organise_resources_data__(acc, curr) -> dict: + logger.debug("ORGANISING... %s", dict(curr)) + resource_row = acc.get(curr["resource_id"], { + "resource_id": curr["resource_id"], + "resource_data": tuple(), + }) + return { + **acc, + curr["resource_id"]: { + **resource_row, + "resource_data": resource_row["resource_data"] + ( + f'{curr["dataset_name"]}::{curr["trait_id"]}',) + } + } + + +def resources_by_datasets_and_traits( + authconn: authdb.DbConnection, + dsets_traits: tuple[tuple[str, str], ...] +) -> tuple[dict, ...]: + """Fetch resources by their attached datasets and traits.""" + paramstr = ", ".join(["(?, ?)"] * len(dsets_traits)) + query = ( + "SELECT r.*, rc.*, lpd.dataset_name, lpd.PublishXRefId AS trait_id " + "FROM linked_phenotype_data AS lpd " + "INNER JOIN phenotype_resources AS pr " + "ON lpd.data_link_id=pr.data_link_id " + "INNER JOIN resources AS r ON pr.resource_id=r.resource_id " + "INNER JOIN resource_categories AS rc " + "ON r.resource_category_id=rc.resource_category_id " + "WHERE (lpd.dataset_name, lpd.PublishXRefId) " + f"IN ({paramstr})") + with authdb.cursor(authconn) as cursor: + cursor.execute( + query, tuple(item for row in dsets_traits for item in row)) + return tuple(reduce( + __organise_resources_data__, + cursor.fetchall(), + {}).values()) diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py index 9123949..1184d63 100644 --- a/gn_auth/auth/authorisation/data/views.py +++ b/gn_auth/auth/authorisation/data/views.py @@ -2,9 +2,9 @@ import sys import uuid import json -from dataclasses import asdict +import logging from typing import Any -from functools import partial +from functools import reduce, partial import redis from MySQLdb.cursors import DictCursor @@ -13,6 +13,7 @@ from flask import request, jsonify, Response, Blueprint, current_app as app from gn_libs import mysqldb as gn3db +from gn_libs import sqlite3 as db from gn_auth import jobs from gn_auth.commands import run_async_cmd @@ -21,53 +22,32 @@ from gn_auth.auth.requests import request_json from gn_auth.auth.errors import InvalidData, NotFoundError from gn_auth.auth.authorisation.resources.groups.models import group_by_id -from ...db import sqlite3 as db -from ...db.sqlite3 import with_db_connection +from gn_auth.auth.db.sqlite3 import with_db_connection # Replace this with gn_libs alternative from ..checks import require_json -from ..users.models import user_resource_roles - -from ..resources.checks import authorised_for -from ..resources.models import ( - user_resources, public_resources, attach_resources_data) - 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, + resources_by_datasets_and_traits as mrna_resources_by_datasets_and_traits) +from .genotypes import ( + link_genotype_data, + ungrouped_genotype_data, + resources_by_datasets_and_traits as geno_resources_by_datasets_and_traits) +from .phenotypes import ( + phenosbp, + link_phenotype_data, + pheno_traits_from_db, + resources_by_datasets_and_traits as pheno_resources_by_datasets_and_traits) + + +logger = logging.getLogger(__name__) data = Blueprint("data", __name__) +data.register_blueprint(phenosbp, url_prefix="/phenotypes") -def build_trait_name(trait_fullname): - """ - Initialises the trait's name, and other values from the search data provided - - This is a copy of `gn3.db.traits.build_trait_name` function. - """ - def dataset_type(dset_name): - if dset_name.find('Temp') >= 0: - return "Temp" - if dset_name.find('Geno') >= 0: - return "Geno" - if dset_name.find('Publish') >= 0: - return "Publish" - return "ProbeSet" - - name_parts = trait_fullname.split("::") - assert len(name_parts) >= 2, f"Name format error: '{trait_fullname}'" - dataset_name = name_parts[0] - dataset_type = dataset_type(dataset_name) - return { - "db": { - "dataset_name": dataset_name, - "dataset_type": dataset_type}, - "trait_fullname": trait_fullname, - "trait_name": name_parts[1], - "cellid": name_parts[2] if len(name_parts) == 3 else "" - } @data.route("species") def list_species() -> Response: @@ -79,101 +59,144 @@ def list_species() -> Response: @data.route("/authorisation", methods=["POST"]) @require_json -def authorisation() -> Response: +def authorisation() -> Response:# pylint: disable=[too-many-locals] """Retrieve the authorisation level for datasets/traits for the user.""" # Access endpoint with something like: - # curl -X POST http://127.0.0.1:8080/api/oauth2/data/authorisation \ + # curl -X POST http://127.0.0.1:8081/auth/data/authorisation \ # -H "Content-Type: application/json" \ # -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695", # "BXDPublish::10001"]}' + def __organise_traits__(acc, curr): + dset, _trt = curr + key = "ProbeSet" + if dset.endswith("Publish"): + key = "Publish" + elif dset.endswith("Geno"): + key="Geno" + elif dset.endswith("Temp"): + key = "Temp" + else: + key = "ProbeSet" + + return { + **acc, + key: acc.get(key, tuple()) + (curr,) + } + _dset_traits: dict[str, tuple[tuple[str, str], ...]] = reduce( + __organise_traits__, + ( + (dset.strip(), trt.strip()) for dset, trt in + (trtstr.split("::") for trtstr in + request_json().get("traits", []))), + {key: tuple() for key in ("Publish", "ProbeSet", "Geno", "Temp")}) + db_uri = app.config["AUTH_DB"] - privileges = {} user = User(uuid.uuid4(), "anon@ymous.user", "Anonymous User") - with db.connection(db_uri) as auth_conn: - try: - with require_oauth.acquire("profile group resource") as _token: - user = _token.user - resources = attach_resources_data( - auth_conn, user_resources(auth_conn, _token.user)) - resources_roles = user_resource_roles(auth_conn, _token.user) - privileges = { - resource_id: tuple( - privilege.privilege_id - for roles in resources_roles[resource_id] - for privilege in roles.privileges)#("group:resource:view-resource",) - for resource_id, is_authorised - in authorised_for( - auth_conn, _token.user, - ("group:resource:view-resource",), tuple( - resource.resource_id for resource in resources)).items() - if is_authorised - } - except _HTTPException as exc: - err_msg = json.loads(exc.body) - if err_msg["error"] == "missing_authorization": - resources = attach_resources_data( - auth_conn, public_resources(auth_conn)) - else: - raise exc from None - - def __gen_key__(resource, data_item): - if resource.resource_category.resource_category_key.lower() == "phenotype": - return ( - f"{resource.resource_category.resource_category_key.lower()}::" - f"{data_item['dataset_name']}::{data_item['PublishXRefId']}") - return ( - f"{resource.resource_category.resource_category_key.lower()}::" - f"{data_item['dataset_name']}") - - data_to_resource_map = { - __gen_key__(resource, data_item): resource.resource_id - for resource in resources - for data_item in resource.resource_data + with (db.connection(db_uri) as authconn, db.cursor(authconn) as cursor): + _all_resources = { + _rrow["resource_id"]: _rrow + for _rtypes in ( + pheno_resources_by_datasets_and_traits( + authconn, _dset_traits["Publish"]), + geno_resources_by_datasets_and_traits( + authconn, _dset_traits["Geno"]), + mrna_resources_by_datasets_and_traits( + authconn, _dset_traits["ProbeSet"])) + for _rrow in _rtypes } - privileges = { - **{ - resource.resource_id: ("system:resource:public-read",) - for resource in resources if resource.public - }, - **privileges} - - args = request.get_json() - traits_names = args["traits"] # type: ignore[index] - def __translate__(val): + if (len(_all_resources.keys()) == 0 and + len(_dset_traits.get("Temp", tuple())) == 0): + raise NotFoundError( + "No resource(s) found for specified trait(s). Do(es) the " + "trait(s) actually exist?") + + # Handle Temp traits specially - they should be public/anonymous resources + if len(_dset_traits.get("Temp", tuple())) > 0: + # Create a synthetic public resource for Temp traits + # Use a predictable ID to identify synthetic temp resources + temp_resource_id = "gn-auth-temp-traits" + _all_resources[temp_resource_id] = { + "resource_id": temp_resource_id, + "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"]) + } + + _resource_ids = tuple(_all_resources.keys()) + + + def __explode_resource_data__(trait_fullname): + _dset, _trt = trait_fullname.split("::") return { - "Temp": "Temp", - "ProbeSet": "mRNA", - "Geno": "Genotype", - "Publish": "Phenotype" - }[val] - - def __trait_key__(trait): - dataset_type = __translate__(trait['db']['dataset_type']).lower() - dataset_name = trait["db"]["dataset_name"] - if dataset_type == "phenotype": - return f"{dataset_type}::{dataset_name}::{trait['trait_name']}" - return f"{dataset_type}::{dataset_name}" - - return jsonify(tuple( - { - "user": asdict(user), - **{key:trait[key] for key in ("trait_fullname", "trait_name")}, - "dataset_name": trait["db"]["dataset_name"], - "dataset_type": __translate__(trait["db"]["dataset_type"]), - "resource_id": data_to_resource_map.get(__trait_key__(trait)), - "privileges": privileges.get( - data_to_resource_map.get( - __trait_key__(trait), - uuid.UUID("4afa415e-94cb-4189-b2c6-f9ce2b6a878d")), - tuple()) + ( - # Temporary traits do not exist in db: Set them - # as public-read - ("system:resource:public-read",) - if trait["db"]["dataset_type"] == "Temp" - else tuple()) - } for trait in - (build_trait_name(trait_fullname) - for trait_fullname in traits_names))) + "dataset_name": _dset, + "dataset_type": ( + "Phenotype" if _dset.endswith("Publish") + else ("Genotype" if _dset.endswith("Geno") + else ("Temporary" if _dset.endswith("Temp") + else "mRNA"))), + "trait_name": _trt, + "trait_fullname": trait_fullname + } + + _paramstr = ", ".join(["?"] * len(_resource_ids)) + _privileges_by_resource: dict[str, tuple[str, ...]] = {} + + # Separate synthetic temp resources from real resources + temp_resource_id = "gn-auth-temp-traits" + real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id) + + # Query privileges only for real resources + if len(real_resource_ids) > 0: + real_paramstr = ", ".join(["?"] * len(real_resource_ids)) + try: + with require_oauth.acquire("profile group resource") as _token: + user = _token.user + cursor.execute( + "SELECT ur.resource_id, r.role_id, rp.privilege_id " + "FROM user_roles AS ur " + "INNER JOIN roles AS r ON ur.role_id=r.role_id " + "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id " + "WHERE ur.user_id = ? " + f"AND ur.resource_id IN ({real_paramstr})", + (str(user.user_id),) + real_resource_ids + ) + _privileges_by_resource = reduce( + lambda acc, curr: { + **acc, + curr["resource_id"]: ( + acc.get(curr["resource_id"], tuple()) + + (curr["privilege_id"],)) + }, + cursor.fetchall(), + {}) + except _HTTPException as exc: + err_msg = json.loads(exc.body) + if err_msg["error"] == "missing_authorization": + cursor.execute( + "SELECT rsc.resource_id " + "FROM resources AS rsc " + "WHERE rsc.public = '1' " + f"AND rsc.resource_id IN ({real_paramstr}) ", + real_resource_ids) + _privileges_by_resource = { + row["resource_id"]: ('group:resource:view-resource',) + for row in cursor.fetchall() + } + else: + raise exc from None + + # Temp resources are always publicly viewable + if temp_resource_id in _resource_ids: + _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',) + + return jsonify({ + "authorisation": [{ + **resource, + "resource_data": [ + __explode_resource_data__(item) + for item in resource["resource_data"]], + "privileges": _privileges_by_resource.get(resource["resource_id"], tuple()) + } for resource in _all_resources.values()] + }) + def __search_mrna__(): query = __request_key__("query", "") @@ -218,7 +241,7 @@ def __search_phenotypes__(): job_id = uuid.uuid4() selected = __request_key__("selected_traits", []) command =[ - sys.executable, "-m", "scripts.search_phenotypes", + sys.executable, "-m", "gn_auth.scripts.search_phenotypes", __request_key__("species_name"), __request_key__("query"), str(job_id), 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 5484dbf..252df2f 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -1,18 +1,28 @@ """Handle authorisation checks for resources""" -from uuid import UUID +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(row["resource_id"]) + resource_id = uuid.UUID(row["resource_id"]) return { **privs, resource_id: (row["privilege_id"],) + privs.get( @@ -24,11 +34,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 +74,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 +98,84 @@ 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) + + +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.""" + warnings.warn( + (f"Function '{__name__}.can_delete' is deprecated. " + "Use `gn_libs.privileges.resources.can_delete` instead."), + category=DeprecationWarning, + stacklevel=2) + 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_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.""" + warnings.warn( + (f"Function '{__name__}.can_edit' is deprecated. " + "Use `gn_libs.privileges.resources.can_edit` instead."), + category=DeprecationWarning, + stacklevel=2) + 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/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..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 @@ -17,6 +18,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) @@ -97,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): @@ -122,31 +130,38 @@ 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( 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) " "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 +328,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 +437,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 +682,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..27ef183 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -1,12 +1,13 @@ """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 -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 +16,11 @@ 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 .base import Resource, ResourceCategory, resource_from_dbrow from .common import assign_resource_owner_role +from .checks import can_edit, authorised_for_spec +from .base import Resource, ResourceCategory, resource_from_dbrow 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, @@ -36,41 +38,91 @@ 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") 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, group: Group, - public: bool + public: bool, + created_at: datetime = datetime.now() ) -> 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, + 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 + # 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 + + 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 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: @@ -99,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 = { @@ -106,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, @@ -129,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, ...]: @@ -159,7 +273,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 +302,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}'.") @@ -214,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)) @@ -232,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)) @@ -330,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 0a68927..f114476 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 @@ -39,18 +41,22 @@ 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 .system.models import system_resource from .groups.models import Group, user_group +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) + 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="/") @@ -75,8 +81,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 +89,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, @@ -96,11 +101,12 @@ 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 + @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.""" @@ -113,6 +119,49 @@ 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): + def __extract_privileges__(roles: tuple[Role, ...]) -> tuple[str, ...]: + return tuple( + priv.privilege_id for role in roles + for priv in role.privileges) + + _sys_resource = system_resource(conn) + _privileges = { + ("system_privileges" + if _rid == _sys_resource.resource_id + else "resource_privileges"): __extract_privileges__(_rroles) + for _rid, _rroles in user_roles_on_resources( + conn, + _token.user, + (resource_id, _sys_resource.resource_id) + ).items() + } + 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: @@ -231,9 +280,11 @@ def resource_users(resource_id: UUID): **users_n_roles, user_id: { "user": user, - "user_group": Group( - UUID(row["group_id"]), row["group_name"], - json.loads(row["group_metadata"])), + "user_group": ( + Group(UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"])) + if bool(row["group_id"]) else False) , "roles": users_n_roles.get( user_id, {}).get("roles", tuple()) + (role,) } @@ -241,7 +292,7 @@ def resource_users(resource_id: UUID): cursor.execute( "SELECT g.*, u.*, r.* " "FROM groups AS g INNER JOIN group_users AS gu " - "ON g.group_id=gu.group_id INNER JOIN users AS u " + "ON g.group_id=gu.group_id RIGHT JOIN users AS u " "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur " "ON u.user_id=ur.user_id INNER JOIN roles AS r " "ON ur.role_id=r.role_id " @@ -254,7 +305,8 @@ def resource_users(resource_id: UUID): results = ( { "user": asdict(row["user"]), - "user_group": asdict(row["user_group"]), + "user_group": ( + asdict(row["user_group"]) if row["user_group"] else False), "roles": tuple(asdict(role) for role in row["roles"]) } for row in ( user_row for user_id, user_row @@ -467,7 +519,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": ( @@ -505,7 +557,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 = { @@ -673,3 +724,45 @@ 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 can_delete(conn, the_token.user.user_id, resource_id): + raise AuthorisationError( + "You are not allowed 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: + logger.debug("Error!", exc_info=True) + return jsonify({ + "error": "ValueError", + "error-description": "An invalid identifier was provided" + }), 400 + except TypeError as _terr: + 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..0594864 100644 --- a/gn_auth/auth/authorisation/users/admin/models.py +++ b/gn_auth/auth/authorisation/users/admin/models.py @@ -1,23 +1,56 @@ """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 +from gn_auth.auth.authorisation.resources.system.models import system_resource -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() + sysresource = system_resource(cursor) cursor.execute( "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)", { "user_id": str(user.user_id), "role_id": admin_role["role_id"], - "resource_id": the_system["resource_id"] + "resource_id": str(sysresource.resource_id) }) 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/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py index 9bc1c36..62eccfd 100644 --- a/gn_auth/auth/authorisation/users/admin/views.py +++ b/gn_auth/auth/authorisation/users/admin/views.py @@ -1,6 +1,5 @@ """UI for admin stuff""" import uuid -import json import random import string from typing import Optional @@ -240,13 +239,6 @@ def register_client(): client_secret = raw_client_secret) -def __parse_client__(sqlite3_row) -> dict: - """Parse the client details into python datatypes.""" - return { - **dict(sqlite3_row), - "client_metadata": json.loads(sqlite3_row["client_metadata"]) - } - @admin.route("/list-client", methods=["GET"]) @is_admin def list_clients(): 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/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py index f619c3d..5ed2c23 100644 --- a/gn_auth/auth/authorisation/users/collections/views.py +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -1,4 +1,5 @@ """Views regarding user collections.""" +import logging from uuid import UUID from redis import Redis @@ -25,8 +26,10 @@ from .models import ( REDIS_COLLECTIONS_KEY, delete_collections as _delete_collections) +logger = logging.getLogger(__name__) collections = Blueprint("collections", __name__) + @collections.route("/list") @require_oauth("profile user") def list_user_collections() -> Response: @@ -44,7 +47,7 @@ def list_anonymous_collections(anon_id: UUID) -> Response: def __list__(conn: db.DbConnection) -> tuple: try: _user = user_by_id(conn, anon_id) - current_app.logger.warning( + logger.warning( "Fetch collections for authenticated user using the " "`list_user_collections()` endpoint.") return tuple() diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py index 8b897f2..12a8c97 100644 --- a/gn_auth/auth/authorisation/users/masquerade/views.py +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -1,14 +1,14 @@ """Endpoints for user masquerade""" from dataclasses import asdict from uuid import UUID -from functools import partial -from flask import request, jsonify, Response, Blueprint +from flask import request, jsonify, Response, Blueprint, current_app from gn_auth.auth.errors import InvalidData +from gn_auth.auth.authorisation.resources.groups.models import user_group +from ....db import sqlite3 as db from ...checks import require_json -from ....db.sqlite3 import with_db_connection from ....authentication.users import user_by_id from ....authentication.oauth2.resource_server import require_oauth @@ -21,13 +21,13 @@ masq = Blueprint("masquerade", __name__) @require_json def masquerade() -> Response: """Masquerade as a particular user.""" - with require_oauth.acquire("profile user masquerade") as token: + with (require_oauth.acquire("profile user masquerade") as token, + db.connection(current_app.config["AUTH_DB"]) as conn): masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index] if masqueradee_id == token.user.user_id: raise InvalidData("You are not allowed to masquerade as yourself.") - masq_user = with_db_connection(partial( - user_by_id, user_id=masqueradee_id)) + masq_user = user_by_id(conn, user_id=masqueradee_id) def __masq__(conn): new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user) @@ -39,6 +39,8 @@ def masquerade() -> Response: }, "masquerade_as": { "user": asdict(masq_user), - "token": with_db_connection(__masq__) + "token": __masq__(conn), + **(user_group(conn, masq_user).maybe(# type: ignore[misc] + {}, lambda grp: {"group": grp})) } }) diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py index d30bfd0..ab7a980 100644 --- a/gn_auth/auth/authorisation/users/models.py +++ b/gn_auth/auth/authorisation/users/models.py @@ -1,5 +1,6 @@ """Functions for acting on users.""" import uuid +import warnings from functools import reduce from datetime import datetime, timedelta @@ -128,3 +129,40 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu (str(user.user_id),)) return __build_resource_roles__( (dict(row) for row in cursor.fetchall())) + + +def delete_users_by_id( + conn: db.DbConnection, + user_ids: tuple[uuid.UUID, ...] +) -> int: + """Delete users unconditionally by ID, removing all dependent data. + + Unlike the HTTP endpoint, this bypasses all policy checks — users are + deleted regardless of their roles or group memberships. Returns the + number of users removed from the users table. + """ + warnings.warn( + (f"Running dangerous function `{__name__}.delete_users_by_id`. " + "Do ensure that is what you actually want."), + category=RuntimeWarning) + if not user_ids: + return 0 + _ids = tuple(str(uid) for uid in user_ids) + _paramstr = ", ".join(["?"] * len(_ids)) + _dependent_tables = ( + ("authorisation_code", "user_id"), + ("forgot_password_tokens", "user_id"), + ("group_join_requests", "requester_id"), + ("jwt_refresh_tokens", "user_id"), + ("oauth2_tokens", "user_id"), + ("user_credentials", "user_id"), + ("user_roles", "user_id"), + ("user_verification_codes", "user_id"), + ) + with db.cursor(conn) as cursor: + for table, col in _dependent_tables: + cursor.execute( + f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids) + cursor.execute( + f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids) + return cursor.rowcount diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index fe0662f..a706067 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -1,12 +1,13 @@ """User authorisation endpoints.""" import uuid +import logging 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 +47,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 ( @@ -56,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") @@ -75,8 +79,24 @@ 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") @@ -218,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] @@ -300,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") @@ -701,7 +730,9 @@ def delete_users(): "deleted": _deleted_rows, "message": ( f"Successfully deleted {_deleted_rows} users." + - (" Some users could not be deleted." if _diff > 0 else "")) + (" Some users could not be deleted." + if len(_user_ids) - _deleted_rows > 0 + else "")) }) _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable) diff --git a/gn_auth/auth/db/sqlite3.py b/gn_auth/auth/db/sqlite3.py index 12a46c7..5f54752 100644 --- a/gn_auth/auth/db/sqlite3.py +++ b/gn_auth/auth/db/sqlite3.py @@ -1,63 +1,28 @@ """Handle connection to auth database.""" -import sqlite3 -import logging -import contextlib -from typing import Any, Protocol, Callable, Iterator - -import traceback +import warnings +from typing import Any, Callable from flask import current_app -from .protocols import DbCursor - -class DbConnection(Protocol): - """Type annotation for a generic database connection object.""" - def cursor(self) -> Any: - """A cursor object""" - - def commit(self) -> Any: - """Commit the transaction.""" - - def rollback(self) -> Any: - """Rollback the transaction.""" +from gn_libs.sqlite3 import cursor, connection # pylint: disable=[unused-import] +from gn_libs.protocols import DbCursor, DbConnection # pylint: disable=[unused-import] -@contextlib.contextmanager -def connection(db_path: str, row_factory: Callable = sqlite3.Row) -> Iterator[DbConnection]: - """Create the connection to the auth database.""" - logging.debug("SQLite3 DB Path: '%s'.", db_path) - conn = sqlite3.connect(db_path) - conn.row_factory = row_factory - conn.set_trace_callback(logging.debug) - conn.execute("PRAGMA foreign_keys = ON") - try: - yield conn - except sqlite3.Error as exc: - conn.rollback() - logging.debug(traceback.format_exc()) - raise exc - finally: - conn.commit() - conn.close() +warnings.warn( + f"Module '{__name__}' is deprecated. Use `gn_libs.sqlite3` instead.", + category=DeprecationWarning, + stacklevel=2) -@contextlib.contextmanager -def cursor(conn: DbConnection) -> Iterator[DbCursor]: - """Get a cursor from the given connection to the auth database.""" - cur = conn.cursor() - try: - yield cur - conn.commit() - except sqlite3.Error as exc: - conn.rollback() - logging.debug(traceback.format_exc()) - raise exc - finally: - cur.close() def with_db_connection(func: Callable[[DbConnection], Any]) -> Any: """ Takes a function of one argument `func`, whose one argument is a database connection. """ + warnings.warn( + (f"Function '{__name__}.with_db_connection' is deprecated. " + "Use `gn_libs.sqlite3.with_db_connection` instead."), + category=DeprecationWarning, + stacklevel=2) db_uri = current_app.config["AUTH_DB"] with connection(db_uri) as conn: return func(conn) 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/debug.py b/gn_auth/debug.py deleted file mode 100644 index 6b7173b..0000000 --- a/gn_auth/debug.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Debug utilities""" -import logging -from flask import current_app - -__this_module_name__ = __name__ - - -# pylint: disable=invalid-name -def getLogger(name: str): - """Return a logger""" - return ( - logging.getLogger(name) - if not bool(current_app) - else current_app.logger) - -def __pk__(*args): - """Format log entry""" - value = args[-1] - title_vals = " => ".join(args[0:-1]) - logger = getLogger(__this_module_name__) - logger.debug("%s: %s", title_vals, value) - return value 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/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/gn_auth/migrations.py b/gn_auth/migrations/__init__.py index 3451e07..6acb058 100644 --- a/gn_auth/migrations.py +++ b/gn_auth/migrations/__init__.py @@ -1,4 +1,5 @@ -"""Run the migrations in the app, rather than with yoyo CLI.""" +"""Migrations package: Provides the migrations, and some utility functions to +help with dealing with migrations.""" from pathlib import Path from typing import Union diff --git a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py index d511f5d..d511f5d 100644 --- a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py +++ b/gn_auth/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py diff --git a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py index 48bd663..48bd663 100644 --- a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py +++ b/gn_auth/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py diff --git a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py index 29f92d4..29f92d4 100644 --- a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py +++ b/gn_auth/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py diff --git a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py index 67720b2..67720b2 100644 --- a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py +++ b/gn_auth/migrations/auth/20221108_02_wxTr9-create-privileges-table.py diff --git a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py index ce752ef..ce752ef 100644 --- a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py +++ b/gn_auth/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py diff --git a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py index 76ffbef..76ffbef 100644 --- a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py +++ b/gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py diff --git a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py index 6c829b1..6c829b1 100644 --- a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py +++ b/gn_auth/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py diff --git a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py index abc8895..abc8895 100644 --- a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py +++ b/gn_auth/migrations/auth/20221110_01_WtZ1I-create-resources-table.py diff --git a/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py index 51e19e8..51e19e8 100644 --- a/migrations/auth/20221110_05_BaNtL-create-roles-table.py +++ b/gn_auth/migrations/auth/20221110_05_BaNtL-create-roles-table.py diff --git a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py index 2b55c2b..2b55c2b 100644 --- a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py +++ b/gn_auth/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py diff --git a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py index 0d0eeb9..0d0eeb9 100644 --- a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py +++ b/gn_auth/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py diff --git a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py index 077182b..077182b 100644 --- a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py +++ b/gn_auth/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py diff --git a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py index 072f226..072f226 100644 --- a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py +++ b/gn_auth/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py diff --git a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py index 2048f4a..2048f4a 100644 --- a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py +++ b/gn_auth/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py diff --git a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py index 6bd101b..6bd101b 100644 --- a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py +++ b/gn_auth/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py diff --git a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py index a7e7b45..a7e7b45 100644 --- a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py +++ b/gn_auth/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py index 386f481..386f481 100644 --- a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py +++ b/gn_auth/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py diff --git a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py index e0de751..e0de751 100644 --- a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py +++ b/gn_auth/migrations/auth/20221114_05_hQun6-create-user-roles-table.py diff --git a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py index 2e4ae28..2e4ae28 100644 --- a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py +++ b/gn_auth/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py diff --git a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py index a4d7806..a4d7806 100644 --- a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py +++ b/gn_auth/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py diff --git a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py index 92885ef..92885ef 100644 --- a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py +++ b/gn_auth/migrations/auth/20221117_02_fmuZh-create-group-users-table.py diff --git a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py index 9aa3667..9aa3667 100644 --- a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py +++ b/gn_auth/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py diff --git a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py index 2238069..2238069 100644 --- a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py +++ b/gn_auth/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py diff --git a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py index 475be01..475be01 100644 --- a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py +++ b/gn_auth/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py diff --git a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py index 778282b..778282b 100644 --- a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py +++ b/gn_auth/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py diff --git a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py index 1683f87..1683f87 100644 --- a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py +++ b/gn_auth/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py diff --git a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py index 7e7fda2..7e7fda2 100644 --- a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py +++ b/gn_auth/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py diff --git a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py index 1ef5ab0..1ef5ab0 100644 --- a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py +++ b/gn_auth/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py diff --git a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py index ceae5ea..ceae5ea 100644 --- a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py +++ b/gn_auth/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py diff --git a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py index 8b406a6..8b406a6 100644 --- a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py +++ b/gn_auth/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py diff --git a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py index 9b3fc2b..9b3fc2b 100644 --- a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py +++ b/gn_auth/migrations/auth/20230210_02_lDK14-create-system-admin-role.py diff --git a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py index 84bbd49..84bbd49 100644 --- a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py +++ b/gn_auth/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py diff --git a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py index 3caad55..3caad55 100644 --- a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py +++ b/gn_auth/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py diff --git a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py index 647325f..647325f 100644 --- a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py +++ b/gn_auth/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py diff --git a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py index 7c9e986..7c9e986 100644 --- a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py +++ b/gn_auth/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py diff --git a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py index 02e8718..02e8718 100644 --- a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py +++ b/gn_auth/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py diff --git a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py index 1a865e0..1a865e0 100644 --- a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py +++ b/gn_auth/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py diff --git a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py index db9a6bf..db9a6bf 100644 --- a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py +++ b/gn_auth/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py diff --git a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py index 2ad1056..2ad1056 100644 --- a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py +++ b/gn_auth/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py diff --git a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py index 37fcfe7..37fcfe7 100644 --- a/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py +++ b/gn_auth/migrations/auth/20230907_01_pjnxz-refactor-add-resource-ownership-table.py diff --git a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py index c4397c9..c4397c9 100644 --- a/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py +++ b/gn_auth/migrations/auth/20230907_02_Enicg-refactor-add-system-and-group-resource-categories.py diff --git a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py index 0f491c2..0f491c2 100644 --- a/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py +++ b/gn_auth/migrations/auth/20230907_03_BwAmf-refactor-drop-group-id-from-resources-table.py diff --git a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py index a26834a..a26834a 100644 --- a/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py +++ b/gn_auth/migrations/auth/20230907_04_3LnrG-refactor-create-group-resources-table.py diff --git a/migrations/auth/20230912_01_BxrhE-add-system-resource.py b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py index 66c6461..66c6461 100644 --- a/migrations/auth/20230912_01_BxrhE-add-system-resource.py +++ b/gn_auth/migrations/auth/20230912_01_BxrhE-add-system-resource.py diff --git a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py index 1b3f0b1..1b3f0b1 100644 --- a/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py +++ b/gn_auth/migrations/auth/20230912_02_hFmSn-drop-group-id-and-fix-foreign-key-references-on-group-user-roles-on-resources-table.py diff --git a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py index 1172034..1172034 100644 --- a/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py +++ b/gn_auth/migrations/auth/20230925_01_TWJuR-add-new-public-view-role.py diff --git a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py index 402e9a5..402e9a5 100644 --- a/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py +++ b/gn_auth/migrations/auth/20231002_01_tzxTf-link-inbredsets-to-auth-system.py diff --git a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py index a4238ed..a4238ed 100644 --- a/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py +++ b/gn_auth/migrations/auth/20231011_01_CS8NZ-create-new-inbredset-group-owner-role.py diff --git a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py index 049ac6b..049ac6b 100644 --- a/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py +++ b/gn_auth/migrations/auth/20240506_01_798tW-create-jwt-refresh-tokens-table.py diff --git a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py index 0cab1c3..0cab1c3 100644 --- a/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py +++ b/gn_auth/migrations/auth/20240529_01_ALNWj-update-schema-for-user-verification.py diff --git a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py index a45fd30..a45fd30 100644 --- a/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py +++ b/gn_auth/migrations/auth/20240606_01_xQDwL-move-role-manipulation-privileges-from-group-to-resources.py diff --git a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py index 0695c0e..0695c0e 100644 --- a/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py +++ b/gn_auth/migrations/auth/20240606_02_ubZri-create-resource-roles-table.py diff --git a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py index 45d689c..45d689c 100644 --- a/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py +++ b/gn_auth/migrations/auth/20240606_03_BY7Us-drop-group-roles-table.py diff --git a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py index 44318bd..44318bd 100644 --- a/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py +++ b/gn_auth/migrations/auth/20240819_01_p2vXR-create-forgot-password-tokens-table.py diff --git a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py index 5c6e81d..5c6e81d 100644 --- a/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py +++ b/gn_auth/migrations/auth/20240924_01_thbvh-hooks-for-edu-domains.py diff --git a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py index d22ad01..d22ad01 100644 --- a/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py +++ b/gn_auth/migrations/auth/20250328_01_72EFk-add-admin-ui-privilege-to-system-administrator-role.py diff --git a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py index 73a4880..73a4880 100644 --- a/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py +++ b/gn_auth/migrations/auth/20250609_01_LB60X-add-batch-edit-privileges.py diff --git a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py index 3b9e928..3b9e928 100644 --- a/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py +++ b/gn_auth/migrations/auth/20250609_01_bj9Pl-add-new-group-data-link-to-group-privilege.py diff --git a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py index 5d9c306..5d9c306 100644 --- a/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py +++ b/gn_auth/migrations/auth/20250609_02_9UBPl-assign-group-data-link-to-group-privilege-to-group-leader.py diff --git a/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py index 6335152..6335152 100644 --- a/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py +++ b/gn_auth/migrations/auth/20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role.py diff --git a/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py new file mode 100644 index 0000000..f00ab11 --- /dev/null +++ b/gn_auth/migrations/auth/20250722_01_7Gro7-create-new-system-user-edit-privilege.py @@ -0,0 +1,18 @@ +""" +Create new 'system:user:edit' privilege. +""" + +from yoyo import step + +__depends__ = {'20250703_01_aDVwP-add-role-management-privileges-to-group-leader-role'} + +steps = [ + step( + """ + INSERT INTO privileges(privilege_id, privilege_description) + VALUES( + 'system:user:edit', + 'Allow general user-information edit.') + """, + "DELETE FROM privileges WHERE privilege_id='system:user:edit'") +] diff --git a/gn_auth/migrations/auth/20250722_02_M8TXv-add-system-user-edit-privilege-to-system-admin-role.py b/gn_auth/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/gn_auth/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/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py b/gn_auth/migrations/auth/20250729_01_CNn2p-create-initial-system-wide-resources-access-privileges.py new file mode 100644 index 0000000..be0d022 --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20250729_02_7ycSm-assign-initial-system-wide-resources-access-privileges-to-sys-admins.py b/gn_auth/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/gn_auth/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/gn_auth/migrations/auth/20250729_03_oCvvq-grant-role-to-all-resources-to-sys-admin-users.py b/gn_auth/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/gn_auth/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/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py b/gn_auth/migrations/auth/20250731_01_Ke1us-add-sysadmin-privileges-for-acting-on-groups-members.py new file mode 100644 index 0000000..95a6fbb --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260206_01_v3f4P-add-role-systemwide-data-curator.py new file mode 100644 index 0000000..63e807a --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py b/gn_auth/migrations/auth/20260311_01_TfRlV-add-privilege-for-gn-docs-documentation-editing.py new file mode 100644 index 0000000..d618f14 --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py b/gn_auth/migrations/auth/20260311_02_v3EFQ-assign-systemwide-docs-editor-role-to-sysadmins.py new file mode 100644 index 0000000..e79ef6a --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py b/gn_auth/migrations/auth/20260311_03_vxBCX-restrict-access-to-resources-make-public-feature.py new file mode 100644 index 0000000..bdf8a56 --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py b/gn_auth/migrations/auth/20260331_01_FV1sL-add-privileges-to-role-systemwide-data-curator.py new file mode 100644 index 0000000..22863ae --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py b/gn_auth/migrations/auth/20260402_01_Bf8nm-add-user-and-time-tracking-to-resources-table.py new file mode 100644 index 0000000..702c418 --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py b/gn_auth/migrations/auth/20260428_01_Tak6O-new-privilege-system-system-wide-data-view.py new file mode 100644 index 0000000..2dddc56 --- /dev/null +++ b/gn_auth/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/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py b/gn_auth/migrations/auth/20260428_02_L6zIV-add-privileges-to-batch-editors-role.py new file mode 100644 index 0000000..537bf9b --- /dev/null +++ b/gn_auth/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/migrations/auth/__init__.py b/gn_auth/migrations/auth/__init__.py index 1358c9a..1358c9a 100644 --- a/migrations/auth/__init__.py +++ b/gn_auth/migrations/auth/__init__.py diff --git a/gn_auth/scripts/__init__.py b/gn_auth/scripts/__init__.py new file mode 100644 index 0000000..5be56d8 --- /dev/null +++ b/gn_auth/scripts/__init__.py @@ -0,0 +1 @@ +"""These are command-line scripts to be run manually or in the background.""" diff --git a/scripts/assign_data_to_default_admin.py b/gn_auth/scripts/assign_data_to_default_admin.py index 69fc50c..69fc50c 100644 --- a/scripts/assign_data_to_default_admin.py +++ b/gn_auth/scripts/assign_data_to_default_admin.py diff --git a/scripts/batch_assign_data_to_default_admin.py b/gn_auth/scripts/batch_assign_data_to_default_admin.py index a468019..95d9794 100644 --- a/scripts/batch_assign_data_to_default_admin.py +++ b/gn_auth/scripts/batch_assign_data_to_default_admin.py @@ -15,8 +15,7 @@ from gn_auth.auth.db import sqlite3 as authdb from gn_auth.auth.authentication.users import User from gn_auth.auth.authorisation.resources.groups.models import ( Group, db_row_to_group) - -from scripts.assign_data_to_default_admin import ( +from gn_auth.scripts.assign_data_to_default_admin import ( default_resources, assign_data_to_resource) diff --git a/scripts/link_inbredsets.py b/gn_auth/scripts/link_inbredsets.py index c78a050..ad743f5 100644 --- a/scripts/link_inbredsets.py +++ b/gn_auth/scripts/link_inbredsets.py @@ -10,7 +10,7 @@ from gn_libs import mysqldb as biodb import gn_auth.auth.db.sqlite3 as authdb -from scripts.assign_data_to_default_admin import ( +from gn_auth.scripts.assign_data_to_default_admin import ( sys_admins, admin_group, select_sys_admin) def linked_inbredsets(conn): diff --git a/scripts/register_sys_admin.py b/gn_auth/scripts/register_sys_admin.py index 06aa845..06aa845 100644 --- a/scripts/register_sys_admin.py +++ b/gn_auth/scripts/register_sys_admin.py diff --git a/scripts/search_phenotypes.py b/gn_auth/scripts/search_phenotypes.py index eee112d..eee112d 100644 --- a/scripts/search_phenotypes.py +++ b/gn_auth/scripts/search_phenotypes.py diff --git a/scripts/worker.py b/gn_auth/scripts/worker.py index 0a77d41..0a77d41 100644 --- a/scripts/worker.py +++ b/gn_auth/scripts/worker.py diff --git a/gn_auth/settings.py b/gn_auth/settings.py index d59e997..f903553 100644 --- a/gn_auth/settings.py +++ b/gn_auth/settings.py @@ -14,7 +14,7 @@ SESSION_EXPIRY_MINUTES = 10 # Database settings SQL_URI = "mysql://webqtlout:webqtlout@localhost/db_webqtl" AUTH_DB = f"{os.environ.get('HOME')}/genenetwork/gn3_files/db/auth.db" -AUTH_MIGRATIONS = "migrations/auth" +AUTH_MIGRATIONS = "gn_auth/migrations/auth" # Redis settings REDIS_URI = "redis://localhost:6379/0" @@ -49,3 +49,5 @@ EMAIL_ADDRESS = "no-reply@uthsc.edu" ## Variable settings for various emails going out to users AUTH_EMAILS_EXPIRY_MINUTES = 15 + +LOGGABLE_MODULES = ["gn_auth"] diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py index e05ef0d..bab9991 100644 --- a/gn_auth/wsgi.py +++ b/gn_auth/wsgi.py @@ -1,10 +1,13 @@ """Main entry point for project""" +import os +import re +import secrets import sys import uuid import json from math import ceil from pathlib import Path -from datetime import datetime +from datetime import datetime, timezone import click from yoyo import get_backend, read_migrations @@ -14,10 +17,16 @@ from gn_auth import create_app from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError -from gn_auth.auth.authentication.users import user_by_id, hash_password -from gn_auth.auth.authorisation.users.admin.models import make_sys_admin - -from scripts import register_sys_admin as rsysadm# type: ignore[import] +from gn_auth.auth.authentication.users import ( + user_by_id, hash_password, save_user, set_user_password) +from gn_auth.auth.authorisation.roles.models import assign_default_roles +from gn_auth.auth.authorisation.users.admin.models import ( + make_sys_admin, grant_sysadmin_role) +from gn_auth.auth.authorisation.users.models import delete_users_by_id +from gn_auth.auth.authentication.oauth2.models.oauth2client import ( + OAuth2Client, save_client, delete_client, + client as oauth2_client_by_id) +from gn_auth.scripts import register_sys_admin as rsysadm# type: ignore[import] app = create_app() @@ -127,6 +136,364 @@ def register_admin(): """Register the administrator.""" rsysadm.register_admin(Path(app.config["AUTH_DB"])) + +_VALID_ROLES_ = ("system-admin", "none") + +_TEST_EMAIL_DOMAIN_ = "regression-tests.genenetwork.org" + + +def __normalise_name_for_email__(name: str) -> str: + """Lowercase and strip non-alphanumeric characters for use in an email.""" + return re.sub(r"[^a-z0-9]", "", name.lower()) + + +def __create_one_user__(cursor, name: str, email: str, password: str, role: str) -> dict: + """Create a single user in the DB and return their credential record.""" + user = save_user(cursor, email, name, verified=True) + set_user_password(cursor, user, password) + assign_default_roles(cursor, user) + if role == "system-admin": + grant_sysadmin_role(cursor, user) + return { + "user_id": str(user.user_id), + "name": user.name, + "email": user.email, + "password": password, + "role": role, + } + + +def __parse_user_spec__(spec: str) -> dict: + """Parse 'key=value,key=value,...' into a dict.""" + result = {} + for part in spec.split(","): + key, _, value = part.partition("=") + if key.strip(): + result[key.strip()] = value.strip() + return result + + +def __write_output__(data: dict, output_path) -> None: + """Write JSON data to a file with 0644 permissions, or stdout.""" + text = json.dumps(data, indent=2) + if output_path is None: + print(text) + return + fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + with os.fdopen(fd, "w") as outfile: + outfile.write(text) + + +@app.cli.command() +@click.option("--user", "user_specs", multiple=True, + help='User spec: "name=...,email=...,password=...,role=..."') +@click.option("--output", "output_path", type=click.Path(), default=None, + help="Write credentials as JSON to this file (default: stdout)") +def create_users(user_specs, output_path): + """Create one or more users with specified credentials and roles. + + Each --user option takes a comma-separated key=value string with the + following keys: name, email, password, role. + + Valid roles: system-admin, none. + """ + if not user_specs: + print("No users specified.", file=sys.stderr) + sys.exit(1) + + records = [] + with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor: + for spec_str in user_specs: + spec = __parse_user_spec__(spec_str) + name = spec.get("name", "").strip() + email = spec.get("email", "").strip() + password = spec.get("password", "").strip() + role = spec.get("role", "none").strip() + + if not name: + print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr) + sys.exit(1) + if not email: + print(f"Missing 'email' in user spec: {spec_str!r}", file=sys.stderr) + sys.exit(1) + if not password: + print(f"Missing 'password' in user spec: {spec_str!r}", file=sys.stderr) + sys.exit(1) + if role not in _VALID_ROLES_: + print( + f"Invalid role {role!r} in spec: {spec_str!r}. " + f"Valid roles: {_VALID_ROLES_}", + file=sys.stderr) + sys.exit(1) + + records.append( + __create_one_user__(cursor, name, email, password, role)) + + __write_output__({"users": records}, output_path) + + +@app.cli.command() +@click.option("--user-id", "user_ids", multiple=True, type=click.UUID, + help="UUID of a user to delete (repeatable)") +def delete_users(user_ids): + """Delete one or more users by ID, bypassing policy checks. + + Removes users unconditionally regardless of their roles or group + memberships. Use with care — intended for test teardown and administration. + """ + if not user_ids: + print("No user IDs specified.", file=sys.stderr) + sys.exit(1) + + with db.connection(app.config["AUTH_DB"]) as conn: + deleted = delete_users_by_id(conn, tuple(user_ids)) + print(f"Deleted {deleted} user(s).") + + +@app.cli.command() +@click.option("--session-timestamp", required=True, + help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)") +@click.option("--user", "user_specs", multiple=True, + help='User spec: "name=...,role=..."') +@click.option("--output", "output_path", required=True, type=click.Path(), + help="Write credentials as JSON to this file (0600 permissions)") +def create_test_users(session_timestamp, user_specs, output_path): + """Create ephemeral test users with auto-generated email and password. + + Each --user option takes a comma-separated key=value string with the + following keys: name, role. + + Email: <normalised-name><timestamp>@regression-tests.genenetwork.org + Password: randomly generated. + + Output is written with 0600 permissions. Valid roles: system-admin, none. + """ + if not user_specs: + print("No users specified.", file=sys.stderr) + sys.exit(1) + + records = [] + with db.connection(app.config["AUTH_DB"]) as conn, db.cursor(conn) as cursor: + for spec_str in user_specs: + spec = __parse_user_spec__(spec_str) + name = spec.get("name", "").strip() + role = spec.get("role", "none").strip() + + if not name: + print(f"Missing 'name' in user spec: {spec_str!r}", file=sys.stderr) + sys.exit(1) + if role not in _VALID_ROLES_: + print( + f"Invalid role {role!r} in spec: {spec_str!r}. " + f"Valid roles: {_VALID_ROLES_}", + file=sys.stderr) + sys.exit(1) + + email = (f"{__normalise_name_for_email__(name)}" + f"{session_timestamp}@{_TEST_EMAIL_DOMAIN_}") + password = secrets.token_urlsafe(32) + + records.append( + __create_one_user__(cursor, name, email, password, role)) + + __write_output__( + {"session_timestamp": session_timestamp, "users": records}, + output_path) + + +_DEFAULT_GRANT_TYPES_ = ( + "password", + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", +) + +_DEFAULT_SCOPES_ = ( + "profile", "group", "role", "resource", + "register-client", "user", "masquerade", + "migrate-data", "introspect", +) + + +def __create_one_client__(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + conn, + client_name: str, + owner_user, + redirect_uris: tuple, + scopes: tuple = _DEFAULT_SCOPES_, + grant_types: tuple = _DEFAULT_GRANT_TYPES_, + jwks_uri: str = "", +) -> dict: + """Create a single OAuth2 client and return its credential record.""" + raw_secret = secrets.token_urlsafe(32) + the_client = OAuth2Client( + client_id=uuid.uuid4(), + client_secret=hash_password(raw_secret), + client_id_issued_at=datetime.now(tz=timezone.utc), + client_secret_expires_at=datetime.fromtimestamp(0), + client_metadata={ + "client_name": client_name, + "token_endpoint_auth_method": [ + "client_secret_post", "client_secret_basic"], + "client_type": "confidential", + "grant_types": list(grant_types), + "default_redirect_uri": redirect_uris[0] if redirect_uris else "", + "redirect_uris": list(redirect_uris), + "response_type": ["code", "token"], + "scope": list(scopes), + "public-jwks-uri": jwks_uri, + }, + user=owner_user) + save_client(conn, the_client) + return { + "client_id": str(the_client.client_id), + "client_secret": raw_secret, + "client_name": client_name, + } + + +@app.cli.command() +@click.option("--name", "client_name", required=True, + help="Human-readable name for the OAuth2 client") +@click.option("--owner-id", required=True, type=click.UUID, + help="UUID of the user who owns this client") +@click.option("--redirect-uri", "redirect_uris", multiple=True, + help="Allowed redirect URI (repeatable)") +@click.option("--scope", "scopes", multiple=True, + default=_DEFAULT_SCOPES_, show_default=False, + help="OAuth2 scope (repeatable; defaults to full scope set)") +@click.option("--grant-type", "grant_types", multiple=True, + default=_DEFAULT_GRANT_TYPES_, show_default=False, + help="Grant type (repeatable; defaults to all standard types)") +@click.option("--jwks-uri", default="", + help="URI to the client's public JWKS (optional)") +@click.option("--output", "output_path", type=click.Path(), default=None, + help="Write credentials as JSON to this file (default: stdout)") +def create_oauth2_client(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + client_name, + owner_id, + redirect_uris, + scopes, + grant_types, + jwks_uri, + output_path +): + """Create an OAuth2 client with specified parameters. + + Scopes and grant types default to the full standard set if not provided. + """ + with db.connection(app.config["AUTH_DB"]) as conn: + try: + owner = user_by_id(conn, owner_id) + except NotFoundError: + print(f"No user found with ID {owner_id}", file=sys.stderr) + sys.exit(1) + record = __create_one_client__( + conn, client_name, owner, redirect_uris, scopes, grant_types, + jwks_uri) + + __write_output__({"client": record}, output_path) + + +@app.cli.command() +@click.option("--session-timestamp", required=True, + help="Compact ISO 8601 UTC timestamp (e.g. 20260602T122700Z)") +@click.option("--users-file", required=True, type=click.Path(exists=True), + help="Credentials file produced by create-test-users") +@click.option("--owner-role", default="system-admin", show_default=True, + help="Role of the user in users-file to assign as client owner") +@click.option("--output", "output_path", required=True, type=click.Path(), + help="Write credentials as JSON to this file (0600 permissions)") +def create_test_oauth2_client(session_timestamp, users_file, owner_role, + output_path): + """Create an ephemeral OAuth2 client for a test session. + + Reads the credentials file produced by create-test-users to find the + owner. Client name and secret are auto-generated using the session + timestamp. Output is written with 0600 permissions. + """ + with open(users_file, encoding="utf8") as f: + users_data = json.load(f) + + owner_record = next( + (u for u in users_data.get("users", []) if u["role"] == owner_role), + None) + if owner_record is None: + print( + f"No user with role {owner_role!r} found in {users_file}", + file=sys.stderr) + sys.exit(1) + + client_name = f"gn-test-client-{session_timestamp}" + + with db.connection(app.config["AUTH_DB"]) as conn: + try: + owner = user_by_id(conn, uuid.UUID(owner_record["user_id"])) + except NotFoundError: + print( + f"Owner user {owner_record['user_id']!r} not found in DB", + file=sys.stderr) + sys.exit(1) + record = __create_one_client__(conn, client_name, owner, tuple()) + + __write_output__( + {"session_timestamp": session_timestamp, "client": record}, + output_path) + + +@app.cli.command() +@click.option("--credentials", "credentials_path", required=True, + type=click.Path(exists=True), + help="Credentials file produced by create-oauth2-client or " + "create-test-oauth2-client") +def delete_oauth2_client(credentials_path): + """Delete an OAuth2 client using a credentials file. + + Reads the client_id from the given credentials file and removes the + client and all associated tokens from the database. + """ + with open(credentials_path, encoding="utf8") as f: + data = json.load(f) + + client_id_str = data.get("client", {}).get("client_id") + if not client_id_str: + print("No client_id found in credentials file.", file=sys.stderr) + sys.exit(1) + + client_id = uuid.UUID(client_id_str) + with db.connection(app.config["AUTH_DB"]) as conn: + the_client = oauth2_client_by_id(conn, client_id) + if the_client.is_nothing(): + print(f"No client found with ID {client_id}", file=sys.stderr) + sys.exit(1) + delete_client(conn, the_client.value) + print(f"Deleted OAuth2 client {client_id}.") + + +@app.cli.command() +@click.option("--credentials", "credentials_path", required=True, + type=click.Path(exists=True), + help="Credentials file produced by create-test-users") +def delete_test_users(credentials_path): + """Delete ephemeral test users using a credentials file. + + Reads the credentials file produced by create-test-users and deletes + all listed users unconditionally, bypassing policy checks. Intended + for CI test teardown. + """ + with open(credentials_path, encoding="utf8") as f: + data = json.load(f) + + user_ids = tuple( + uuid.UUID(u["user_id"]) for u in data.get("users", [])) + if not user_ids: + print("No users found in credentials file.", file=sys.stderr) + sys.exit(1) + + with db.connection(app.config["AUTH_DB"]) as conn: + deleted = delete_users_by_id(conn, user_ids) + print(f"Deleted {deleted} user(s).") + ##### END: CLI Commands ##### if __name__ == '__main__': diff --git a/migrations/__init__.py b/migrations/__init__.py deleted file mode 100644 index cedf48d..0000000 --- a/migrations/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Migrations package""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f5f624d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "gn-auth" +# version = "1.0.1" +dynamic = ["version"] # Read from git, or elsewhere +description = "Authentication/Authorisation server for GeneNetwork Services." +requires-python = ">= 3.10" +authors = [ +{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"}, +] +dependencies = [ +"argon2-cffi>= 20.1.0", +"click", +"Flask>= 1.1.2", +"mypy>= 0.790", +"mypy-extensions>= 0.4.3", +"mysqlclient>= 2.0.1", +"pylint>= 2.5.3", +"pymonad", +"redis>= 3.5.3", +"requests>= 2.25.1", +"flask-cors", # with the `>= 3.0.9` specification, it breaks the build +"gn-libs @ git+https://git.genenetwork.org/gn-libs" +] +maintainers = [ +{name = "Frederick M. Muriithi", email = "fredmanglis@gmail.com"}, +] +license = "AGPL-3.0" +readme = {file = "README.md", content-type = "text/markdown"} + +[project.urls] +Homepage = "https://git.genenetwork.org/gn-auth/" +Repository = "https://git.genenetwork.org/gn-auth/" + +[dependency-groups]# PEP 735 +tests = ["pytest"] +checks = [{include-group = "tests"}, "mypy", "pylint", "vulture"] + +[tool.pylint.main] +ignore = ["tests", "venv"] +ignore-paths = ["^gn_auth/migrations/auth/.*"] +ignore-imports = true +disable = ["fixme", "duplicate-code", "no-else-return"] +load-plugins = ["pylint.extensions.no_self_use"] + +[tool.vulture] +ignore_decorators = [ +"@admin.before_request", +"@admin.route", +"@app.cli.command", +"@auth.route", +"@collections.route", +"@data.route", +"@genobp.route", +"@groups.route", +"@masq.route", +"@misc.route", +"@phenobp.route", +"@phenosbp.route", +"@popbp.route", +"@privileges.route", +"@resources.route", +"@roles.route", +"@system.route", +"@users.route" +] +exclude = ["*/tests/unit/*", "*/gn_auth/settings.py", "*/gn_auth/migrations/*"] +min_confidence = 60 \ No newline at end of file diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/scripts/__init__.py +++ /dev/null diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 41d118e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[aliases] -run_unit_tests = run_tests --type=unit -run_integration_tests = run_tests --type=integration -run_performance_tests = run_tests --type=performance diff --git a/setup.py b/setup.py deleted file mode 100755 index 77e5eb3..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python -"""Setup script for GeneNetwork Auth package.""" -from setuptools import setup, find_packages -from setup_commands import RunTests - -LONG_DESCRIPTION = """ -gn-auth project is the authentication/authorisation server to be used -across all GeneNetwork services. -""" - -setup(author="Frederick M. Muriithi", - author_email="fredmanglis@gmail.com", - description=( - "Authentication/Authorisation server for GeneNetwork Services."), - install_requires=[ - "argon2-cffi>=20.1.0", - "click", - "Flask>=1.1.2", - "mypy>=0.790", - "mypy-extensions>=0.4.3", - "mysqlclient>=2.0.1", - "pylint>=2.5.3", - "pymonad", - "redis>=3.5.3", - "requests>=2.25.1", - "flask-cors>=3.0.9", - "gn-libs>=0.0.0" - ], - include_package_data=True, - packages=find_packages( - where=".", - exclude=( - "tests", - "tests.*", - "setup_commands", - "setup_commands.*")), - # `package_data` doesn't seem to work. Use MANIFEST.in instead - scripts=[], - license="AGPLV3", - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - name="gn-auth", - url="https://github.com/genenetwork/gn-auth", - version="0.0.1", - tests_require=["pytest", "hypothesis"], - cmdclass={ - "run_tests": RunTests # testing - }) diff --git a/setup_commands/__init__.py b/setup_commands/__init__.py deleted file mode 100644 index 967bb11..0000000 --- a/setup_commands/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Module for custom setup commands.""" - -from .run_tests import RunTests diff --git a/setup_commands/run_tests.py b/setup_commands/run_tests.py deleted file mode 100644 index 1bb5dab..0000000 --- a/setup_commands/run_tests.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import sys -from distutils.core import Command - -class RunTests(Command): - """ - A custom command to run tests. - """ - description = "Run the tests" - test_types = ( - "all", "unit", "integration", "performance") - user_options = [ - ("type=", None, - f"""Specify the type of tests to run. - Valid types are {tuple(test_types)}. - Default is `all`.""")] - - def __init__(self, dist): - """Initialise the command.""" - super().__init__(dist) - self.command = "pytest" - - def initialize_options(self): - """Initialise the default values of all the options.""" - self.type = "all" - - def finalize_options(self): - """Set final value of all the options once they are processed.""" - if self.type not in RunTests.test_types: - raise Exception(f""" - Invalid test type (self.type) requested! - Valid types are - {tuple(RunTests.test_types)}""") - - if self.type != "all": - self.command = f"pytest -m {self.type}_test" - def run(self): - """Run the chosen tests""" - print(f"Running {self.type} tests") - os.system(self.command) 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_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py index c34a549..a32cacb 100644 --- a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py +++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py @@ -8,7 +8,7 @@ from gn_auth.migrations import get_migration, apply_migrations, rollback_migrati from tests.unit.auth.conftest import ( apply_single_migration, rollback_single_migration, migrations_up_to) -MIGRATION_PATH = "migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py" +MIGRATION_PATH = "gn_auth/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py" @pytest.mark.unit_test def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend): diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py index 292f7dc..81f967e 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.""" @@ -115,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 @@ -135,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 |
