diff options
Diffstat (limited to 'gn_auth/auth')
28 files changed, 547 insertions, 143 deletions
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 27783ac..c802091 100644 --- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py @@ -1,8 +1,12 @@ """JWT as Authorisation Grant""" import uuid +import time +from typing import Optional from flask import current_app as app +from authlib.jose import jwt +from authlib.common.encoding import to_native from authlib.common.security import generate_token from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant from authlib.oauth2.rfc7523.token import ( @@ -10,7 +14,8 @@ from authlib.oauth2.rfc7523.token import ( from gn_auth.debug import __pk__ 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.users import User, user_by_id +from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): @@ -20,12 +25,24 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): DEFAULT_EXPIRES_IN = 300 - def get_token_data(#pylint: disable=[too-many-arguments] + def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments] self, grant_type, client, expires_in=None, user=None, scope=None ): """Post process data to prevent JSON serialization problems.""" - tokendata = super().get_token_data( - grant_type, client, expires_in, user, scope) + issued_at = int(time.time()) + tokendata = { + "scope": self.get_allowed_scope(client, scope), + "grant_type": grant_type, + "iat": issued_at, + "client_id": client.get_client_id() + } + if isinstance(expires_in, int) and expires_in > 0: + tokendata["exp"] = issued_at + expires_in + if self.issuer: + tokendata["iss"] = self.issuer + if user: + tokendata["sub"] = self.get_sub_value(user) + return { **{ key: str(value) if key.endswith("_id") else value @@ -36,8 +53,38 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator): "oauth2_client_id": str(client.client_id) } + def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments] + self, + grant_type: str, + client: OAuth2Client, + user: Optional[User] = None, + scope: Optional[str] = None, + expires_in: Optional[int] = None + ) -> dict: + """Generate a bearer token for OAuth 2.0 authorization token endpoint. + + :param client: the client that making the request. + :param grant_type: current requested grant_type. + :param user: current authorized user. + :param expires_in: if provided, use this value as expires_in. + :param scope: current requested scope. + :return: Token dict + """ + + token_data = self.get_token_data(grant_type, client, expires_in, user, scope) + access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False) + token = { + "token_type": "Bearer", + "access_token": to_native(access_token) + } + if expires_in: + token["expires_in"] = expires_in + if scope: + token["scope"] = scope + return token + - def __call__(# pylint: disable=[too-many-arguments] + def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments] self, grant_type, client, user=None, scope=None, expires_in=None, include_refresh_token=True ): diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py index fd6804d..f897d89 100644 --- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py +++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py @@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): else Nothing) ).maybe(None, lambda _tok: _tok) - def authenticate_user(self, credential): + def authenticate_user(self, refresh_token): """Check that user is valid for given token.""" with connection(app.config["AUTH_DB"]) as conn: try: - return user_by_id(conn, credential.user.user_id) + return user_by_id(conn, refresh_token.user.user_id) except NotFoundError as _nfe: return None return None - def revoke_old_credential(self, credential): + def revoke_old_credential(self, refresh_token): """Revoke any old refresh token after issuing new refresh token.""" with connection(app.config["AUTH_DB"]) as conn: - if credential.parent_of is not None: - revoke_refresh_token(conn, credential) + if refresh_token.parent_of is not None: + revoke_refresh_token(conn, refresh_token) 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 cca75f4..71769e1 100644 --- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py +++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py @@ -1,5 +1,7 @@ """Implement model for JWTBearerToken""" import uuid +import time +from typing import Optional from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken @@ -28,3 +30,21 @@ class JWTBearerToken(_JWTBearerToken): def check_client(self, client): """Check that the client is right.""" return self.client.get_client_id() == client.get_client_id() + + + def get_expires_in(self) -> Optional[int]: + """Return the number of seconds the token is valid for since issue. + + If `None`, the token never expires.""" + if "exp" in self: + return self['exp'] - self['iat'] + return None + + + def is_expired(self): + """Check whether the token is expired. + + If there is no 'exp' member, assume this token will never expire.""" + if "exp" in self: + return self["exp"] < time.time() + return False diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py index 79b6e53..1639e2e 100644 --- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -1,6 +1,5 @@ """OAuth2 Client model.""" import json -import logging import datetime from uuid import UUID from functools import cached_property @@ -8,6 +7,7 @@ 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 @@ -65,7 +65,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): - logging.debug("No Public JWKs URI set for client!") + app.logger.debug("No Public JWKs URI set for client!") return KeySet([]) try: ## IMPORTANT: This can cause a deadlock if the client is working in @@ -74,15 +74,16 @@ class OAuth2Client(ClientMixin): return KeySet([JsonWebKey.import_key(key) for key in requests.get( jwksuri, + timeout=300, allow_redirects=True).json()["jwks"]]) except requests.ConnectionError as _connerr: - logging.debug( + app.logger.debug( "Could not connect to provided URI: %s", jwksuri, exc_info=True) except JSONDecodeError as _jsonerr: - logging.debug( + app.logger.debug( "Could not convert response to JSON", exc_info=True) except Exception as _exc:# pylint: disable=[broad-except] - logging.debug( + app.logger.debug( "Error retrieving the JWKs for the client.", exc_info=True) return KeySet([]) diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py index 9c885e2..8ecf923 100644 --- a/gn_auth/auth/authentication/oauth2/resource_server.py +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -43,6 +43,11 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator): self._last_jwks_update = datetime.now(tz=timezone.utc) self._refresh_frequency = timedelta(hours=int( extra_attributes.get("jwt_refresh_frequency_hours", 6))) + self.claims_options = { + 'exp': {'essential': False}, + 'client_id': {'essential': True}, + 'grant_type': {'essential': True}, + } def __refresh_jwks__(self): now = datetime.now(tz=timezone.utc) diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py index a8109b7..8ac5106 100644 --- a/gn_auth/auth/authentication/oauth2/server.py +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -3,12 +3,12 @@ import uuid from typing import Callable from datetime import datetime -from flask import Flask, current_app -from authlib.jose import jwt, KeySet +from flask import Flask, current_app, request as flask_request +from authlib.jose import KeySet +from authlib.oauth2.rfc6749 import OAuth2Request from authlib.oauth2.rfc6749.errors import InvalidClientError from authlib.integrations.flask_oauth2 import AuthorizationServer -from authlib.oauth2.rfc6749 import OAuth2Request -from authlib.integrations.flask_helpers import create_oauth_request +from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.jwks import ( @@ -16,13 +16,9 @@ from gn_auth.auth.jwks import ( jwks_directory, newest_jwk_with_rotation) +from .models.jwt_bearer_token import JWTBearerToken from .models.oauth2client import client as fetch_client from .models.oauth2token import OAuth2Token, save_token -from .models.jwtrefreshtoken import ( - JWTRefreshToken, - link_child_token, - save_refresh_token, - load_refresh_token) from .grants.password_grant import PasswordGrant from .grants.refresh_token_grant import RefreshTokenGrant @@ -34,6 +30,8 @@ from .endpoints.introspection import IntrospectionEndpoint from .resource_server import require_oauth, JWTBearerTokenValidator +_TWO_HOURS_ = 2 * 60 * 60 + def create_query_client_func() -> Callable: """Create the function that loads the client.""" @@ -50,54 +48,32 @@ def create_query_client_func() -> Callable: return __query_client__ -def create_save_token_func(token_model: type, app: Flask) -> Callable: +def create_save_token_func(token_model: type) -> Callable: """Create the function that saves the token.""" + def __ignore_token__(token, request):# pylint: disable=[unused-argument] + """Ignore the token: i.e. Do not save it.""" + def __save_token__(token, request): - _jwt = jwt.decode( - token["access_token"], - newest_jwk_with_rotation( - jwks_directory(app), - int(app.config["JWKS_ROTATION_AGE_DAYS"]))) - _token = token_model( - token_id=uuid.UUID(_jwt["jti"]), - client=request.client, - user=request.user, - **{ - "refresh_token": None, - "revoked": False, - "issued_at": datetime.now(), - **token - }) with db.connection(current_app.config["AUTH_DB"]) as conn: - save_token(conn, _token) - old_refresh_token = load_refresh_token( + save_token( conn, - request.form.get("refresh_token", "nosuchtoken") - ) - new_refresh_token = JWTRefreshToken( - token=_token.refresh_token, + token_model( + **token, + token_id=uuid.uuid4(), client=request.client, user=request.user, - issued_with=uuid.UUID(_jwt["jti"]), - issued_at=datetime.fromtimestamp(_jwt["iat"]), - expires=datetime.fromtimestamp( - old_refresh_token.then( - lambda _tok: _tok.expires.timestamp() - ).maybe((int(_jwt["iat"]) + - RefreshTokenGrant.DEFAULT_EXPIRES_IN), - lambda _expires: _expires)), - scope=_token.get_scope(), + issued_at=datetime.now(), revoked=False, - parent_of=None) - save_refresh_token(conn, new_refresh_token) - old_refresh_token.then(lambda _tok: link_child_token( - conn, _tok.token, new_refresh_token.token)) + expires_in=_TWO_HOURS_)) - return __save_token__ + return { + OAuth2Token: __save_token__, + JWTBearerToken: __ignore_token__ + }[token_model] def make_jwt_token_generator(app): """Make token generator function.""" - def __generator__(# pylint: disable=[too-many-arguments] + def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments] grant_type, client, user=None, @@ -106,15 +82,17 @@ def make_jwt_token_generator(app): include_refresh_token=True ): return JWTBearerTokenGenerator( - newest_jwk_with_rotation( + secret_key=newest_jwk_with_rotation( jwks_directory(app), - int(app.config["JWKS_ROTATION_AGE_DAYS"]))).__call__( - grant_type, - client, - user, - scope, - JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN, - include_refresh_token) + int(app.config["JWKS_ROTATION_AGE_DAYS"])), + issuer=flask_request.host_url, + alg="RS256").__call__( + grant_type=grant_type, + client=client, + user=user, + scope=scope, + expires_in=expires_in, + include_refresh_token=include_refresh_token) return __generator__ @@ -124,8 +102,16 @@ class JsonAuthorizationServer(AuthorizationServer): def create_oauth2_request(self, request): """Create an OAuth2 Request from the flask request.""" - res = create_oauth_request(request, OAuth2Request, True) - return res + match flask_request.headers.get("Content-Type"): + case "application/json": + req = OAuth2Request(flask_request.method, + flask_request.url, + flask_request.get_json(), + flask_request.headers) + case _: + req = FlaskOAuth2Request(flask_request) + + return req def setup_oauth2_server(app: Flask) -> None: @@ -153,7 +139,7 @@ def setup_oauth2_server(app: Flask) -> None: server.init_app( app, query_client=create_query_client_func(), - save_token=create_save_token_func(OAuth2Token, app)) + save_token=create_save_token_func(JWTBearerToken)) app.config["OAUTH2_SERVER"] = server ## Set up the token validators diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py index d0b55b4..0e2c4eb 100644 --- a/gn_auth/auth/authentication/oauth2/views.py +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -77,7 +77,7 @@ def authorise(): try: email = validate_email( form.get("user:email"), check_deliverability=False) - user = user_by_email(conn, email["email"]) + user = user_by_email(conn, email["email"]) # type: ignore if valid_login(conn, user, form.get("user:password", "")): if not user.verified: return redirect( diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py index 7cae91a..ddb0add 100644 --- a/gn_auth/auth/authorisation/data/genotypes.py +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -22,7 +22,7 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]: "You do not have sufficient privileges to link data to (a) " "group(s)."), oauth2_scope="profile group resource") -def ungrouped_genotype_data(# pylint: disable=[too-many-arguments] +def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments] authconn: authdb.DbConnection, gn3conn: gn3db.Connection, search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py index 82a0f82..0cc644e 100644 --- a/gn_auth/auth/authorisation/data/mrna.py +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -22,7 +22,7 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]: "You do not have sufficient privileges to link data to (a) " "group(s)."), oauth2_scope="profile group resource") -def ungrouped_mrna_data(# pylint: disable=[too-many-arguments] +def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments] authconn: authdb.DbConnection, gn3conn: gn3db.Connection, search_query: str, selected: tuple[dict, ...] = tuple(), limit: int = 10000, offset: int = 0) -> tuple[ diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py index 08a0524..3e45af3 100644 --- a/gn_auth/auth/authorisation/data/phenotypes.py +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -8,8 +8,12 @@ from MySQLdb.cursors import DictCursor from gn_auth.auth.db import sqlite3 as authdb +from gn_auth.auth.errors import AuthorisationError from gn_auth.auth.authorisation.checks import authorised_p -from gn_auth.auth.authorisation.resources.groups.models import Group +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 def linked_phenotype_data( authconn: authdb.DbConnection, gn3conn: gn3db.Connection, @@ -83,7 +87,7 @@ def ungrouped_phenotype_data( return tuple() -def __traits__(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]: +def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]: """An internal utility function. Don't use outside of this module.""" if len(params) < 1: return tuple() @@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dic for itm in sublist)) return cursor.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 link_phenotype_data( - authconn:authdb.DbConnection, gn3conn: gn3db.Connection, group: Group, - traits: tuple[dict, ...]) -> dict: + authconn: authdb.DbConnection, + 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",)) + or + authorised_for2(authconn, + user, + group_resource(authconn, group.group_id), + ("group:data:link-to-group",)) + ): + raise AuthorisationError( + "You do not have sufficient privileges to link data to group " + f"'{group.group_name}'.") with authdb.cursor(authconn) as cursor: params = tuple({ "data_link_id": str(uuid.uuid4()), "group_id": str(group.group_id), **item - } for item in __traits__(gn3conn, traits)) + } for item in traits) cursor.executemany( "INSERT INTO linked_phenotype_data " "VALUES (" diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py index 38eaad6..9123949 100644 --- a/gn_auth/auth/authorisation/data/views.py +++ b/gn_auth/auth/authorisation/data/views.py @@ -35,8 +35,8 @@ from ..resources.models import ( from ...authentication.users import User from ...authentication.oauth2.resource_server import require_oauth -from ..data.phenotypes import link_phenotype_data 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 data = Blueprint("data", __name__) @@ -189,7 +189,7 @@ def __search_mrna__(): def __request_key__(key: str, default: Any = ""): if bool(request_json()): return request_json().get(#type: ignore[union-attr] - key, request.args.get(key, request_json().get(key, default))) + key, request.args.get(key, default)) return request.args.get(key, request_json().get(key, default)) def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()): @@ -312,6 +312,7 @@ def link_mrna() -> Response: partial(__link__, **__values__(request_json())))) @data.route("/link/phenotype", methods=["POST"]) +@require_oauth("profile group resource") def link_phenotype() -> Response: """Link phenotype data to group.""" def __values__(form): @@ -327,14 +328,27 @@ def link_phenotype() -> Response: raise InvalidData("Expected at least one dataset to be provided.") return { "group_id": uuid.UUID(form["group_id"]), - "traits": form["selected"] + "traits": form["selected"], + "using_raw_ids": bool(form.get("using-raw-ids") == "on") } - with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn: - def __link__(conn: db.DbConnection, group_id: uuid.UUID, - traits: tuple[dict, ...]) -> dict: - return link_phenotype_data( - conn, gn3conn, group_by_id(conn, group_id), traits) + with (require_oauth.acquire("profile group resource") as token, + gn3db.database_connection(app.config["SQL_URI"]) as gn3conn): + def __link__( + conn: db.DbConnection, + group_id: uuid.UUID, + traits: tuple[dict, ...], + using_raw_ids: bool = False + ) -> dict: + if using_raw_ids: + return link_phenotype_data(conn, + token.user, + group_by_id(conn, group_id), + traits) + return link_phenotype_data(conn, + token.user, + group_by_id(conn, group_id), + pheno_traits_from_db(gn3conn, traits)) return jsonify(with_db_connection( partial(__link__, **__values__(request_json())))) diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py index d8e3a9f..5484dbf 100644 --- a/gn_auth/auth/authorisation/resources/checks.py +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -3,9 +3,13 @@ from uuid import UUID from functools import reduce from typing import Sequence +from .base import 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(row["resource_id"]) @@ -16,6 +20,7 @@ def __organise_privileges_by_resource_id__(rows): } return reduce(__organise__, rows, {}) + def authorised_for(conn: db.DbConnection, user: User, privileges: tuple[str, ...], @@ -45,3 +50,35 @@ def authorised_for(conn: db.DbConnection, 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. + """ + 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) diff --git a/gn_auth/auth/authorisation/resources/genotypes/models.py b/gn_auth/auth/authorisation/resources/genotypes/models.py index e8dca9b..762ee7c 100644 --- a/gn_auth/auth/authorisation/resources/genotypes/models.py +++ b/gn_auth/auth/authorisation/resources/genotypes/models.py @@ -27,14 +27,15 @@ def resource_data( def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: """Link Genotype data with a resource using the GUI.""" with db.cursor(conn) as cursor: - params = { + params = tuple({ "resource_id": str(resource.resource_id), "data_link_id": str(data_link_id) - } - cursor.execute( + } for data_link_id in data_link_ids) + cursor.executemany( "INSERT INTO genotype_resources VALUES" "(:resource_id, :data_link_id)", params) @@ -68,7 +69,7 @@ def attach_resources_data( return __attach_data__(cursor.fetchall(), resources) -def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments] +def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] cursor, resource_id: uuid.UUID, group_id: uuid.UUID, diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py index 3263e37..2df5f04 100644 --- a/gn_auth/auth/authorisation/resources/groups/models.py +++ b/gn_auth/auth/authorisation/resources/groups/models.py @@ -8,14 +8,18 @@ from typing import Any, Sequence, Iterable, Optional import sqlite3 from flask import g from pymonad.maybe import Just, Maybe, Nothing +from pymonad.either import Left, Right, Either +from pymonad.tools import monad_from_none_or_value from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.authentication.users import User, user_by_id from gn_auth.auth.authorisation.checks import authorised_p from gn_auth.auth.authorisation.privileges import Privilege -from gn_auth.auth.authorisation.resources.base import Resource from gn_auth.auth.authorisation.resources.errors import MissingGroupError +from gn_auth.auth.authorisation.resources.base import ( + Resource, + resource_from_dbrow) from gn_auth.auth.errors import ( NotFoundError, AuthorisationError, InconsistencyError) from gn_auth.auth.authorisation.roles.models import ( @@ -118,7 +122,7 @@ def create_group( cursor, group_name, ( {"group_description": group_description} if group_description else {})) - group_resource = { + _group_resource = { "group_id": str(new_group.group_id), "resource_id": str(uuid4()), "resource_name": group_name, @@ -131,17 +135,17 @@ def create_group( cursor.execute( "INSERT INTO resources VALUES " "(:resource_id, :resource_name, :resource_category_id, :public)", - group_resource) + _group_resource) cursor.execute( "INSERT INTO group_resources(resource_id, group_id) " "VALUES(:resource_id, :group_id)", - group_resource) + _group_resource) add_user_to_group(cursor, new_group, group_leader) revoke_user_role_by_name(cursor, group_leader, "group-creator") assign_user_role_by_name( cursor, group_leader, - UUID(str(group_resource["resource_id"])), + UUID(str(_group_resource["resource_id"])), "group-leader") return new_group @@ -497,3 +501,44 @@ def add_resources_to_group(conn: db.DbConnection, "group_id": str(group.group_id), "resource_id": str(rsc.resource_id) } for rsc in resources)) + + +def admin_group(conn: db.DbConnection) -> Either: + """Return a group where at least one system admin is a member.""" + query = ( + "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata " + "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id " + "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id " + "INNER JOIN groups AS g ON gu.group_id=g.group_id " + "WHERE role_name='system-administrator'") + with db.cursor(conn) as cursor: + cursor.execute(query) + return monad_from_none_or_value( + Left("There is no group of which the system admininstrator is a " + "member."), + lambda row: Right(Group( + UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"]))), + cursor.fetchone()) + + +def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource: + """Retrieve the system resource.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT group_resources.group_id, resource_categories.*, " + "resources.resource_id, resources.resource_name, resources.public " + "FROM group_resources INNER JOIN resources " + "ON group_resources.resource_id=resources.resource_id " + "INNER JOIN resource_categories " + "ON resources.resource_category_id=resource_categories.resource_category_id " + "WHERE group_resources.group_id=? " + "AND resource_categories.resource_category_key='group'", + (str(group_id),)) + row = cursor.fetchone() + if row: + return resource_from_dbrow(row) + + raise NotFoundError("Could not find a resource for group with ID " + f"{group_id}") diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py index 368284f..746e23c 100644 --- a/gn_auth/auth/authorisation/resources/groups/views.py +++ b/gn_auth/auth/authorisation/resources/groups/views.py @@ -235,7 +235,7 @@ def unlinked_data(resource_type: str) -> Response: if resource_type in ("system", "group"): return jsonify(tuple()) - if resource_type not in ("all", "mrna", "genotype", "phenotype"): + if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"): raise AuthorisationError(f"Invalid resource type {resource_type}") with require_oauth.acquire("profile group resource") as the_token: @@ -253,7 +253,8 @@ def unlinked_data(resource_type: str) -> Response: "genotype": unlinked_genotype_data, "phenotype": lambda conn, grp: partial( unlinked_phenotype_data, gn3conn=gn3conn)( - authconn=conn, group=grp) + authconn=conn, group=grp), + "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this } return jsonify(tuple( dict(row) for row in unlinked_fns[resource_type]( diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py index de1c18a..64d41e3 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/models.py +++ b/gn_auth/auth/authorisation/resources/inbredset/models.py @@ -62,7 +62,7 @@ def assign_inbredset_group_owner_role( return resource -def link_data_to_resource(# pylint: disable=[too-many-arguments] +def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] cursor: sqlite3.Cursor, resource_id: UUID, species_id: int, diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py index b559105..40dd38d 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/views.py +++ b/gn_auth/auth/authorisation/resources/inbredset/views.py @@ -7,7 +7,7 @@ from gn_auth.auth.db import sqlite3 as db from gn_auth.auth.requests import request_json from gn_auth.auth.db.sqlite3 import with_db_connection from gn_auth.auth.authentication.oauth2.resource_server import require_oauth -from gn_auth.auth.authorisation.resources.groups.models import user_group +from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group from .models import (create_resource, link_data_to_resource, @@ -83,7 +83,14 @@ def create_population_resource(): return Right({"formdata": form, "group": usergroup}) - return user_group(conn, _token.user).then( + def __default_group_if_none__(group) -> Either: + if group.is_nothing(): + return admin_group(conn) + return Right(group.value) + + return __default_group_if_none__( + user_group(conn, _token.user) + ).then( lambda group: __check_form__(request_json(), group) ).then( lambda formdata: { diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index c1748f1..e538a87 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -39,7 +39,7 @@ from .phenotypes.models import ( @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] +def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments] cursor: sqlite3.Cursor, resource_name: str, resource_category: ResourceCategory, @@ -207,8 +207,12 @@ def resource_by_id( raise NotFoundError(f"Could not find a resource with id '{resource_id}'") def link_data_to_resource( - conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str, - data_link_id: UUID) -> dict: + conn: db.DbConnection, + user: User, + resource_id: UUID, + dataset_type: str, + data_link_ids: tuple[UUID, ...] +) -> tuple[dict, ...]: """Link data to resource.""" if not authorised_for( conn, user, ("group:resource:edit-resource",), @@ -223,7 +227,7 @@ def link_data_to_resource( "mrna": mrna_link_data_to_resource, "genotype": genotype_link_data_to_resource, "phenotype": phenotype_link_data_to_resource, - }[dataset_type.lower()](conn, resource, data_link_id) + }[dataset_type.lower()](conn, resource, data_link_ids) def unlink_data_from_resource( conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID): diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py index 7fce227..66f8824 100644 --- a/gn_auth/auth/authorisation/resources/mrna.py +++ b/gn_auth/auth/authorisation/resources/mrna.py @@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor, def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: """Link mRNA Assay data with a resource.""" with db.cursor(conn) as cursor: - params = { + params = tuple({ "resource_id": str(resource.resource_id), "data_link_id": str(data_link_id) - } - cursor.execute( + } for data_link_id in data_link_ids) + cursor.executemany( "INSERT INTO mrna_resources VALUES" "(:resource_id, :data_link_id)", params) diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py index d4a516a..0ef91ab 100644 --- a/gn_auth/auth/authorisation/resources/phenotypes/models.py +++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py @@ -29,14 +29,15 @@ def resource_data( def link_data_to_resource( conn: db.DbConnection, resource: Resource, - data_link_id: uuid.UUID) -> dict: + data_link_ids: tuple[uuid.UUID, ...] +) -> tuple[dict, ...]: """Link Phenotype data with a resource.""" with db.cursor(conn) as cursor: - params = { + params = tuple({ "resource_id": str(resource.resource_id), "data_link_id": str(data_link_id) - } - cursor.execute( + } for data_link_id in data_link_ids) + cursor.executemany( "INSERT INTO phenotype_resources VALUES" "(:resource_id, :data_link_id)", params) diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py index 7c176aa..303b0ac 100644 --- a/gn_auth/auth/authorisation/resources/system/models.py +++ b/gn_auth/auth/authorisation/resources/system/models.py @@ -4,11 +4,15 @@ from functools import reduce from typing import Sequence from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.errors import NotFoundError from gn_auth.auth.authentication.users import User from gn_auth.auth.authorisation.roles import Role from gn_auth.auth.authorisation.privileges import Privilege +from gn_auth.auth.authorisation.resources.base import ( + Resource, + resource_from_dbrow) def __organise_privileges__(acc, row): role_id = UUID(row["role_id"]) @@ -24,6 +28,7 @@ def __organise_privileges__(acc, row): (Privilege(row["privilege_id"], row["privilege_description"]),))) } + def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]: """ Retrieve all roles assigned to the `user` that act on `system` resources. @@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]: return tuple(reduce( __organise_privileges__, cursor.fetchall(), {}).values()) return tuple() + + +def system_resource(conn: db.DbConnection) -> Resource: + """Retrieve the system resource.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT resource_categories.*, resources.resource_id, " + "resources.resource_name, resources.public " + "FROM resource_categories INNER JOIN resources " + "ON resource_categories.resource_category_id=resources.resource_category_id " + "WHERE resource_categories.resource_category_key='system'") + row = cursor.fetchone() + if row: + return resource_from_dbrow(row) + + raise NotFoundError("Could not find a system resource!") diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 1c4104a..0a68927 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -137,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response: with require_oauth.acquire("profile group resource") as the_token: db_uri = app.config["AUTH_DB"] count_per_page = __safe_get_requests_count__("count_per_page") - offset = (__safe_get_requests_page__("page") - 1) + offset = __safe_get_requests_page__("page") - 1 with db.connection(db_uri) as conn: resource = resource_by_id(conn, the_token.user, resource_id) return jsonify(resource_data( @@ -153,7 +153,7 @@ def link_data(): try: form = request_json() assert "resource_id" in form, "Resource ID not provided." - assert "data_link_id" in form, "Data Link ID not provided." + assert "data_link_ids" in form, "Data Link IDs not provided." assert "dataset_type" in form, "Dataset type not specified" assert form["dataset_type"].lower() in ( "mrna", "genotype", "phenotype"), "Invalid dataset type provided." @@ -161,8 +161,11 @@ def link_data(): with require_oauth.acquire("profile group resource") as the_token: def __link__(conn: db.DbConnection): return link_data_to_resource( - conn, the_token.user, UUID(form["resource_id"]), - form["dataset_type"], UUID(form["data_link_id"])) + conn, + the_token.user, + UUID(form["resource_id"]), + form["dataset_type"], + tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"])) return jsonify(with_db_connection(__link__)) except AssertionError as aserr: diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py index 2729b3b..6faeaca 100644 --- a/gn_auth/auth/authorisation/roles/models.py +++ b/gn_auth/auth/authorisation/roles/models.py @@ -271,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]: _roles = db_rows_to_roles(results) if len(_roles) > 1: - raise Exception("Data corruption: Expected a single role.") + raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised] return _roles[0] diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index f0a7fa2..63443ef 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool: def __toggle_boolean_field__( rconn: Redis, email: str, field: str): """Toggle the valuen of a boolean field""" - mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") + mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore if bool(mig_dict): rconn.hset("migratable-accounts", email, json.dumps({**mig_dict, field: not mig_dict.get(field, True)})) @@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis): "resources_migrated": False } for account in ( acct for acct in - (json.loads(usr) for usr in rconn.hgetall("users").values()) + (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore if (bool(acct.get("email_address", False)) and __valid_email__(acct["email_address"]))) } @@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict: accounts = rconn.hgetall("migratable-accounts") if accounts: return { - key: json.loads(value) for key, value in accounts.items() + key: json.loads(value) for key, value in accounts.items() # type: ignore } return __build_email_uuid_bridge__(rconn) @@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple: """Retrieve any old collections relating to the user.""" return tuple(parse_collection(coll) for coll in json.loads(rconn.hget( - __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) + __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]: """Retrieve current user collections.""" collections = tuple(parse_collection(coll) for coll in json.loads( rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or - "[]")) + "[]")) # type: ignore old_accounts = __retrieve_old_accounts__(rconn) if (user.email in old_accounts and not old_accounts[user.email]["collections-migrated"]): diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py index a155899..5c11f34 100644 --- a/gn_auth/auth/authorisation/users/masquerade/models.py +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -20,7 +20,7 @@ from ....db import sqlite3 as db from ....authentication.users import User from ....authentication.oauth2.models.oauth2token import OAuth2Token -__FIVE_HOURS__ = (60 * 60 * 5) +__FIVE_HOURS__ = 60 * 60 * 5 def can_masquerade(func): """Security decorator.""" diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py index bde2e33..ef3ce7f 100644 --- a/gn_auth/auth/authorisation/users/models.py +++ b/gn_auth/auth/authorisation/users/models.py @@ -1,6 +1,8 @@ """Functions for acting on users.""" import uuid +from typing import Union from functools import reduce +from datetime import datetime, timedelta from ..roles.models import Role from ..checks import authorised_p @@ -9,14 +11,72 @@ from ..privileges import Privilege from ...db import sqlite3 as db from ...authentication.users import User + +def __process_age_clause__(age_desc: str) -> tuple[str, int]: + """Process the age clause and parameter for 'LIST USERS' query.""" + _today = datetime.now() + _clause = "created" + _parts = age_desc.split(" ") + _multipliers = { + # Temporary hack before dateutil module can make it to our deployment. + "days": 1, + "months": 30, + "years": 365 + } + assert len(_parts) in (3, 4), "Invalid age descriptor!" + + _param = int(( + _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]}) + ).timestamp()) + + match _parts[0]: + case "older": + return "created < :created", _param + case "younger": + return "created > :created", _param + case "exactly": + return "created = :created", _param + case _: + raise Exception("Invalid age descriptor.") + + +def __list_user_clauses_and_params__(**kwargs) -> tuple[list[str], dict[str, Union[int, str]]]: + """Process the WHERE clauses, and params for the 'LIST USERS' query.""" + clauses = [] + params = {} + if bool(kwargs.get("email", "").strip()): + clauses = clauses + ["email LIKE :email"] + params["email"] = f'%{kwargs["email"].strip()}%' + + if bool(kwargs.get("name", "").strip()): + clauses = clauses + ["name LIKE :name"] + params["name"] = f'%{kwargs["name"].strip()}%' + + if bool(kwargs.get("verified", "").strip()): + clauses = clauses + ["verified=:verified"] + params["verified"] = 1 if kwargs["verified"].strip() == "yes" else "no" + + if bool(kwargs.get("age", "").strip()): + _clause, _param = __process_age_clause__(kwargs["age"].strip()) + clauses = clauses + [_clause] + params["created"] = _param + + return clauses, params + + @authorised_p( ("system:user:list",), "You do not have the appropriate privileges to list users.", oauth2_scope="profile user") -def list_users(conn: db.DbConnection) -> tuple[User, ...]: +def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]: """List out all users.""" + _query = "SELECT * FROM users" + _clauses, _params = __list_user_clauses_and_params__(**kwargs) + if len(_clauses) > 0: + _query = _query + " WHERE " + " AND ".join(_clauses) + with db.cursor(conn) as cursor: - cursor.execute("SELECT * FROM users") + cursor.execute(_query, _params) return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall()) def __build_resource_roles__(rows): diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 7adcd06..be4296b 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -28,6 +28,7 @@ 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.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 ( @@ -39,6 +40,7 @@ from gn_auth.auth.errors import ( NotFoundError, UsernameError, PasswordError, + AuthorisationError, UserRegistrationError) @@ -114,6 +116,30 @@ def user_address(user: User) -> Address: """Compute the `email.headerregistry.Address` from a `User`""" return Address(display_name=user.name, addr_spec=user.email) + +def display_minutes_for_humans(minutes): + """Convert minutes into human-readable display.""" + _week_ = 10080 # minutes + _day_ = 1440 # minutes + _remainder_ = minutes + + _human_readable_ = "" + if _remainder_ >= _week_: + _weeks_ = _remainder_ // _week_ + _remainder_ = _remainder_ % _week_ + _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "") + + if _remainder_ >= _day_: + _days_ = _remainder_ // _day_ + _remainder_ = _remainder_ % _day_ + _human_readable_ += (" " if bool(_human_readable_) else "") + \ + f"{_days_} day" + ("s" if _days_ > 1 else "") + + if _remainder_ > 0: + _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes" + + return _human_readable_ + def send_verification_email( conn, user: User, @@ -125,7 +151,7 @@ def send_verification_email( subject="GeneNetwork: Please Verify Your Email" verification_code = secrets.token_urlsafe(64) generated = datetime.now() - expiration_minutes = 15 + expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"] def __render__(template): return render_template(template, subject=subject, @@ -137,7 +163,8 @@ def send_verification_email( client_id=client_id, redirect_uri=redirect_uri, verificationcode=verification_code)), - expiration_minutes=expiration_minutes) + expiration_minutes=display_minutes_for_humans( + expiration_minutes)) with db.cursor(conn) as cursor: cursor.execute( ("INSERT INTO " @@ -180,7 +207,7 @@ def register_user() -> Response: with db.cursor(conn) as cursor: user, _hashed_password = set_user_password( cursor, save_user( - cursor, email["email"], user_name), password) + cursor, email["email"], user_name), password) # type: ignore assign_default_roles(cursor, user) send_verification_email(conn, user, @@ -196,7 +223,7 @@ def register_user() -> Response: current_app.logger.error(traceback.format_exc()) raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve - raise Exception( + raise Exception(# pylint: disable=[broad-exception-raised] "unknown_error", "The system experienced an unexpected error.") def delete_verification_code(cursor, code: str): @@ -306,9 +333,27 @@ def user_join_request_exists(): @require_oauth("profile user") def list_all_users() -> Response: """List all the users.""" - with require_oauth.acquire("profile group") as _the_token: - return jsonify(tuple( - asdict(user) for user in with_db_connection(list_users))) + _kwargs = { + key: value + for key, value in request.json.items() + if key in ("email", "name", "verified", "age") + } + + with (require_oauth.acquire("profile group") as _the_token, + db.connection(current_app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + _users = list_users(conn, **_kwargs) + _start = int(_kwargs.get("start", "0")) + _length = int(_kwargs.get("length", "0")) + cursor.execute("SELECT COUNT(*) FROM users") + _total_users = int(cursor.fetchone()["COUNT(*)"]) + return jsonify({ + "users": tuple(asdict(user) for user in + (_users[_start:_start+_length] + if _length else _users)), + "total-users": _total_users, + "total-filtered": len(_users) + }) @users.route("/handle-unverified", methods=["POST"]) def handle_unverified(): @@ -380,7 +425,7 @@ def send_forgot_password_email( subject="GeneNetwork: Change Your Password" token = secrets.token_urlsafe(64) generated = datetime.now() - expiration_minutes = 15 + expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"] def __render__(template): return render_template(template, subject=subject, @@ -391,7 +436,8 @@ def send_forgot_password_email( client_id=client_id, redirect_uri=redirect_uri, response_type=response_type)), - expiration_minutes=expiration_minutes) + expiration_minutes=display_minutes_for_humans( + expiration_minutes)) with db.cursor(conn) as cursor: cursor.execute( @@ -504,3 +550,83 @@ def change_password(forgot_password_token): flash("Both the password and its confirmation MUST be provided!", "alert-danger") return change_password_page + + +@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((str(_token.user.user_id),)) + + cursor.execute("SELECT user_id FROM group_users") + _non_deletable.update(row["user_id"] for row in cursor.fetchall()) + + cursor.execute("SELECT user_id FROM oauth2_clients;") + _non_deletable.update(row["user_id"] for row in cursor.fetchall()) + + _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(row["user_id"] for row in cursor.fetchall()) + + _delete = tuple(uid for uid in _user_ids if uid not 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")) + for _table, _col in _dependent_tables: + cursor.execute( + f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})", + _delete) + + cursor.execute( + f"DELETE FROM users WHERE user_id IN ({_paramstr})", + _delete) + _deleted_rows = cursor.rowcount + _diff = len(_user_ids) - _deleted_rows + return jsonify({ + "total-requested": len(_user_ids), + "total-deleted": _deleted_rows, + "not-deleted": _diff, + "message": ( + f"Successfully deleted {_deleted_rows} users." + + (f" Some users could not be deleted." if _diff > 0 else "")) + }) + + return jsonify({ + "total-requested": len(_user_ids), + "total-deleted": 0, + "not-deleted": len(_user_ids), + "error": "Zero users were deleted", + "error_description": ( + "Either no users were selected or all the selected users are " + "system administrators, group members, or resource owners.") + }), 400 diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py index 00e9b35..cd939dd 100644 --- a/gn_auth/auth/requests.py +++ b/gn_auth/auth/requests.py @@ -1,6 +1,14 @@ """Utilities to deal with requests.""" +import werkzeug from flask import request def request_json() -> dict: """Retrieve the JSON sent in a request.""" - return request.json or dict(request.form) or {} + try: + json_data = request.json + # KLUDGE: We have this check here since request.json has the + # type Any | None; see: + # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545> + return json_data if isinstance(json_data, dict) else {} + except werkzeug.exceptions.UnsupportedMediaType: + return dict(request.form) or {} |