diff options
Diffstat (limited to 'gn_auth/auth')
41 files changed, 1546 insertions, 801 deletions
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..e647214 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py @@ -1,9 +1,9 @@ """Handle token revocation.""" from flask import current_app +from gn_libs import sqlite3 as db from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.oauth2.models.oauth2token import ( save_token, OAuth2Token, revoke_token) @@ -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..82fd8e1 100644 --- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py +++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py @@ -1,16 +1,14 @@ """endpoint utilities""" -from typing import Any, Optional +from typing import Optional from flask import current_app from pymonad.maybe import Nothing +from gn_libs import sqlite3 as db -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/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py index 71769e1..39249ba 100644 --- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -3,9 +3,10 @@ import uuid import time from typing import Optional +from flask import current_app as app from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken +from gn_libs.sqlite3 import with_db_connection -from gn_auth.auth.db.sqlite3 import with_db_connection from gn_auth.auth.authentication.users import user_by_id from gn_auth.auth.authentication.oauth2.models.oauth2client import ( client as fetch_client) @@ -19,8 +20,10 @@ class JWTBearerToken(_JWTBearerToken): # OAuth2Client is a dataclass super().__init__(payload, header, options, params) self.user = with_db_connection( - lambda conn:user_by_id(conn, uuid.UUID(payload["sub"]))) + app.config["AUTH_DB"], + lambda conn: user_by_id(conn, uuid.UUID(payload["sub"]))) self.client = with_db_connection( + app.config["AUTH_DB"], lambda conn: fetch_client( conn, uuid.UUID(payload["oauth2_client_id"]) ) diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py index 1639e2e..818399d 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,20 +1,21 @@ """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 import sqlite3 as db +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, fetch_users, @@ -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]: @@ -292,6 +298,8 @@ def delete_client( cursor.execute("DELETE FROM authorisation_code WHERE client_id=?", params) cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params) + cursor.execute("DELETE FROM jwt_refresh_tokens WHERE client_id=?", + params) cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params) return the_client diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py index 6ec5c3d..eb13f43 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2token.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py @@ -8,8 +8,8 @@ from typing import Optional from authlib.oauth2.rfc6749 import TokenMixin from pymonad.tools import monad_from_none_or_value from pymonad.maybe import Just, Maybe, Nothing +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, user_by_id diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 8ecf923..c09f6b6 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 @@ -8,14 +9,17 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator from authlib.oauth2.rfc7523 import ( JWTBearerTokenValidator as _JWTBearerTokenValidator) from authlib.integrations.flask_oauth2 import ResourceProtector +from gn_libs import sqlite3 as db -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.jwks import list_jwks, jwks_directory from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import ( JWTBearerToken) 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/server.py b/gn_auth/auth/authentication/oauth2/server.py index 8ac5106..fd45b63 100644 --- a/gn_auth/auth/authentication/oauth2/server.py +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -129,6 +129,7 @@ def setup_oauth2_server(app: Flask) -> None: server.register_token_generator( "urn:ietf:params:oauth:grant-type:jwt-bearer", jwttokengenerator) server.register_token_generator("refresh_token", jwttokengenerator) + server.register_token_generator("password", jwttokengenerator) server.register_grant(RefreshTokenGrant) # register endpoints diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py index 0e2c4eb..f73a712 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 @@ -15,9 +16,9 @@ from flask import ( Blueprint, render_template, current_app as app) +from gn_libs import sqlite3 as db +from gn_libs.sqlite3 import with_db_connection -from gn_auth.auth.db import sqlite3 as db -from gn_auth.auth.db.sqlite3 import with_db_connection from gn_auth.auth.jwks import jwks_directory, list_jwks from gn_auth.auth.errors import NotFoundError, ForbiddenAccess from gn_auth.auth.authentication.users import valid_login, user_by_email @@ -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): @@ -65,6 +68,7 @@ def authorise(): redirect_uri=request.args["redirect_uri"], source_uri=f"{_src.scheme}://{_src.netloc}/", display_forgot_password=with_db_connection( + app.config["AUTH_DB"], __forgot_password_table_exists__)) form = request.form @@ -88,18 +92,18 @@ 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__) + return with_db_connection(app.config["AUTH_DB"], __authorise__) except InvalidClientError as ice: return render_template( "oauth2/oauth2_error.html", error=ice), ice.status_code diff --git a/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py index 140ce36..5c6a430 100644 --- a/gn_auth/auth/authentication/users.py +++ b/gn_auth/auth/authentication/users.py @@ -1,14 +1,14 @@ """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 import sqlite3 from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError +from gn_libs import sqlite3 as db -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.errors import NotFoundError @@ -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/checks.py b/gn_auth/auth/authorisation/checks.py index 66bb723..15d4b99 100644 --- a/gn_auth/auth/authorisation/checks.py +++ b/gn_auth/auth/authorisation/checks.py @@ -2,12 +2,12 @@ from functools import wraps from typing import Callable +from gn_libs import sqlite3 as db from flask import request, current_app as app from gn_auth.auth.errors import InvalidData, AuthorisationError from . import privileges as auth_privs -from ..db import sqlite3 as db from ..authentication.oauth2.resource_server import require_oauth def __system_privileges_in_roles__(conn, user): # TODO: Remove this hack. diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py index ddb0add..d5af3ae 100644 --- a/gn_auth/auth/authorisation/data/genotypes.py +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -1,16 +1,20 @@ """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 gn_libs import sqlite3 as authdb from MySQLdb.cursors import DictCursor -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: @@ -27,12 +31,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ dict, ...]: - """Retrieve genotype data that is not linked to any user group.""" - params = tuple( - (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"]) - for row in linked_genotype_data(authconn)) + tuple( - (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"]) - for row in selected) + """Retrieve genotype data that is not linked to any user group. + + The set of linked datasets is read from the auth database (SQLite) and the + exclusion happens in Python. This avoids embedding the (ever-growing) list + of linked datasets into the MariaDB query as a giant `NOT IN` list, which + MariaDB executes with an unindexed nested-loop join and which degrades + badly as more datasets get linked. + """ + def __key__(row): + """Normalise a row's dataset identity to a comparable tuple.""" + return (int(row["SpeciesId"]), int(row["InbredSetId"]), + int(row["GenoFreezeId"])) + + excluded = {__key__(row) for row in linked_genotype_data(authconn)} | { + __key__(row) for row in selected} query = ( "SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, " "gf.Id AS GenoFreezeId, gf.Name AS dataset_name, " @@ -41,27 +54,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi "FROM Species AS s INNER JOIN InbredSet AS iset " "ON s.SpeciesId=iset.SpeciesId INNER JOIN GenoFreeze AS gf " "ON iset.InbredSetId=gf.InbredSetId ") - - if len(params) > 0 or bool(search_query): - query = query + "WHERE " - - if len(params) > 0: - paramstr = ", ".join(["(%s, %s, %s)"] * len(params)) - query = query + ( - "(s.SpeciesId, iset.InbredSetId, gf.Id) " - f"NOT IN ({paramstr}) " - ) + ("AND " if bool(search_query) else "") - + params: tuple[str, ...] = tuple() if bool(search_query): query = query + ( - "CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) LIKE %s ") - params = params + ((f"%{search_query}%",),)# type: ignore[operator] + "WHERE CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) " + "LIKE %s ") + params = (f"%{search_query}%",) - query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}" with gn3conn.cursor(DictCursor) as cursor: - cursor.execute( - query, tuple(item for sublist in params for item in sublist)) - return tuple(row for row in cursor.fetchall()) + cursor.execute(query, params) + _rows = tuple(row for row in cursor.fetchall()) + + # Filter out linked/selected datasets and apply pagination in Python. + return tuple( + row for row in _rows + if __key__(row) not in excluded)[offset:offset + limit] @authorised_p( ("system:data:link-to-group",), @@ -95,3 +102,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..dd589e0 100644 --- a/gn_auth/auth/authorisation/data/mrna.py +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -1,16 +1,21 @@ """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 gn_libs import sqlite3 as authdb from MySQLdb.cursors import DictCursor -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: @@ -27,14 +32,21 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ dict, ...]: - """Retrieve mrna data that is not linked to any user group.""" - params = tuple( - (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"], - row["ProbeSetFreezeId"]) - for row in linked_mrna_data(authconn)) + tuple( - (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"], - row["ProbeSetFreezeId"]) - for row in selected) + """Retrieve mrna data that is not linked to any user group. + + The set of linked datasets is read from the auth database (SQLite) and the + exclusion happens in Python. This avoids embedding the (ever-growing) list + of linked datasets into the MariaDB query as a giant `NOT IN` list, which + MariaDB executes with an unindexed nested-loop join and which degrades + badly as more datasets get linked. + """ + def __key__(row): + """Normalise a row's dataset identity to a comparable tuple.""" + return (int(row["SpeciesId"]), int(row["InbredSetId"]), + int(row["ProbeFreezeId"]), int(row["ProbeSetFreezeId"])) + + excluded = {__key__(row) for row in linked_mrna_data(authconn)} | { + __key__(row) for row in selected} query = ( "SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, " "pf.ProbeFreezeId, pf.Name AS StudyName, psf.Id AS ProbeSetFreezeId, " @@ -43,27 +55,22 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position "FROM Species AS s INNER JOIN InbredSet AS iset " "ON s.SpeciesId=iset.SpeciesId INNER JOIN ProbeFreeze AS pf " "ON iset.InbredSetId=pf.InbredSetId INNER JOIN ProbeSetFreeze AS psf " - "ON pf.ProbeFreezeId=psf.ProbeFreezeId ") + ( - "WHERE " if (len(params) > 0 or bool(search_query)) else "") - - if len(params) > 0: - paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(params)) - query = query + ( - "(s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) " - f"NOT IN ({paramstr}) " - ) + ("AND " if bool(search_query) else "") - + "ON pf.ProbeFreezeId=psf.ProbeFreezeId ") + params: tuple[str, ...] = tuple() if bool(search_query): query = query + ( - "CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', psf.ShortName) " - "LIKE %s ") - params = params + ((f"%{search_query}%",),)# type: ignore[operator] + "WHERE CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', " + "psf.ShortName) LIKE %s ") + params = (f"%{search_query}%",) - query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}" with gn3conn.cursor(DictCursor) as cursor: - cursor.execute( - query, tuple(item for sublist in params for item in sublist)) - return tuple(row for row in cursor.fetchall()) + cursor.execute(query, params) + _rows = tuple(row for row in cursor.fetchall()) + + # Filter out linked/selected datasets and apply pagination in Python. + return tuple( + row for row in _rows + if __key__(row) not in excluded)[offset:offset + limit] @authorised_p( ("system:data:link-to-group",), @@ -100,3 +107,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(traits_by_datasets.keys())) + 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..bc9a6f7 100644 --- a/gn_auth/auth/authorisation/data/phenotypes.py +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -1,19 +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.resources.checks import authorised_for2 + +from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.checks import require_json +from gn_auth.auth.authorisation.resources.checks import authorised_for_spec + +logger = logging.getLogger(__name__) +phenosbp = Blueprint("phenotypes", __name__) + def linked_phenotype_data( authconn: authdb.DbConnection, gn3conn: gn3db.Connection, @@ -51,41 +62,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.""" @@ -117,20 +93,22 @@ def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> def link_phenotype_data( authconn: authdb.DbConnection, - user, + user: User, group: Group, traits: tuple[dict, ...] ) -> dict: """Link phenotype traits to a user group.""" - if not (authorised_for2(authconn, - user, - system_resource(authconn), - ("system:data:link-to-group",)) + if not (authorised_for_spec( + authconn, + user.user_id, + system_resource(authconn).resource_id, + "(AND system:data:link-to-group)") or - authorised_for2(authconn, - user, - group_resource(authconn, group.group_id), - ("group:data:link-to-group",)) + authorised_for_spec( + authconn, + user.user_id, + group_resource(authconn, group.group_id).resource_id, + "(AND group:data:link-to-group)") ): raise AuthorisationError( "You do not have sufficient privileges to link data to group " @@ -155,3 +133,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..0ffc08e 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,8 @@ 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_libs.sqlite3 import with_db_connection from gn_auth import jobs from gn_auth.commands import run_async_cmd @@ -21,53 +23,30 @@ 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 ..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 +58,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", "") @@ -184,7 +206,7 @@ def __search_mrna__(): ungrouped_mrna_data, gn3conn=gn3conn, search_query=query, selected=__request_key_list__("selected"), limit=limit, offset=offset) - return jsonify(with_db_connection(__ungrouped__)) + return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__)) def __request_key__(key: str, default: Any = ""): if bool(request_json()): @@ -209,7 +231,7 @@ def __search_genotypes__(): ungrouped_genotype_data, gn3conn=gn3conn, search_query=query, selected=__request_key_list__("selected"), limit=limit, offset=offset) - return jsonify(with_db_connection(__ungrouped__)) + return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__)) def __search_phenotypes__(): # launch the external process to search for phenotypes @@ -218,7 +240,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), @@ -284,6 +306,7 @@ def link_genotypes() -> Response: return link_genotype_data(conn, group_by_id(conn, group_id), datasets) return jsonify(with_db_connection( + app.config["SQL_URI"], partial(__link__, **__values__(request_json())))) @data.route("/link/mrna", methods=["POST"]) @@ -309,6 +332,7 @@ def link_mrna() -> Response: return link_mrna_data(conn, group_by_id(conn, group_id), datasets) return jsonify(with_db_connection( + app.config["SQL_URI"], partial(__link__, **__values__(request_json())))) @data.route("/link/phenotype", methods=["POST"]) @@ -351,4 +375,5 @@ def link_phenotype() -> Response: pheno_traits_from_db(gn3conn, traits)) return jsonify(with_db_connection( + app.config["SQL_URI"], partial(__link__, **__values__(request_json())))) diff --git a/gn_auth/auth/authorisation/privileges/models.py b/gn_auth/auth/authorisation/privileges/models.py index 77be7c0..cd23a0c 100644 --- a/gn_auth/auth/authorisation/privileges/models.py +++ b/gn_auth/auth/authorisation/privileges/models.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import Iterable, 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 diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py index 333ba0d..e4a1239 100644 --- a/gn_auth/auth/authorisation/resources/base.py +++ b/gn_auth/auth/authorisation/resources/base.py @@ -1,10 +1,17 @@ """Base types for resources.""" +import logging +import datetime from uuid import UUID from dataclasses import dataclass -from typing import Any, Sequence +from typing import Any, Sequence, Optional import sqlite3 +from gn_auth.auth.authentication.users import User + + +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class ResourceCategory: @@ -22,10 +29,49 @@ class Resource: resource_category: ResourceCategory public: bool resource_data: Sequence[dict[str, Any]] = tuple() + created_by: Optional[User] = None + created_at: datetime.datetime = datetime.datetime(1970, 1, 1, 0, 0, 0) + + @staticmethod + def from_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + resource, + resource_id: Optional[UUID] = None, + resource_name: Optional[str] = None, + resource_category: Optional[ResourceCategory] = None, + public: Optional[bool] = None, + resource_data: Optional[Sequence[dict[str, Any]]] = None, + created_by: Optional[User] = None, + created_at: Optional[datetime.datetime] = None + ): + """Takes a Resource object `resource` and updates the attributes specified in `kwargs`.""" + return Resource( + resource_id=resource_id or resource.resource_id, + resource_name=resource_name or resource.resource_name, + resource_category=resource_category or resource.resource_category, + public=bool(public) or resource.public, + resource_data=resource_data or resource.resource_data, + created_by=created_by or resource.created_by, + created_at=created_at or resource.created_at) def resource_from_dbrow(row: sqlite3.Row): """Convert an SQLite3 resultset row into a resource.""" + try: + created_at = datetime.datetime.fromtimestamp(row["created_at"]) + except IndexError as _ie: + created_at = datetime.datetime(1970, 1, 1, 0, 0, 0) + + try: + created_by = User.from_sqlite3_row({ + "user_id": row["creator_user_id"], + "email": row["creator_email"], + "name": row["creator_name"], + "verified": row["creator_verified"], + "created": row["creator_created"] + }) + except IndexError as _ie: + created_by = None + return Resource( resource_id=UUID(row["resource_id"]), resource_name=row["resource_name"], @@ -33,4 +79,6 @@ def resource_from_dbrow(row: sqlite3.Row): UUID(row["resource_category_id"]), row["resource_category_key"], row["resource_category_description"]), - public=bool(int(row["public"]))) + public=bool(int(row["public"])), + created_by=created_by, + created_at=created_at) diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py index ce2b821..7b33fcc 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -1,100 +1,19 @@ """Handle authorisation checks for resources""" import uuid +import logging import warnings -from functools import reduce -from typing import Sequence +import gn_libs.sqlite3 as authdb from gn_libs.privileges import check -from .base import Resource +from .system.models import system_resource -from ...db import sqlite3 as db -from ...authentication.users import User -from ..privileges.models import db_row_to_privilege - -def __organise_privileges_by_resource_id__(rows): - def __organise__(privs, row): - resource_id = uuid.UUID(row["resource_id"]) - return { - **privs, - resource_id: (row["privilege_id"],) + privs.get( - resource_id, tuple()) - } - return reduce(__organise__, rows, {}) - - -def authorised_for(conn: db.DbConnection, - user: User, - privileges: tuple[str, ...], - 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 " - "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 ({', '.join(['?']*len(resource_ids))})" - f"AND rp.privilege_id IN ({', '.join(['?']*len(privileges))})"), - ((str(user.user_id),) + tuple( - str(r_id) for r_id in resource_ids) + tuple(privileges))) - resource_privileges = __organise_privileges_by_resource_id__( - cursor.fetchall()) - authorised = tuple(resource_id for resource_id, res_privileges - in resource_privileges.items() - if all(priv in res_privileges - for priv in privileges)) - return { - resource_id: resource_id in authorised - for resource_id in resource_ids - } - - -def authorised_for2( - conn: db.DbConnection, - user: User, - resource: Resource, - privileges: tuple[str, ...] -) -> bool: - """ - 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, " - "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.resource_id), str(user.user_id))) - _db_privileges = tuple( - db_row_to_privilege(row) for row in cursor.fetchall()) - - str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges) - return all((requested_privilege in str_privileges) - for requested_privilege in privileges) +logger = logging.getLogger(__name__) def authorised_for_spec( - conn: db.DbConnection, + conn: authdb.DbConnection, user_id: uuid.UUID, resource_id: uuid.UUID, auth_spec: str @@ -103,7 +22,7 @@ def authorised_for_spec( 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: + with authdb.cursor(conn) as cursor: _query = ( "SELECT resources.resource_id, user_roles.user_id, roles.role_id, " "privileges.* " @@ -120,3 +39,55 @@ def authorised_for_spec( (str(resource_id), str(user_id))) _privileges = tuple(row["privilege_id"] for row in cursor.fetchall()) return check(auth_spec, _privileges) + + +def can_delete( + conn: authdb.DbConnection, + user_id: uuid.UUID, + resource_id: uuid.UUID +) -> bool: + """Check whether user is allowed delete a resource and/or its data.""" + 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 fd358f1..13a0c87 100644 --- a/gn_auth/auth/authorisation/resources/common.py +++ b/gn_auth/auth/authorisation/resources/common.py @@ -1,7 +1,7 @@ """Utilities common to more than one resource.""" import uuid -from gn_auth.auth.db import sqlite3 as db +from gn_libs import sqlite3 as db def assign_resource_owner_role( cursor: db.DbCursor, @@ -22,27 +22,3 @@ 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 a1937ce..79bead4 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 @@ -10,16 +11,13 @@ from flask import g from pymonad.maybe import Just, Maybe, Nothing from pymonad.either import Left, Right, Either from pymonad.tools import monad_from_none_or_value +from gn_libs import sqlite3 as db -from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.users import User, user_by_id from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.privileges import Privilege from gn_auth.auth.authorisation.resources.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) @@ -100,8 +98,12 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]: "create a new group."), oauth2_scope="profile group") def create_group( - conn: db.DbConnection, group_name: str, group_leader: User, - group_description: Optional[str] = None) -> Group: + conn: db.DbConnection, + group_name: str, + group_leader: User, + group_description: Optional[str] = None, + creator: Optional[User] = None +) -> Group: """Create a new group.""" def resource_category_by_key( cursor: db.DbCursor, category_key: str): @@ -134,19 +136,20 @@ def create_group( resource_category_by_key( cursor, "group")["resource_category_id"] ), - "public": 0 + "public": 0, + "created_by": str( + creator.user_id if creator else group_leader.user_id), + "created_at": datetime.datetime.now().timestamp() } cursor.execute( "INSERT INTO resources VALUES " - "(:resource_id, :resource_name, :resource_category_id, :public)", + "(:resource_id, :resource_name, :resource_category_id, :public, " + ":created_by, :created_at)", _group_resource) cursor.execute( "INSERT INTO group_resources(resource_id, group_id) " "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, @@ -366,9 +369,6 @@ def remove_user_from_group( user, grp_resource.resource_id, "group-creator") - grant_access_to_sysadmins(cursor, - grp_resource.resource_id, - system_resource(conn).resource_id) @authorised_p( @@ -428,8 +428,8 @@ gjr.status='PENDING'", return tuple(dict(row)for row in cursor.fetchall()) raise AuthorisationError( - "You do not have the appropriate authorisation to access the " - "group's join requests.") + "You need to be the group's leader in order to access the group's join " + "requests.") @authorised_p(("system:group:view-group", "system:group:edit-group"), diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index 31371fd..5762551 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -1,22 +1,26 @@ """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 + +from flask import current_app as app + +from gn_libs import sqlite3 as db +from gn_libs.sqlite3 import with_db_connection -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 from gn_auth.auth.authorisation.roles import Role from gn_auth.auth.authorisation.privileges import Privilege from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.errors import NotFoundError, AuthorisationError -from .system.models import system_resource -from .checks import authorised_for, authorised_for_spec +from .common import assign_resource_owner_role +from .checks import can_edit, authorised_for_spec from .base import Resource, ResourceCategory, resource_from_dbrow -from .common import assign_resource_owner_role, grant_access_to_sysadmins from .groups.models import Group, is_group_leader from .inbredset.models import resource_data as inbredset_resource_data from .mrna import ( @@ -36,26 +40,32 @@ 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] - conn: db.DbConnection, + 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.""" - with db.cursor(conn) as cursor: + def __create_resource__(cursor: db.DbCursor) -> Resource: resource = Resource(uuid4(), resource_name, resource_category, public) cursor.execute( - "INSERT INTO resources VALUES (?, ?, ?, ?)", + "INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?)", (str(resource.resource_id), resource_name, str(resource.resource_category.resource_category_id), - 1 if resource.public else 0)) + 1 if resource.public else 0, + str(user.user_id), + created_at.timestamp())) # TODO: @fredmanglis,@rookie101 # 1. Move the actions below into a (the?) hooks system # 2. Do more checks: A resource can have varying hooks depending on type @@ -70,11 +80,15 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a "VALUES (?, ?)", (str(group.group_id), str(resource.resource_id))) assign_resource_owner_role(cursor, resource.resource_id, user.user_id) - grant_access_to_sysadmins( - cursor, resource.resource_id, system_resource(conn).resource_id) return resource + 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.""" @@ -91,6 +105,27 @@ def delete_resource(conn: db.DbConnection, resource_id: UUID): (str(resource_id),)) +def edit_resource(conn: db.DbConnection, resource_id: UUID, name: str) -> Resource: + """Edit basic resource details.""" + with db.cursor(conn) as cursor: + cursor.execute("UPDATE resources SET resource_name=? " + "WHERE resource_id=?", + (name, str(resource_id))) + cursor.execute( + "SELECT r.*, rc.* FROM resources AS r " + "INNER JOIN resource_categories AS rc " + "ON r.resource_category_id=rc.resource_category_id " + "WHERE r.resource_id=?", + (str(resource_id),)) + _resource = resource_from_dbrow(cursor.fetchone()) + cursor.execute( + "SELECT u.* FROM resources AS r INNER JOIN users AS u " + "ON r.created_by=u.user_id WHERE r.resource_id=?", + (str(resource_id),)) + return Resource.from_resource( + _resource, created_by=User.from_sqlite3_row(cursor.fetchone())) + + def resource_category_by_id( conn: db.DbConnection, category_id: UUID) -> ResourceCategory: """Retrieve a resource category by its ID.""" @@ -118,6 +153,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 = { @@ -125,10 +172,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, @@ -148,22 +204,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, ...]: @@ -236,15 +333,13 @@ 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)) + resource = with_db_connection( + app.config["AUTH_DB"], + partial(resource_by_id, user=user, resource_id=resource_id)) return {# type: ignore[operator] "mrna": mrna_link_data_to_resource, "genotype": genotype_link_data_to_resource, @@ -254,15 +349,13 @@ 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)) + resource = with_db_connection( + app.config["AUTH_DB"], + partial(resource_by_id, user=user, resource_id=resource_id)) dataset_type = resource.resource_category.resource_category_key return { "mrna": mrna_unlink_data_from_resource, @@ -352,9 +445,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..54aa086 100644 --- a/gn_auth/auth/authorisation/resources/system/views.py +++ b/gn_auth/auth/authorisation/resources/system/views.py @@ -1,19 +1,41 @@ """Views relating to `System` resource(s).""" +import logging from dataclasses import asdict -from flask import jsonify, Blueprint +from flask import (request, + jsonify, + Response, + Blueprint, + make_response, + 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(): +def system_roles() -> Response: """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'") + roles_data = tuple( + asdict(role) for role in db_rows_to_roles(cursor.fetchall())) + else: + with require_oauth.acquire("profile group") as the_token: + roles_data = tuple( + asdict(role) for role in + user_roles_on_system(conn, the_token.user)) + + return make_response(jsonify(roles_data), 200) diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index a960ca3..b8c7e24 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -1,9 +1,11 @@ """The views/routes for the resources package""" -from uuid import UUID, uuid4 +import time import json +import logging import operator +import warnings import sqlite3 -import time +from uuid import UUID, uuid4 from dataclasses import asdict from functools import reduce @@ -11,8 +13,14 @@ from functools import reduce from werkzeug.exceptions import BadRequest 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) +from flask import (request, + jsonify, + url_for, + Response, + Blueprint, + make_response, + current_app as app) +import gn_libs.privileges.resources from gn_auth.auth.requests import request_json @@ -43,19 +51,26 @@ from .inbredset.views import popbp from .genotypes.views import genobp from .phenotypes.views import phenobp from .errors import MissingGroupError +from .system.models import system_resource from .groups.models import Group, user_group -from .checks import authorised_for, authorised_for_spec +from .checks import can_delete, authorised_for_spec from .models import ( Resource, resource_data, resource_by_id, public_resources, resource_categories, assign_resource_user, link_data_to_resource, unassign_resource_user, resource_category_by_id, user_roles_on_resources, unlink_data_from_resource, create_resource as _create_resource, - get_resource_id, delete_resource as _delete_resource) + get_resource_id, delete_resource as _delete_resource, + edit_resource as _edit_resource) + +from .system.views import system + +logger = logging.getLogger(__name__) resources = Blueprint("resources", __name__) resources.register_blueprint(popbp, url_prefix="/") resources.register_blueprint(genobp, url_prefix="/") resources.register_blueprint(phenobp, url_prefix="/") +resources.register_blueprint(system, url_prefix="/system") @resources.route("/categories", methods=["GET"]) @require_oauth("profile group resource") @@ -95,8 +110,7 @@ def create_resource() -> Response: "resources.resource_name"): raise InconsistencyError( "You cannot have duplicate resource names.") from sql3ie - app.logger.debug( - f"{type(sql3ie)=}: {sql3ie=}") + logger.debug("type(sql3ie)=%s: sql3ie=%s", type(sql3ie), sql3ie) raise @@ -114,6 +128,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: @@ -198,64 +255,55 @@ def resource_users(resource_id: UUID): """Retrieve all users with access to the given resource.""" with require_oauth.acquire("profile group resource") as the_token: def __the_users__(conn: db.DbConnection): - ########## BEGIN: HACK ########## - # This hack gets the UI to work, but needs replacing. - # It resolves (albeit, temporarily) the bug introduced after a - # refactor that made the system itself, and the groups into - # resources. - grouplevelauth = authorised_for( - conn, - the_token.user, - ("group:resource:view-resource",), - (resource_id,)) - systemlevelauth = authorised_for( - conn, - the_token.user, - ("system:user:list",), - (resource_id,)) - authorised = { - key: (grouplevelauth.get(key, False) - or systemlevelauth.get(key, False)) - for key in grouplevelauth.keys() | systemlevelauth.keys() - } - ########## END: HACK ########## - if authorised.get(resource_id, False): - with db.cursor(conn) as cursor: - def __organise_users_n_roles__(users_n_roles, row): - user_id = UUID(row["user_id"]) - user = users_n_roles.get(user_id, {}).get( - "user", User.from_sqlite3_row(row)) - role = Role( - UUID(row["role_id"]), row["role_name"], - bool(int(row["user_editable"])), tuple()) - return { - **users_n_roles, - user_id: { - "user": user, - "user_group": Group( - UUID(row["group_id"]), row["group_name"], - json.loads(row["group_metadata"])), - "roles": users_n_roles.get( - user_id, {}).get("roles", tuple()) + (role,) - } + if not (authorised_for_spec( + conn, + the_token.user.user_id, + resource_id, + "(AND group:resource:view-resource)") + or authorised_for_spec( + conn, + the_token.user.user_id, + system_resource(conn).resource_id, + "(AND system:user:list)")): + raise AuthorisationError( + "You do not have sufficient privileges to view the resource " + "users.") + with db.cursor(conn) as cursor: + def __organise_users_n_roles__(users_n_roles, row): + user_id = UUID(row["user_id"]) + user = users_n_roles.get(user_id, {}).get( + "user", User.from_sqlite3_row(row)) + role = Role( + UUID(row["role_id"]), row["role_name"], + bool(int(row["user_editable"])), tuple()) + return { + **users_n_roles, + user_id: { + "user": user, + "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,) } - 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 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 " - "WHERE ur.resource_id=?", - (str(resource_id),)) - return reduce(__organise_users_n_roles__, cursor.fetchall(), {}) - raise AuthorisationError( - "You do not have sufficient privileges to view the resource " - "users.") + } + cursor.execute( + "SELECT g.*, u.*, r.* " + "FROM groups AS g INNER JOIN group_users AS gu " + "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 " + "WHERE ur.resource_id=?", + (str(resource_id),)) + return reduce(__organise_users_n_roles__, cursor.fetchall(), {}) 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 @@ -266,6 +314,11 @@ def resource_users(resource_id: UUID): @require_oauth("profile group resource role") def assign_role_to_user(resource_id: UUID) -> Response: """Assign a role on the specified resource to a user.""" + warnings.warn( + f"The function `{__name__}.assign_role_to_user` is deprecated. Please " + " use `gn_auth.auth.authorisation.users.views.assign_user_role`", + DeprecationWarning, + stacklevel=2) with require_oauth.acquire("profile group resource role") as _token: try: form = request_json() @@ -275,11 +328,13 @@ def assign_role_to_user(resource_id: UUID) -> Response: assert bool(user_email), "The user email must be provided." def __assign__(conn: db.DbConnection) -> dict: - authorised_for( - conn, - _token.user, - ("resource:role:assign-role",), - (resource_id,)) + if not authorised_for_spec( + conn, + _token.user.user_id, + resource_id, + "(AND resource:user:assign-role)"): + raise AuthorisationError( + "You are not authorised to assign roles on this resource.") resource = resource_by_id(conn, _token.user, resource_id) user = user_by_email(conn, user_email) return assign_resource_user( @@ -288,12 +343,25 @@ def assign_role_to_user(resource_id: UUID) -> Response: except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr - return jsonify(with_db_connection(__assign__)) + new_uri = url_for( + "oauth2.users.assign_user_role", + user_id=str(_token.user.user_id) + ).replace(str(_token.user.user_id), "<uuid:user_id>") + return jsonify({ + **with_db_connection(__assign__), + "DeprecationWarning": ( + "This endpoint is deprecated and will be removed. Please use " + f"the {new_uri} endpoint.")}) @resources.route("<uuid:resource_id>/user/unassign", methods=["POST"]) @require_oauth("profile group resource role") def unassign_role_to_user(resource_id: UUID) -> Response: """Unassign a role on the specified resource from a user.""" + warnings.warn( + f"The function `{__name__}.unassign_role_to_user` is deprecated. Please " + " use `gn_auth.auth.authorisation.users.views.revoke_user_role`", + DeprecationWarning, + stacklevel=2) with require_oauth.acquire("profile group resource role") as _token: try: form = request_json() @@ -303,11 +371,13 @@ def unassign_role_to_user(resource_id: UUID) -> Response: assert bool(user_id), "The user id must be provided." def __assign__(conn: db.DbConnection) -> dict: - authorised_for( - conn, - _token.user, - ("resource:role:assign-role",), - (resource_id,)) + if not authorised_for_spec( + conn, + _token.user.user_id, + resource_id, + "(AND resource:user:assign-role)"): + raise AuthorisationError( + "You are not authorised to assign roles on this resource.") resource = resource_by_id(conn, _token.user, resource_id) return unassign_resource_user( conn, resource, user_by_id(conn, UUID(user_id)), @@ -315,7 +385,15 @@ def unassign_role_to_user(resource_id: UUID) -> Response: except AssertionError as aserr: raise AuthorisationError(aserr.args[0]) from aserr - return jsonify(with_db_connection(__assign__)) + new_uri = url_for( + "oauth2.users.revoke_user_role", + user_id=str(_token.user.user_id) + ).replace(str(_token.user.user_id), "<uuid:user_id>") + return jsonify({ + **with_db_connection(__assign__), + "DeprecationWarning": ( + "This endpoint is deprecated and will be removed. Please use " + f"the {new_uri} endpoint.")}) def __public_view_params__(cursor, user_id, resource_id): ignore = (str(user_id),) @@ -468,7 +546,7 @@ def resources_authorisation(): }) resp.status_code = 400 except Exception as _exc:#pylint: disable=[broad-except] - app.logger.debug("Generic exception.", exc_info=True) + logger.debug("Generic exception.", exc_info=True) resp = jsonify({ "status": "general-exception", "error_description": ( @@ -506,7 +584,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 = { @@ -581,12 +658,11 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID): db.cursor(conn) as cursor): _role = role_by_id(conn, role_id) - _authorised = authorised_for( - conn, - _token.user, - privileges=("resource:role:edit-role",), - resource_ids=(resource_id,)).get(resource_id) - if not _authorised: + if not authorised_for_spec( + conn, + _token.user.user_id, + resource_id, + "(AND resource:role:edit-role)"): raise AuthorisationError( "You are not authorised to edit/update this role.") @@ -685,13 +761,9 @@ def delete_resource(): form = request_json() try: resource_id = UUID(form.get("resource_id")) - if not authorised_for_spec( - conn, - the_token.user.user_id, - resource_id, - "(OR group:resource:delete-resource system:resource:delete)"): - raise AuthorisationError("You do not have the appropriate " - "privileges to delete this resource.") + if not can_delete(conn, the_token.user.user_id, resource_id): + raise AuthorisationError( + "You are not allowed to delete this resource.") data = resource_data( conn, @@ -709,13 +781,13 @@ def delete_resource(): "description": f"Successfully deleted resource with ID '{resource_id}'." }) except ValueError as _verr: - app.logger.debug("Error!", exc_info=True) + logger.debug("Error!", exc_info=True) return jsonify({ "error": "ValueError", "error-description": "An invalid identifier was provided" }), 400 except TypeError as _terr: - app.logger.debug("Error!", exc_info=True) + logger.debug("Error!", exc_info=True) return jsonify({ "error": "TypeError", "error-description": "An invalid identifier was provided" diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py index 6faeaca..89556a6 100644 --- a/gn_auth/auth/authorisation/roles/models.py +++ b/gn_auth/auth/authorisation/roles/models.py @@ -4,12 +4,12 @@ from functools import reduce from dataclasses import dataclass from typing import Sequence, Iterable, Optional +from gn_libs import sqlite3 as db from pymonad.either import Left, Right, Either from gn_auth.auth.errors import NotFoundError, AuthorisationError from gn_auth.auth.authorisation.resources.base import Resource -from ...db import sqlite3 as db from ...authentication.users import User from ..checks import authorised_p @@ -255,6 +255,25 @@ def assign_user_role_by_name( }) +def unassign_user_role_by_name( + cursor: db.DbCursor, user: User, resource_id: UUID, role_name: str): + """Revoke a role from `user` on `resource_id` by the role's name.""" + cursor.execute( + "SELECT role_id FROM roles WHERE role_name=:role_name", + {"role_name": role_name}) + role = cursor.fetchone() + if role: + cursor.execute( + ("DELETE FROM user_roles " + "WHERE user_id=:user_id AND role_id=:role_id " + "AND resource_id=:resource_id"), + { + "user_id": str(user.user_id), + "role_id": role["role_id"], + "resource_id": str(resource_id) + }) + + def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]: """Fetch a role from the database by its ID.""" with db.cursor(conn) as cursor: diff --git a/gn_auth/auth/authorisation/roles/views.py b/gn_auth/auth/authorisation/roles/views.py index 00def89..91292e7 100644 --- a/gn_auth/auth/authorisation/roles/views.py +++ b/gn_auth/auth/authorisation/roles/views.py @@ -7,7 +7,7 @@ from flask import jsonify, Response, Blueprint, current_app from ...db import sqlite3 as db -from .models import user_role +from .models import role_by_id from ...authentication.oauth2.resource_server import require_oauth @@ -17,11 +17,7 @@ roles = Blueprint("roles", __name__) @require_oauth("profile role") def view_role(role_id: uuid.UUID) -> Response: """Retrieve a user role with id `role_id`""" - def __error__(exc: Exception): - raise exc - with require_oauth.acquire("profile role") as the_token: + with require_oauth.acquire("profile role") as _token: db_uri = current_app.config["AUTH_DB"] with db.connection(db_uri) as conn: - the_role = user_role(conn, the_token.user, role_id) - return the_role.either( - __error__, lambda a_role: jsonify((asdict(a_role[0]), str(a_role[1])))) + return jsonify(asdict(role_by_id(conn, role_id)))# type: ignore[arg-type] diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py index 3d68932..65db8cc 100644 --- a/gn_auth/auth/authorisation/users/admin/models.py +++ b/gn_auth/auth/authorisation/users/admin/models.py @@ -4,6 +4,8 @@ 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 +from gn_auth.auth.authorisation.users.models import create_credentialed_user def sysadmin_role(conn: db.DbConnection) -> Role: @@ -28,14 +30,14 @@ def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User: cursor.execute( "SELECT * FROM roles WHERE role_name='system-administrator'") admin_role = cursor.fetchone() - cursor.execute("SELECT resources.resource_id FROM resources") - cursor.executemany( + sysresource = system_resource(cursor) + cursor.execute( "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)", - tuple({ + { "user_id": str(user.user_id), "role_id": admin_role["role_id"], - "resource_id": resource_id - } for resource_id in cursor.fetchall())) + "resource_id": str(sysresource.resource_id) + }) return user @@ -53,3 +55,13 @@ def revoke_sysadmin_role(conn: db.DbConnection, user: 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))) + + +def create_verified_user( + conn: db.DbConnection, + email: str, + name: str, + password: str +) -> User: + """Create a pre-verified credentialed user with no roles.""" + return create_credentialed_user(conn, email, name, password, verified=True) 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/models.py b/gn_auth/auth/authorisation/users/models.py index d30bfd0..21a9627 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 @@ -8,7 +9,7 @@ from ..checks import authorised_p from ..privileges import Privilege from ...db import sqlite3 as db -from ...authentication.users import User +from ...authentication.users import User, save_user, set_user_password def __process_age_clause__(age_desc: str) -> tuple[str, int]: @@ -128,3 +129,59 @@ 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 + + +def create_credentialed_user( + conn: db.DbConnection, + email: str, + name: str, + password: str, + *, + verified: bool = False +) -> User: + """Create a user with stored password credentials. + + Caller controls the verified flag — pass verified=True to bypass the + normal email-verification flow (e.g. admin provisioning). + """ + with db.cursor(conn) as cursor: + user = save_user(cursor, email, name, verified=verified) + set_user_password(cursor, user, password) + return user diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 4061e07..f7ac055 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -1,14 +1,18 @@ """User authorisation endpoints.""" import uuid +import logging import sqlite3 import secrets import traceback +from functools import partial +from typing import Any, Union from dataclasses import asdict -from typing import Any, Sequence from urllib.parse import urljoin -from functools import reduce, partial from datetime import datetime, timedelta from email.headerregistry import Address + + +import werkzeug.wrappers.response from email_validator import validate_email, EmailNotValidError from flask import ( flash, @@ -16,25 +20,26 @@ from flask import ( jsonify, url_for, redirect, - Response, Blueprint, current_app, - render_template) + make_response, + render_template, + Response as _Response) + +from gn_libs import sqlite3 as db +from gn_libs.sqlite3 import with_db_connection +from gn_libs.privileges.resources import can_assign_role from gn_auth.smtp import send_message, build_email_message from gn_auth.auth.requests import request_json -from gn_auth.auth.db import sqlite3 as db -from gn_auth.auth.db.sqlite3 import with_db_connection -from gn_auth.auth.authorisation.resources.system.models import system_resource - -from gn_auth.auth.authorisation.resources.checks import authorised_for2 from gn_auth.auth.authorisation.resources.models import ( user_resources as _user_resources) from gn_auth.auth.authorisation.roles.models import ( - assign_default_roles, user_roles as _user_roles) + assign_default_roles, assign_user_role_by_name, unassign_user_role_by_name, + user_roles as _user_roles, user_roles_on_resource) from gn_auth.auth.authorisation.resources.groups.models import ( user_group as _user_group) @@ -42,11 +47,12 @@ from gn_auth.auth.errors import ( NotFoundError, UsernameError, PasswordError, - AuthorisationError, + ForbiddenAccess, 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,10 +62,14 @@ 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") +Response = Union[_Response, werkzeug.wrappers.response.Response] + @users.route("/", methods=["GET"]) @require_oauth("profile") def user_details() -> Response: @@ -78,6 +88,22 @@ def user_details() -> Response: **({"group": asdict(the_group)} if the_group else {}) }) +@users.route("/<user_id>", methods=["GET"]) +def get_user(user_id: str) -> Union[Response, tuple[Response, int]]: + """Fetch user details by user_id.""" + try: + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_id(conn, uuid.UUID(user_id)) + return jsonify({ + "user_id": str(user.user_id), + "email": user.email, + "name": user.name + }) + except ValueError: + return jsonify({"error": "Invalid user ID format"}), 400 + except NotFoundError: + return jsonify({"error": "User not found"}), 404 + @users.route("/roles", methods=["GET"]) @require_oauth("role") def user_roles() -> Response: @@ -218,11 +244,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 +326,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") @@ -328,8 +363,9 @@ def user_join_request_exists(): "exists": False } with require_oauth.acquire("profile group") as the_token: - return jsonify(with_db_connection(partial( - __request_exists__, user=the_token.user))) + return jsonify(with_db_connection( + current_app.config["SQL_URI"], + partial(__request_exists__, user=the_token.user))) @users.route("/list", methods=["GET"]) @require_oauth("profile user") @@ -560,163 +596,51 @@ def change_password(forgot_password_token): return change_password_page -def __delete_users_individually__(cursor, user_ids, tables): - """Recovery function with dismal performance.""" - _errors = tuple() - for _user_id in user_ids: - for _table, _col in tables: - try: - cursor.execute( - f"DELETE FROM {_table} WHERE {_col}=?", - (str(_user_id),)) - except sqlite3.IntegrityError: - _errors = _errors + ( - (("user_id", _user_id), - ("reason", f"User has data in table {_table}")),) - - return _errors - - -def __fetch_non_deletable_users__(cursor, ids_and_reasons): - """Fetch detail for non-deletable users.""" - def __merge__(acc, curr): - _curr = dict(curr) - _this_dict = acc.get( - curr["user_id"], {"reasons": tuple()}) - _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],) - return {**acc, curr["user_id"]: _this_dict} - - _reasons_by_id = reduce(__merge__, - (dict(row) for row in ids_and_reasons), - {}) - _user_ids = tuple(_reasons_by_id.keys()) - _paramstr = ", ".join(["?"] * len(_user_ids)) - cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})", - _user_ids) - return tuple({ - "user": dict(row), - "reasons": _reasons_by_id[row["user_id"]]["reasons"] - } for row in cursor.fetchall()) - - -def __non_deletable_with_reason__( - user_ids: tuple[str, ...], - dbrows: Sequence[sqlite3.Row], - reason: str - ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]: - """Build a list of 'non-deletable' user objects.""" - return tuple((("user_id", _uid), ("reason", reason)) - for _uid in user_ids - if _uid in tuple(row["user_id"] for row in dbrows)) - - -@users.route("/delete", methods=["POST"]) -@require_oauth("profile user role") -def delete_users(): - """Delete the specified user.""" - with (require_oauth.acquire("profile") as _token, - db.connection(current_app.config["AUTH_DB"]) as conn, - db.cursor(conn) as cursor): - if not authorised_for2(conn, - _token.user, - system_resource(conn), - ("system:user:delete-user",)): - raise AuthorisationError( - "You need the `system:user:delete-user` privilege to delete " - "users from the system.") - - _form = request_json() - _user_ids = _form.get("user_ids", []) - _non_deletable = set() - if str(_token.user.user_id) in _user_ids: - _non_deletable.add( - (("user_id", str(_token.user.user_id),), - ("reason", "You are not allowed to delete yourself."))) - - cursor.execute("SELECT user_id FROM group_users") - _group_members = tuple(row["user_id"] for row in cursor.fetchall()) - _non_deletable.update(__non_deletable_with_reason__( - _user_ids, - cursor.fetchall(), - "User is member of a user group.")) - - cursor.execute("SELECT user_id FROM oauth2_clients;") - _non_deletable.update(__non_deletable_with_reason__( - _user_ids, - cursor.fetchall(), - "User is registered owner of an OAuth client.")) - - _important_roles = ( - "group-leader", - "resource-owner", - "system-administrator", - "inbredset-group-owner") - _paramstr = ",".join(["?"] * len(_important_roles)) - cursor.execute( - "SELECT DISTINCT user_roles.user_id FROM user_roles " - "INNER JOIN roles ON user_roles.role_id=roles.role_id " - f"WHERE roles.role_name IN ({_paramstr})", - _important_roles) - _non_deletable.update(__non_deletable_with_reason__( - _user_ids, - cursor.fetchall(), - f"User holds on of the following roles: {_important_roles}")) - - _delete = tuple(uid for uid in _user_ids if uid not in - (dict(row)["user_id"] for row in _non_deletable)) - _paramstr = ", ".join(["?"] * len(_delete)) - if len(_delete) > 0: - _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")) - try: - for _table, _col in _dependent_tables: - cursor.execute( - f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})", - _delete) - except sqlite3.IntegrityError: - _non_deletable.update(__delete_users_individually__( - cursor, _delete, _dependent_tables)) - - _not_deleted = __fetch_non_deletable_users__( - cursor, _non_deletable) - _delete = tuple(# rebuild with those that failed. - _user_id for _user_id in _delete if _user_id not in - tuple(row["user"]["user_id"] for row in _not_deleted)) - _paramstr = ", ".join(["?"] * len(_delete)) - cursor.execute( - f"DELETE FROM users WHERE user_id IN ({_paramstr})", - _delete) - _deleted_rows = cursor.rowcount - return jsonify({ - "total-requested": len(_user_ids), - "total-deleted": _deleted_rows, - "not-deleted": _not_deleted, - "deleted": _deleted_rows, - "message": ( - f"Successfully deleted {_deleted_rows} users." + - (" Some users could not be deleted." - if len(_user_ids) - _deleted_rows > 0 - else "")) - }) - - _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable) - - return jsonify({ - "total-requested": len(_user_ids), - "total-deleted": 0, - "not-deleted": _not_deleted, - "deleted": 0, - "error": "Zero users were deleted", - "error_description": ( - "No users were selected for deletion." - if len(_user_ids) == 0 - else ("The selected users are system administrators, group " - "members, or resource owners.")) - }), 400 +@users.route("/<uuid:user_id>/roles/assign", methods=["POST"]) +def assign_user_role(user_id: uuid.UUID) -> Response: + """Assign a role to a user on a given resource.""" + with (require_oauth.acquire("profile user resource role") as token, + db.connection(current_app.config["AUTH_DB"]) as conn): + form = request_json() + resource_id = uuid.UUID(form["resource_id"]) + caller_roles = user_roles_on_resource( + conn, token.user.user_id, resource_id) + if not can_assign_role(tuple( + priv.privilege_id for role in caller_roles + for priv in role.privileges)): + raise ForbiddenAccess( + "You need the `resource:user:assign-role` privilege.") + target = user_by_id(conn, user_id) + with db.cursor(conn) as cursor: + assign_user_role_by_name( + cursor, target, resource_id, form["role_name"]) + return make_response(jsonify({ + "user_id": str(user_id), + "role_name": form["role_name"], + "resource_id": form["resource_id"] + }), 200) + + +@users.route("/<uuid:user_id>/roles/revoke", methods=["POST"]) +def revoke_user_role(user_id: uuid.UUID) -> Response: + """Revoke a role from a user on a given resource.""" + with (require_oauth.acquire("profile user resource role") as token, + db.connection(current_app.config["AUTH_DB"]) as conn): + form = request_json() + resource_id = uuid.UUID(form["resource_id"]) + caller_roles = user_roles_on_resource( + conn, token.user.user_id, resource_id) + if not can_assign_role(tuple( + priv.privilege_id for role in caller_roles + for priv in role.privileges)): + raise ForbiddenAccess( + "You need the `resource:user:assign-role` privilege.") + target = user_by_id(conn, user_id) + with db.cursor(conn) as cursor: + unassign_user_role_by_name( + cursor, target, resource_id, form["role_name"]) + return make_response(jsonify({ + "user_id": str(user_id), + "role_name": form["role_name"], + "resource_id": form["resource_id"] + }), 200) 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..832d1bd 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.""" @@ -14,6 +14,7 @@ class ForbiddenAccess(AuthorisationError): class UserRegistrationError(AuthorisationError): """Raised whenever a user registration fails""" + error_code: int = 400 class UserVerificationError(UserRegistrationError): """Raised when verification of a user fails.""" @@ -26,6 +27,7 @@ class InvalidData(AuthorisationError): """ Exception if user requests invalid data """ + error_code: int = 400 class InconsistencyError(AuthorisationError): """ @@ -37,8 +39,10 @@ class PasswordError(AuthorisationError): """ Raise in case of an error with passwords. """ + error_code: int = 400 class UsernameError(AuthorisationError): """ Raise in case of an error with a user's name. """ + error_code: int = 400 diff --git a/gn_auth/auth/system/__init__.py b/gn_auth/auth/system/__init__.py new file mode 100644 index 0000000..5455f73 --- /dev/null +++ b/gn_auth/auth/system/__init__.py @@ -0,0 +1,5 @@ +"""This is for system-specific functionality, e.g. administration. + +This is not meant for day-to-day user activities, more for administrative tasks +to fix things. +""" diff --git a/gn_auth/auth/system/admin/resources.py b/gn_auth/auth/system/admin/resources.py new file mode 100644 index 0000000..59d7686 --- /dev/null +++ b/gn_auth/auth/system/admin/resources.py @@ -0,0 +1,71 @@ +"""Administrative endpoints concerning resources.""" +from uuid import UUID + +from flask import jsonify, Response, Blueprint, current_app as app + +from gn_libs import sqlite3 as db + +from gn_auth.auth.errors import ForbiddenAccess +from gn_auth.auth.requests import request_json +from gn_auth.auth.authentication.users import user_by_id +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth +from gn_auth.auth.authorisation.roles.models import ( + user_roles_on_resource, + assign_user_role_by_name, + unassign_user_role_by_name) +from gn_auth.auth.authorisation.resources.system.models import system_resource + +resources = Blueprint("resources", __name__) + + +def _require_assign_owner_privilege(conn, user) -> None: + """Raise ForbiddenAccess if user lacks system:resource:assign-owner on the system resource.""" + _sys = system_resource(conn) + sys_roles = user_roles_on_resource(conn, user.user_id, _sys.resource_id) + sys_privs = tuple( + priv.privilege_id for role in sys_roles for priv in role.privileges) + if "system:resource:assign-owner" not in sys_privs: + raise ForbiddenAccess( + "You need the 'system:resource:assign-owner' privilege.") + + +@resources.route("/<uuid:resource_id>/assign-owner", methods=["POST"]) +def assign_resource_owner(resource_id: UUID) -> Response: + """Assign the resource-owner role to a user on the given resource. + + Only users with system:resource:assign-owner (sysadmins) may call this. + This is the correct path to bootstrap ownership on a resource that has + no owner yet. + """ + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + _require_assign_owner_privilege(conn, _token.user) + form = request_json() + target = user_by_id(conn, UUID(form["user_id"])) + with db.cursor(conn) as cursor: + assign_user_role_by_name(cursor, target, resource_id, "resource-owner") + return jsonify({ + "user_id": form["user_id"], + "resource_id": str(resource_id), + "role_name": "resource-owner", + }) + + +@resources.route("/<uuid:resource_id>/revoke-owner", methods=["POST"]) +def revoke_resource_owner(resource_id: UUID) -> Response: + """Revoke the resource-owner role from a user on the given resource. + + Requires the same system:resource:assign-owner privilege as assign-owner. + """ + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn): + _require_assign_owner_privilege(conn, _token.user) + form = request_json() + target = user_by_id(conn, UUID(form["user_id"])) + with db.cursor(conn) as cursor: + unassign_user_role_by_name(cursor, target, resource_id, "resource-owner") + return jsonify({ + "user_id": form["user_id"], + "resource_id": str(resource_id), + "role_name": "resource-owner", + }) diff --git a/gn_auth/auth/system/admin/users.py b/gn_auth/auth/system/admin/users.py new file mode 100644 index 0000000..7dfc7ca --- /dev/null +++ b/gn_auth/auth/system/admin/users.py @@ -0,0 +1,232 @@ +"""Administrative endpoints for user management.""" +import sqlite3 +from functools import reduce +from typing import Sequence + +from flask import jsonify, Response, Blueprint, make_response, current_app as app + +from gn_libs import sqlite3 as db + +from email_validator import validate_email, EmailNotValidError + +from gn_libs.privileges.system import can_create_or_delete_user + +from gn_auth.auth.errors import ( + PasswordError, + UsernameError, + ForbiddenAccess, + UserRegistrationError) +from gn_auth.auth.requests import request_json +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth +from gn_auth.auth.authorisation.resources.system.models import user_roles_on_system +from gn_auth.auth.authorisation.users.admin.models import create_verified_user +from gn_auth.auth.authorisation.users.views import ( + validate_password, + validate_username) + +users = Blueprint("users", __name__) + + +@users.route("/create", methods=["POST"]) +def create_user() -> Response: + """Create a new user. Requires system:user:create-user privilege.""" + with (require_oauth.acquire("profile") as token, + db.connection(app.config["AUTH_DB"]) as conn): + u_roles = user_roles_on_system(conn, token.user) + if not can_create_or_delete_user(tuple( + priv.privilege_id for role in u_roles + for priv in role.privileges)): + raise ForbiddenAccess( + "You need the `system:user:create-user` privilege.") + form = request_json() + errors = {} + try: + email = validate_email( + form.get("email", "").strip(), check_deliverability=False) + except EmailNotValidError as enve: + errors["email"] = ( + f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}") + + try: + username = validate_username(form.get("name", "").strip()) + except UsernameError as uerr: + errors["name"] = str(uerr.args[0]) + + try: + passwd = validate_password( + form.get("password", "").strip(), + form.get("password", "").strip()) + except PasswordError as perr: + errors["password"] = str(perr.args[0]) + + if len(tuple(errors.keys())) > 0: + raise UserRegistrationError(tuple(errors.values())) + + user = create_verified_user(conn, email.normalized, username, passwd) + + return make_response(jsonify({ + "user_id": str(user.user_id), + "email": user.email, + "name": user.name + }), 201) + + +def __delete_users_individually__(cursor, user_ids, tables): + """Recovery function with dismal performance.""" + _errors = tuple() + for _user_id in user_ids: + for _table, _col in tables: + try: + cursor.execute( + f"DELETE FROM {_table} WHERE {_col}=?", + (str(_user_id),)) + except sqlite3.IntegrityError: + _errors = _errors + ( + (("user_id", _user_id), + ("reason", f"User has data in table {_table}")),) + + return _errors + + +def __fetch_non_deletable_users__(cursor, ids_and_reasons): + """Fetch detail for non-deletable users.""" + def __merge__(acc, curr): + _curr = dict(curr) + _this_dict = acc.get( + curr["user_id"], {"reasons": tuple()}) + _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],) + return {**acc, curr["user_id"]: _this_dict} + + _reasons_by_id = reduce(__merge__, + (dict(row) for row in ids_and_reasons), + {}) + _user_ids = tuple(_reasons_by_id.keys()) + _paramstr = ", ".join(["?"] * len(_user_ids)) + cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})", + _user_ids) + return tuple({ + "user": dict(row), + "reasons": _reasons_by_id[row["user_id"]]["reasons"] + } for row in cursor.fetchall()) + + +def __non_deletable_with_reason__( + user_ids: tuple[str, ...], + dbrows: Sequence[sqlite3.Row], + reason: str + ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]: + """Build a list of 'non-deletable' user objects.""" + return tuple((("user_id", _uid), ("reason", reason)) + for _uid in user_ids + if _uid in tuple(row["user_id"] for row in dbrows)) + + +@users.route("/delete", methods=["POST"]) +def delete_users() -> Response: + """Delete the specified users. Requires system:user:delete-user privilege.""" + with (require_oauth.acquire("profile user role") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + u_roles = user_roles_on_system(conn, _token.user) + if not can_create_or_delete_user(tuple( + priv.privilege_id for role in u_roles + for priv in role.privileges)): + raise ForbiddenAccess( + "You need the `system:user:delete-user` privilege to delete " + "users from the system.") + + _form = request_json() + _user_ids = _form.get("user_ids", []) + _non_deletable = set() + if str(_token.user.user_id) in _user_ids: + _non_deletable.add( + (("user_id", str(_token.user.user_id),), + ("reason", "You are not allowed to delete yourself."))) + + cursor.execute("SELECT user_id FROM group_users") + _group_members = tuple(row["user_id"] for row in cursor.fetchall()) + _non_deletable.update(__non_deletable_with_reason__( + _user_ids, + cursor.fetchall(), + "User is member of a user group.")) + + cursor.execute("SELECT user_id FROM oauth2_clients;") + _non_deletable.update(__non_deletable_with_reason__( + _user_ids, + cursor.fetchall(), + "User is registered owner of an OAuth client.")) + + _important_roles = ( + "group-leader", + "resource-owner", + "system-administrator", + "inbredset-group-owner") + _paramstr = ",".join(["?"] * len(_important_roles)) + cursor.execute( + "SELECT DISTINCT user_roles.user_id FROM user_roles " + "INNER JOIN roles ON user_roles.role_id=roles.role_id " + f"WHERE roles.role_name IN ({_paramstr})", + _important_roles) + _non_deletable.update(__non_deletable_with_reason__( + _user_ids, + cursor.fetchall(), + f"User holds on of the following roles: {_important_roles}")) + + _delete = tuple(uid for uid in _user_ids if uid not in + (dict(row)["user_id"] for row in _non_deletable)) + _paramstr = ", ".join(["?"] * len(_delete)) + if len(_delete) > 0: + _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")) + try: + for _table, _col in _dependent_tables: + cursor.execute( + f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})", + _delete) + except sqlite3.IntegrityError: + _non_deletable.update(__delete_users_individually__( + cursor, _delete, _dependent_tables)) + + _not_deleted = __fetch_non_deletable_users__( + cursor, _non_deletable) + _delete = tuple(# rebuild with those that failed. + _user_id for _user_id in _delete if _user_id not in + tuple(row["user"]["user_id"] for row in _not_deleted)) + _paramstr = ", ".join(["?"] * len(_delete)) + cursor.execute( + f"DELETE FROM users WHERE user_id IN ({_paramstr})", + _delete) + _deleted_rows = cursor.rowcount + return jsonify({ + "total-requested": len(_user_ids), + "total-deleted": _deleted_rows, + "not-deleted": _not_deleted, + "deleted": _deleted_rows, + "message": ( + f"Successfully deleted {_deleted_rows} users." + + (" Some users could not be deleted." + if len(_user_ids) - _deleted_rows > 0 + else "")) + }) + + _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable) + + return make_response(jsonify({ + "total-requested": len(_user_ids), + "total-deleted": 0, + "not-deleted": _not_deleted, + "deleted": 0, + "error": "Zero users were deleted", + "error_description": ( + "No users were selected for deletion." + if len(_user_ids) == 0 + else ("The selected users are system administrators, group " + "members, or resource owners.")) + }), 400) diff --git a/gn_auth/auth/system/admin/views.py b/gn_auth/auth/system/admin/views.py new file mode 100644 index 0000000..f1f76f9 --- /dev/null +++ b/gn_auth/auth/system/admin/views.py @@ -0,0 +1,10 @@ +"""Administrative endpoints.""" + +from flask import Blueprint + +from .resources import resources +from .users import users + +admin = Blueprint("admin", __name__) +admin.register_blueprint(resources, url_prefix="/resources") +admin.register_blueprint(users, url_prefix="/users") diff --git a/gn_auth/auth/system/views.py b/gn_auth/auth/system/views.py new file mode 100644 index 0000000..2cebfbc --- /dev/null +++ b/gn_auth/auth/system/views.py @@ -0,0 +1,8 @@ +"""The Blueprints for this package.""" + +from flask import Blueprint + +from .admin.views import admin + +systembp = Blueprint("sysadm", __name__) +systembp.register_blueprint(admin, url_prefix="/administration") diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py index 6867f38..383dc9f 100644 --- a/gn_auth/auth/views.py +++ b/gn_auth/auth/views.py @@ -10,7 +10,9 @@ from .authorisation.roles.views import roles from .authorisation.resources.views import resources from .authorisation.privileges.views import privileges from .authorisation.resources.groups.views import groups -from .authorisation.resources.system.views import system + +from .system.views import systembp + oauth2 = Blueprint("oauth2", __name__) @@ -20,6 +22,6 @@ oauth2.register_blueprint(users, url_prefix="/user") oauth2.register_blueprint(roles, url_prefix="/role") oauth2.register_blueprint(admin, url_prefix="/admin") oauth2.register_blueprint(groups, url_prefix="/group") -oauth2.register_blueprint(system, url_prefix="/system") oauth2.register_blueprint(resources, url_prefix="/resource") oauth2.register_blueprint(privileges, url_prefix="/privileges") +oauth2.register_blueprint(systembp, url_prefix="/system") |
